mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
471 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|