mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
refactor(commands): add command contract and migrate shell completion
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Command Execution Contract
|
||||
*
|
||||
* Standardized lifecycle: parse -> validate -> execute -> render
|
||||
* for CLI command handlers.
|
||||
*/
|
||||
|
||||
export interface CommandExecutionContract<TParsedArgs, TExecutionResult> {
|
||||
parse(rawArgs: string[]): TParsedArgs;
|
||||
validate(parsedArgs: TParsedArgs): void;
|
||||
execute(parsedArgs: TParsedArgs): Promise<TExecutionResult> | TExecutionResult;
|
||||
render(
|
||||
result: TExecutionResult,
|
||||
context: { rawArgs: string[]; parsedArgs: TParsedArgs }
|
||||
): Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command through the standard lifecycle.
|
||||
*/
|
||||
export async function runCommandWithContract<TParsedArgs, TExecutionResult>(
|
||||
rawArgs: string[],
|
||||
contract: CommandExecutionContract<TParsedArgs, TExecutionResult>
|
||||
): Promise<{ parsedArgs: TParsedArgs; result: TExecutionResult }> {
|
||||
const parsedArgs = contract.parse(rawArgs);
|
||||
contract.validate(parsedArgs);
|
||||
const result = await contract.execute(parsedArgs);
|
||||
await contract.render(result, { rawArgs, parsedArgs });
|
||||
return { parsedArgs, result };
|
||||
}
|
||||
@@ -5,6 +5,75 @@
|
||||
*/
|
||||
|
||||
import { initUI, header, ok, fail, color } from '../utils/ui';
|
||||
import {
|
||||
runCommandWithContract,
|
||||
type CommandExecutionContract,
|
||||
} from './command-execution-contract';
|
||||
|
||||
type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
|
||||
|
||||
interface ShellCompletionParsedArgs {
|
||||
targetShell: ShellTarget;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
interface ShellCompletionInstallResult {
|
||||
success: boolean;
|
||||
alreadyInstalled?: boolean;
|
||||
message?: string;
|
||||
reload?: string;
|
||||
}
|
||||
|
||||
interface ShellCompletionInstallerLike {
|
||||
install(
|
||||
shell: ShellTarget,
|
||||
options: { force: boolean }
|
||||
): ShellCompletionInstallResult;
|
||||
}
|
||||
|
||||
export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs {
|
||||
let targetShell: ShellTarget = null;
|
||||
const force = args.includes('--force') || args.includes('-f');
|
||||
|
||||
if (args.includes('--bash')) targetShell = 'bash';
|
||||
else if (args.includes('--zsh')) targetShell = 'zsh';
|
||||
else if (args.includes('--fish')) targetShell = 'fish';
|
||||
else if (args.includes('--powershell')) targetShell = 'powershell';
|
||||
|
||||
return { targetShell, force };
|
||||
}
|
||||
|
||||
export function createShellCompletionCommandContract(
|
||||
installer: ShellCompletionInstallerLike
|
||||
): CommandExecutionContract<ShellCompletionParsedArgs, ShellCompletionInstallResult> {
|
||||
return {
|
||||
parse: parseShellCompletionArgs,
|
||||
validate: () => {
|
||||
// No validation at this stage to preserve existing behavior exactly.
|
||||
},
|
||||
execute: (parsed) => installer.install(parsed.targetShell, { force: parsed.force }),
|
||||
render: (result, context) => {
|
||||
if (result.alreadyInstalled && !context.parsedArgs.force) {
|
||||
console.log(ok('Shell completion already installed'));
|
||||
console.log(` Use ${color('--force', 'warning')} to reinstall`);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(ok('Shell completion installed successfully!'));
|
||||
console.log('');
|
||||
console.log(result.message);
|
||||
console.log('');
|
||||
console.log(color('To activate:', 'info'));
|
||||
console.log(` ${result.reload}`);
|
||||
console.log('');
|
||||
console.log(color('Then test:', 'info'));
|
||||
console.log(' ccs <TAB> # See available profiles');
|
||||
console.log(' ccs auth <TAB> # See auth subcommands');
|
||||
console.log('');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle shell completion command
|
||||
@@ -16,38 +85,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise<void
|
||||
console.log(header('Shell Completion Installer'));
|
||||
console.log('');
|
||||
|
||||
// Parse flags
|
||||
let targetShell: string | null = null;
|
||||
const force = args.includes('--force') || args.includes('-f');
|
||||
if (args.includes('--bash')) targetShell = 'bash';
|
||||
else if (args.includes('--zsh')) targetShell = 'zsh';
|
||||
else if (args.includes('--fish')) targetShell = 'fish';
|
||||
else if (args.includes('--powershell')) targetShell = 'powershell';
|
||||
|
||||
try {
|
||||
const installer = new ShellCompletionInstaller();
|
||||
const result = installer.install(targetShell as 'bash' | 'zsh' | 'fish' | 'powershell' | null, {
|
||||
force,
|
||||
});
|
||||
|
||||
if (result.alreadyInstalled && !force) {
|
||||
console.log(ok('Shell completion already installed'));
|
||||
console.log(` Use ${color('--force', 'warning')} to reinstall`);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(ok('Shell completion installed successfully!'));
|
||||
console.log('');
|
||||
console.log(result.message);
|
||||
console.log('');
|
||||
console.log(color('To activate:', 'info'));
|
||||
console.log(` ${result.reload}`);
|
||||
console.log('');
|
||||
console.log(color('Then test:', 'info'));
|
||||
console.log(' ccs <TAB> # See available profiles');
|
||||
console.log(' ccs auth <TAB> # See auth subcommands');
|
||||
console.log('');
|
||||
const contract = createShellCompletionCommandContract(installer);
|
||||
await runCommandWithContract(args, contract);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(fail(`Error: ${err.message}`));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { runCommandWithContract } from '../../../src/commands/command-execution-contract';
|
||||
|
||||
describe('runCommandWithContract', () => {
|
||||
it('runs parse -> validate -> execute -> render in order', async () => {
|
||||
const lifecycle: string[] = [];
|
||||
|
||||
const result = await runCommandWithContract(['--flag'], {
|
||||
parse: (rawArgs) => {
|
||||
lifecycle.push('parse');
|
||||
expect(rawArgs).toEqual(['--flag']);
|
||||
return { parsed: true, value: rawArgs[0] };
|
||||
},
|
||||
validate: (parsedArgs) => {
|
||||
lifecycle.push('validate');
|
||||
expect(parsedArgs).toEqual({ parsed: true, value: '--flag' });
|
||||
},
|
||||
execute: async (parsedArgs) => {
|
||||
lifecycle.push('execute');
|
||||
return { output: parsedArgs.value.toUpperCase() };
|
||||
},
|
||||
render: (executionResult, context) => {
|
||||
lifecycle.push('render');
|
||||
expect(executionResult).toEqual({ output: '--FLAG' });
|
||||
expect(context.rawArgs).toEqual(['--flag']);
|
||||
expect(context.parsedArgs).toEqual({ parsed: true, value: '--flag' });
|
||||
},
|
||||
});
|
||||
|
||||
expect(lifecycle).toEqual(['parse', 'validate', 'execute', 'render']);
|
||||
expect(result.parsedArgs).toEqual({ parsed: true, value: '--flag' });
|
||||
expect(result.result).toEqual({ output: '--FLAG' });
|
||||
});
|
||||
|
||||
it('short-circuits after validate failure', async () => {
|
||||
const lifecycle: string[] = [];
|
||||
|
||||
const promise = runCommandWithContract([], {
|
||||
parse: () => {
|
||||
lifecycle.push('parse');
|
||||
return { valid: false };
|
||||
},
|
||||
validate: () => {
|
||||
lifecycle.push('validate');
|
||||
throw new Error('validation failed');
|
||||
},
|
||||
execute: () => {
|
||||
lifecycle.push('execute');
|
||||
return { ok: true };
|
||||
},
|
||||
render: () => {
|
||||
lifecycle.push('render');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('validation failed');
|
||||
expect(lifecycle).toEqual(['parse', 'validate']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, mock } from 'bun:test';
|
||||
|
||||
type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
|
||||
|
||||
interface InstallResult {
|
||||
success: boolean;
|
||||
alreadyInstalled?: boolean;
|
||||
message?: string;
|
||||
reload?: string;
|
||||
}
|
||||
|
||||
interface InstallCall {
|
||||
shell: ShellTarget;
|
||||
options: { force: boolean };
|
||||
}
|
||||
|
||||
const installCalls: InstallCall[] = [];
|
||||
let installResult: InstallResult = {
|
||||
success: true,
|
||||
message: 'Added to ~/.zshrc',
|
||||
reload: 'source ~/.zshrc',
|
||||
};
|
||||
let installError: Error | null = null;
|
||||
|
||||
mock.module('../../../src/utils/shell-completion', () => ({
|
||||
ShellCompletionInstaller: class {
|
||||
install(shell: ShellTarget, options: { force: boolean }): InstallResult {
|
||||
installCalls.push({ shell, options });
|
||||
if (installError) {
|
||||
throw installError;
|
||||
}
|
||||
return installResult;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/utils/ui', () => ({
|
||||
initUI: async () => {},
|
||||
header: (value: string) => value,
|
||||
ok: (value: string) => value,
|
||||
fail: (value: string) => value,
|
||||
color: (value: string) => value,
|
||||
}));
|
||||
|
||||
let handleShellCompletionCommand: (args: string[]) => Promise<void>;
|
||||
let parseShellCompletionArgs: (args: string[]) => { targetShell: ShellTarget; force: boolean };
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let originalConsoleError: typeof console.error;
|
||||
let originalProcessExit: typeof process.exit;
|
||||
let logLines: string[] = [];
|
||||
let errorLines: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../../../src/commands/shell-completion-command');
|
||||
handleShellCompletionCommand = mod.handleShellCompletionCommand;
|
||||
parseShellCompletionArgs = mod.parseShellCompletionArgs;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installCalls.length = 0;
|
||||
installError = null;
|
||||
installResult = {
|
||||
success: true,
|
||||
message: 'Added to ~/.zshrc',
|
||||
reload: 'source ~/.zshrc',
|
||||
};
|
||||
|
||||
logLines = [];
|
||||
errorLines = [];
|
||||
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleError = console.error;
|
||||
originalProcessExit = process.exit;
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
logLines.push(args.map(String).join(' '));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errorLines.push(args.map(String).join(' '));
|
||||
};
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
process.exit = originalProcessExit;
|
||||
});
|
||||
|
||||
describe('shell-completion command', () => {
|
||||
it('parses shell flags and force flag', () => {
|
||||
const parsed = parseShellCompletionArgs(['--zsh', '--force']);
|
||||
expect(parsed).toEqual({ targetShell: 'zsh', force: true });
|
||||
});
|
||||
|
||||
it('preserves existing priority when multiple shell flags are present', () => {
|
||||
const parsed = parseShellCompletionArgs(['--zsh', '--bash']);
|
||||
expect(parsed).toEqual({ targetShell: 'bash', force: false });
|
||||
});
|
||||
|
||||
it('executes installer with parsed args and renders success output', async () => {
|
||||
await handleShellCompletionCommand(['--zsh', '--force']);
|
||||
|
||||
expect(installCalls).toHaveLength(1);
|
||||
expect(installCalls[0]).toEqual({
|
||||
shell: 'zsh',
|
||||
options: { force: true },
|
||||
});
|
||||
|
||||
expect(logLines.some((line) => line.includes('Shell completion installed successfully!'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(logLines.some((line) => line.includes('source ~/.zshrc'))).toBe(true);
|
||||
});
|
||||
|
||||
it('renders already-installed output without forcing reinstall', async () => {
|
||||
installResult = {
|
||||
success: true,
|
||||
alreadyInstalled: true,
|
||||
message: 'Updated completion files',
|
||||
reload: 'source ~/.zshrc',
|
||||
};
|
||||
|
||||
await handleShellCompletionCommand(['--zsh']);
|
||||
|
||||
expect(logLines.some((line) => line.includes('Shell completion already installed'))).toBe(true);
|
||||
expect(logLines.some((line) => line.includes('Use --force to reinstall'))).toBe(true);
|
||||
expect(logLines.some((line) => line.includes('installed successfully!'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prints usage and exits with code 1 on installer error', async () => {
|
||||
installError = new Error('boom');
|
||||
|
||||
await expect(handleShellCompletionCommand([])).rejects.toThrow('process.exit(1)');
|
||||
expect(errorLines.some((line) => line.includes('Error: boom'))).toBe(true);
|
||||
expect(errorLines.some((line) => line.includes('ccs --shell-completion --zsh'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user