import { mkdtempSync, rmSync, } from 'node:fs'; import { mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; import { ensureReusableNodeModules, } from '../utils/repo-deps.js'; export { hasUsableNodeModules, resolveGitCommonDir, resolveReusableNodeModulesSource, } from '../utils/repo-deps.js'; export const PACKED_INSTALL_SMOKE_CORE_COMMANDS = [ ['--help'], ['version'], ] as const; function usage(): string { return [ 'Usage: node scripts/smoke-packed-install.mjs', '', 'Creates an npm tarball, installs it into an isolated prefix, and smoke tests the installed rcs CLI.', 'Release smoke stays intentionally minimal: install + boot + 1-2 core commands only.', ].join('\n'); } interface EnsureRepoDepsOptions { gitRunner?: typeof spawnSync; install?: (cwd: string) => void; log?: (message: string) => void; } interface EnsureRepoDepsResult { strategy: string; nodeModulesPath: string; sourceNodeModulesPath?: string; } function formatCommandFailure(cmd: string, args: string[], result: { stdout?: string; stderr?: string }): string { return [ `Command failed: ${cmd} ${args.join(' ')}`, result.stdout?.trim() ? `stdout:\n${result.stdout.trim()}` : '', result.stderr?.trim() ? `stderr:\n${result.stderr.trim()}` : '', ].filter(Boolean).join('\n\n'); } function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } export function ensureRepoDependencies(repoRoot: string, options: EnsureRepoDepsOptions = {}): EnsureRepoDepsResult { const { gitRunner = spawnSync, install = (cwd: string) => { const result = spawnSync('npm', ['ci'], { cwd, encoding: 'utf-8', stdio: 'pipe', }); if (result.status !== 0) { throw new Error(formatCommandFailure('npm', ['ci'], result)); } }, log = () => {}, } = options; const reusable = ensureReusableNodeModules(repoRoot, { gitRunner }); if (reusable.strategy === 'existing') { return reusable; } if (reusable.strategy === 'symlink') { log(`[smoke:packed-install] Reusing node_modules from ${reusable.sourceNodeModulesPath}`); return reusable; } log('[smoke:packed-install] Installing repo dependencies with npm ci'); install(repoRoot); return { strategy: 'installed', nodeModulesPath: join(repoRoot, 'node_modules'), }; } function parseArgs(argv: string[]): void { for (const token of argv) { if (token === '--help' || token === '-h') { console.log(usage()); process.exit(0); } throw new Error(`Unknown argument: ${token}\n${usage()}`); } } function run(cmd: string, args: readonly string[], options: Record = {}): ReturnType { let result; if (cmd === 'npm' && process.platform !== 'win32') { result = spawnSync('/bin/bash', ['-lc', ['npm', ...args].map(quoteShellArg).join(' ')], { encoding: 'utf-8', stdio: 'pipe', ...options, }); } else { result = spawnSync(cmd, [...args], { encoding: 'utf-8', stdio: 'pipe', ...options, }); if ( cmd === 'npm' && result.error && /(EPERM|EACCES)/i.test(result.error.message || '') ) { const npmCli = join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); result = spawnSync(process.execPath, [npmCli, ...args], { encoding: 'utf-8', stdio: 'pipe', ...options, }); } } if (result.status !== 0) { throw new Error(formatCommandFailure(cmd, [...args], result)); } return result; } function npmBinName(name: string): string { return process.platform === 'win32' ? `${name}.cmd` : name; } export function parseNpmPackJsonOutput(stdout: string): Array<{ filename: string }> { const start = stdout.lastIndexOf('\n['); const jsonText = (start >= 0 ? stdout.slice(start + 1) : stdout).trim(); if (!jsonText.startsWith('[')) { throw new Error(`npm pack did not return JSON output: ${stdout.trim()}`); } return JSON.parse(jsonText) as Array<{ filename: string }>; } async function main(): Promise { parseArgs(process.argv.slice(2)); const repoRoot = process.cwd(); const tempRoot = mkdtempSync(join(tmpdir(), 'rcs-packed-install-')); const prefixDir = join(tempRoot, 'prefix'); mkdirSync(prefixDir, { recursive: true }); let tarballPath: string | undefined; try { ensureRepoDependencies(repoRoot, { log: (message: string) => console.log(message), }); const pack = run('npm', ['pack', '--json'], { cwd: repoRoot }); const packOutput = parseNpmPackJsonOutput(pack.stdout as string); const tarballName = packOutput[0]?.filename; if (!tarballName) throw new Error('npm pack did not return a tarball filename'); tarballPath = join(repoRoot, tarballName); run('npm', ['install', '-g', tarballPath, '--prefix', prefixDir], { cwd: repoRoot }); const rcsPath = join(prefixDir, process.platform === 'win32' ? '' : 'bin', npmBinName('rcs')); for (const argv of PACKED_INSTALL_SMOKE_CORE_COMMANDS) { run(rcsPath, argv, { cwd: repoRoot }); } console.log('packed install smoke: PASS'); } finally { if (tarballPath) rmSync(tarballPath, { force: true }); rmSync(tempRoot, { recursive: true, force: true }); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { console.error(`packed install smoke: FAIL\n${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); }