diff --git a/CHANGELOG.md b/CHANGELOG.md index 124a0f0e5..f500f0086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.1.121](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.121) - 2026-06-17 + +### Fixed +- `socket config set` now persists correctly when a Socket API token is supplied via an environment variable. Previously, setting `SOCKET_CLI_API_TOKEN` / `SOCKET_SECURITY_API_TOKEN` put the entire config into read-only mode, so `socket config set ` silently failed to save (and a later `socket config get` showed nothing) while still printing `OK`. A token from the environment now overrides authentication only: unrelated keys such as `defaultOrg` are written to disk as expected, and the env-supplied token itself is still never persisted. +- `socket config set` no longer reports a misleading `OK` when the value genuinely cannot be saved. When the config is fully overridden (and therefore ephemeral) via `--config`, `SOCKET_CLI_CONFIG`, or `SOCKET_CLI_NO_API_TOKEN`, the command now fails with a clear error explaining that the value was not saved, instead of pretending it succeeded. + ## [1.1.120](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.120) - 2026-06-12 ### Changed diff --git a/package.json b/package.json index 84925b163..226655aba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.120", + "version": "1.1.121", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT", diff --git a/src/commands/config/cmd-config-set.test.mts b/src/commands/config/cmd-config-set.test.mts index 8d9bf9f31..8526f68cb 100644 --- a/src/commands/config/cmd-config-set.test.mts +++ b/src/commands/config/cmd-config-set.test.mts @@ -1,3 +1,5 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' import { describe, expect } from 'vitest' @@ -114,4 +116,52 @@ describe('socket config get', async () => { expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) }, ) + + cmdit( + ['config', 'set', 'defaultOrg', 'my-test-org', FLAG_CONFIG, '{}'], + 'should fail (not report OK) when a full config override prevents persisting', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + // A full --config override makes the config read-only, so the value cannot + // be saved. `config set` is a no-op here, so it must fail rather than + // report a misleading "OK". + const combined = `${stdout}\n${stderr}` + expect(combined).toContain('was not saved') + expect(stdout).not.toContain('OK') + expect(code, 'an unpersistable set should exit non-zero').toBe(1) + }, + ) + + cmdit( + ['config', 'set', 'defaultOrg', 'my-test-org'], + 'should persist a non-token key when only the API token is overridden via env', + async cmd => { + // Isolate the config file via XDG_DATA_HOME so the test never writes to + // the real user config. NOTE: socketAppDataPath only honors XDG_DATA_HOME + // on macOS/Linux; on Windows it uses LOCALAPPDATA, so this isolation (and + // thus the test) assumes a POSIX runner. CI is Linux-only today. + const dataHome = mkdtempSync(path.join(os.tmpdir(), 'socket-cfg-')) + try { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { + env: { + SOCKET_SECURITY_API_TOKEN: 'sktsec_faketoken', + XDG_DATA_HOME: dataHome, + }, + }) + expect(code, 'a persistable set should exit 0').toBe(0) + expect(stdout).toContain('OK') + + const raw = readFileSync( + path.join(dataHome, 'socket', 'settings', 'config.json'), + 'utf8', + ) + const saved = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) + expect(saved.defaultOrg).toBe('my-test-org') + // The env token must never be written to disk. + expect(saved.apiToken).toBeUndefined() + } finally { + rmSync(dataHome, { recursive: true, force: true }) + } + }, + ) }) diff --git a/src/commands/config/handle-config-get.mts b/src/commands/config/handle-config-get.mts index 3ef21f348..e0c317bb3 100644 --- a/src/commands/config/handle-config-get.mts +++ b/src/commands/config/handle-config-get.mts @@ -1,7 +1,8 @@ import { outputConfigGet } from './output-config-get.mts' +import constants, { CONFIG_KEY_API_TOKEN } from '../../constants.mts' import { getConfigValue } from '../../utils/config.mts' -import type { OutputKind } from '../../types.mts' +import type { CResult, OutputKind } from '../../types.mts' import type { LocalConfig } from '../../utils/config.mts' export async function handleConfigGet({ @@ -11,7 +12,19 @@ export async function handleConfigGet({ key: keyof LocalConfig outputKind: OutputKind }) { - const result = getConfigValue(key) + // A Socket API token supplied via the environment (SOCKET_CLI_API_TOKEN / + // SOCKET_SECURITY_API_TOKEN and legacy aliases, all aggregated into + // constants.ENV.SOCKET_CLI_API_TOKEN) takes precedence over any persisted or + // --config value. The env token is no longer mirrored into the in-memory + // config (so unrelated keys stay persistable via `config set`), so surface it + // explicitly here to preserve "env token wins" for `config get apiToken`. + const { ENV } = constants + const result: CResult = + key === CONFIG_KEY_API_TOKEN && + !ENV.SOCKET_CLI_NO_API_TOKEN && + ENV.SOCKET_CLI_API_TOKEN + ? { ok: true, data: ENV.SOCKET_CLI_API_TOKEN } + : getConfigValue(key) await outputConfigGet(key, result, outputKind) } diff --git a/src/commands/config/handle-config-set.mts b/src/commands/config/handle-config-set.mts index 0d1d1ff93..2256633d6 100644 --- a/src/commands/config/handle-config-set.mts +++ b/src/commands/config/handle-config-set.mts @@ -3,7 +3,7 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' import { outputConfigSet } from './output-config-set.mts' import { updateConfigValue } from '../../utils/config.mts' -import type { OutputKind } from '../../types.mts' +import type { CResult, OutputKind } from '../../types.mts' import type { LocalConfig } from '../../utils/config.mts' export async function handleConfigSet({ @@ -20,8 +20,23 @@ export async function handleConfigSet({ const result = updateConfigValue(key, value) - debugFn('notice', `Config update ${result.ok ? 'succeeded' : 'failed'}`) - debugDir('inspect', { result }) + // `config set` is a one-shot command: an in-memory-only change is a no-op + // because the process exits before anything reads it. updateConfigValue only + // populates `data` when the config is read-only (a full --config / + // SOCKET_CLI_CONFIG / SOCKET_CLI_NO_API_TOKEN override), so in that case + // report a failure instead of a misleading success. + const outcome: CResult = + result.ok && result.data + ? { + ok: false, + code: 1, + message: `Config key '${key}' was not saved`, + cause: result.data, + } + : result - await outputConfigSet(result, outputKind) + debugFn('notice', `Config update ${outcome.ok ? 'succeeded' : 'failed'}`) + debugDir('inspect', { outcome, result }) + + await outputConfigSet(outcome, outputKind) } diff --git a/src/commands/config/output-config-set.mts b/src/commands/config/output-config-set.mts index a264b79c3..308b3fc99 100644 --- a/src/commands/config/output-config-set.mts +++ b/src/commands/config/output-config-set.mts @@ -26,16 +26,9 @@ export async function outputConfigSet( logger.log(`# Update config`) logger.log('') logger.log(result.message) - if (result.data) { - logger.log('') - logger.log(result.data) - } - } else { - logger.log(`OK`) - logger.log(result.message) - if (result.data) { - logger.log('') - logger.log(result.data) - } + return } + + logger.log(`OK`) + logger.log(result.message) } diff --git a/src/utils/config.mts b/src/utils/config.mts index a5dd0d2b6..3d3de8c2f 100644 --- a/src/utils/config.mts +++ b/src/utils/config.mts @@ -304,12 +304,19 @@ export function overrideCachedConfig(jsonConfig: unknown): CResult { export function overrideConfigApiToken(apiToken: unknown) { debugFn('notice', 'override: Socket API token (not stored)') - // Set token to the local cached config and mark it read-only so it doesn't persist. - _cachedConfig = { - ...config, - ...(apiToken === undefined ? {} : { apiToken: String(apiToken) }), - } as LocalConfig - _configFromFlag = true + if (apiToken === undefined) { + // SOCKET_CLI_NO_API_TOKEN: operate with no token and lock the config to + // read-only so nothing is persisted for this run. + _cachedConfig = { ...config } as LocalConfig + _configFromFlag = true + return + } + // A token supplied via env (SOCKET_CLI_API_TOKEN / SOCKET_SECURITY_API_TOKEN) + // overrides authentication only. getDefaultApiToken() reads it straight from + // the environment, so we intentionally do NOT inject it into the cached config + // and do NOT mark the config read-only: unrelated keys (e.g. defaultOrg) can + // still be saved with `socket config set`, while the env token never reaches + // disk because it never enters the persisted cache. } let _pendingSave = false @@ -344,7 +351,10 @@ export function updateConfigValue( return { ok: true, message: `Config key '${key}' was ${wasDeleted ? 'deleted' : `updated`}`, - data: 'Change applied but not persisted; current config is overridden through env var or flag', + data: + 'The active config is read-only because it was fully overridden by the ' + + '--config flag, SOCKET_CLI_CONFIG, or SOCKET_CLI_NO_API_TOKEN. Remove ' + + 'the override to save changes to disk.', } } diff --git a/src/utils/config.test.mts b/src/utils/config.test.mts index d68308e67..e7c2251dd 100644 --- a/src/utils/config.test.mts +++ b/src/utils/config.test.mts @@ -12,7 +12,10 @@ import { beforeEach, describe, expect, it } from 'vitest' import { findSocketYmlSync, + isConfigFromFlag, overrideCachedConfig, + overrideConfigApiToken, + resetConfigForTesting, updateConfigValue, } from './config.mts' import { testPath } from '../../test/utils.mts' @@ -30,7 +33,7 @@ describe('utils/config', () => { updateConfigValue('defaultOrg', 'fake_test_org'), ).toMatchInlineSnapshot(` { - "data": "Change applied but not persisted; current config is overridden through env var or flag", + "data": "The active config is read-only because it was fully overridden by the --config flag, SOCKET_CLI_CONFIG, or SOCKET_CLI_NO_API_TOKEN. Remove the override to save changes to disk.", "message": "Config key 'defaultOrg' was updated", "ok": true, } @@ -54,6 +57,28 @@ describe('utils/config', () => { }) }) + describe('read-only state', () => { + it('does not mark the config read-only when only the API token is overridden via env', () => { + // A token from SOCKET_CLI_API_TOKEN / SOCKET_SECURITY_API_TOKEN overrides + // auth only; unrelated keys must still be persistable. + resetConfigForTesting() + overrideConfigApiToken('sktsec_faketoken') + expect(isConfigFromFlag()).toBe(false) + }) + + it('marks the config read-only when fully overridden via --config / SOCKET_CLI_CONFIG', () => { + resetConfigForTesting() + overrideCachedConfig({}) + expect(isConfigFromFlag()).toBe(true) + }) + + it('marks the config read-only when no token is forced (SOCKET_CLI_NO_API_TOKEN)', () => { + resetConfigForTesting() + overrideConfigApiToken(undefined) + expect(isConfigFromFlag()).toBe(true) + }) + }) + describe('findSocketYmlSync', () => { it('should find socket.yml when walking up directory tree', () => { // This test verifies that findSocketYmlSync correctly walks up the directory diff --git a/src/utils/meow-with-subcommands.mts b/src/utils/meow-with-subcommands.mts index 1b03f5315..930ff6258 100644 --- a/src/utils/meow-with-subcommands.mts +++ b/src/utils/meow-with-subcommands.mts @@ -476,8 +476,10 @@ export async function meowWithSubcommands( } else { const tokenOverride = constants.ENV.SOCKET_CLI_API_TOKEN if (tokenOverride) { - // This will set the token (even if there was a config override) and - // set it to readOnly, making sure the temp token won't be persisted. + // The env token overrides authentication only. getDefaultApiToken reads it + // straight from the environment, so overrideConfigApiToken does not inject + // it into the cached config and leaves the config writable — unrelated keys + // still persist, while the env token itself is never written to disk. overrideConfigApiToken(tokenOverride) } }