Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> <value>` 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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
50 changes: 50 additions & 0 deletions src/commands/config/cmd-config-set.test.mts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 })
}
},
)
})
17 changes: 15 additions & 2 deletions src/commands/config/handle-config-get.mts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<LocalConfig[keyof LocalConfig]> =
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)
}
23 changes: 19 additions & 4 deletions src/commands/config/handle-config-set.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<undefined | string> =
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)
}
15 changes: 4 additions & 11 deletions src/commands/config/output-config-set.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
24 changes: 17 additions & 7 deletions src/utils/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,19 @@ export function overrideCachedConfig(jsonConfig: unknown): CResult<undefined> {

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
Expand Down Expand Up @@ -344,7 +351,10 @@ export function updateConfigValue<Key extends keyof LocalConfig>(
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.',
}
}

Expand Down
27 changes: 26 additions & 1 deletion src/utils/config.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
}
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/utils/meow-with-subcommands.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Loading