mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 18:17:26 +00:00
fix(update): target the current CCS installation
This commit is contained in:
+134
-36
@@ -7,7 +7,14 @@
|
|||||||
|
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { initUI, header, ok, fail, warn, info, color } from '../utils/ui';
|
import { initUI, header, ok, fail, warn, info, color } from '../utils/ui';
|
||||||
import { detectPackageManager } from '../utils/package-manager-detector';
|
import {
|
||||||
|
buildPackageManagerEnv,
|
||||||
|
detectCurrentInstall,
|
||||||
|
formatManualUpdateCommand,
|
||||||
|
readInstalledPackageState,
|
||||||
|
type CurrentInstall,
|
||||||
|
type InstalledPackageState,
|
||||||
|
} from '../utils/package-manager-detector';
|
||||||
import { compareVersionsWithPrerelease } from '../utils/update-checker';
|
import { compareVersionsWithPrerelease } from '../utils/update-checker';
|
||||||
import { getVersion } from '../utils/version';
|
import { getVersion } from '../utils/version';
|
||||||
|
|
||||||
@@ -22,6 +29,24 @@ export interface UpdateOptions {
|
|||||||
// Version (from centralized utility)
|
// Version (from centralized utility)
|
||||||
const CCS_VERSION = getVersion();
|
const CCS_VERSION = getVersion();
|
||||||
|
|
||||||
|
async function resolveTargetVersion(
|
||||||
|
currentVersion: string,
|
||||||
|
targetTag: 'latest' | 'dev'
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const { checkForUpdates } = await import('../utils/update-checker');
|
||||||
|
const result = await checkForUpdates(currentVersion, true, 'npm', targetTag);
|
||||||
|
|
||||||
|
if (result.status === 'update_available' && result.latest) {
|
||||||
|
return result.latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'no_update') {
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the update command
|
* Handle the update command
|
||||||
* Checks for updates and installs the latest version
|
* Checks for updates and installs the latest version
|
||||||
@@ -30,6 +55,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise<
|
|||||||
await initUI();
|
await initUI();
|
||||||
const { force = false, beta = false } = options;
|
const { force = false, beta = false } = options;
|
||||||
const targetTag = beta ? 'dev' : 'latest';
|
const targetTag = beta ? 'dev' : 'latest';
|
||||||
|
const currentInstall = detectCurrentInstall();
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(header('Checking for updates...'));
|
console.log(header('Checking for updates...'));
|
||||||
@@ -39,16 +65,16 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise<
|
|||||||
if (force) {
|
if (force) {
|
||||||
console.log(info(`Force reinstall from @${targetTag} channel...`));
|
console.log(info(`Force reinstall from @${targetTag} channel...`));
|
||||||
console.log('');
|
console.log('');
|
||||||
await performNpmUpdate(targetTag, true);
|
const expectedVersion = await resolveTargetVersion(CCS_VERSION, targetTag);
|
||||||
|
await performNpmUpdate(currentInstall, targetTag, true, expectedVersion);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { checkForUpdates } = await import('../utils/update-checker');
|
const { checkForUpdates } = await import('../utils/update-checker');
|
||||||
|
|
||||||
const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag);
|
const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag);
|
||||||
|
|
||||||
if (updateResult.status === 'check_failed') {
|
if (updateResult.status === 'check_failed') {
|
||||||
handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag);
|
handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag, currentInstall);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,13 +115,17 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise<
|
|||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
await performNpmUpdate(targetTag);
|
await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle failed update check
|
* Handle failed update check
|
||||||
*/
|
*/
|
||||||
function handleCheckFailed(message: string, targetTag: string = 'latest'): void {
|
function handleCheckFailed(
|
||||||
|
message: string,
|
||||||
|
targetTag: string = 'latest',
|
||||||
|
currentInstall: CurrentInstall = detectCurrentInstall()
|
||||||
|
): void {
|
||||||
console.log(fail(message));
|
console.log(fail(message));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(warn('Possible causes:'));
|
console.log(warn('Possible causes:'));
|
||||||
@@ -105,27 +135,7 @@ function handleCheckFailed(message: string, targetTag: string = 'latest'): void
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('Try again later or update manually:');
|
console.log('Try again later or update manually:');
|
||||||
|
|
||||||
const packageManager = detectPackageManager();
|
console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
|
||||||
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('');
|
console.log('');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -157,15 +167,94 @@ function handleNoUpdate(reason: string | undefined): void {
|
|||||||
/**
|
/**
|
||||||
* Perform update via npm/yarn/pnpm/bun
|
* Perform update via npm/yarn/pnpm/bun
|
||||||
*/
|
*/
|
||||||
async function performNpmUpdate(
|
function verifyCurrentInstallVersion(
|
||||||
targetTag: string = 'latest',
|
currentInstall: CurrentInstall,
|
||||||
|
targetTag: string,
|
||||||
|
expectedVersion?: string,
|
||||||
|
previousState?: InstalledPackageState,
|
||||||
isReinstall: boolean = false
|
isReinstall: boolean = false
|
||||||
|
): void {
|
||||||
|
const nextState = 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(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const installChanged =
|
||||||
|
previousState !== undefined &&
|
||||||
|
(previousState.version !== nextState.version ||
|
||||||
|
previousState.packageJsonMtimeMs !== nextState.packageJsonMtimeMs ||
|
||||||
|
previousState.scriptMtimeMs !== nextState.scriptMtimeMs);
|
||||||
|
|
||||||
|
if (expectedVersion && installedVersion !== expectedVersion) {
|
||||||
|
if (previousState?.version && installedVersion !== previousState.version) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = compareVersionsWithPrerelease(installedVersion, expectedVersion);
|
||||||
|
if (comparison < 0) {
|
||||||
|
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(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isReinstall &&
|
||||||
|
previousState?.version &&
|
||||||
|
installedVersion === previousState.version &&
|
||||||
|
!installChanged
|
||||||
|
) {
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
fail(
|
||||||
|
`Reinstall completed, but CCS could not verify that the current installation changed from ${previousState.version}.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
console.log('Re-run manually against the current install:');
|
||||||
|
console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performNpmUpdate(
|
||||||
|
currentInstall: CurrentInstall,
|
||||||
|
targetTag: string = 'latest',
|
||||||
|
isReinstall: boolean = false,
|
||||||
|
expectedVersion?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const packageManager = detectPackageManager();
|
const packageManager = currentInstall.manager;
|
||||||
let updateCommand: string;
|
let updateCommand: string;
|
||||||
let updateArgs: string[];
|
let updateArgs: string[];
|
||||||
let cacheCommand: string | null;
|
let cacheCommand: string | null;
|
||||||
let cacheArgs: string[] | null;
|
let cacheArgs: string[] | null;
|
||||||
|
const childEnv = buildPackageManagerEnv(currentInstall);
|
||||||
|
const previousState = readInstalledPackageState(currentInstall);
|
||||||
|
|
||||||
switch (packageManager) {
|
switch (packageManager) {
|
||||||
case 'npm':
|
case 'npm':
|
||||||
@@ -214,9 +303,9 @@ async function performNpmUpdate(
|
|||||||
? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], {
|
? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], {
|
||||||
stdio: ['inherit', 'inherit', 'pipe'],
|
stdio: ['inherit', 'inherit', 'pipe'],
|
||||||
shell: true,
|
shell: true,
|
||||||
env: { ...process.env, NODE_NO_WARNINGS: '1' },
|
env: { ...childEnv, NODE_NO_WARNINGS: '1' },
|
||||||
})
|
})
|
||||||
: spawn(updateCommand, updateArgs, { stdio: 'inherit' });
|
: spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv });
|
||||||
|
|
||||||
// On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.)
|
// 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
|
// These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing
|
||||||
@@ -244,6 +333,15 @@ async function performNpmUpdate(
|
|||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
if (expectedVersion || previousState?.version) {
|
||||||
|
verifyCurrentInstallVersion(
|
||||||
|
currentInstall,
|
||||||
|
targetTag,
|
||||||
|
expectedVersion,
|
||||||
|
previousState,
|
||||||
|
isReinstall
|
||||||
|
);
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`));
|
console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`));
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -255,7 +353,7 @@ async function performNpmUpdate(
|
|||||||
console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`));
|
console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Try manually:');
|
console.log('Try manually:');
|
||||||
console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command'));
|
console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
process.exit(code || 0);
|
process.exit(code || 0);
|
||||||
@@ -266,7 +364,7 @@ async function performNpmUpdate(
|
|||||||
console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`));
|
console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Try manually:');
|
console.log('Try manually:');
|
||||||
console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command'));
|
console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command'));
|
||||||
console.log('');
|
console.log('');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -288,9 +386,9 @@ async function performNpmUpdate(
|
|||||||
? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], {
|
? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
shell: true,
|
||||||
env: { ...process.env, NODE_NO_WARNINGS: '1' },
|
env: { ...childEnv, NODE_NO_WARNINGS: '1' },
|
||||||
})
|
})
|
||||||
: spawn(cacheCommand, cacheArgs, { stdio: 'inherit' });
|
: spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv });
|
||||||
|
|
||||||
cacheChild.on('exit', (code) => {
|
cacheChild.on('exit', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
|
|||||||
@@ -1,75 +1,338 @@
|
|||||||
/**
|
/**
|
||||||
* Package Manager Detector Utilities
|
* Package Manager Detector Utilities
|
||||||
*
|
*
|
||||||
* Cross-platform package manager detection utilities for CCS.
|
* Detect the package manager and install root that own the CURRENT CCS binary.
|
||||||
* Now only supports npm-based installation (npm/yarn/pnpm/bun).
|
* 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 * 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<CurrentInstall, 'manager' | 'prefix'> | 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')
|
||||||
|
) {
|
||||||
|
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' {
|
export function detectCurrentInstall(scriptPath: string = process.argv[1] || ''): CurrentInstall {
|
||||||
const scriptPath = process.argv[1];
|
const resolvedScriptPath = resolveScriptPath(scriptPath);
|
||||||
|
const pathMatch = inferInstallFromPath(resolvedScriptPath) ?? inferInstallFromPath(scriptPath);
|
||||||
|
const packageRoot = findPackageRoot(resolvedScriptPath);
|
||||||
|
|
||||||
// Check if script path contains package manager indicators
|
if (pathMatch) {
|
||||||
if (scriptPath.includes('.pnpm')) return 'pnpm';
|
return {
|
||||||
if (scriptPath.includes('yarn')) return 'yarn';
|
manager: pathMatch.manager,
|
||||||
if (scriptPath.includes('bun')) return 'bun';
|
scriptPath,
|
||||||
|
resolvedScriptPath,
|
||||||
// Check parent directories for lock files
|
packageRoot,
|
||||||
const binDir = path.dirname(scriptPath);
|
prefix: pathMatch.prefix,
|
||||||
|
detectionSource: 'path',
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if package managers are available on the system
|
if (packageRoot) {
|
||||||
try {
|
const packageRootMatch = inferInstallFromPath(packageRoot);
|
||||||
const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], {
|
if (packageRootMatch) {
|
||||||
encoding: 'utf8',
|
return {
|
||||||
shell: true,
|
manager: packageRootMatch.manager,
|
||||||
timeout: 5000,
|
scriptPath,
|
||||||
});
|
resolvedScriptPath,
|
||||||
if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) {
|
packageRoot,
|
||||||
return 'yarn';
|
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 {
|
try {
|
||||||
const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], {
|
return fs.statSync(filePath).mtimeMs;
|
||||||
encoding: 'utf8',
|
} catch {
|
||||||
shell: true,
|
return null;
|
||||||
timeout: 5000,
|
}
|
||||||
});
|
}
|
||||||
if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) {
|
|
||||||
return 'pnpm';
|
export function readInstalledPackageState(install: CurrentInstall): InstalledPackageState {
|
||||||
}
|
if (!install.packageRoot) {
|
||||||
} catch (_err) {
|
return {
|
||||||
// Continue to next check
|
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 {
|
try {
|
||||||
const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], {
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
||||||
encoding: 'utf8',
|
version?: string;
|
||||||
shell: true,
|
};
|
||||||
timeout: 5000,
|
|
||||||
});
|
return {
|
||||||
if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) {
|
version: typeof packageJson.version === 'string' ? packageJson.version : null,
|
||||||
return 'bun';
|
packageJsonMtimeMs,
|
||||||
}
|
scriptMtimeMs,
|
||||||
} catch (_err) {
|
};
|
||||||
// Continue to default
|
} catch {
|
||||||
}
|
return {
|
||||||
|
version: null,
|
||||||
return 'npm';
|
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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||||
|
|
||||||
|
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:
|
||||||
|
| { 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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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', () => ({
|
||||||
|
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') {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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)');
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(1)');
|
||||||
|
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow(
|
||||||
|
'process.exit(1)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logLines.join('\n')).toContain('outside the current installation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails force mode 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' };
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow(
|
||||||
|
'process.exit(1)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logLines.join('\n')).toContain('could not verify that the current installation changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails force mode 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' };
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow(
|
||||||
|
'process.exit(1)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logLines.join('\n')).toContain('could not verify that the current installation changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)');
|
||||||
|
|
||||||
|
expect(logLines.join('\n')).not.toContain('outside the current installation');
|
||||||
|
});
|
||||||
|
|
||||||
|
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' };
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow(
|
||||||
|
'process.exit(0)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logLines.join('\n')).not.toContain(
|
||||||
|
'could not verify that the current installation changed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCommand = await loadHandleUpdateCommand();
|
||||||
|
|
||||||
|
await expect(handleUpdateCommand({ beta: true })).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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user