Files
ccs/src/commands/update-command.ts
T

471 lines
14 KiB
TypeScript

/**
* Update Command Handler
*
* Handles `ccs update` command - checks for updates and installs latest version.
* Uses npm/yarn/pnpm/bun package managers exclusively.
*/
import { spawn } from 'child_process';
import { initUI, header, ok, fail, warn, info, color } from '../utils/ui';
import {
buildPackageManagerEnv,
detectCurrentInstall,
formatManualUpdateCommand,
readInstalledPackageState,
type CurrentInstall,
type InstalledPackageState,
} from '../utils/package-manager-detector';
import { compareVersionsWithPrerelease, type UpdateResult } from '../utils/update-checker';
import { getVersion } from '../utils/version';
/**
* Options for the update command
*/
export interface UpdateOptions {
force?: boolean;
beta?: boolean;
}
type TargetTag = 'latest' | 'dev';
export interface UpdateCommandDeps {
initUI: typeof initUI;
getVersion: typeof getVersion;
log: typeof console.log;
exit: typeof process.exit;
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<UpdateResult>;
spawn: typeof spawn;
}
async function loadCheckForUpdates(
currentVersion: string,
interactive: boolean,
channel: 'npm' | 'direct',
targetTag: TargetTag
): Promise<UpdateResult> {
const { checkForUpdates } = await import('../utils/update-checker');
return checkForUpdates(currentVersion, interactive, channel, targetTag);
}
const defaultDeps: UpdateCommandDeps = {
initUI,
getVersion,
log: console.log,
exit: process.exit.bind(process) as typeof process.exit,
detectCurrentInstall,
buildPackageManagerEnv,
formatManualUpdateCommand,
readInstalledPackageState,
compareVersionsWithPrerelease,
checkForUpdates: loadCheckForUpdates,
spawn,
};
async function resolveTargetVersion(
currentVersion: string,
targetTag: TargetTag,
deps: UpdateCommandDeps
): Promise<string | undefined> {
const result = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag);
if (result.status === 'update_available' && result.latest) {
return result.latest;
}
if (result.status === 'no_update') {
return currentVersion;
}
return undefined;
}
export async function handleUpdateCommand(
options: UpdateOptions = {},
injectedDeps: Partial<UpdateCommandDeps> = {}
): Promise<void> {
const deps = { ...defaultDeps, ...injectedDeps };
await deps.initUI();
const { force = false, beta = false } = options;
const targetTag: TargetTag = beta ? 'dev' : 'latest';
const currentInstall = deps.detectCurrentInstall();
const currentVersion = deps.getVersion();
deps.log('');
deps.log(header('Checking for updates...'));
deps.log('');
// Force reinstall - skip update check
if (force) {
deps.log(info(`Force reinstall from @${targetTag} channel...`));
deps.log('');
const expectedVersion = await resolveTargetVersion(currentVersion, targetTag, deps);
await performNpmUpdate(currentInstall, targetTag, true, expectedVersion, deps);
return;
}
const updateResult = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag);
if (updateResult.status === 'check_failed') {
handleCheckFailed(
updateResult.message ?? 'Update check failed',
targetTag,
currentInstall,
deps
);
return;
}
if (updateResult.status === 'no_update') {
handleNoUpdate(updateResult.reason, currentVersion, deps);
return;
}
// Update available
deps.log(warn(`Update available: ${updateResult.current} -> ${updateResult.latest}`));
deps.log('');
// Check if this is a downgrade (e.g., stable to older dev)
const isDowngrade =
updateResult.latest &&
updateResult.current &&
deps.compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0;
// This happens when stable user requests @dev but @dev base is older
if (isDowngrade && beta) {
deps.log(
warn(
'WARNING: Downgrading from ' +
(updateResult.current || 'unknown') +
' to ' +
(updateResult.latest || 'unknown')
)
);
deps.log(warn('Dev channel may be behind stable.'));
deps.log('');
}
// Show beta warning
if (beta) {
deps.log(warn('Installing from @dev channel (unstable)'));
deps.log(warn('Not recommended for production use'));
deps.log(info('Use `ccs update` (without --beta) to return to stable'));
deps.log('');
}
await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest, deps);
}
/**
* Handle failed update check
*/
function handleCheckFailed(
message: string,
targetTag: string = 'latest',
currentInstall: CurrentInstall = defaultDeps.detectCurrentInstall(),
deps: UpdateCommandDeps = defaultDeps
): void {
deps.log(fail(message));
deps.log('');
deps.log(warn('Possible causes:'));
deps.log(' - Network connection issues');
deps.log(' - Firewall blocking requests');
deps.log(' - GitHub/npm API temporarily unavailable');
deps.log('');
deps.log('Try again later or update manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
}
/**
* Handle no update available
*/
function handleNoUpdate(
reason: string | undefined,
version: string,
deps: UpdateCommandDeps
): void {
let message = `You are already on the latest version (${version})`;
switch (reason) {
case 'dismissed':
message = `Update dismissed. You are on version ${version}`;
deps.log(warn(message));
break;
case 'cached':
message = `No updates available (cached result). You are on version ${version}`;
deps.log(info(message));
break;
default:
deps.log(ok(message));
}
deps.log('');
deps.exit(0);
}
/**
* Perform update verification against the current install.
*/
async function verifyCurrentInstallVersion(
currentInstall: CurrentInstall,
targetTag: string,
expectedVersion?: string,
previousState?: InstalledPackageState,
isReinstall: boolean = false,
deps: UpdateCommandDeps = defaultDeps
): Promise<void> {
const nextState = deps.readInstalledPackageState(currentInstall);
const installedVersion = nextState.version;
if (!installedVersion) {
deps.log('');
deps.log(fail('Update finished, but CCS could not verify the current installation version.'));
deps.log('');
deps.log('Current install remains ambiguous. Re-run manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
return;
}
const installChanged =
previousState !== undefined &&
(previousState.version !== nextState.version ||
previousState.packageJsonMtimeMs !== nextState.packageJsonMtimeMs ||
previousState.scriptMtimeMs !== nextState.scriptMtimeMs);
if (expectedVersion && installedVersion !== expectedVersion) {
const postUpdateResult = await deps.checkForUpdates(
installedVersion,
true,
'npm',
targetTag as TargetTag
);
if (postUpdateResult.status === 'no_update') {
return;
}
if (
postUpdateResult.status === 'update_available' &&
postUpdateResult.latest === installedVersion
) {
return;
}
const comparison = deps.compareVersionsWithPrerelease(installedVersion, expectedVersion);
if (comparison < 0 || installedVersion === previousState?.version) {
deps.log('');
deps.log(
fail(
`Update completed outside the current installation. Current binary still reports ${installedVersion}; expected ${expectedVersion}.`
)
);
if (previousState?.version && previousState.version === installedVersion) {
deps.log(
warn(
`The current install path did not change from ${previousState.version}; another package manager likely updated a different copy of CCS.`
)
);
}
deps.log('');
deps.log('Re-run manually against the current install:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
return;
}
}
if (
isReinstall &&
previousState?.version &&
installedVersion === previousState.version &&
!installChanged
) {
deps.log('');
deps.log(
warn(
`Reinstall completed, but CCS could not prove that the current installation changed from ${previousState.version}. Verify the current binary manually if this reinstall was meant to repair a same-version install.`
)
);
}
}
function runChildProcess(
deps: UpdateCommandDeps,
command: string,
args: string[],
options: {
isWindows: boolean;
env: NodeJS.ProcessEnv;
filterCleanupWarnings?: boolean;
}
): Promise<number> {
return new Promise((resolve, reject) => {
const { isWindows, env, filterCleanupWarnings = false } = options;
const child = isWindows
? deps.spawn(`${command} ${args.join(' ')}`, [], {
stdio: ['inherit', 'inherit', 'pipe'],
shell: true,
env: { ...env, NODE_NO_WARNINGS: '1' },
})
: deps.spawn(command, args, { stdio: 'inherit', env });
if (isWindows && filterCleanupWarnings && child.stderr) {
let stderrBuffer = '';
child.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
const lines = stderrBuffer.split('\n');
stderrBuffer = lines.pop() || '';
for (const line of lines) {
if (!/npm warn cleanup/i.test(line)) {
process.stderr.write(line + '\n');
}
}
});
child.stderr.on('close', () => {
if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) {
process.stderr.write(stderrBuffer);
}
});
}
child.on('error', reject);
child.on('exit', (code) => resolve(code ?? 0));
});
}
async function performNpmUpdate(
currentInstall: CurrentInstall,
targetTag: string = 'latest',
isReinstall: boolean = false,
expectedVersion?: string,
deps: UpdateCommandDeps = defaultDeps
): Promise<void> {
const packageManager = currentInstall.manager;
let updateCommand: string;
let updateArgs: string[];
let cacheCommand: string | null;
let cacheArgs: string[] | null;
const childEnv = deps.buildPackageManagerEnv(currentInstall);
const previousState = deps.readInstalledPackageState(currentInstall);
switch (packageManager) {
case 'npm':
updateCommand = 'npm';
updateArgs = ['install', '-g', `@kaitranntt/ccs@${targetTag}`];
cacheCommand = 'npm';
cacheArgs = ['cache', 'clean', '--force'];
break;
case 'yarn':
updateCommand = 'yarn';
updateArgs = ['global', 'add', `@kaitranntt/ccs@${targetTag}`];
cacheCommand = 'yarn';
cacheArgs = ['cache', 'clean'];
break;
case 'pnpm':
updateCommand = 'pnpm';
updateArgs = ['add', '-g', `@kaitranntt/ccs@${targetTag}`];
cacheCommand = 'pnpm';
cacheArgs = ['store', 'prune'];
break;
case 'bun':
updateCommand = 'bun';
updateArgs = ['add', '-g', `@kaitranntt/ccs@${targetTag}`];
// On Windows, bun's global bin symlink may not update properly without removal first
// Pre-remove to ensure clean reinstall (mirrors dev-install.sh behavior)
cacheCommand = process.platform === 'win32' ? 'bun' : null;
cacheArgs = process.platform === 'win32' ? ['remove', '-g', '@kaitranntt/ccs'] : null;
break;
default:
updateCommand = 'npm';
updateArgs = ['install', '-g', `@kaitranntt/ccs@${targetTag}`];
cacheCommand = 'npm';
cacheArgs = ['cache', 'clean', '--force'];
}
deps.log(info(`${isReinstall ? 'Reinstalling' : 'Updating'} via ${packageManager}...`));
deps.log('');
const isWindows = process.platform === 'win32';
if (cacheCommand && cacheArgs) {
// For bun on Windows, we pre-remove instead of cache clear
const isBunPreRemove = packageManager === 'bun' && cacheArgs.includes('remove');
const stepMessage = isBunPreRemove
? 'Removing existing installation...'
: 'Clearing package cache...';
const failMessage = isBunPreRemove
? 'Pre-removal failed, proceeding anyway...'
: 'Cache clearing failed, proceeding anyway...';
deps.log(info(stepMessage));
try {
const cacheCode = await runChildProcess(deps, cacheCommand, cacheArgs, {
isWindows,
env: childEnv,
});
if (cacheCode !== 0) {
deps.log(warn(failMessage));
}
} catch {
deps.log(warn(failMessage));
}
}
try {
const exitCode = await runChildProcess(deps, updateCommand, updateArgs, {
isWindows,
env: childEnv,
filterCleanupWarnings: true,
});
if (exitCode === 0) {
if (expectedVersion || previousState?.version) {
await verifyCurrentInstallVersion(
currentInstall,
targetTag,
expectedVersion,
previousState,
isReinstall,
deps
);
}
deps.log('');
deps.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`));
deps.log('');
deps.log(`Run ${color('ccs --version', 'command')} to verify`);
deps.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`));
deps.log('');
} else {
deps.log('');
deps.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`));
deps.log('');
deps.log('Try manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
}
deps.exit(exitCode || 0);
} catch {
deps.log('');
deps.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`));
deps.log('');
deps.log('Try manually:');
deps.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
deps.log('');
deps.exit(1);
}
}