mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(commands): follow up command routing hardening regressions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './api-command/index';
|
||||
@@ -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>'));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>]')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user