From 1fd205d9511df22ba916451c32916ef4323124f4 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Wed, 17 Jun 2026 10:13:50 +0200 Subject: [PATCH] fix(config): persist `config set` under an env token; fail on ephemeral overrides (1.1.121) A Socket API token supplied via env (SOCKET_CLI_API_TOKEN / SOCKET_SECURITY_API_TOKEN and legacy aliases) used to put the entire config into read-only mode, so `socket config set ` silently failed to save while still printing `OK`, and a later `socket config get` then showed nothing. A token from the environment now overrides authentication only: unrelated keys such as defaultOrg are written to disk as expected, while the env token itself is still never persisted (getDefaultApiToken resolves it straight from the environment, so it is no longer mirrored into the cached config). When the config is genuinely ephemeral, because it was fully overridden via --config, SOCKET_CLI_CONFIG, or SOCKET_CLI_NO_API_TOKEN, `socket config set` now fails with a clear error instead of pretending it succeeded; the in-memory-only change is a no-op for a one-shot command. `config get apiToken` still reports the env-supplied token, which takes precedence over persisted / --config values. Adds unit and command-level regression tests and bumps the CLI to 1.1.121. --- CHANGELOG.md | 6 +++ package.json | 2 +- src/commands/config/cmd-config-set.test.mts | 50 +++++++++++++++++++++ src/commands/config/handle-config-get.mts | 17 ++++++- src/commands/config/handle-config-set.mts | 23 ++++++++-- src/commands/config/output-config-set.mts | 15 ++----- src/utils/config.mts | 24 +++++++--- src/utils/config.test.mts | 27 ++++++++++- src/utils/meow-with-subcommands.mts | 6 ++- 9 files changed, 142 insertions(+), 28 deletions(-) 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) } }