diff --git a/package.json b/package.json index 02e431c6..96a06db1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts new file mode 100644 index 00000000..6aa43a58 --- /dev/null +++ b/src/commands/api-command.ts @@ -0,0 +1 @@ +export * from './api-command/index'; diff --git a/src/commands/api-command/copy-command.ts b/src/commands/api-command/copy-command.ts index 512d56ab..b1be7d44 100644 --- a/src/commands/api-command/copy-command.ts +++ b/src/commands/api-command/copy-command.ts @@ -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 { 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 ')); diff --git a/src/commands/api-command/discover-command.ts b/src/commands/api-command/discover-command.ts index 2d6cdb9a..4b9c2f4d 100644 --- a/src/commands/api-command/discover-command.ts +++ b/src/commands/api-command/discover-command.ts @@ -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 { await initUI(); @@ -15,6 +15,15 @@ export async function handleApiDiscoverCommand(args: string[]): Promise { 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)); diff --git a/src/commands/api-command/export-command.ts b/src/commands/api-command/export-command.ts index 35fd518e..8ce79789 100644 --- a/src/commands/api-command/export-command.ts +++ b/src/commands/api-command/export-command.ts @@ -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 { await initUI(); @@ -11,14 +11,24 @@ export async function handleApiExportCommand(args: string[]): Promise { 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 [--out ]')); process.exit(1); diff --git a/src/commands/api-command/import-command.ts b/src/commands/api-command/import-command.ts index ed319cdc..cac4e949 100644 --- a/src/commands/api-command/import-command.ts +++ b/src/commands/api-command/import-command.ts @@ -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 { 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 { } 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 [--name ]') diff --git a/src/commands/api-command/index.ts b/src/commands/api-command/index.ts index 43bd0ec5..79e6b560 100644 --- a/src/commands/api-command/index.ts +++ b/src/commands/api-command/index.ts @@ -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 }, diff --git a/src/commands/api-command/list-command.ts b/src/commands/api-command/list-command.ts index c308c410..4f805d87 100644 --- a/src/commands/api-command/list-command.ts +++ b/src/commands/api-command/list-command.ts @@ -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 { +export async function handleApiListCommand(args: string[] = []): Promise { 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(''); diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index 0fffe582..d01a3107 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -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(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; } diff --git a/src/commands/arg-extractor.ts b/src/commands/arg-extractor.ts index 6c2709c3..26f7c5d9 100644 --- a/src/commands/arg-extractor.ts +++ b/src/commands/arg-extractor.ts @@ -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 }; +} diff --git a/src/commands/config-auth/index.ts b/src/commands/config-auth/index.ts index 4fb7b70f..bf1140cb 100644 --- a/src/commands/config-auth/index.ts +++ b/src/commands/config-auth/index.ts @@ -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 { + 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, + 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), ]; /** diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index 100f7076..1e19e5db 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -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 }; } diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 4296e8e1..1965ce86 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -51,6 +51,12 @@ const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ * Handle config command */ export async function handleConfigCommand(args: string[]): Promise { + 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); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index b73e1e08..3ee6916b 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -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']); + }); }); diff --git a/tests/unit/commands/api-command-router.test.ts b/tests/unit/commands/api-command-router.test.ts index 3fa54905..25d33812 100644 --- a/tests/unit/commands/api-command-router.test.ts +++ b/tests/unit/commands/api-command-router.test.ts @@ -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(); diff --git a/tests/unit/commands/api-export-command.test.ts b/tests/unit/commands/api-export-command.test.ts new file mode 100644 index 00000000..7538534b --- /dev/null +++ b/tests/unit/commands/api-export-command.test.ts @@ -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}`); + }); +}); diff --git a/tests/unit/commands/arg-extractor.test.ts b/tests/unit/commands/arg-extractor.test.ts index b65d6862..8ade4e18 100644 --- a/tests/unit/commands/arg-extractor.test.ts +++ b/tests/unit/commands/arg-extractor.test.ts @@ -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: [], + }); + }); + }); }); diff --git a/tests/unit/commands/config-auth-command.test.ts b/tests/unit/commands/config-auth-command.test.ts new file mode 100644 index 00000000..3f3182a7 --- /dev/null +++ b/tests/unit/commands/config-auth-command.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/commands/config-command-options.test.ts b/tests/unit/commands/config-command-options.test.ts index 9c8d94ff..abf95b1b 100644 --- a/tests/unit/commands/config-command-options.test.ts +++ b/tests/unit/commands/config-command-options.test.ts @@ -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'); + }); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 11c7bb14..27c5b44c 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -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();