fix(commands): follow up command routing hardening regressions

This commit is contained in:
Tam Nhu Tran
2026-03-17 14:06:39 -04:00
parent 22cb91adb6
commit d52465058e
20 changed files with 634 additions and 56 deletions
+1
View File
@@ -58,6 +58,7 @@
"build:server": "tsc && node scripts/add-shebang.js",
"build:all": "bun run ui:build && bun run build:server",
"prebuild": "node scripts/clean-dist.js",
"prebuild:server": "node scripts/clean-dist.js",
"prebuild:all": "rm -rf dist tsconfig.tsbuildinfo",
"postbuild:all": "node scripts/verify-bundle.js",
"typecheck": "tsc --noEmit",
+1
View File
@@ -0,0 +1 @@
export * from './api-command/index';
+4 -5
View File
@@ -1,16 +1,15 @@
import { copyApiProfile } from '../../api/services';
import { fail, info, initUI, ok, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitOnApiCommandErrors, extractPositionalArgs, parseApiCommandArgs } from './shared';
import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
export async function handleApiCopyCommand(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args);
const parsedArgs = parseApiCommandArgs(args, { maxPositionals: 2 });
exitOnApiCommandErrors(parsedArgs.errors);
const positionals = extractPositionalArgs(args);
const source = positionals[0];
let destination = positionals[1];
const source = parsedArgs.positionals[0];
let destination = parsedArgs.positionals[1];
if (!source) {
console.log(fail('Source profile is required. Usage: ccs api copy <source> <destination>'));
+10 -1
View File
@@ -1,7 +1,7 @@
import { discoverApiProfileOrphans, registerApiProfileOrphans } from '../../api/services';
import { color, fail, header, info, initUI, ok, table, warn } from '../../utils/ui';
import { hasAnyFlag } from '../arg-extractor';
import { API_KNOWN_FLAGS, parseOptionalTargetFlag } from './shared';
import { API_KNOWN_FLAGS, collectUnexpectedApiArgs, parseOptionalTargetFlag } from './shared';
export async function handleApiDiscoverCommand(args: string[]): Promise<void> {
await initUI();
@@ -15,6 +15,15 @@ export async function handleApiDiscoverCommand(args: string[]): Promise<void> {
process.exit(1);
}
const syntax = collectUnexpectedApiArgs(targetParsed.remainingArgs, {
knownFlags: ['--register', '--json', '--force'],
maxPositionals: 0,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const result = discoverApiProfileOrphans();
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
+13 -3
View File
@@ -3,7 +3,7 @@ import * as path from 'path';
import { exportApiProfile } from '../../api/services';
import { fail, initUI, ok, warn } from '../../utils/ui';
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { API_KNOWN_FLAGS, extractPositionalArgs } from './shared';
import { collectUnexpectedApiArgs } from './shared';
export async function handleApiExportCommand(args: string[]): Promise<void> {
await initUI();
@@ -11,14 +11,24 @@ export async function handleApiExportCommand(args: string[]): Promise<void> {
const outExtracted = extractOption(args, ['--out'], {
allowDashValue: true,
knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'],
allowLongDashValue: true,
knownFlags: ['--out', '--include-secrets'],
});
if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) {
console.log(fail('Missing value for --out'));
process.exit(1);
}
const name = extractPositionalArgs(outExtracted.remainingArgs)[0];
const syntax = collectUnexpectedApiArgs(outExtracted.remainingArgs, {
knownFlags: ['--include-secrets'],
maxPositionals: 1,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const name = syntax.positionals[0];
if (!name) {
console.log(fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
process.exit(1);
+16 -5
View File
@@ -3,7 +3,7 @@ import { importApiProfileBundle, type ProfileValidationIssue } from '../../api/s
import { color, fail, info, initUI, ok, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { API_KNOWN_FLAGS, extractPositionalArgs, parseOptionalTargetFlag } from './shared';
import { collectUnexpectedApiArgs, parseOptionalTargetFlag } from './shared';
function renderValidationIssue(issue: ProfileValidationIssue): void {
const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning');
@@ -16,8 +16,7 @@ export async function handleApiImportCommand(args: string[]): Promise<void> {
const yes = hasAnyFlag(args, ['--yes', '-y']);
const nameExtracted = extractOption(args, ['--name'], {
allowDashValue: true,
knownFlags: [...API_KNOWN_FLAGS, '--name'],
knownFlags: ['--name', '--target', '--force', '--yes', '-y'],
});
if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) {
console.log(fail('Missing value for --name'));
@@ -25,15 +24,27 @@ export async function handleApiImportCommand(args: string[]): Promise<void> {
}
const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [
...API_KNOWN_FLAGS,
'--name',
'--target',
'--force',
'--yes',
'-y',
]);
if (targetParsed.errors.length > 0) {
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const importPath = extractPositionalArgs(targetParsed.remainingArgs)[0];
const syntax = collectUnexpectedApiArgs(targetParsed.remainingArgs, {
knownFlags: ['--force', '--yes', '-y'],
maxPositionals: 1,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const importPath = syntax.positionals[0];
if (!importPath) {
console.log(
fail('Import file path is required. Usage: ccs api import <file> [--name <new-name>]')
+1 -1
View File
@@ -12,7 +12,7 @@ export { parseApiCommandArgs } from './shared';
const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
{ name: 'create', handle: handleApiCreateCommand },
{ name: 'list', handle: async () => handleApiListCommand() },
{ name: 'list', handle: handleApiListCommand },
{ name: 'discover', handle: handleApiDiscoverCommand },
{ name: 'copy', handle: handleApiCopyCommand },
{ name: 'export', handle: handleApiExportCommand },
+11 -2
View File
@@ -1,8 +1,17 @@
import { listApiProfiles, isUsingUnifiedConfig } from '../../api/services';
import { dim, header, initUI, subheader, table, warn, color } from '../../utils/ui';
import { color, dim, fail, header, initUI, subheader, table, warn } from '../../utils/ui';
import { collectUnexpectedApiArgs } from './shared';
export async function handleApiListCommand(): Promise<void> {
export async function handleApiListCommand(args: string[] = []): Promise<void> {
await initUI();
const syntax = collectUnexpectedApiArgs(args, {
maxPositionals: 0,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
console.log(header('CCS API Profiles'));
console.log('');
+54 -11
View File
@@ -1,9 +1,10 @@
import type { TargetType } from '../../targets/target-adapter';
import { fail } from '../../utils/ui';
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor';
export interface ApiCommandArgs {
name?: string;
positionals: string[];
baseUrl?: string;
apiKey?: string;
model?: string;
@@ -26,6 +27,10 @@ export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_
const API_VALUE_FLAG_SET = new Set<string>(API_VALUE_FLAGS);
export interface ParseApiCommandArgsOptions {
maxPositionals?: number;
}
export function sanitizeHelpText(value: string): string {
return value
.replace(/[\r\n\t]+/g, ' ')
@@ -38,13 +43,14 @@ function applyRepeatedOption(
args: string[],
flags: readonly string[],
onValue: (value: string) => void,
onMissing: () => void
onMissing: () => void,
allowDashValue = false
): string[] {
let remaining = [...args];
while (true) {
const extracted = extractOption(remaining, flags, {
allowDashValue: true,
allowDashValue,
knownFlags: API_KNOWN_FLAGS,
});
if (!extracted.found) {
@@ -100,7 +106,6 @@ export function parseOptionalTargetFlag(
knownFlags: readonly string[]
): { target?: TargetType; remainingArgs: string[]; errors: string[] } {
const extracted = extractOption(args, ['--target'], {
allowDashValue: true,
knownFlags,
});
if (!extracted.found) {
@@ -121,8 +126,35 @@ export function parseOptionalTargetFlag(
return { target, remainingArgs: extracted.remainingArgs, errors: [] };
}
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
export function collectUnexpectedApiArgs(
args: string[],
options: {
knownFlags?: readonly string[];
maxPositionals: number;
}
): { positionals: string[]; errors: string[] } {
const scanned = scanCommandArgs(args, {
knownFlags: options.knownFlags ?? [],
});
const errors = scanned.unknownFlags.map((flag) => `Unknown option: ${flag}`);
if (scanned.positionals.length > options.maxPositionals) {
errors.push(
`Unexpected arguments: ${scanned.positionals.slice(options.maxPositionals).join(' ')}`
);
}
return {
positionals: scanned.positionals,
errors,
};
}
export function parseApiCommandArgs(
args: string[],
options: ParseApiCommandArgsOptions = {}
): ApiCommandArgs {
const result: ApiCommandArgs = {
positionals: [],
force: hasAnyFlag(args, ['--force']),
yes: hasAnyFlag(args, ['--yes', '-y']),
errors: [],
@@ -138,7 +170,8 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
},
() => {
result.errors.push('Missing value for --base-url');
}
},
false
);
remaining = applyRepeatedOption(
@@ -149,7 +182,8 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
},
() => {
result.errors.push('Missing value for --api-key');
}
},
false
);
remaining = applyRepeatedOption(
@@ -160,7 +194,8 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
},
() => {
result.errors.push('Missing value for --model');
}
},
true
);
remaining = applyRepeatedOption(
@@ -171,7 +206,8 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
},
() => {
result.errors.push('Missing value for --preset');
}
},
false
);
remaining = applyRepeatedOption(
@@ -187,10 +223,17 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
},
() => {
result.errors.push('Missing value for --target');
}
},
false
);
result.name = extractPositionalArgs(remaining)[0];
const unexpected = collectUnexpectedApiArgs(remaining, {
knownFlags: API_BOOLEAN_FLAGS,
maxPositionals: options.maxPositionals ?? 1,
});
result.positionals = unexpected.positionals;
result.name = unexpected.positionals[0];
result.errors.push(...unexpected.errors);
return result;
}
+80 -1
View File
@@ -15,6 +15,11 @@ export interface ExtractOptionOptions {
* Useful for model IDs or other arbitrary strings.
*/
allowDashValue?: boolean;
/**
* Allow values that start with "--" when allowDashValue is enabled.
* Keep this opt-in narrow so unknown long flags are still rejected by default.
*/
allowLongDashValue?: boolean;
/**
* Known flags for the current command. Used with allowDashValue to avoid
* treating a real flag token as a value.
@@ -22,6 +27,17 @@ export interface ExtractOptionOptions {
knownFlags?: readonly string[];
}
export interface ScanCommandArgsOptions {
knownFlags: readonly string[];
valueFlags?: readonly string[];
allowDashValue?: boolean;
}
export interface ScannedCommandArgs {
positionals: string[];
unknownFlags: string[];
}
function findInlineOption(arg: string, flag: string): string | undefined {
const prefix = `${flag}=`;
return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
@@ -35,6 +51,17 @@ function isKnownFlagToken(token: string, knownFlags: readonly string[] | undefin
return knownFlags.some((flag) => token === flag || token.startsWith(`${flag}=`));
}
function findMatchingFlagToken(
token: string,
knownFlags: readonly string[] | undefined
): string | undefined {
if (!knownFlags || knownFlags.length === 0) {
return undefined;
}
return knownFlags.find((flag) => token === flag || token.startsWith(`${flag}=`));
}
/**
* Extract a single-value option and remove it from args.
* Supports `--flag value` and `--flag=value` forms.
@@ -46,6 +73,7 @@ export function extractOption(
): ExtractedOption {
const remaining = [...args];
const allowDashValue = options.allowDashValue ?? false;
const allowLongDashValue = options.allowLongDashValue ?? false;
for (let i = 0; i < remaining.length; i++) {
const token = remaining[i];
@@ -59,8 +87,11 @@ export function extractOption(
}
const nextLooksLikeFlag = next.startsWith('-');
const nextLooksLikeLongFlag = next.startsWith('--');
const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags);
if (nextLooksLikeFlag && (!allowDashValue || nextIsKnownFlag)) {
const canTreatAsDashValue =
allowDashValue && !nextIsKnownFlag && (!nextLooksLikeLongFlag || allowLongDashValue);
if (nextLooksLikeFlag && !canTreatAsDashValue) {
remaining.splice(i, 1);
return { found: true, missingValue: true, remainingArgs: remaining };
}
@@ -112,3 +143,51 @@ export function hasAnyFlag(args: string[], flags: readonly string[]): boolean {
})
);
}
export function scanCommandArgs(
args: string[],
options: ScanCommandArgsOptions
): ScannedCommandArgs {
const positionals: string[] = [];
const unknownFlags: string[] = [];
const allowDashValue = options.allowDashValue ?? false;
const valueFlags = new Set(options.valueFlags ?? []);
for (let i = 0; i < args.length; i++) {
const token = args[i];
if (token === '--') {
positionals.push(...args.slice(i + 1));
break;
}
if (token === '-' || !token.startsWith('-')) {
positionals.push(token);
continue;
}
const matchedFlag = findMatchingFlagToken(token, options.knownFlags);
if (!matchedFlag) {
unknownFlags.push(token);
continue;
}
if (!valueFlags.has(matchedFlag) || token.includes('=')) {
continue;
}
const next = args[i + 1];
if (!next) {
continue;
}
const nextLooksLikeFlag = next.startsWith('-');
const nextLooksLikeLongFlag = next.startsWith('--');
const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags);
if (!nextLooksLikeFlag || (allowDashValue && !nextIsKnownFlag && !nextLooksLikeLongFlag)) {
i++;
}
}
return { positionals, unknownFlags };
}
+31 -19
View File
@@ -15,26 +15,38 @@ import { handleSetup } from './setup-command';
import { handleShow } from './show-command';
import { handleDisable } from './disable-command';
async function ensureNoConfigAuthArgs(command: string, args: string[]): Promise<void> {
if (args.length === 0) {
return;
}
await initUI();
console.log(fail(`Unexpected arguments for "config auth ${command}": ${args.join(' ')}`));
console.log('');
console.log('Run for help:');
console.log(` ${color('ccs config auth --help', 'command')}`);
process.exit(1);
}
function createZeroArgConfigAuthRoute(
name: string,
handler: () => Promise<unknown>,
aliases?: readonly string[]
): NamedCommandRoute {
return {
name,
aliases,
handle: async (args) => {
await ensureNoConfigAuthArgs(name, args);
await handler();
},
};
}
const CONFIG_AUTH_ROUTES: readonly NamedCommandRoute[] = [
{
name: 'setup',
handle: async () => {
await handleSetup();
},
},
{
name: 'show',
aliases: ['status'],
handle: async () => {
await handleShow();
},
},
{
name: 'disable',
handle: async () => {
await handleDisable();
},
},
createZeroArgConfigAuthRoute('setup', handleSetup),
createZeroArgConfigAuthRoute('show', handleShow, ['status']),
createZeroArgConfigAuthRoute('disable', handleDisable),
];
/**
+17 -1
View File
@@ -1,4 +1,4 @@
import { extractOption, hasAnyFlag } from './arg-extractor';
import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor';
const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const;
@@ -15,6 +15,10 @@ export interface ConfigCommandParseResult {
options: ConfigCommandOptions;
}
function formatUnexpectedArgsError(tokens: string[]): string {
return `Unexpected arguments: ${tokens.join(' ')}`;
}
export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult {
const options: ConfigCommandOptions = {
hostProvided: false,
@@ -56,6 +60,18 @@ export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult
options.dev = hasAnyFlag(hostOption.remainingArgs, ['--dev']);
const unexpected = scanCommandArgs(hostOption.remainingArgs, {
knownFlags: ['--dev'],
});
const unexpectedTokens = [...unexpected.unknownFlags, ...unexpected.positionals];
if (unexpectedTokens.length > 0) {
return {
help: false,
error: formatUnexpectedArgsError(unexpectedTokens),
options,
};
}
return { help: false, options };
}
+6
View File
@@ -51,6 +51,12 @@ const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
* Handle config command
*/
export async function handleConfigCommand(args: string[]): Promise<void> {
if (args.length === 1 && args[0] === 'help') {
await initUI();
showConfigCommandHelp();
process.exit(0);
}
const subcommand = args[0]?.startsWith('-')
? undefined
: resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES);
+68 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { parseApiCommandArgs } from '../../../src/commands/api-command';
import {
collectUnexpectedApiArgs,
parseApiCommandArgs,
} from '../../../src/commands/api-command/shared';
describe('api-command arg parser', () => {
test('keeps positional API name when boolean flags precede it', () => {
@@ -56,7 +59,9 @@ describe('api-command arg parser', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']);
expect(parsed.errors).toEqual([
'Invalid --target value "invalid-target". Use: claude or droid',
]);
});
test('collects missing-value error for --target with no value', () => {
@@ -80,4 +85,65 @@ describe('api-command arg parser', () => {
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('collects unknown options and unexpected trailing positionals', () => {
const parsed = parseApiCommandArgs(['my-api', '--taret', 'droid', '--yes']);
expect(parsed.name).toBe('my-api');
expect(parsed.errors).toEqual(['Unknown option: --taret', 'Unexpected arguments: droid']);
});
test('rejects extra positionals for single-name commands by default', () => {
const parsed = parseApiCommandArgs(['source', 'destination', '--yes']);
expect(parsed.positionals).toEqual(['source', 'destination']);
expect(parsed.errors).toEqual(['Unexpected arguments: destination']);
});
test('allows copy-style two-positional parsing when requested', () => {
const parsed = parseApiCommandArgs(['source', 'destination', '--yes'], {
maxPositionals: 2,
});
expect(parsed.positionals).toEqual(['source', 'destination']);
expect(parsed.errors).toEqual([]);
});
test('preserves dash-prefixed names after option terminator', () => {
const parsed = parseApiCommandArgs(['--yes', '--', '-my-api', 'backup'], {
maxPositionals: 2,
});
expect(parsed.positionals).toEqual(['-my-api', 'backup']);
expect(parsed.errors).toEqual([]);
});
test('accepts single-dash model values without treating them as unknown flags', () => {
const parsed = parseApiCommandArgs(['my-api', '--model', '-preview']);
expect(parsed.model).toBe('-preview');
expect(parsed.errors).toEqual([]);
});
});
describe('collectUnexpectedApiArgs', () => {
test('rejects extra args after a no-arg command', () => {
const parsed = collectUnexpectedApiArgs(['--register', 'extra'], {
knownFlags: ['--register'],
maxPositionals: 0,
});
expect(parsed.positionals).toEqual(['extra']);
expect(parsed.errors).toEqual(['Unexpected arguments: extra']);
});
test('reports unknown flags separately from leftover positionals', () => {
const parsed = collectUnexpectedApiArgs(['--bogus', 'value', '--yes'], {
knownFlags: ['--yes'],
maxPositionals: 0,
});
expect(parsed.positionals).toEqual(['value']);
expect(parsed.errors).toEqual(['Unknown option: --bogus', 'Unexpected arguments: value']);
});
});
+26 -2
View File
@@ -21,8 +21,8 @@ beforeEach(() => {
}));
mock.module('../../../src/commands/api-command/list-command', () => ({
handleApiListCommand: async () => {
calls.push('list');
handleApiListCommand: async (args: string[]) => {
calls.push(`list:${args.join(' ')}`);
},
}));
@@ -83,6 +83,30 @@ describe('api-command router', () => {
expect(calls).toEqual(['remove:profile-a']);
});
it('forwards list arguments to the handler for validation', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand(['list', 'unexpected']);
expect(calls).toEqual(['list:unexpected']);
});
it('routes hardened subcommands through their handlers', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand(['discover', '--json']);
await handleApiCommand(['copy', 'source', 'dest']);
await handleApiCommand(['export', 'profile-a', '--out', 'backup.json']);
await handleApiCommand(['import', 'bundle.json', '--force']);
expect(calls).toEqual([
'discover:--json',
'copy:source dest',
'export:profile-a --out backup.json',
'import:bundle.json --force',
]);
});
it('delegates unknown commands to the shared unknown handler', async () => {
const handleApiCommand = await loadHandleApiCommand();
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
let tempDir = '';
let originalCwd = '';
let originalConsoleLog: typeof console.log;
let logLines: string[] = [];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-'));
originalCwd = process.cwd();
process.chdir(tempDir);
logLines = [];
originalConsoleLog = console.log;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
subheader: (message: string) => message,
color: (message: string) => message,
dim: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/api/services', () => ({
exportApiProfile: () => ({
success: true,
redacted: false,
bundle: {
profile: { name: 'profile-a' },
},
}),
}));
});
afterEach(() => {
console.log = originalConsoleLog;
process.chdir(originalCwd);
rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function loadHandleApiExportCommand() {
const mod = await import(
`../../../src/commands/api-command/export-command?test=${Date.now()}-${Math.random()}`
);
return mod.handleApiExportCommand;
}
describe('api export command', () => {
it('accepts dash-prefixed output paths', async () => {
const handleApiExportCommand = await loadHandleApiExportCommand();
await handleApiExportCommand(['profile-a', '--out', '--snapshot.json']);
const outputPath = resolve(process.cwd(), '--snapshot.json');
expect(existsSync(outputPath)).toBe(true);
expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"');
expect(logLines.join('\n')).toContain(`Profile exported to: ${outputPath}`);
});
});
+88 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { extractOption, hasAnyFlag } from '../../../src/commands/arg-extractor';
import { extractOption, hasAnyFlag, scanCommandArgs } from '../../../src/commands/arg-extractor';
describe('arg-extractor', () => {
describe('extractOption', () => {
@@ -70,6 +70,21 @@ describe('arg-extractor', () => {
});
});
it('accepts long dash-prefixed values when allowLongDashValue is enabled', () => {
const result = extractOption(['--out', '--snapshot.json', '--yes'], ['--out'], {
allowDashValue: true,
allowLongDashValue: true,
knownFlags: ['--out', '--yes'],
});
expect(result).toEqual({
found: true,
value: '--snapshot.json',
missingValue: false,
remainingArgs: ['--yes'],
});
});
it('still treats known flags as missing when allowDashValue is enabled', () => {
const result = extractOption(['--model', '--yes', 'prompt'], ['--model'], {
allowDashValue: true,
@@ -83,6 +98,20 @@ describe('arg-extractor', () => {
});
});
it('still treats known long flags as missing when allowLongDashValue is enabled', () => {
const result = extractOption(['--out', '--yes', 'prompt'], ['--out'], {
allowDashValue: true,
allowLongDashValue: true,
knownFlags: ['--out', '--yes'],
});
expect(result).toEqual({
found: true,
missingValue: true,
remainingArgs: ['--yes', 'prompt'],
});
});
it('supports repeated extraction loops with deterministic last-value wins behavior', () => {
let remaining = ['--model', 'gpt-4.1-mini', '--model', 'gpt-4.1'];
let selected: string | undefined;
@@ -133,4 +162,62 @@ describe('arg-extractor', () => {
expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false);
});
});
describe('scanCommandArgs', () => {
it('collects positionals and unknown flags while ignoring known boolean flags', () => {
const result = scanCommandArgs(['profile-a', '--yes', '--bogus', 'extra'], {
knownFlags: ['--yes'],
});
expect(result).toEqual({
positionals: ['profile-a', 'extra'],
unknownFlags: ['--bogus'],
});
});
it('skips values for known value flags', () => {
const result = scanCommandArgs(['--model', 'claude-3-7-sonnet', 'profile-a', '--force'], {
knownFlags: ['--model', '--force'],
valueFlags: ['--model'],
});
expect(result).toEqual({
positionals: ['profile-a'],
unknownFlags: [],
});
});
it('preserves option-terminator positionals', () => {
const result = scanCommandArgs(['--yes', '--', '--literal-name'], {
knownFlags: ['--yes'],
});
expect(result).toEqual({
positionals: ['--literal-name'],
unknownFlags: [],
});
});
it('consumes single-dash values but not long-flag lookalikes when allowDashValue is enabled', () => {
const singleDashValue = scanCommandArgs(['--model', '-preview', '--yes'], {
knownFlags: ['--model', '--yes'],
valueFlags: ['--model'],
allowDashValue: true,
});
const longFlagLookalike = scanCommandArgs(['--model', '--target', 'droid'], {
knownFlags: ['--model', '--target'],
valueFlags: ['--model'],
allowDashValue: true,
});
expect(singleDashValue).toEqual({
positionals: [],
unknownFlags: [],
});
expect(longFlagLookalike).toEqual({
positionals: ['droid'],
unknownFlags: [],
});
});
});
});
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
let calls: string[] = [];
let logLines: string[] = [];
let originalConsoleLog: typeof console.log;
let originalProcessExit: typeof process.exit;
beforeEach(() => {
calls = [];
logLines = [];
originalConsoleLog = console.log;
originalProcessExit = process.exit;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
subheader: (message: string) => message,
color: (message: string) => message,
dim: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/commands/config-auth/setup-command', () => ({
handleSetup: async () => {
calls.push('setup');
},
}));
mock.module('../../../src/commands/config-auth/show-command', () => ({
handleShow: async () => {
calls.push('show');
},
}));
mock.module('../../../src/commands/config-auth/disable-command', () => ({
handleDisable: async () => {
calls.push('disable');
},
}));
});
afterEach(() => {
console.log = originalConsoleLog;
process.exit = originalProcessExit;
mock.restore();
});
async function loadHandleConfigAuthCommand() {
const mod = await import(
`../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}`
);
return mod.handleConfigAuthCommand;
}
describe('config-auth command routing', () => {
it('routes the status alias to show', async () => {
const handleConfigAuthCommand = await loadHandleConfigAuthCommand();
await handleConfigAuthCommand(['status']);
expect(calls).toEqual(['show']);
});
it('keeps auth help available', async () => {
const handleConfigAuthCommand = await loadHandleConfigAuthCommand();
await handleConfigAuthCommand(['--help']);
expect(calls).toEqual([]);
expect(logLines.join('\n')).toContain('Dashboard Auth Management');
});
it('rejects trailing arguments for zero-arg subcommands', async () => {
const handleConfigAuthCommand = await loadHandleConfigAuthCommand();
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigAuthCommand(['disable', 'now'])).rejects.toThrow('process.exit(1)');
expect(calls).toEqual([]);
expect(logLines.join('\n')).toContain('Unexpected arguments for "config auth disable": now');
});
});
@@ -35,4 +35,16 @@ describe('config command options parser', () => {
expect(accepted.options.port).toBe(65535);
expect(rejected.error).toBe('Invalid port number');
});
it('rejects unknown options', () => {
const result = parseConfigCommandArgs(['--hst', '0.0.0.0']);
expect(result.error).toBe('Unexpected arguments: --hst 0.0.0.0');
});
it('rejects unexpected trailing positionals', () => {
const result = parseConfigCommandArgs(['--port', '3000', 'extra']);
expect(result.error).toBe('Unexpected arguments: extra');
});
});
+30 -2
View File
@@ -80,14 +80,16 @@ beforeEach(() => {
}),
}));
mock.module('../../../src/utils/ui', () => ({
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
}));
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/commands/config-dashboard-host', () => ({
normalizeDashboardHost: (host: string | undefined) => {
@@ -147,6 +149,19 @@ async function loadHandleConfigCommand() {
}
describe('config command dashboard startup', () => {
it('shows help for literal help token instead of starting the dashboard', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)');
expect(startServerCalls).toHaveLength(0);
expect(resolveDashboardUrlsCalls).toHaveLength(0);
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
});
it('routes auth subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
@@ -157,6 +172,19 @@ describe('config command dashboard startup', () => {
expect(resolveDashboardUrlsCalls).toHaveLength(0);
});
it('rejects unknown config subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)');
expect(startServerCalls).toHaveLength(0);
expect(resolveDashboardUrlsCalls).toHaveLength(0);
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
});
it('keeps the default startup path free of an explicit host override', async () => {
const handleConfigCommand = await loadHandleConfigCommand();