From fc4b77bc520da688af6e26add33fc7b25ff4d5c0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 12:59:17 +0700 Subject: [PATCH 1/5] refactor(commands): add command contract and migrate shell completion --- src/commands/command-execution-contract.ts | 30 ++++ src/commands/shell-completion-command.ts | 101 +++++++++---- .../command-execution-contract.test.ts | 59 ++++++++ .../commands/shell-completion-command.test.ts | 140 ++++++++++++++++++ 4 files changed, 300 insertions(+), 30 deletions(-) create mode 100644 src/commands/command-execution-contract.ts create mode 100644 tests/unit/commands/command-execution-contract.test.ts create mode 100644 tests/unit/commands/shell-completion-command.test.ts diff --git a/src/commands/command-execution-contract.ts b/src/commands/command-execution-contract.ts new file mode 100644 index 00000000..f52af12c --- /dev/null +++ b/src/commands/command-execution-contract.ts @@ -0,0 +1,30 @@ +/** + * Command Execution Contract + * + * Standardized lifecycle: parse -> validate -> execute -> render + * for CLI command handlers. + */ + +export interface CommandExecutionContract { + parse(rawArgs: string[]): TParsedArgs; + validate(parsedArgs: TParsedArgs): void; + execute(parsedArgs: TParsedArgs): Promise | TExecutionResult; + render( + result: TExecutionResult, + context: { rawArgs: string[]; parsedArgs: TParsedArgs } + ): Promise | void; +} + +/** + * Run a command through the standard lifecycle. + */ +export async function runCommandWithContract( + rawArgs: string[], + contract: CommandExecutionContract +): 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 }; +} diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index f01de47c..e37be618 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -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 { + 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 # See available profiles'); + console.log(' ccs auth # See auth subcommands'); + console.log(''); + }, + }; +} /** * Handle shell completion command @@ -16,38 +85,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise # See available profiles'); - console.log(' ccs auth # 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}`)); diff --git a/tests/unit/commands/command-execution-contract.test.ts b/tests/unit/commands/command-execution-contract.test.ts new file mode 100644 index 00000000..2e116766 --- /dev/null +++ b/tests/unit/commands/command-execution-contract.test.ts @@ -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']); + }); +}); diff --git a/tests/unit/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts new file mode 100644 index 00000000..81dfe254 --- /dev/null +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -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; +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); + }); +}); From b98335c1620bda79a3e27e5392f5e77f6bdd7f03 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 14:18:48 +0700 Subject: [PATCH 2/5] fix(commands): await async command validation --- src/commands/command-execution-contract.ts | 4 +- .../command-execution-contract.test.ts | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/commands/command-execution-contract.ts b/src/commands/command-execution-contract.ts index f52af12c..3fff0098 100644 --- a/src/commands/command-execution-contract.ts +++ b/src/commands/command-execution-contract.ts @@ -7,7 +7,7 @@ export interface CommandExecutionContract { parse(rawArgs: string[]): TParsedArgs; - validate(parsedArgs: TParsedArgs): void; + validate(parsedArgs: TParsedArgs): void | Promise; execute(parsedArgs: TParsedArgs): Promise | TExecutionResult; render( result: TExecutionResult, @@ -23,7 +23,7 @@ export async function runCommandWithContract( contract: CommandExecutionContract ): Promise<{ parsedArgs: TParsedArgs; result: TExecutionResult }> { const parsedArgs = contract.parse(rawArgs); - contract.validate(parsedArgs); + await contract.validate(parsedArgs); const result = await contract.execute(parsedArgs); await contract.render(result, { rawArgs, parsedArgs }); return { parsedArgs, result }; diff --git a/tests/unit/commands/command-execution-contract.test.ts b/tests/unit/commands/command-execution-contract.test.ts index 2e116766..e9cd4eb6 100644 --- a/tests/unit/commands/command-execution-contract.test.ts +++ b/tests/unit/commands/command-execution-contract.test.ts @@ -56,4 +56,57 @@ describe('runCommandWithContract', () => { await expect(promise).rejects.toThrow('validation failed'); expect(lifecycle).toEqual(['parse', 'validate']); }); + + it('awaits async validate before execute and render', async () => { + const lifecycle: string[] = []; + + const result = await runCommandWithContract(['--flag'], { + parse: (rawArgs) => { + lifecycle.push('parse'); + return { parsed: true, value: rawArgs[0] }; + }, + validate: async () => { + lifecycle.push('validate:start'); + await Promise.resolve(); + lifecycle.push('validate:end'); + }, + execute: (parsedArgs) => { + lifecycle.push('execute'); + return { output: parsedArgs.value.toUpperCase() }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + expect(lifecycle).toEqual(['parse', 'validate:start', 'validate:end', 'execute', 'render']); + expect(result.result).toEqual({ output: '--FLAG' }); + }); + + it('short-circuits after async validate failure', async () => { + const lifecycle: string[] = []; + + const promise = runCommandWithContract([], { + parse: () => { + lifecycle.push('parse'); + return { valid: false }; + }, + validate: async () => { + lifecycle.push('validate:start'); + await Promise.resolve(); + lifecycle.push('validate:reject'); + throw new Error('async validation failed'); + }, + execute: () => { + lifecycle.push('execute'); + return { ok: true }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + await expect(promise).rejects.toThrow('async validation failed'); + expect(lifecycle).toEqual(['parse', 'validate:start', 'validate:reject']); + }); }); From 9585f0664d69e7a2c1c7c5aaebdf8edb66ad0872 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 15:20:09 +0700 Subject: [PATCH 3/5] chore(format): apply prettier fixes for validate gate --- src/commands/shell-completion-command.ts | 5 +---- src/management/checks/image-analysis-check.ts | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index e37be618..65fe4c58 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -25,10 +25,7 @@ interface ShellCompletionInstallResult { } interface ShellCompletionInstallerLike { - install( - shell: ShellTarget, - options: { force: boolean } - ): ShellCompletionInstallResult; + install(shell: ShellTarget, options: { force: boolean }): ShellCompletionInstallResult; } export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs { diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 39a0f7c5..e539bff4 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -94,9 +94,8 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/unified-config-loader' - ); + const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = + await import('../../config/unified-config-loader'); const config = loadOrCreateUnifiedConfig(); let fixed = false; From ae83be159052e064ab639db2bde140326d42769f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 15:22:37 +0700 Subject: [PATCH 4/5] fix(format): align image analysis check with pinned prettier --- src/management/checks/image-analysis-check.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index e539bff4..39a0f7c5 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -94,8 +94,9 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = - await import('../../config/unified-config-loader'); + const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/unified-config-loader' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From 851f870fa8cce523205f547712ad5596e96bf3d6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 15:52:52 +0700 Subject: [PATCH 5/5] fix(test): avoid global ui mock leakage in shell completion tests --- .../commands/shell-completion-command.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/unit/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts index 81dfe254..f9c93a53 100644 --- a/tests/unit/commands/shell-completion-command.test.ts +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -34,13 +34,9 @@ mock.module('../../../src/utils/shell-completion', () => ({ }, })); -mock.module('../../../src/utils/ui', () => ({ - initUI: async () => {}, - header: (value: string) => value, - ok: (value: string) => value, - fail: (value: string) => value, - color: (value: string) => value, -})); +function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*m/g, ''); +} let handleShellCompletionCommand: (args: string[]) => Promise; let parseShellCompletionArgs: (args: string[]) => { targetShell: ShellTarget; force: boolean }; @@ -125,16 +121,20 @@ describe('shell-completion command', () => { 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); + const plainLogLines = logLines.map(stripAnsi); + expect(plainLogLines.some((line) => line.includes('Shell completion already installed'))).toBe( + true + ); + expect(plainLogLines.some((line) => line.includes('Use --force to reinstall'))).toBe(true); + expect(plainLogLines.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); + const plainErrorLines = errorLines.map(stripAnsi); + expect(plainErrorLines.some((line) => line.includes('Error: boom'))).toBe(true); + expect(plainErrorLines.some((line) => line.includes('ccs --shell-completion --zsh'))).toBe(true); }); });