504 lines
14 KiB
TypeScript
504 lines
14 KiB
TypeScript
import fs from 'fs-extra';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import chalk from 'chalk';
|
|
import { spawn } from 'child_process';
|
|
|
|
type InstallScript = 'ci' | 'install';
|
|
|
|
interface InstallProjectDependenciesOptions {
|
|
mode?: 'auto' | InstallScript;
|
|
}
|
|
|
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
const SLOW_INSTALL_NOTICE_MS = 60 * 1000;
|
|
const CAPTURED_OUTPUT_LIMIT = 12000;
|
|
const STALE_LOG_ONLY_MARKER_MS = 90 * 1000;
|
|
const STOP_BACKGROUND_GRACE_MS = 3000;
|
|
const STOP_BACKGROUND_KILL_GRACE_MS = 2000;
|
|
const STOP_BACKGROUND_POLL_MS = 200;
|
|
|
|
// Marker files written by the background install so other commands (`pinme save`)
|
|
// can tell whether dependencies are still installing, finished, or failed.
|
|
export const INSTALL_LOG_FILE = '.pinme-install.log';
|
|
export const INSTALL_EXITCODE_FILE = '.pinme-install.exitcode';
|
|
export const INSTALL_PID_FILE = '.pinme-install.pid';
|
|
|
|
export type BackgroundInstallStatus =
|
|
| 'idle'
|
|
| 'running'
|
|
| 'success'
|
|
| 'failed'
|
|
| 'interrupted';
|
|
|
|
export interface BackgroundInstallState {
|
|
status: BackgroundInstallStatus;
|
|
exitCode: number | null;
|
|
logPath: string;
|
|
}
|
|
|
|
export class DependencyInstallError extends Error {
|
|
command: string;
|
|
|
|
constructor(command: string, cause: unknown) {
|
|
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
super(`${command} failed: ${reason}`);
|
|
this.name = 'DependencyInstallError';
|
|
this.command = command;
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
function makeTempCacheDir(): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'pinme-npm-cache-'));
|
|
}
|
|
|
|
function getNpmCommand(): string {
|
|
// On Windows, npm is often installed as npm.cmd
|
|
if (process.platform === 'win32') {
|
|
return 'npm.cmd';
|
|
}
|
|
return 'npm';
|
|
}
|
|
|
|
function hasPackageLock(cwd: string): boolean {
|
|
return fs.existsSync(path.join(cwd, 'package-lock.json'));
|
|
}
|
|
|
|
function getInstallScript(cwd: string, mode: 'auto' | InstallScript): InstallScript {
|
|
if (mode !== 'auto') {
|
|
return mode;
|
|
}
|
|
|
|
return hasPackageLock(cwd) ? 'ci' : 'install';
|
|
}
|
|
|
|
function getInstallArgs(script: InstallScript, cacheDir?: string): string[] {
|
|
const args = [
|
|
script,
|
|
'--no-audit',
|
|
'--no-fund',
|
|
'--fetch-retries=3',
|
|
'--fetch-retry-factor=2',
|
|
'--fetch-retry-mintimeout=10000',
|
|
'--fetch-retry-maxtimeout=60000',
|
|
'--fetch-timeout=60000',
|
|
];
|
|
|
|
if (cacheDir) {
|
|
args.splice(1, 0, '--cache', cacheDir);
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function formatInstallCommand(
|
|
script: InstallScript,
|
|
cacheMode: 'default' | 'isolated' = 'default',
|
|
): string {
|
|
const parts = [
|
|
`npm ${script}`,
|
|
'--no-audit',
|
|
'--no-fund',
|
|
'--fetch-retries=3',
|
|
'--fetch-timeout=60000',
|
|
];
|
|
|
|
if (cacheMode === 'isolated') {
|
|
parts.splice(1, 0, '--cache <isolated npm cache>');
|
|
}
|
|
|
|
return parts.join(' ');
|
|
}
|
|
|
|
function appendCapturedOutput(output: string, chunk: Buffer): string {
|
|
const next = output + chunk.toString();
|
|
if (next.length <= CAPTURED_OUTPUT_LIMIT) {
|
|
return next;
|
|
}
|
|
|
|
return next.slice(next.length - CAPTURED_OUTPUT_LIMIT);
|
|
}
|
|
|
|
function runInstall(
|
|
cwd: string,
|
|
script: InstallScript,
|
|
cacheDir?: string,
|
|
): Promise<void> {
|
|
const npm = getNpmCommand();
|
|
const args = getInstallArgs(script, cacheDir);
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
npm_config_audit: 'false',
|
|
npm_config_fund: 'false',
|
|
npm_config_fetch_retries: '3',
|
|
npm_config_fetch_retry_factor: '2',
|
|
npm_config_fetch_retry_mintimeout: '10000',
|
|
npm_config_fetch_retry_maxtimeout: '60000',
|
|
npm_config_fetch_timeout: '60000',
|
|
};
|
|
|
|
if (cacheDir) {
|
|
env.npm_config_cache = cacheDir;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let capturedOutput = '';
|
|
let settled = false;
|
|
let timedOut = false;
|
|
const child = spawn(npm, args, {
|
|
cwd,
|
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
shell: process.platform === 'win32',
|
|
env,
|
|
});
|
|
|
|
const slowNoticeTimer = setTimeout(() => {
|
|
console.log(chalk.yellow(' Still installing dependencies. Slow npm registry or network can make this take a few minutes...'));
|
|
}, SLOW_INSTALL_NOTICE_MS);
|
|
|
|
const timeoutTimer = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill();
|
|
}, INSTALL_TIMEOUT_MS);
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(slowNoticeTimer);
|
|
clearTimeout(timeoutTimer);
|
|
};
|
|
|
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
process.stdout.write(chunk);
|
|
capturedOutput = appendCapturedOutput(capturedOutput, chunk);
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
process.stderr.write(chunk);
|
|
capturedOutput = appendCapturedOutput(capturedOutput, chunk);
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(error);
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
|
|
if (timedOut) {
|
|
reject(new Error(`npm ${script} timed out after 10 minutes.\n${capturedOutput}`));
|
|
return;
|
|
}
|
|
|
|
if (code !== 0) {
|
|
reject(new Error(`npm ${script} failed with exit code ${code}${signal ? ` and signal ${signal}` : ''}.\n${capturedOutput}`));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
type ShellPlatform = NodeJS.Platform | 'posix';
|
|
|
|
interface BackgroundInstallCommand {
|
|
shellBin: string;
|
|
shellArgs: string[];
|
|
installCommand: string;
|
|
}
|
|
|
|
function quoteForShell(value: string, platform: ShellPlatform = process.platform): string {
|
|
// tmp paths / project paths can contain spaces; wrap everything in quotes.
|
|
if (platform === 'win32') {
|
|
return `"${value}"`;
|
|
}
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
export function buildBackgroundInstallCommand(
|
|
script: InstallScript,
|
|
logPath: string,
|
|
exitCodePath: string,
|
|
platform: ShellPlatform = process.platform,
|
|
): BackgroundInstallCommand {
|
|
const args = getInstallArgs(script);
|
|
const npmCmd = platform === 'win32' ? 'npm' : getNpmCommand();
|
|
const installCommand = `${npmCmd} ${args.map((arg) => quoteForShell(arg, platform)).join(' ')}`;
|
|
const qLog = quoteForShell(logPath, platform);
|
|
const qExit = quoteForShell(exitCodePath, platform);
|
|
|
|
if (platform === 'win32') {
|
|
// Enable delayed expansion so !errorlevel! is evaluated after npm exits.
|
|
return {
|
|
shellBin: process.env.ComSpec || 'cmd.exe',
|
|
shellArgs: [
|
|
'/d',
|
|
'/s',
|
|
'/v:on',
|
|
'/c',
|
|
`${installCommand} >> ${qLog} 2>&1 & echo !errorlevel! > ${qExit}`,
|
|
],
|
|
installCommand,
|
|
};
|
|
}
|
|
|
|
return {
|
|
shellBin: '/bin/sh',
|
|
shellArgs: [
|
|
'-c',
|
|
`${installCommand} >> ${qLog} 2>&1; printf '%s' "$?" > ${qExit}`,
|
|
],
|
|
installCommand,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start a dependency install in a detached background process and return
|
|
* immediately. The child keeps running after the current CLI process exits,
|
|
* so `pinme create` can finish (and `process.exit`) while dependencies keep
|
|
* installing for later use (`pinme save`, local dev).
|
|
*
|
|
* The install is wrapped in a shell command that:
|
|
* 1. Clears any previous exit-code marker.
|
|
* 2. Streams all output to `<cwd>/.pinme-install.log`.
|
|
* 3. Writes the install's exit code to `<cwd>/.pinme-install.exitcode` when done.
|
|
*
|
|
* Other commands read those markers (see {@link readBackgroundInstallStatus}) to
|
|
* know whether the install is still running, succeeded, or failed.
|
|
*/
|
|
export function startBackgroundInstall(cwd: string): { logPath: string } {
|
|
const script = getInstallScript(cwd, 'auto');
|
|
const logPath = path.join(cwd, INSTALL_LOG_FILE);
|
|
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
|
|
const pidPath = path.join(cwd, INSTALL_PID_FILE);
|
|
|
|
// Reset markers from any previous run.
|
|
fs.removeSync(exitCodePath);
|
|
fs.removeSync(pidPath);
|
|
fs.writeFileSync(logPath, `[pinme] ${new Date().toISOString()} starting "npm ${script}"\n`);
|
|
|
|
const { shellBin, shellArgs } = buildBackgroundInstallCommand(script, logPath, exitCodePath);
|
|
|
|
const child = spawn(shellBin, shellArgs, {
|
|
cwd,
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env: {
|
|
...process.env,
|
|
npm_config_audit: 'false',
|
|
npm_config_fund: 'false',
|
|
npm_config_fetch_retries: '3',
|
|
npm_config_fetch_retry_factor: '2',
|
|
npm_config_fetch_retry_mintimeout: '10000',
|
|
npm_config_fetch_retry_maxtimeout: '60000',
|
|
npm_config_fetch_timeout: '60000',
|
|
},
|
|
});
|
|
|
|
child.unref();
|
|
if (child.pid) {
|
|
fs.writeFileSync(pidPath, String(child.pid));
|
|
}
|
|
|
|
return { logPath };
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error: any) {
|
|
return error?.code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
function readPidFile(pidPath: string): number | null {
|
|
if (!fs.existsSync(pidPath)) {
|
|
return null;
|
|
}
|
|
|
|
const raw = fs.readFileSync(pidPath, 'utf-8').trim();
|
|
const pid = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(pid) || pid <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return pid;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function signalInstallProcess(pid: number, signal: NodeJS.Signals): boolean {
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
process.kill(pid, signal);
|
|
} else {
|
|
process.kill(-pid, signal);
|
|
}
|
|
return true;
|
|
} catch {
|
|
try {
|
|
process.kill(pid, signal);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForProcessExit(
|
|
pid: number,
|
|
timeoutMs: number,
|
|
): Promise<boolean> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (!isProcessAlive(pid)) {
|
|
return true;
|
|
}
|
|
await sleep(STOP_BACKGROUND_POLL_MS);
|
|
}
|
|
|
|
return !isProcessAlive(pid);
|
|
}
|
|
|
|
export async function stopBackgroundInstall(cwd: string): Promise<boolean> {
|
|
const pidPath = path.join(cwd, INSTALL_PID_FILE);
|
|
const pid = readPidFile(pidPath);
|
|
let stopped = false;
|
|
|
|
if (pid !== null && isProcessAlive(pid)) {
|
|
if (signalInstallProcess(pid, 'SIGTERM')) {
|
|
stopped = true;
|
|
const stoppedGracefully = await waitForProcessExit(
|
|
pid,
|
|
STOP_BACKGROUND_GRACE_MS,
|
|
);
|
|
|
|
if (!stoppedGracefully) {
|
|
signalInstallProcess(pid, 'SIGKILL');
|
|
await waitForProcessExit(pid, STOP_BACKGROUND_KILL_GRACE_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
fs.removeSync(pidPath);
|
|
return stopped;
|
|
}
|
|
|
|
function isStaleLogOnlyMarker(logPath: string): boolean {
|
|
try {
|
|
const ageMs = Date.now() - fs.statSync(logPath).mtimeMs;
|
|
return ageMs > STALE_LOG_ONLY_MARKER_MS;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the state of a background install started by {@link startBackgroundInstall}.
|
|
* - `idle`: no install was ever started here (no log, no exit-code marker).
|
|
* - `running`: a log exists but the exit code has not been written yet.
|
|
* - `success` / `failed`: the install finished with the recorded exit code.
|
|
* - `interrupted`: an install started, but the tracked background process is
|
|
* gone or a legacy log-only marker is too old to trust.
|
|
*/
|
|
export function readBackgroundInstallStatus(cwd: string): BackgroundInstallState {
|
|
const logPath = path.join(cwd, INSTALL_LOG_FILE);
|
|
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
|
|
const pidPath = path.join(cwd, INSTALL_PID_FILE);
|
|
|
|
if (fs.existsSync(exitCodePath)) {
|
|
const raw = fs.readFileSync(exitCodePath, 'utf-8').trim();
|
|
const code = Number.parseInt(raw, 10);
|
|
if (Number.isNaN(code)) {
|
|
// The marker is being written but the value is not flushed yet.
|
|
return { status: 'running', exitCode: null, logPath };
|
|
}
|
|
return { status: code === 0 ? 'success' : 'failed', exitCode: code, logPath };
|
|
}
|
|
|
|
if (fs.existsSync(logPath)) {
|
|
const pid = readPidFile(pidPath);
|
|
if (pid !== null) {
|
|
return {
|
|
status: isProcessAlive(pid) ? 'running' : 'interrupted',
|
|
exitCode: null,
|
|
logPath,
|
|
};
|
|
}
|
|
|
|
if (isStaleLogOnlyMarker(logPath)) {
|
|
return { status: 'interrupted', exitCode: null, logPath };
|
|
}
|
|
|
|
return { status: 'running', exitCode: null, logPath };
|
|
}
|
|
|
|
return { status: 'idle', exitCode: null, logPath };
|
|
}
|
|
|
|
/** Return the tail of the background install log, for surfacing failure causes. */
|
|
export function readBackgroundInstallLogTail(cwd: string, maxChars = 1500): string {
|
|
const logPath = path.join(cwd, INSTALL_LOG_FILE);
|
|
if (!fs.existsSync(logPath)) {
|
|
return '';
|
|
}
|
|
const content = fs.readFileSync(logPath, 'utf-8');
|
|
return content.length > maxChars ? content.slice(content.length - maxChars) : content;
|
|
}
|
|
|
|
export async function installProjectDependencies(
|
|
cwd: string,
|
|
options: InstallProjectDependenciesOptions = {},
|
|
): Promise<void> {
|
|
const mode = options.mode || 'auto';
|
|
const script = getInstallScript(cwd, mode);
|
|
const command = formatInstallCommand(script);
|
|
let lastError: unknown;
|
|
|
|
if (script === 'ci' && !hasPackageLock(cwd)) {
|
|
throw new DependencyInstallError(
|
|
command,
|
|
new Error('package-lock.json is required for npm ci but was not found.'),
|
|
);
|
|
}
|
|
|
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
const useIsolatedCache = attempt > 1;
|
|
const cacheDir = useIsolatedCache ? makeTempCacheDir() : undefined;
|
|
|
|
try {
|
|
if (attempt > 1) {
|
|
console.log(chalk.yellow(' Retrying dependency install with a fresh npm cache...'));
|
|
}
|
|
|
|
if (attempt === 1 && script === 'ci' && mode === 'auto') {
|
|
console.log(chalk.gray(' package-lock.json found; using npm ci for a reproducible install.'));
|
|
} else if (attempt === 1 && script === 'ci') {
|
|
console.log(chalk.gray(' Using npm ci for a clean, reproducible install.'));
|
|
}
|
|
|
|
await runInstall(cwd, script, cacheDir);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
} finally {
|
|
if (cacheDir) {
|
|
fs.removeSync(cacheDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new DependencyInstallError(
|
|
formatInstallCommand(script, 'isolated'),
|
|
lastError,
|
|
);
|
|
}
|