diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index c78329c9..a7354102 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -7,8 +7,15 @@ import { spawn } from 'child_process'; import { initUI, header, ok, fail, warn, info, color } from '../utils/ui'; -import { detectPackageManager } from '../utils/package-manager-detector'; -import { compareVersionsWithPrerelease } from '../utils/update-checker'; +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'; /** @@ -19,17 +26,75 @@ export interface UpdateOptions { beta?: boolean; } -// Version (from centralized utility) -const CCS_VERSION = getVersion(); +type TargetTag = 'latest' | 'dev'; -/** - * Handle the update command - * Checks for updates and installs the latest version - */ -export async function handleUpdateCommand(options: UpdateOptions = {}): Promise { - await initUI(); +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); +} + +const defaultDeps: UpdateCommandDeps = { + initUI, + getVersion, + detectCurrentInstall, + buildPackageManagerEnv, + formatManualUpdateCommand, + readInstalledPackageState, + compareVersionsWithPrerelease, + checkForUpdates: loadCheckForUpdates, + spawn, +}; + +async function resolveTargetVersion( + currentVersion: string, + targetTag: TargetTag, + deps: UpdateCommandDeps +): Promise { + 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 = {} +): Promise { + const deps = { ...defaultDeps, ...injectedDeps }; + await deps.initUI(); const { force = false, beta = false } = options; - const targetTag = beta ? 'dev' : 'latest'; + const targetTag: TargetTag = beta ? 'dev' : 'latest'; + const currentInstall = deps.detectCurrentInstall(); + const currentVersion = deps.getVersion(); console.log(''); console.log(header('Checking for updates...')); @@ -39,21 +104,25 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - await performNpmUpdate(targetTag, true); + 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); + handleCheckFailed( + updateResult.message ?? 'Update check failed', + targetTag, + currentInstall, + deps + ); return; } if (updateResult.status === 'no_update') { - handleNoUpdate(updateResult.reason); + handleNoUpdate(updateResult.reason, currentVersion); return; } @@ -65,7 +134,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) { @@ -89,13 +158,18 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - await performNpmUpdate(targetTag); + await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest, deps); } /** * Handle failed update check */ -function handleCheckFailed(message: string, targetTag: string = 'latest'): void { +function handleCheckFailed( + message: string, + targetTag: string = 'latest', + currentInstall: CurrentInstall = defaultDeps.detectCurrentInstall(), + deps: UpdateCommandDeps = defaultDeps +): void { console.log(fail(message)); console.log(''); console.log(warn('Possible causes:')); @@ -105,27 +179,7 @@ function handleCheckFailed(message: string, targetTag: string = 'latest'): void console.log(''); console.log('Try again later or update manually:'); - const packageManager = detectPackageManager(); - let manualCommand: string; - - switch (packageManager) { - case 'npm': - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - break; - case 'yarn': - manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; - break; - case 'pnpm': - manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; - break; - case 'bun': - manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; - break; - default: - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - } - - console.log(color(` ${manualCommand}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -133,9 +187,7 @@ function handleCheckFailed(message: string, targetTag: string = 'latest'): void /** * Handle no update available */ -function handleNoUpdate(reason: string | undefined): void { - const version = getVersion(); - +function handleNoUpdate(reason: string | undefined, version: string): void { let message = `You are already on the latest version (${version})`; switch (reason) { @@ -155,17 +207,155 @@ function handleNoUpdate(reason: string | undefined): void { } /** - * Perform update via npm/yarn/pnpm/bun + * Perform update verification against the current install. */ -async function performNpmUpdate( - targetTag: string = 'latest', - isReinstall: boolean = false +async function verifyCurrentInstallVersion( + currentInstall: CurrentInstall, + targetTag: string, + expectedVersion?: string, + previousState?: InstalledPackageState, + isReinstall: boolean = false, + deps: UpdateCommandDeps = defaultDeps ): Promise { - const packageManager = detectPackageManager(); + const nextState = deps.readInstalledPackageState(currentInstall); + const installedVersion = nextState.version; + if (!installedVersion) { + console.log(''); + console.log( + fail('Update finished, but CCS could not verify the current installation version.') + ); + console.log(''); + console.log('Current install remains ambiguous. Re-run manually:'); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.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) { + console.log(''); + console.log( + fail( + `Update completed outside the current installation. Current binary still reports ${installedVersion}; expected ${expectedVersion}.` + ) + ); + if (previousState?.version && previousState.version === installedVersion) { + console.log( + warn( + `The current install path did not change from ${previousState.version}; another package manager likely updated a different copy of CCS.` + ) + ); + } + console.log(''); + console.log('Re-run manually against the current install:'); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); + console.log(''); + process.exit(1); + return; + } + } + + if ( + isReinstall && + previousState?.version && + installedVersion === previousState.version && + !installChanged + ) { + console.log(''); + console.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 { + 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 { + 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': @@ -206,72 +396,6 @@ async function performNpmUpdate( const isWindows = process.platform === 'win32'; - const performUpdate = (): void => { - // On Windows, use shell with full command string to avoid deprecation warning - // 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(' ')}`, [], { - stdio: ['inherit', 'inherit', 'pipe'], - shell: true, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, - }) - : spawn(updateCommand, updateArgs, { stdio: 'inherit' }); - - // 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 - // Use line-buffering to handle chunk splitting (data events don't guarantee message boundaries) - if (isWindows && child.stderr) { - let stderrBuffer = ''; - child.stderr.on('data', (data: Buffer) => { - stderrBuffer += data.toString(); - const lines = stderrBuffer.split('\n'); - stderrBuffer = lines.pop() || ''; // Keep incomplete line in buffer - for (const line of lines) { - // Skip npm cleanup warnings (EPERM, ENOTEMPTY, EBUSY on native module prebuilds) - if (!/npm warn cleanup/i.test(line)) { - process.stderr.write(line + '\n'); - } - } - }); - child.stderr.on('close', () => { - // Flush remaining buffer on stream close - if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) { - process.stderr.write(stderrBuffer); - } - }); - } - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); - console.log(''); - console.log(`Run ${color('ccs --version', 'command')} to verify`); - console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); - console.log(''); - } else { - console.log(''); - console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); - console.log(''); - console.log('Try manually:'); - console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', () => { - console.log(''); - console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); - console.log(''); - console.log('Try manually:'); - console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); - console.log(''); - process.exit(1); - }); - }; - if (cacheCommand && cacheArgs) { // For bun on Windows, we pre-remove instead of cache clear const isBunPreRemove = packageManager === 'bun' && cacheArgs.includes('remove'); @@ -283,27 +407,62 @@ async function performNpmUpdate( : 'Cache clearing failed, proceeding anyway...'; console.log(info(stepMessage)); - // On Windows, use shell with full command string to avoid deprecation warning - const cacheChild = isWindows - ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { - stdio: 'inherit', - shell: true, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, - }) - : spawn(cacheCommand, cacheArgs, { stdio: 'inherit' }); - - cacheChild.on('exit', (code) => { - if (code !== 0) { + try { + const cacheCode = await runChildProcess(deps, cacheCommand, cacheArgs, { + isWindows, + env: childEnv, + }); + if (cacheCode !== 0) { console.log(warn(failMessage)); } - performUpdate(); + } catch { + console.log(warn(failMessage)); + } + } + + try { + const exitCode = await runChildProcess(deps, updateCommand, updateArgs, { + isWindows, + env: childEnv, + filterCleanupWarnings: true, }); - cacheChild.on('error', () => { - console.log(warn(failMessage)); - performUpdate(); - }); - } else { - performUpdate(); + if (exitCode === 0) { + if (expectedVersion || previousState?.version) { + await verifyCurrentInstallVersion( + currentInstall, + targetTag, + expectedVersion, + previousState, + isReinstall, + deps + ); + } + console.log(''); + console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); + console.log(''); + console.log(`Run ${color('ccs --version', 'command')} to verify`); + console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); + console.log(''); + } else { + console.log(''); + console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); + console.log(''); + console.log('Try manually:'); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); + console.log(''); + } + + process.exit(exitCode || 0); + } catch { + console.log(''); + console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); + console.log(''); + console.log('Try manually:'); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); } } diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index ac5840a0..606c58ed 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -1,75 +1,344 @@ /** * Package Manager Detector Utilities * - * Cross-platform package manager detection utilities for CCS. - * Now only supports npm-based installation (npm/yarn/pnpm/bun). + * Detect the package manager and install root that own the CURRENT CCS binary. + * This is intentionally different from "which package manager has CCS installed + * somewhere on the machine" because self-update must target the current install. */ -import * as path from 'path'; import * as fs from 'fs'; -import { spawnSync } from 'child_process'; +import * as path from 'path'; + +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +export interface CurrentInstall { + manager: PackageManager; + scriptPath: string; + resolvedScriptPath: string; + packageRoot: string | null; + prefix: string | null; + detectionSource: 'path' | 'package-root' | 'default'; +} + +export interface InstalledPackageState { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +} + +const CCS_PACKAGE_NAME = '@kaitranntt/ccs'; + +function resolveScriptPath(scriptPath: string): string { + if (path.win32.isAbsolute(scriptPath)) { + return scriptPath; + } + + try { + return fs.realpathSync(scriptPath); + } catch { + return path.resolve(scriptPath); + } +} + +function findPackageRoot(scriptPath: string): string | null { + let currentDir = path.dirname(scriptPath); + + for (let i = 0; i < 8; i++) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: string; + }; + if (packageJson.name === CCS_PACKAGE_NAME) { + return currentDir; + } + } catch { + // Ignore malformed package.json and keep walking upward. + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return null; +} + +function getPrefixBeforeMarker(packageRoot: string, marker: string): string | null { + const index = packageRoot.lastIndexOf(marker); + return index >= 0 ? packageRoot.slice(0, index) || path.parse(packageRoot).root : null; +} + +function inferInstallFromPath( + targetPath: string +): Pick | null { + const normalizedPath = targetPath.split(path.sep).join('/'); + + if ( + normalizedPath.includes(`/install/global/node_modules/@kaitranntt/ccs`) || + normalizedPath.includes(`/.bun/install/global/node_modules/@kaitranntt/ccs`) + ) { + return { + manager: 'bun', + prefix: getPrefixBeforeMarker( + targetPath, + `${path.sep}install${path.sep}global${path.sep}node_modules` + ), + }; + } + + if (normalizedPath.includes(`/global/node_modules/@kaitranntt/ccs`)) { + return { + manager: 'yarn', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}node_modules`), + }; + } + + if ( + normalizedPath.includes('/global/') && + normalizedPath.includes('/.pnpm/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + const pnpmVirtualStoreMatch = targetPath.match( + new RegExp( + `${path.sep.replace(/\\/g, '\\\\')}global${path.sep.replace(/\\/g, '\\\\')}[^${path.sep.replace( + /\\/g, + '\\\\' + )}]+${path.sep.replace(/\\/g, '\\\\')}\\.pnpm${path.sep.replace(/\\/g, '\\\\')}` + ) + ); + if (pnpmVirtualStoreMatch) { + return { + manager: 'pnpm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), + }; + } + } + + if ( + normalizedPath.includes('/global/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + const pnpmFlatMatch = normalizedPath.match(/\/global\/([^/]+)\/node_modules\/@kaitranntt\/ccs/); + + if (!pnpmFlatMatch || pnpmFlatMatch[1] === 'lib') { + return null; + } + + return { + manager: 'pnpm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), + }; + } + + if ( + normalizedPath.includes('/.pnpm/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + return { + manager: 'pnpm', + prefix: null, + }; + } + + if (normalizedPath.includes('/lib/node_modules/@kaitranntt/ccs')) { + return { + manager: 'npm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}lib${path.sep}node_modules`), + }; + } + + if ( + normalizedPath.includes('/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/global/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/install/global/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/.pnpm/') + ) { + return { + manager: 'npm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}node_modules`), + }; + } + + return null; +} /** - * Detect which package manager was used for installation + * Detect the current install owner from the path of the running script. + * Defaults to npm when the path is ambiguous because npm's global layout is + * the safest fallback for the existing manual remediation commands. */ -export function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' { - const scriptPath = process.argv[1]; +export function detectCurrentInstall(scriptPath: string = process.argv[1] || ''): CurrentInstall { + const resolvedScriptPath = resolveScriptPath(scriptPath); + const pathMatch = inferInstallFromPath(resolvedScriptPath) ?? inferInstallFromPath(scriptPath); + const packageRoot = findPackageRoot(resolvedScriptPath); - // Check if script path contains package manager indicators - if (scriptPath.includes('.pnpm')) return 'pnpm'; - if (scriptPath.includes('yarn')) return 'yarn'; - if (scriptPath.includes('bun')) return 'bun'; - - // Check parent directories for lock files - const binDir = path.dirname(scriptPath); - - let checkDir = binDir; - for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(checkDir, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(checkDir, 'yarn.lock'))) return 'yarn'; - if (fs.existsSync(path.join(checkDir, 'bun.lockb'))) return 'bun'; - checkDir = path.dirname(checkDir); + if (pathMatch) { + return { + manager: pathMatch.manager, + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: pathMatch.prefix, + detectionSource: 'path', + }; } - // Check if package managers are available on the system - try { - const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) { - return 'yarn'; + if (packageRoot) { + const packageRootMatch = inferInstallFromPath(packageRoot); + if (packageRootMatch) { + return { + manager: packageRootMatch.manager, + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: packageRootMatch.prefix, + detectionSource: 'package-root', + }; } - } catch (_err) { - // Continue to next check } + return { + manager: 'npm', + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: null, + detectionSource: 'default', + }; +} + +/** + * Backward-compatible helper for callers that only need the package manager. + */ +export function detectPackageManager(scriptPath: string = process.argv[1] || ''): PackageManager { + return detectCurrentInstall(scriptPath).manager; +} + +export function buildPackageManagerEnv( + install: CurrentInstall, + baseEnv: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + if (!install.prefix) { + return { ...baseEnv }; + } + + switch (install.manager) { + case 'npm': + return { + ...baseEnv, + npm_config_prefix: install.prefix, + NPM_CONFIG_PREFIX: install.prefix, + }; + case 'yarn': + return { + ...baseEnv, + YARN_GLOBAL_FOLDER: install.prefix, + }; + case 'pnpm': + return { + ...baseEnv, + PNPM_HOME: install.prefix, + }; + case 'bun': + return { + ...baseEnv, + BUN_INSTALL: install.prefix, + }; + default: + return { ...baseEnv }; + } +} + +export function readInstalledPackageVersion(install: CurrentInstall): string | null { + return readInstalledPackageState(install).version; +} + +function readFileMtimeMs(filePath: string): number | null { try { - const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) { - return 'pnpm'; - } - } catch (_err) { - // Continue to next check + return fs.statSync(filePath).mtimeMs; + } catch { + return null; + } +} + +export function readInstalledPackageState(install: CurrentInstall): InstalledPackageState { + if (!install.packageRoot) { + return { + version: null, + packageJsonMtimeMs: null, + scriptMtimeMs: readFileMtimeMs(install.resolvedScriptPath), + }; } + const packageJsonPath = path.join(install.packageRoot, 'package.json'); + const packageJsonMtimeMs = readFileMtimeMs(packageJsonPath); + const scriptMtimeMs = readFileMtimeMs(install.resolvedScriptPath); + try { - const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) { - return 'bun'; - } - } catch (_err) { - // Continue to default - } - - return 'npm'; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + version?: string; + }; + + return { + version: typeof packageJson.version === 'string' ? packageJson.version : null, + packageJsonMtimeMs, + scriptMtimeMs, + }; + } catch { + return { + version: null, + packageJsonMtimeMs, + scriptMtimeMs, + }; + } +} + +function quoteForShell(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function quoteForCmd(value: string): string { + return value.replace(/'/g, "''"); +} + +function formatWindowsEnvCommand(envVar: string, value: string, command: string): string { + return `powershell -NoProfile -Command "$env:${envVar}='${quoteForCmd(value)}'; ${command}"`; +} + +export function formatManualUpdateCommand( + targetTag: string, + install: CurrentInstall = detectCurrentInstall(), + platform: NodeJS.Platform = process.platform +): string { + const command = { + npm: `npm install -g @kaitranntt/ccs@${targetTag}`, + yarn: `yarn global add @kaitranntt/ccs@${targetTag}`, + pnpm: `pnpm add -g @kaitranntt/ccs@${targetTag}`, + bun: `bun add -g @kaitranntt/ccs@${targetTag}`, + }[install.manager]; + + if (!install.prefix) { + return command; + } + + const envVar = { + npm: 'NPM_CONFIG_PREFIX', + yarn: 'YARN_GLOBAL_FOLDER', + pnpm: 'PNPM_HOME', + bun: 'BUN_INSTALL', + }[install.manager]; + + if (platform === 'win32') { + return formatWindowsEnvCommand(envVar, install.prefix, command); + } + + return `${envVar}=${quoteForShell(install.prefix)} ${command}`; } diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 019e0c6e..002a17a5 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -21,7 +21,7 @@ interface UpdateCache { dismissed_version: string | null; } -interface UpdateResult { +export interface UpdateResult { status: 'update_available' | 'no_update' | 'check_failed'; reason?: string; latest?: string; diff --git a/tests/integration/update-command-install-origin.test.ts b/tests/integration/update-command-install-origin.test.ts new file mode 100644 index 00000000..ab10fabe --- /dev/null +++ b/tests/integration/update-command-install-origin.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +let tempRoot = ''; +let currentPackageRoot = ''; +let currentPrefix = ''; +let bunPackageRoot = ''; +let fakeBinDir = ''; +let originalArgv1 = ''; +let originalPath = ''; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; +let logLines: string[] = []; +let exitCodes: number[] = []; + +function writePackage(root: string, version: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@kaitranntt/ccs', version }, null, 2) + ); +} + +function readPackageVersion(root: string): string { + return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version as string; +} + +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents); + chmodSync(filePath, 0o755); +} + +async function loadHandleUpdateCommand() { + const mod = await import(`../../src/commands/update-command?test=${Date.now()}-${Math.random()}`); + return mod.handleUpdateCommand; +} + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'ccs-update-origin-')); + currentPrefix = join(tempRoot, 'prefix'); + currentPackageRoot = join(currentPrefix, 'lib', 'node_modules', '@kaitranntt', 'ccs'); + bunPackageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + fakeBinDir = join(tempRoot, 'bin'); + + mkdirSync(join(currentPackageRoot, 'dist'), { recursive: true }); + mkdirSync(fakeBinDir, { recursive: true }); + writePackage(currentPackageRoot, '7.67.0-dev.5'); + writePackage(bunPackageRoot, '0.0.0-stale'); + + originalArgv1 = process.argv[1] ?? ''; + process.argv[1] = join(currentPackageRoot, 'dist', 'ccs.js'); + + originalPath = process.env.PATH ?? ''; + process.env.PATH = `${fakeBinDir}:${originalPath}`; + + logLines = []; + exitCodes = []; + originalConsoleLog = console.log; + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + originalProcessExit = process.exit; + process.exit = ((code?: number) => { + exitCodes.push(code ?? 0); + }) as typeof process.exit; + + mock.module('../../src/utils/ui', () => ({ + 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 () => ({ + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }), + })); +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; + process.argv[1] = originalArgv1; + process.env.PATH = originalPath; + mock.restore(); + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +async function waitForExitCode(expectedCode: number): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if (exitCodes.includes(expectedCode)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`process.exit(${expectedCode}) was not observed`); +} + +describe('update-command install origin integration', () => { + it('updates the current npm-owned install instead of drifting to another manager', async () => { + writeExecutable( + join(fakeBinDir, 'npm'), + `#!/bin/sh +if [ "$npm_config_prefix" = "${currentPrefix}" ] || [ "$NPM_CONFIG_PREFIX" = "${currentPrefix}" ]; then + cat > "${join(currentPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF + exit 0 +fi +exit 13 +` + ); + + writeExecutable( + join(fakeBinDir, 'bun'), + `#!/bin/sh +cat > "${join(bunPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF +exit 0 +` + ); + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await handleUpdateCommand({ beta: true }); + await waitForExitCode(0); + + expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.9'); + expect(readPackageVersion(bunPackageRoot)).toBe('0.0.0-stale'); + expect(logLines.join('\n')).toContain('Updating via npm'); + }); + + it('fails if the update exits 0 but the current install stays stale', async () => { + writeExecutable( + join(fakeBinDir, 'npm'), + `#!/bin/sh +exit 0 +` + ); + + writeExecutable( + join(fakeBinDir, 'bun'), + `#!/bin/sh +cat > "${join(bunPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF +exit 0 +` + ); + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await handleUpdateCommand({ beta: true }); + await waitForExitCode(1); + + expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.5'); + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(logLines.join('\n')).toContain('NPM_CONFIG_PREFIX='); + }); +}); diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index d32055b4..77c3a0a5 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -1,13 +1,24 @@ const assert = require('assert'); +const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); const { createTestEnvironment } = require('../shared/fixtures/test-environment'); describe('npm CLI', () => { - const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const distCcsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const srcCcsPath = path.join(__dirname, '..', '..', 'src', 'ccs.ts'); let testEnv; let testCcsHome; + function buildCliCommand(args = '') { + if (fs.existsSync(distCcsPath)) { + return `node "${distCcsPath}" ${args}`; + } + + // Some test files rebuild or clean dist during the same Bun process. + return `bun "${srcCcsPath}" ${args}`; + } + beforeAll(() => { // Create isolated test environment testEnv = createTestEnvironment(); @@ -30,7 +41,7 @@ describe('npm CLI', () => { // Helper to run CLI with test environment function runCli(args, options = {}) { - return execSync(`node "${ccsPath}" ${args}`, { + return execSync(buildCliCommand(args), { ...options, env: { ...process.env, CCS_HOME: testCcsHome } }); diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index 84f3f4d6..13726274 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -1,25 +1,36 @@ const assert = require('assert'); +const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); describe('integration: special commands', () => { - const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const distCcsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const srcCcsPath = path.join(__dirname, '..', '..', 'src', 'ccs.ts'); + + function buildCliCommand(args = '') { + if (fs.existsSync(distCcsPath)) { + return `node "${distCcsPath}" ${args}`; + } + + // Some tests rebuild or clean dist during the same Bun run. + return `bun "${srcCcsPath}" ${args}`; + } it('shows version with --version', () => { - const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--version'), { encoding: 'utf8' }); assert(output.includes('CCS (Claude Code Switch)')); assert(/v\d+\.\d+\.\d+/.test(output)); }); it('shows version with -v', () => { - const output = execSync(`node ${ccsPath} -v`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('-v'), { encoding: 'utf8' }); assert(/v\d+\.\d+\.\d+/.test(output)); }); it('shows help with --help', function() { // Note: Requires claude installation, so we just test that it doesn't crash try { - const output = execSync(`node ${ccsPath} --help`, { + const output = execSync(buildCliCommand('--help'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); @@ -31,14 +42,14 @@ describe('integration: special commands', () => { }); it('handles --install command', () => { - const output = execSync(`node ${ccsPath} --install`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--install'), { encoding: 'utf8' }); assert(output.includes('Feature not available')); assert(output.includes('under development')); assert(output.includes('.claude/ integration testing')); }); it('handles --uninstall command', () => { - const output = execSync(`node ${ccsPath} --uninstall`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--uninstall'), { encoding: 'utf8' }); assert(output.includes('Uninstalling CCS')); assert(output.includes('[OK] Uninstall complete!') || output.includes('Nothing to uninstall')); }); @@ -47,7 +58,7 @@ describe('integration: special commands', () => { it.skip('parses --force flag without error', function() { // Skip: requires network/child process // Note: This will fail at update check (no network in test), but proves flag parsing works try { - execSync(`node ${ccsPath} update --force`, { + execSync(buildCliCommand('update --force'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -62,7 +73,7 @@ describe('integration: special commands', () => { it.skip('parses --beta flag without error', function() { // Skip: requires network/child process try { - execSync(`node ${ccsPath} update --beta`, { + execSync(buildCliCommand('update --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -75,7 +86,7 @@ describe('integration: special commands', () => { it.skip('parses combined --force --beta flags', function() { // Skip: requires network/child process try { - execSync(`node ${ccsPath} update --force --beta`, { + execSync(buildCliCommand('update --force --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -89,7 +100,7 @@ describe('integration: special commands', () => { it.skip('shows appropriate error for direct install with --beta', function() { // Skip: requires network/child process // Test direct install rejection of --beta flag try { - execSync(`node ${ccsPath} update --beta`, { + execSync(buildCliCommand('update --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -111,4 +122,4 @@ describe('integration: special commands', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/cliproxy/session-tracker-port.test.js b/tests/unit/cliproxy/session-tracker-port.test.js index 1826a2aa..e5a99d94 100644 --- a/tests/unit/cliproxy/session-tracker-port.test.js +++ b/tests/unit/cliproxy/session-tracker-port.test.js @@ -15,7 +15,10 @@ const testHome = path.join( os.tmpdir(), `ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}` ); +const originalCcsHome = process.env.CCS_HOME; +const originalCcsDir = process.env.CCS_DIR; process.env.CCS_HOME = testHome; +delete process.env.CCS_DIR; const { getExistingProxy, @@ -28,6 +31,7 @@ const { deleteSessionLockForPort, } = require('../../../dist/cliproxy/session-tracker'); const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator'); +const { setGlobalConfigDir } = require('../../../dist/utils/config-manager'); describe('Session Tracker Port-Specific', function () { const variantPort1 = 8318; @@ -35,6 +39,11 @@ describe('Session Tracker Port-Specific', function () { let cliproxyDir; beforeEach(function () { + // Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process. + process.env.CCS_HOME = testHome; + delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); + // Create test directories cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); @@ -49,6 +58,8 @@ describe('Session Tracker Port-Specific', function () { }); afterEach(function () { + setGlobalConfigDir(undefined); + // Clean up session files try { const files = fs.readdirSync(cliproxyDir); @@ -69,7 +80,11 @@ describe('Session Tracker Port-Specific', function () { } catch { // Ignore cleanup errors } - delete process.env.CCS_HOME; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); }); describe('Session Lock Path', function () { diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js index ac4d7de2..edc75d57 100644 --- a/tests/unit/cliproxy/session-tracker.test.js +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -12,7 +12,10 @@ const os = require('os'); // Set test isolation environment before importing const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); +const originalCcsHome = process.env.CCS_HOME; +const originalCcsDir = process.env.CCS_DIR; process.env.CCS_HOME = testHome; +delete process.env.CCS_DIR; const { getExistingProxy, @@ -24,6 +27,7 @@ const { stopProxy, getProxyStatus, } = require('../../../dist/cliproxy/session-tracker'); +const { setGlobalConfigDir } = require('../../../dist/utils/config-manager'); describe('Session Tracker', function () { const testPort = 18317; @@ -31,6 +35,11 @@ describe('Session Tracker', function () { let cliproxyDir; beforeEach(function () { + // Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process. + process.env.CCS_HOME = testHome; + delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); + // Create test directories cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); @@ -51,6 +60,8 @@ describe('Session Tracker', function () { }); afterEach(function () { + setGlobalConfigDir(undefined); + // Clean up lock files try { const files = fs.readdirSync(cliproxyDir); @@ -71,7 +82,11 @@ describe('Session Tracker', function () { } catch { // Ignore cleanup errors } - delete process.env.CCS_HOME; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); }); describe('getExistingProxy', function () { diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts new file mode 100644 index 00000000..ef540deb --- /dev/null +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/commands/update-command'; +import type { UpdateResult } from '../../../src/utils/update-checker'; + +let logLines: string[] = []; +let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; +let exitCodes: number[] = []; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; + +type InstalledState = { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +}; + +type Scenario = { + beforeState: InstalledState; + afterState: InstalledState; +}; + +let scenario: Scenario; +let updateCheckResult: UpdateResult; +let currentInstallOverride: ReturnType; +let stateReads = 0; + +function installDescriptor() { + return { + manager: 'npm' as const, + scriptPath: '/tmp/ccs-prefix/bin/ccs', + resolvedScriptPath: '/tmp/ccs-prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/ccs-prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/ccs-prefix', + detectionSource: 'path' as const, + }; +} + +function createDeps(overrides: Partial = {}): UpdateCommandDeps { + return { + initUI: async () => {}, + getVersion: () => '7.67.0-dev.5', + detectCurrentInstall: () => currentInstallOverride, + buildPackageManagerEnv: () => { + if (currentInstallOverride.manager === 'npm') { + return { + PATH: '/usr/bin', + npm_config_prefix: '/tmp/ccs-prefix', + NPM_CONFIG_PREFIX: '/tmp/ccs-prefix', + }; + } + + if (currentInstallOverride.manager === 'bun') { + return { PATH: '/usr/bin', BUN_INSTALL: '/tmp/bun-prefix' }; + } + + if (currentInstallOverride.manager === 'yarn') { + return { PATH: '/usr/bin', YARN_GLOBAL_FOLDER: '/tmp/yarn-prefix' }; + } + + return { PATH: '/usr/bin', PNPM_HOME: '/tmp/pnpm-prefix' }; + }, + formatManualUpdateCommand: () => { + if (currentInstallOverride.manager === 'npm') { + return 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev'; + } + + if (currentInstallOverride.manager === 'bun') { + return 'BUN_INSTALL=/tmp/bun-prefix bun add -g @kaitranntt/ccs@dev'; + } + + if (currentInstallOverride.manager === 'yarn') { + return 'YARN_GLOBAL_FOLDER=/tmp/yarn-prefix yarn global add @kaitranntt/ccs@dev'; + } + + return 'PNPM_HOME=/tmp/pnpm-prefix pnpm add -g @kaitranntt/ccs@dev'; + }, + readInstalledPackageState: () => { + 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, + ...overrides, + }; +} + +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); + }) as typeof process.exit; +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; +}); + +describe('update-command current install handling', () => { + it('updates through the current install manager and prefix', async () => { + await handleUpdateCommand({ beta: true }, createDeps()); + + const installCall = spawnCalls.find((call) => call.args.includes('install')); + + expect(installCall?.command).toBe('npm'); + expect(installCall?.args).toEqual(['install', '-g', '@kaitranntt/ccs@dev']); + expect(installCall?.env?.npm_config_prefix).toBe('/tmp/ccs-prefix'); + expect(exitCodes).toContain(0); + }); + + it('fails when another manager updated elsewhere but the current binary stayed stale', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + + await handleUpdateCommand({ beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(logLines.join('\n')).toContain( + 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev' + ); + expect(exitCodes).toContain(1); + }); + + it('keeps force mode under exact target-version verification', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(exitCodes).toContain(1); + }); + + it('warns but succeeds when target resolution says no update and the current install stays unchanged', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('could not prove that the current installation changed'); + expect(exitCodes).toContain(0); + }); + + it('warns but succeeds when target version resolution fails and the current install stays unchanged', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + updateCheckResult = { status: 'check_failed', message: 'network' }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).toContain('could not prove that the current installation changed'); + expect(exitCodes).toContain(0); + }); + + it('uses the injected version in the no-update message', async () => { + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand( + {}, + createDeps({ + getVersion: () => '9.9.9-test.1', + }) + ); + + expect(logLines.join('\n')).toContain('latest version (9.9.9-test.1)'); + expect(exitCodes).toContain(0); + }); + + it('accepts a newer installed version when the dist-tag moves during update', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.1-dev.0', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + + await handleUpdateCommand({ beta: true }, createDeps()); + + expect(logLines.join('\n')).not.toContain('outside the current installation'); + expect(exitCodes).toContain(0); + }); + + it('accepts force reinstall when the version stays the same but the current install files change', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand({ force: true, beta: true }, createDeps()); + + expect(logLines.join('\n')).not.toContain( + 'could not verify that the current installation changed' + ); + expect(exitCodes).toContain(0); + }); + + it.each([ + ['bun', 'add', 'BUN_INSTALL', '/tmp/bun-prefix'], + ['yarn', 'global', 'YARN_GLOBAL_FOLDER', '/tmp/yarn-prefix'], + ['pnpm', 'add', 'PNPM_HOME', '/tmp/pnpm-prefix'], + ])( + 'routes updates through the current %s install and env', + async (manager, expectedArg, envKey, envValue) => { + currentInstallOverride = { + ...installDescriptor(), + manager: manager as 'bun' | 'yarn' | 'pnpm', + prefix: envValue, + }; + + await handleUpdateCommand({ beta: true }, createDeps()); + + 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); + expect(exitCodes).toContain(0); + } + ); +}); diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts new file mode 100644 index 00000000..693e0920 --- /dev/null +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + buildPackageManagerEnv, + detectCurrentInstall, + formatManualUpdateCommand, + readInstalledPackageVersion, +} from '../../../src/utils/package-manager-detector'; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writePackage(root: string, version: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@kaitranntt/ccs', version }, null, 2) + ); +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('package-manager-detector', () => { + it('detects npm installs from the current binary path and keeps the custom prefix', () => { + const tempRoot = makeTempDir('ccs-install-detector-npm-'); + const packageRoot = join(tempRoot, 'prefix', 'lib', 'node_modules', '@kaitranntt', 'ccs'); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + + writePackage(packageRoot, '7.67.0-dev.5'); + + const install = detectCurrentInstall(scriptPath); + + expect(install.manager).toBe('npm'); + expect(install.prefix).toBe(join(tempRoot, 'prefix')); + expect(install.packageRoot).toBe(packageRoot); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.5'); + }); + + it('detects bun installs from the resolved package path', () => { + const tempRoot = makeTempDir('ccs-install-detector-bun-'); + const packageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(scriptPath); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(tempRoot, '.bun')); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); + }); + + it('detects custom bun install roots that still use install/global/node_modules', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-bun-'); + const packageRoot = join( + tempRoot, + 'custom-bun-root', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(tempRoot, 'custom-bun-root')); + }); + + it('detects custom yarn global layouts', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-yarn-'); + const packageRoot = join( + tempRoot, + 'custom-yarn-root', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('yarn'); + expect(install.prefix).toBe(join(tempRoot, 'custom-yarn-root')); + }); + + it('detects custom pnpm global layouts that include a store version segment', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-pnpm-'); + const packageRoot = join( + tempRoot, + 'custom-pnpm-root', + 'global', + '5', + '.pnpm', + '@kaitranntt+ccs@7.67.0-dev.9', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); + }); + + it('detects pnpm global layouts without a visible .pnpm segment in the script path', () => { + const tempRoot = makeTempDir('ccs-install-detector-pnpm-global-flat-'); + const packageRoot = join( + tempRoot, + 'custom-pnpm-root', + 'global', + '5', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); + }); + + it('detects Windows npm globals without a lib directory', () => { + const install = detectCurrentInstall( + 'C:/Program Files/node-prefix/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).toBe('npm'); + expect(install.prefix).toBe('C:/Program Files/node-prefix'); + }); + + it('formats manual npm update commands with the current prefix', () => { + const install = { + manager: 'npm' as const, + scriptPath: '/tmp/prefix/bin/ccs', + resolvedScriptPath: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/prefix', + detectionSource: 'path' as const, + }; + + expect(formatManualUpdateCommand('dev', install)).toBe( + 'NPM_CONFIG_PREFIX=/tmp/prefix npm install -g @kaitranntt/ccs@dev' + ); + }); + + it('formats Windows-safe manual npm update commands for prefixes with spaces', () => { + const install = { + manager: 'npm' as const, + scriptPath: 'C:/Tools/CCS/ccs.cmd', + resolvedScriptPath: 'C:/Program Files/CCS/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: 'C:/Program Files/CCS/lib/node_modules/@kaitranntt/ccs', + prefix: 'C:/Program Files/CCS', + detectionSource: 'path' as const, + }; + + expect(formatManualUpdateCommand('dev', install, 'win32')).toBe( + `powershell -NoProfile -Command "$env:NPM_CONFIG_PREFIX='C:/Program Files/CCS'; npm install -g @kaitranntt/ccs@dev"` + ); + }); + + it('builds manager-specific env overrides for the current install', () => { + const install = { + manager: 'npm' as const, + scriptPath: '/tmp/prefix/bin/ccs', + resolvedScriptPath: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/prefix', + detectionSource: 'path' as const, + }; + + const env = buildPackageManagerEnv(install, { PATH: '/usr/bin' }); + + expect(env.PATH).toBe('/usr/bin'); + expect(env.npm_config_prefix).toBe('/tmp/prefix'); + expect(env.NPM_CONFIG_PREFIX).toBe('/tmp/prefix'); + }); +});