diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 3a511c93..27fbd174 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -3,6 +3,7 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { readConfigFile, writeConfigFile } from '../../config/loader'; +import { maskToken } from '../../utils/token'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; @@ -16,6 +17,10 @@ const KEY_ALIASES: Record = { 'default-music-model': 'default_music_model', }; +function valueForOutput(key: string, value: unknown): unknown { + return key === 'api_key' ? maskToken(String(value)) : value; +} + export default defineCommand({ name: 'config set', description: 'Set a config value', @@ -53,6 +58,14 @@ export default defineCommand({ ); } + const valueToSet = resolvedKey === 'api_key' ? value.trim() : value; + if (resolvedKey === 'api_key' && valueToSet.length === 0) { + throw new CLIError( + 'Invalid api_key. The value must not be empty.', + ExitCode.USAGE, + ); + } + // Validate specific values if (resolvedKey === 'region' && !['global', 'cn'].includes(value)) { throw new CLIError( @@ -95,22 +108,28 @@ export default defineCommand({ const format = detectOutputFormat(config.output); if (config.dryRun) { - console.log(formatOutput({ would_set: { [resolvedKey]: value } }, format)); + console.log(formatOutput({ + would_set: { [resolvedKey]: valueForOutput(resolvedKey, valueToSet) }, + }, format)); return; } const existing = readConfigFile() as Record; - existing[resolvedKey] = resolvedKey === 'timeout' ? Number(value) : value; + existing[resolvedKey] = resolvedKey === 'timeout' ? Number(valueToSet) : valueToSet; - // When API key changes, clear cached region so it gets re-detected + // API key and OAuth are mutually exclusive. Clear OAuth and the cached + // region so subsequent requests resolve the new key and re-detect its host. if (resolvedKey === 'api_key') { + delete existing.oauth; delete existing.region; } await writeConfigFile(existing); if (!config.quiet) { - console.log(formatOutput({ [resolvedKey]: existing[resolvedKey] }, format)); + console.log(formatOutput({ + [resolvedKey]: valueForOutput(resolvedKey, existing[resolvedKey]), + }, format)); } }, }); diff --git a/test/commands/config/set.test.ts b/test/commands/config/set.test.ts index 0851ac1e..5def15ce 100644 --- a/test/commands/config/set.test.ts +++ b/test/commands/config/set.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it, spyOn } from 'bun:test'; import { default as setCommand } from '../../../src/commands/config/set'; +import * as configLoader from '../../../src/config/loader'; describe('config set command', () => { it('has correct name', () => { @@ -129,4 +130,140 @@ describe('config set command', () => { }), ).resolves.toBeUndefined(); }); + + it('masks api_key in dry-run output', async () => { + const apiKey = 'sk-test-secret-123456'; + const writeConfigFile = spyOn(configLoader, 'writeConfigFile').mockResolvedValue(); + let output = ''; + const originalLog = console.log; + console.log = (message: string) => { output += message; }; + + try { + await setCommand.execute({ + region: 'global', + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }, { + key: 'api_key', + value: apiKey, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + } finally { + console.log = originalLog; + writeConfigFile.mockRestore(); + } + + expect(output).not.toContain(apiKey); + expect(JSON.parse(output).would_set.api_key).toBe('sk-t...3456'); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it('removes OAuth and masks output when setting api_key', async () => { + const apiKey = 'sk-test-secret-123456'; + const readConfigFile = spyOn(configLoader, 'readConfigFile').mockReturnValue({ + region: 'cn', + oauth: { + access_token: 'old-access-token', + refresh_token: 'old-refresh-token', + expires_at: '2099-01-01T00:00:00.000Z', + }, + }); + let writtenConfig: Record | undefined; + const writeConfigFile = spyOn(configLoader, 'writeConfigFile').mockImplementation(async (data) => { + writtenConfig = { ...data }; + }); + let output = ''; + const originalLog = console.log; + console.log = (message: string) => { output += message; }; + + try { + await setCommand.execute({ + region: 'cn', + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }, { + key: 'api_key', + value: ` ${apiKey} `, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }); + } finally { + console.log = originalLog; + readConfigFile.mockRestore(); + writeConfigFile.mockRestore(); + } + + expect(writtenConfig).toEqual({ api_key: apiKey }); + expect(output).not.toContain(apiKey); + expect(JSON.parse(output).api_key).toBe('sk-t...3456'); + }); + + it('rejects empty api_key values without changing existing credentials', async () => { + const readConfigFile = spyOn(configLoader, 'readConfigFile'); + const writeConfigFile = spyOn(configLoader, 'writeConfigFile').mockResolvedValue(); + + try { + for (const value of ['', ' ']) { + await expect(setCommand.execute({ + region: 'cn', + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }, { + key: 'api_key', + value, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + })).rejects.toThrow('must not be empty'); + } + } finally { + readConfigFile.mockRestore(); + writeConfigFile.mockRestore(); + } + + expect(readConfigFile).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); });