diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index a85a38a3..4346df7c 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -26,15 +26,81 @@ export interface UpdateOptions { beta?: boolean; } -// Version (from centralized utility) -const CCS_VERSION = getVersion(); +type TargetTag = 'latest' | 'dev'; + +type UpdateCheckResult = + | { + status: 'update_available'; + current?: string; + latest?: string; + message?: string; + reason?: string; + } + | { + status: 'check_failed'; + message?: string; + latest?: string; + current?: string; + reason?: string; + } + | { + status: 'no_update'; + reason?: string; + latest?: string; + current?: string; + message?: string; + }; + +export interface UpdateCommandDeps { + initUI: typeof initUI; + getVersion: typeof getVersion; + detectCurrentInstall: typeof detectCurrentInstall; + buildPackageManagerEnv: typeof buildPackageManagerEnv; + formatManualUpdateCommand: typeof formatManualUpdateCommand; + readInstalledPackageState: typeof readInstalledPackageState; + compareVersionsWithPrerelease: typeof compareVersionsWithPrerelease; + checkForUpdates: ( + currentVersion: string, + interactive: boolean, + channel: 'npm' | 'direct', + targetTag: TargetTag + ) => Promise; + spawn: typeof spawn; +} + +async function loadCheckForUpdates( + currentVersion: string, + interactive: boolean, + channel: 'npm' | 'direct', + targetTag: TargetTag +): Promise { + const { checkForUpdates } = await import('../utils/update-checker'); + return checkForUpdates( + currentVersion, + interactive, + channel, + targetTag + ) as Promise; +} + +const defaultDeps: UpdateCommandDeps = { + initUI, + getVersion, + detectCurrentInstall, + buildPackageManagerEnv, + formatManualUpdateCommand, + readInstalledPackageState, + compareVersionsWithPrerelease, + checkForUpdates: loadCheckForUpdates, + spawn, +}; async function resolveTargetVersion( currentVersion: string, - targetTag: 'latest' | 'dev' + targetTag: TargetTag, + deps: UpdateCommandDeps ): Promise { - const { checkForUpdates } = await import('../utils/update-checker'); - const result = await checkForUpdates(currentVersion, true, 'npm', targetTag); + const result = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag); if (result.status === 'update_available' && result.latest) { return result.latest; @@ -47,15 +113,16 @@ async function resolveTargetVersion( return undefined; } -/** - * Handle the update command - * Checks for updates and installs the latest version - */ -export async function handleUpdateCommand(options: UpdateOptions = {}): Promise { - await initUI(); +export async function handleUpdateCommand( + options: UpdateOptions = {}, + injectedDeps: Partial = {} +): Promise { + const deps = { ...defaultDeps, ...injectedDeps }; + await deps.initUI(); const { force = false, beta = false } = options; - const targetTag = beta ? 'dev' : 'latest'; - const currentInstall = detectCurrentInstall(); + const targetTag: TargetTag = beta ? 'dev' : 'latest'; + const currentInstall = deps.detectCurrentInstall(); + const currentVersion = deps.getVersion(); console.log(''); console.log(header('Checking for updates...')); @@ -65,16 +132,20 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - const expectedVersion = await resolveTargetVersion(CCS_VERSION, targetTag); - await performNpmUpdate(currentInstall, targetTag, true, expectedVersion); + const expectedVersion = await resolveTargetVersion(currentVersion, targetTag, deps); + await performNpmUpdate(currentInstall, targetTag, true, expectedVersion, deps); return; } - const { checkForUpdates } = await import('../utils/update-checker'); - const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag); + const updateResult = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag); if (updateResult.status === 'check_failed') { - handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag, currentInstall); + handleCheckFailed( + updateResult.message ?? 'Update check failed', + targetTag, + currentInstall, + deps + ); return; } @@ -91,7 +162,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< const isDowngrade = updateResult.latest && updateResult.current && - compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0; + deps.compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0; // This happens when stable user requests @dev but @dev base is older if (isDowngrade && beta) { @@ -115,7 +186,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest); + await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest, deps); } /** @@ -124,7 +195,8 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< function handleCheckFailed( message: string, targetTag: string = 'latest', - currentInstall: CurrentInstall = detectCurrentInstall() + currentInstall: CurrentInstall = defaultDeps.detectCurrentInstall(), + deps: UpdateCommandDeps = defaultDeps ): void { console.log(fail(message)); console.log(''); @@ -135,7 +207,7 @@ function handleCheckFailed( console.log(''); console.log('Try again later or update manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -172,9 +244,10 @@ function verifyCurrentInstallVersion( targetTag: string, expectedVersion?: string, previousState?: InstalledPackageState, - isReinstall: boolean = false + isReinstall: boolean = false, + deps: UpdateCommandDeps = defaultDeps ): void { - const nextState = readInstalledPackageState(currentInstall); + const nextState = deps.readInstalledPackageState(currentInstall); const installedVersion = nextState.version; if (!installedVersion) { console.log(''); @@ -183,7 +256,7 @@ function verifyCurrentInstallVersion( ); console.log(''); console.log('Current install remains ambiguous. Re-run manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -199,7 +272,7 @@ function verifyCurrentInstallVersion( return; } - const comparison = compareVersionsWithPrerelease(installedVersion, expectedVersion); + const comparison = deps.compareVersionsWithPrerelease(installedVersion, expectedVersion); if (comparison < 0) { console.log(''); console.log( @@ -216,7 +289,9 @@ function verifyCurrentInstallVersion( } console.log(''); console.log('Re-run manually against the current install:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); console.log(''); process.exit(1); } @@ -236,7 +311,7 @@ function verifyCurrentInstallVersion( ); console.log(''); console.log('Re-run manually against the current install:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -246,15 +321,16 @@ async function performNpmUpdate( currentInstall: CurrentInstall, targetTag: string = 'latest', isReinstall: boolean = false, - expectedVersion?: string + expectedVersion?: string, + deps: UpdateCommandDeps = defaultDeps ): Promise { const packageManager = currentInstall.manager; let updateCommand: string; let updateArgs: string[]; let cacheCommand: string | null; let cacheArgs: string[] | null; - const childEnv = buildPackageManagerEnv(currentInstall); - const previousState = readInstalledPackageState(currentInstall); + const childEnv = deps.buildPackageManagerEnv(currentInstall); + const previousState = deps.readInstalledPackageState(currentInstall); switch (packageManager) { case 'npm': @@ -300,12 +376,12 @@ async function performNpmUpdate( // Also suppress Node deprecation warnings that may come from package managers // Pipe stderr on Windows to filter npm cleanup warnings (EPERM on native modules) const child = isWindows - ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { + ? deps.spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { stdio: ['inherit', 'inherit', 'pipe'], shell: true, env: { ...childEnv, NODE_NO_WARNINGS: '1' }, }) - : spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv }); + : deps.spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv }); // On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.) // These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing @@ -339,7 +415,8 @@ async function performNpmUpdate( targetTag, expectedVersion, previousState, - isReinstall + isReinstall, + deps ); } console.log(''); @@ -353,7 +430,9 @@ async function performNpmUpdate( console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); console.log(''); console.log('Try manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); console.log(''); } process.exit(code || 0); @@ -364,7 +443,9 @@ async function performNpmUpdate( console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); console.log(''); console.log('Try manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); console.log(''); process.exit(1); }); @@ -383,12 +464,12 @@ async function performNpmUpdate( console.log(info(stepMessage)); // On Windows, use shell with full command string to avoid deprecation warning const cacheChild = isWindows - ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { + ? deps.spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { stdio: 'inherit', shell: true, env: { ...childEnv, NODE_NO_WARNINGS: '1' }, }) - : spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv }); + : deps.spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv }); cacheChild.on('exit', (code) => { if (code !== 0) { diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts index 33e7cf51..a0f44ad8 100644 --- a/tests/unit/commands/update-command-current-install.test.ts +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -1,31 +1,31 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/commands/update-command'; let logLines: string[] = []; let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; -let exitCodes: number[] = []; -let stateReads = 0; let originalConsoleLog: typeof console.log; let originalProcessExit: typeof process.exit; -let updateCheckResult: + +type InstalledState = { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +}; + +type Scenario = { + beforeState: InstalledState; + afterState: InstalledState; +}; + +type UpdateCheckResult = | { status: 'update_available'; current: string; latest: string } | { status: 'no_update' } | { status: 'check_failed'; message: string }; -let currentInstallOverride = installDescriptor(); - -type Scenario = { - beforeState: { - version: string | null; - packageJsonMtimeMs: number | null; - scriptMtimeMs: number | null; - }; - afterState: { - version: string | null; - packageJsonMtimeMs: number | null; - scriptMtimeMs: number | null; - }; -}; let scenario: Scenario; +let updateCheckResult: UpdateCheckResult; +let currentInstallOverride: ReturnType; +let stateReads = 0; function installDescriptor() { return { @@ -38,67 +38,10 @@ function installDescriptor() { }; } -beforeEach(() => { - logLines = []; - spawnCalls = []; - exitCodes = []; - stateReads = 0; - scenario = { - beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, - afterState: { version: '7.67.0-dev.9', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, - }; - updateCheckResult = { - status: 'update_available', - current: '7.67.0-dev.5', - latest: '7.67.0-dev.9', - }; - currentInstallOverride = installDescriptor(); - - originalConsoleLog = console.log; - originalProcessExit = process.exit; - - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - - process.exit = ((code?: number) => { - exitCodes.push(code ?? 0); - throw new Error(`process.exit(${code ?? 0})`); - }) as typeof process.exit; - - mock.module('child_process', () => ({ - spawn: (command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => { - spawnCalls.push({ command, args, env: options?.env }); - return { - on: (event: string, callback: (code?: number) => void) => { - if (event === 'exit') { - callback(0); - } - }, - }; - }, - })); - - mock.module('../../../src/utils/ui', () => ({ +function createDeps(): UpdateCommandDeps { + return { initUI: async () => {}, - header: (value: string) => value, - ok: (value: string) => `[OK] ${value}`, - fail: (value: string) => `[X] ${value}`, - warn: (value: string) => `[!] ${value}`, - info: (value: string) => `[i] ${value}`, - color: (value: string) => value, - })); - - mock.module('../../../src/utils/version', () => ({ getVersion: () => '7.67.0-dev.5', - })); - - mock.module('../../../src/utils/update-checker', () => ({ - compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), - checkForUpdates: async () => updateCheckResult, - })); - - mock.module('../../../src/utils/package-manager-detector', () => ({ detectCurrentInstall: () => currentInstallOverride, buildPackageManagerEnv: () => { if (currentInstallOverride.manager === 'npm') { @@ -138,27 +81,59 @@ beforeEach(() => { stateReads += 1; return stateReads === 1 ? scenario.beforeState : scenario.afterState; }, - })); + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => updateCheckResult, + spawn: ((command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => { + spawnCalls.push({ command, args, env: options?.env }); + return { + stderr: undefined, + on: (event: string, callback: (code?: number) => void) => { + if (event === 'exit') { + callback(0); + } + }, + }; + }) as typeof UpdateCommandDeps.prototype.spawn, + }; +} + +beforeEach(() => { + logLines = []; + spawnCalls = []; + stateReads = 0; + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.9', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }; + currentInstallOverride = installDescriptor(); + + originalConsoleLog = console.log; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; }); afterEach(() => { console.log = originalConsoleLog; process.exit = originalProcessExit; - mock.restore(); }); -async function loadHandleUpdateCommand() { - const mod = await import( - `../../../src/commands/update-command?test=${Date.now()}-${Math.random()}` - ); - return mod.handleUpdateCommand; -} - describe('update-command current install handling', () => { it('updates through the current install manager and prefix', async () => { - const handleUpdateCommand = await loadHandleUpdateCommand(); - - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(0)' + ); const installCall = spawnCalls.find((call) => call.args.includes('install')); @@ -172,9 +147,10 @@ describe('update-command current install handling', () => { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(1)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(1)' + ); expect(logLines.join('\n')).toContain('outside the current installation'); expect(logLines.join('\n')).toContain( @@ -187,9 +163,8 @@ describe('update-command current install handling', () => { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(1)' ); @@ -202,9 +177,8 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; updateCheckResult = { status: 'no_update' }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(1)' ); @@ -217,9 +191,8 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; updateCheckResult = { status: 'check_failed', message: 'network' }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(1)' ); @@ -231,9 +204,10 @@ describe('update-command current install handling', () => { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.1-dev.0', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(0)' + ); expect(logLines.join('\n')).not.toContain('outside the current installation'); }); @@ -244,9 +218,8 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, }; updateCheckResult = { status: 'no_update' }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(0)' ); @@ -268,14 +241,15 @@ describe('update-command current install handling', () => { prefix: envValue, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(0)' + ); const updateCall = spawnCalls.find( (call) => call.command === manager && call.args.some((arg) => arg.includes('@kaitranntt/ccs@dev')) ); + expect(updateCall?.args).toContain(expectedArg); expect(updateCall?.env?.[envKey]).toBe(envValue); }