#!/usr/bin/env node /** * scripts/release-tarball-smoke.cjs * * Release tarball smoke test for issue #3686. * * Guards against the class of bugs that can't be caught by working-tree tests: * - #3684: maskIfSecret import/export mismatch shipped in v1.42.3 (runtime * crash on installed package, invisible to unit tests) * * Strategy: pack the working tree, install into a temp prefix, invoke the * installed binary, assert the version matches package.json. Exercises the * INSTALLED package, not the working tree. * * Exports: * SMOKE — frozen enum of result codes * runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir, * lifecycleCommands, dryRun }) * → { code: SMOKE.*, details: { version, tarball, ... } } * * CLI entry: node scripts/release-tarball-smoke.cjs --json * Packs working tree, installs to a temp prefix, checks version. * Exits 0 on SMOKE.OK, 1 otherwise. * Always prints JSON to stdout when --json flag is present. * * Lifecycle command checks (Cycle 2): * For each command name (other than 'init') in lifecycleCommands: * - Assert commands/msd/.md exists in the installed package * - Parse the .md for a workflow @-import or inline reference * - Assert the referenced workflow .md exists in the installed package * If 'init' is in lifecycleCommands, runs `msd-core --local --claude` * in fixtureDir to verify the installer is callable (INIT_FAILED on crash). * Non-interactive: --local --claude flags skip all prompts. * * Workflow-body checks (Cycle 3 — informational): * - Scans all installed msd-core/workflows/*.md for /msd: * colon-namespace leaks (WORKFLOW_BODY_COLON_LEAK). * This check populates result.details with counters but does NOT return a * failure code by default; it is informational until enforcement is enabled. * * Configured-entrypoint checks (Cycle 4 — #4154): * For each runtime in entrypointRuntimes, runs the tarball-installed * installer into a throwaway HOME, then re-reads that runtime's own written * config files and asserts every MSD-managed script path they name resolves * to a file (ENTRYPOINT_UNRESOLVED). Requires fixtureDir. */ 'use strict'; const { execFileSync, spawnSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { PACKAGE_NAME } = require('../msd-core/bin/lib/package-identity.cjs'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const shellCmdProjection = require('../msd-core/bin/lib/shell-command-projection.cjs'); const { escapeRegex } = require('../msd-core/bin/lib/pattern.cjs'); // 120 s proved too tight for cold-cache `npm install -g` of a 1499-file tarball: // spawnSync fires SIGTERM at the deadline and returns { status: null, stdout: '', // stderr: '' } (Node docs: status is null when a subprocess is terminated by a // signal). The INSTALL_FAILED branch checks `status !== 0`, which null satisfies, // so the test sees empty stdout/stderr and a spurious INSTALL_FAILED (surfaced as // `installError: spawnSync npm ETIMEDOUT`). // // First observed on Windows GitHub-hosted runners, which are slower for // filesystem-heavy work // (https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories), // then again on a slow Linux bench (cartographer: cold disk, constrained CPU) — // the same failure the before() helper's SLOW_HOST_TIMEOUT already guards for // pack+install. The slow-host reality is not platform-specific, so the ceiling is // now uniform 600 s across all platforms AND shared with before() via this // exported constant, so the two surfaces cannot diverge again. 600 s stays well // clear of a real cold install (3–6 min) without masking a genuine hang. const CHILD_TIMEOUT_MS = 600_000; const QUIET_NPM_ENV = Object.freeze({ npm_config_loglevel: 'error', npm_config_update_notifier: 'false', NO_UPDATE_NOTIFIER: '1', }); // --------------------------------------------------------------------------- // Frozen result-code enum // --------------------------------------------------------------------------- const SMOKE = Object.freeze({ OK: 'ok', VERSION_MISMATCH: 'version_mismatch', PACK_FAILED: 'pack_failed', INSTALL_FAILED: 'install_failed', BIN_NOT_CALLABLE: 'bin_not_callable', // Cycle 2 codes COMMAND_FILE_MISSING: 'command_file_missing', WORKFLOW_FILE_MISSING: 'workflow_file_missing', INIT_FAILED: 'init_failed', // Cycle 3 code WORKFLOW_BODY_COLON_LEAK: 'workflow_body_colon_leak', // Cycle 4 code (#4154) ENTRYPOINT_UNRESOLVED: 'entrypoint_unresolved', }); // --------------------------------------------------------------------------- // Exported helper: binInvocation // --------------------------------------------------------------------------- /** * Build the { command, args, shell } descriptor needed to spawn an installed * npm bin correctly on both Windows and POSIX. * * On Windows, npm installs a `.cmd` (or `.bat`) shim in .bin/. Node ≥18.20.2 * / ≥20.12.2 throws EINVAL when you try to spawnSync a .cmd/.bat without * shell:true (CVE-2024-27980 mitigation). With shell:true, Node does NOT * auto-quote argv, so a bin path that contains spaces must be wrapped in * double-quotes to arrive at the shell as one token. * * On POSIX the bin is a regular shebang JS file; we invoke it directly via * process.execPath (the same Node binary) without a shell. * * @param {string} binPath - Absolute path to the resolved bin file. * @param {string[]} [args] - Additional arguments (e.g. ['--help']). * @returns {{ command: string, args: string[], shell: boolean }} */ function binInvocation(binPath, args = []) { const lower = binPath.toLowerCase(); // Note: .ps1 shims are intentionally NOT handled here. The bin-resolution // helpers (findMsdToolsBin / findInstallerBin) only ever surface a .cmd path // on Windows — npm does not write .ps1 shims into .bin/ by default — so a // .ps1 path never reaches this function in practice. if (lower.endsWith('.cmd') || lower.endsWith('.bat')) { // Quote the path if it contains a space so the Windows shell treats it as // a single token. Simple double-quote wrap is sufficient because npm- // generated shim paths don't contain embedded double-quotes. const command = binPath.includes(' ') ? `"${binPath}"` : binPath; return { command, args: [...args], shell: true }; } // POSIX: invoke via node, no shell needed. return { command: process.execPath, args: [binPath, ...args], shell: false }; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Locate the lib/node_modules/@golem15/msd-core package root inside * an npm --prefix install directory. */ function pkgRoot(installPrefix) { // POSIX: /lib/node_modules// // Windows: /node_modules// // PACKAGE_NAME is scoped (@scope/pkg), so split('/') yields the two path segments. const pkgSegments = PACKAGE_NAME.split('/'); const posix = path.join(installPrefix, 'lib', 'node_modules', ...pkgSegments); const win = path.join(installPrefix, 'node_modules', ...pkgSegments); return fs.existsSync(posix) ? posix : win; } /** * Return the ordered list of candidate paths to check when locating an npm * global bin named `name` under `installPrefix`. * * On Windows, `npm install -g --prefix X` writes shims (*.cmd, *.ps1, bare) * to the PREFIX ROOT (X\), NOT to X\node_modules\.bin\. We therefore probe * the prefix root first, then fall back to node_modules\.bin in case a * non-standard layout puts them there. * * On POSIX the shim lands in /bin/ as a symlink; only one candidate. */ function binCandidates(installPrefix, name) { if (process.platform === 'win32') { return [ // npm global --prefix on Windows writes shims to the prefix ROOT path.join(installPrefix, `${name}.cmd`), path.join(installPrefix, name), // fallback: some layouts use node_modules/.bin path.join(installPrefix, 'node_modules', '.bin', `${name}.cmd`), path.join(installPrefix, 'node_modules', '.bin', name), ]; } return [path.join(installPrefix, 'bin', name)]; } /** * Locate the installed msd-tools binary (symlink in /bin/). */ function findMsdToolsBin(installPrefix) { for (const c of binCandidates(installPrefix, 'msd-tools')) { if (fs.existsSync(c)) return c; } return null; } /** * Locate the msd-core installer binary (the symlink in /bin/). */ function findInstallerBin(installPrefix) { for (const c of binCandidates(installPrefix, 'msd-core')) { if (fs.existsSync(c)) return c; } return null; } /** * Parse a command .md file and return the first workflow path it references. * * Structured parser — only inspects individual lines; never regexes on the * whole-file string. Two recognised forms (in priority order): * * 1. @-import line: `@~/.claude/msd-core/workflows/.md` * 2. Inline mention: any line containing `~/.claude/msd-core/workflows/.md` * (takes the LAST occurrence so conditional-dispatch files resolve to the * default / unconditional branch, e.g. discuss-phase.md) * * Returns the bare workflow filename (e.g. `"discuss-phase.md"`) or null. */ function parseWorkflowRef(mdContent) { const WORKFLOW_PREFIX = 'msd-core/workflows/'; let atImportResult = null; let lastInlineResult = null; const lines = mdContent.split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); // Form 1: @-import if (trimmed.startsWith('@') && trimmed.includes(WORKFLOW_PREFIX)) { const idx = trimmed.indexOf(WORKFLOW_PREFIX); const rest = trimmed.slice(idx + WORKFLOW_PREFIX.length); // rest is like "discuss-phase.md" or "discuss-phase.md end-to-end." const name = rest.split(/[\s`"]/)[0]; if (name.endsWith('.md')) { atImportResult = name; break; // @-imports are authoritative; stop on first } } // Form 2: inline mention (collect last) if (trimmed.includes(WORKFLOW_PREFIX)) { const idx = trimmed.indexOf(WORKFLOW_PREFIX); const rest = trimmed.slice(idx + WORKFLOW_PREFIX.length); const name = rest.split(/[\s`"]/)[0]; if (name.endsWith('.md')) { lastInlineResult = name; } } } return atImportResult !== null ? atImportResult : lastInlineResult; } /** * Read the list of known MSD command names from the installed package. * Returns an array of strings like `['init', 'discuss-phase', ...]`. */ function readInstalledCmdNames(pkg) { const commandsDir = path.join(pkg, 'commands', 'msd'); if (!fs.existsSync(commandsDir)) return []; return fs.readdirSync(commandsDir) .filter((f) => f.endsWith('.md')) .map((f) => f.slice(0, -3)); // strip .md } /** * Scan a single workflow .md file for /msd: colon-namespace leaks. * * Uses the word-boundary-safe regex shape from scripts/fix-slash-commands.cjs: * /msd-(||...)(?=[^a-zA-Z0-9_-]|$)/g — forward * We check the colon form: /msd: leaking in installed workflow bodies. * * Returns the first leaking { line, lineNumber } or null. */ function scanWorkflowColonLeak(filePath, cmdNames) { if (!cmdNames || cmdNames.length === 0) return null; const sorted = [...cmdNames].sort((a, b) => b.length - a.length); const pattern = new RegExp(`/msd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g'); const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { pattern.lastIndex = 0; if (pattern.test(lines[i])) { return { line: i + 1, content: lines[i].trim() }; } } return null; } // --------------------------------------------------------------------------- // Cycle 4 helpers: configured-entrypoint resolution (#4154) // --------------------------------------------------------------------------- /** * Top-level config files this scan reads. These are the surfaces that carry * every MSD-managed launch path for the runtimes Cycle 4 actually installs * (`entrypointRuntimes`, default claude + codex): claude registers into * settings.json, codex into hooks.json and config.toml. * * This is NOT an exhaustive map of where MSD writes launch paths across all * runtimes, and the scan is top-level only by design. A runtime whose hook * surface lives in a subdirectory or outside its MSD configDir must first be * taught to scanConfiguredEntrypoints before being added to entrypointRuntimes, * otherwise the scan reports zero entrypoints and silently proves nothing. */ const RUNTIME_CONFIG_FILES = Object.freeze(['settings.json', 'hooks.json', 'config.toml']); /** * Extract every script path `text` names underneath `configDir`. * * #4249 (antigravity review): anchored on the literal, already-known * `configDir` prefix instead of a generic "any absolute path" character * class. The prior version excluded whitespace from the match to avoid * swallowing a shell command's trailing args, which also truncated any * legitimate path containing a space (e.g. `/Users/John Doe/.claude`) — * `scanConfiguredEntrypoints` would then silently report zero checked * paths. Anchoring on `configDir` removes the ambiguity outright: a match * can only start where the known prefix literally occurs in the text, so * an interpreter path concatenated ahead of it (`"/usr/bin/node * /configDir/hooks/foo.js"`) is never swallowed either, and interior * whitespace inside `configDir` or the script's own path segments is safe * to allow. This still scans raw, unparsed config text on purpose (see * scanConfiguredEntrypoints's doc comment) — it catches a writer that * embeds a launch path without registering it, which a structured * JSON.parse of the expected schema would miss entirely. * * Windows configs store paths with backslashes, which JSON/TOML doubles on * write; collapsing `\\` to `\` first makes the raw text scan work on both * platforms without parsing each config format separately (POSIX text has no * backslashes, so the collapse is a no-op there). * * Every writer bakes `configDir` through the same posixNormalize seam * (src/runtime-hooks-surface.cts) before writing it into config text, on * every platform — so the anchor must match that projection, not the * OS-native `configDir` string this function receives. */ function configuredEntrypointsIn(text, configDir) { const normalizedPrefix = shellCmdProjection.posixNormalize(configDir).replace(/\/+$/, '') + '/'; const scriptPathRe = new RegExp(`${escapeRegex(normalizedPrefix)}[^"']{0,400}?\\.(?:js|cjs|mjs|sh|cmd|ps1)`, 'g'); const found = new Set(); for (const match of text.replace(/\\\\/g, '\\').matchAll(scriptPathRe)) { found.add(path.resolve(match[0])); } return [...found]; } /** * Throwaway HOME the Cycle 4 install for `runtime` runs against. Exported so a * test can seed that runtime's config before the install rather than * hard-coding the layout runSmoke picks. */ function entrypointFixtureHome(fixtureDir, runtime) { return path.join(fixtureDir, `entrypoints-${runtime}`); } /** * Re-derive the configured entrypoints a completed install wrote into a * runtime's own config files and report the ones that do not resolve to a * file. Internal to Cycle 4; the smoke's verdict is the exported contract. * * This deliberately does NOT consult the installer's own entrypoint list (the * assertConfiguredEntrypoints gate in bin/install.js). That gate can only * validate paths a config writer remembered to register; reading the written * config back is what catches a writer that emits a launch path without * registering it, and a stale registration an install left behind. * * @param {string} configDir - Absolute path to the runtime config dir. * @returns {{ checked: string[], unresolved: { configPath: string, scriptPath: string }[] }} */ function scanConfiguredEntrypoints(configDir) { const checked = []; const unresolved = []; for (const name of RUNTIME_CONFIG_FILES) { const configPath = path.join(configDir, name); if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) continue; const text = fs.readFileSync(configPath, 'utf-8'); for (const scriptPath of configuredEntrypointsIn(text, configDir)) { checked.push(scriptPath); if (fs.existsSync(scriptPath) && fs.statSync(scriptPath).isFile()) continue; unresolved.push({ configPath, scriptPath }); } } return { checked, unresolved }; } // --------------------------------------------------------------------------- // Pure function: runSmoke // --------------------------------------------------------------------------- /** * @param {object} opts * @param {string} opts.tarballPath - Absolute path to a pre-packed .tgz * @param {string} opts.installPrefix - Temp directory to use as npm --prefix * @param {string} opts.expectedVersion - semver string to assert (e.g. "1.50.0") * @param {string} [opts.fixtureDir] - Temp dir to run `init` into (must NOT be HOME) * @param {string[]} [opts.lifecycleCommands] - Commands to file-check (default: see below) * @param {string[]} [opts.entrypointRuntimes] - Runtime profiles whose configured entrypoints are * re-checked after a real install (default: see below). Requires fixtureDir; pass [] to skip. * @param {boolean} [opts.dryRun=false] - If true, skip actual npm install; validate input only * @param {object} [opts.npmEnv] - Optional env dict for the internal npm install * spawnSync call. Pass an isolated HOME env (e.g. from isolatedNpmEnv() in tests/helpers.cjs) * to prevent npm from reading/writing the caller's $HOME — required on Docker hosts where HOME * may be unwritable. Defaults to process.env. (#131) * @returns {{ code: string, details: object }} */ function runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir, lifecycleCommands = ['init', 'discuss-phase', 'plan-phase', 'execute-phase'], // claude and codex cover the two top-level config surfaces this scan knows // how to read (settings.json, and hooks.json + config.toml). Most other // runtimes reuse one of those two shapes; Cline does not (see // RUNTIME_CONFIG_FILES), so they are out of scope here rather than covered. entrypointRuntimes = ['claude', 'codex'], dryRun = false, npmEnv = undefined, }) { const details = { tarball: tarballPath, prefix: installPrefix, expectedVersion, }; if (dryRun) { return { code: SMOKE.OK, details: { ...details, version: expectedVersion, dryRun: true } }; } // --- Install the tarball into the temp prefix ---------------------------- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; // Use the caller-supplied npmEnv if provided (allows HOME isolation on Docker // hosts where HOME may be unwritable — same pattern as runNpm() in helpers.cjs). // Falls back to process.env to preserve existing CLI / programmatic behaviour. (#131) const effectiveNpmEnv = { ...(npmEnv !== undefined ? npmEnv : process.env), ...QUIET_NPM_ENV }; const installResult = spawnSync( npmCmd, ['install', '-g', '--prefix', installPrefix, tarballPath], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: CHILD_TIMEOUT_MS, env: effectiveNpmEnv }, ); if (installResult.status !== 0) { return { code: SMOKE.INSTALL_FAILED, details: { ...details, stderr: installResult.stderr, stdout: installResult.stdout, // Expose signal + error so a timeout (status=null, signal='SIGTERM', // stdout='', stderr='') is immediately diagnosable in CI logs. signal: installResult.signal ?? null, installError: installResult.error ? String(installResult.error) : null, }, }; } // --- Locate the installed msd-tools binary -------------------------------- const actualBin = findMsdToolsBin(installPrefix); if (!actualBin) { const searched = binCandidates(installPrefix, 'msd-tools'); return { code: SMOKE.BIN_NOT_CALLABLE, details: { ...details, searched }, }; } // --- Invoke `msd-tools --help` to assert the shipped binary is callable --- // Use effectiveNpmEnv so the installed binary sees an isolated HOME on Docker // hosts where HOME may be unwritable (same isolation as the npm install). (#131) const versionInvocation = binInvocation(actualBin, ['--help']); const versionResult = spawnSync( versionInvocation.command, versionInvocation.args, { encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS, env: effectiveNpmEnv, shell: versionInvocation.shell }, ); if (versionResult.status !== 0) { return { code: SMOKE.BIN_NOT_CALLABLE, details: { ...details, bin: actualBin, stderr: versionResult.stderr, stdout: versionResult.stdout, }, }; } // Source of truth for shipped version is the installed package.json. const installedPkgPath = path.join(pkgRoot(installPrefix), 'package.json'); const installedPkg = JSON.parse(fs.readFileSync(installedPkgPath, 'utf-8')); const installedVersion = String(installedPkg.version || '').trim(); details.version = installedVersion; details.bin = actualBin; details.installedPackageJson = installedPkgPath; if (installedVersion !== expectedVersion) { return { code: SMOKE.VERSION_MISMATCH, details: { ...details, installedVersion, expectedVersion }, }; } // ───────────────────────────────────────────────────────────────────────── // Cycle 2: lifecycle command file-resolution checks // ───────────────────────────────────────────────────────────────────────── const pkg = pkgRoot(installPrefix); const shouldRunInit = lifecycleCommands.includes('init'); const commandsToCheck = lifecycleCommands.filter((c) => c !== 'init'); // --- Run init if requested ----------------------------------------------- if (shouldRunInit && fixtureDir) { const installerBin = findInstallerBin(installPrefix); if (!installerBin) { return { code: SMOKE.INIT_FAILED, details: { ...details, reason: 'msd-core binary not found in installPrefix', installPrefix, }, }; } // Non-interactive: --local --claude installs to .claude/ in cwd (fixtureDir). // MSD_TEST_MODE must be cleared — install.js skips its main() block when // MSD_TEST_MODE is set, which would cause the installer to exit 0 silently // without actually creating any files. const initEnv = { ...process.env }; delete initEnv.MSD_TEST_MODE; const initInvocation = binInvocation(installerBin, ['--local', '--claude']); const initResult = spawnSync( initInvocation.command, initInvocation.args, { encoding: 'utf-8', cwd: fixtureDir, // Ensure no TTY so the installer's non-interactive fallback fires stdio: ['pipe', 'pipe', 'pipe'], env: initEnv, timeout: CHILD_TIMEOUT_MS, shell: initInvocation.shell, }, ); if (initResult.status !== 0) { return { code: SMOKE.INIT_FAILED, details: { ...details, fixtureDir, stderr: initResult.stderr, stdout: initResult.stdout, }, }; } // Verify expected dirs were created const expectedDirs = [ path.join(fixtureDir, '.claude', 'commands'), path.join(fixtureDir, '.claude', 'msd-core'), ]; for (const dir of expectedDirs) { if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { return { code: SMOKE.INIT_FAILED, details: { ...details, fixtureDir, reason: `expected dir not created: ${dir}`, }, }; } } } // --- Check command files and workflow references ------------------------- const lifecycleResolved = []; for (const cmd of commandsToCheck) { const cmdFilePath = path.join(pkg, 'commands', 'msd', `${cmd}.md`); if (!fs.existsSync(cmdFilePath) || !fs.statSync(cmdFilePath).isFile()) { return { code: SMOKE.COMMAND_FILE_MISSING, details: { ...details, command: cmd, path: cmdFilePath, }, }; } // Parse workflow reference const mdContent = fs.readFileSync(cmdFilePath, 'utf-8'); const workflowName = parseWorkflowRef(mdContent); let workflowPath = null; if (workflowName) { // Workflow files live at msd-core/workflows/ in the package. // Some live in subdirectories; try flat first then scan once. const flat = path.join(pkg, 'msd-core', 'workflows', workflowName); workflowPath = fs.existsSync(flat) ? flat : null; if (!workflowPath) { return { code: SMOKE.WORKFLOW_FILE_MISSING, details: { ...details, command: cmd, path: flat, }, }; } } lifecycleResolved.push({ command: cmd, commandPath: cmdFilePath, workflowPath, }); } details.lifecycleResolved = lifecycleResolved; // ───────────────────────────────────────────────────────────────────────── // Cycle 3: workflow-body validation (informational) // ───────────────────────────────────────────────────────────────────────── // --- Workflow-body checks (informational — #3668 not yet fixed) ---------- const workflowsDir = path.join(pkg, 'msd-core', 'workflows'); const installedCmdNames = readInstalledCmdNames(pkg); let workflowsScanned = 0; let colonLeakCount = 0; // Store first finding for potential future enforcement mode. let firstColonLeak = null; if (fs.existsSync(workflowsDir)) { // Collect all .md files (flat only — subdirs contain sub-workflows that // follow the same contract, but the top-level .md files are the primary surface) const entries = fs.readdirSync(workflowsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const filePath = path.join(workflowsDir, entry.name); workflowsScanned++; const leak = scanWorkflowColonLeak(filePath, installedCmdNames); if (leak) { colonLeakCount++; if (!firstColonLeak) { firstColonLeak = { file: filePath, line: leak.line }; } } } } details.workflowsScanned = workflowsScanned; details.colonLeakCount = colonLeakCount; if (firstColonLeak) details.firstColonLeak = firstColonLeak; // NOTE: colonLeakCount is informational here. Once the backlog is fixed, // a future enforcement mode can fail on non-zero counts. // ───────────────────────────────────────────────────────────────────────── // Cycle 4: configured-entrypoint resolution (#4154) // ───────────────────────────────────────────────────────────────────────── // The installer's own gate (assertConfiguredEntrypoints) runs in-process and // only sees the paths a config writer registered with it. Installing the // packed tarball for real and reading each runtime's written config back is // what proves the launch paths a user's runtime will actually invoke exist // in the shipped layout. const entrypointProfiles = []; if (fixtureDir && entrypointRuntimes.length > 0) { for (const runtime of entrypointRuntimes) { // Own HOME per runtime so a --global install cannot reach the real one. const runtimeHome = entrypointFixtureHome(fixtureDir, runtime); const configDir = path.join(runtimeHome, `.${runtime}`); fs.mkdirSync(runtimeHome, { recursive: true }); const installResult = spawnSync( process.execPath, [path.join(pkg, 'bin', 'install.js'), `--${runtime}`, '--global', '--config-dir', configDir], { encoding: 'utf-8', cwd: runtimeHome, stdio: ['pipe', 'pipe', 'pipe'], env: { ...effectiveNpmEnv, HOME: runtimeHome, USERPROFILE: runtimeHome, // Same reason as the init check above: install.js skips its main() // block when MSD_TEST_MODE is set and would exit 0 writing nothing. MSD_TEST_MODE: '', NO_UPDATE_NOTIFIER: '1', }, timeout: CHILD_TIMEOUT_MS, }, ); if (installResult.status !== 0) { // #4249 (antigravity review): this is the Cycle 4 per-runtime install, // not Cycle 1's `msd init` — SMOKE.INSTALL_FAILED is the code Cycle 2's // identical spawnSync-failure check already uses for the same failure // class; reusing SMOKE.INIT_FAILED here conflated the two lifecycle // stages in the reported code. return { code: SMOKE.INSTALL_FAILED, details: { ...details, runtime, configDir, stderr: installResult.stderr, stdout: installResult.stdout, }, }; } const scan = scanConfiguredEntrypoints(configDir); if (scan.unresolved.length > 0) { return { code: SMOKE.ENTRYPOINT_UNRESOLVED, details: { ...details, runtime, configDir, unresolved: scan.unresolved }, }; } entrypointProfiles.push({ runtime, configDir, entrypointsChecked: scan.checked.length }); } } details.entrypointProfiles = entrypointProfiles; return { code: SMOKE.OK, details }; } // --------------------------------------------------------------------------- // CLI entry // --------------------------------------------------------------------------- function cliMain() { const args = process.argv.slice(2); const isJson = args.includes('--json'); const pkgPath = path.join(__dirname, '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); const expectedVersion = process.env.SMOKE_FORCE_EXPECTED_VERSION || pkg.version; // Pack the working tree into a temp directory const packDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-smoke-pack-')); const installPrefix = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-smoke-prefix-')); const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-smoke-fixture-')); let tarballPath; try { const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const packOutput = execFileSync( npmCmd, ['pack', '--pack-destination', packDir], { cwd: path.join(__dirname, '..'), encoding: 'utf-8', shell: process.platform === 'win32', timeout: CHILD_TIMEOUT_MS, env: { ...process.env, ...QUIET_NPM_ENV }, }, ).trim(); // npm pack outputs the filename on stdout (last line when verbose) const lines = packOutput.split(/\r?\n/).filter(Boolean); const tgzName = lines[lines.length - 1]; tarballPath = path.join(packDir, tgzName); if (!fs.existsSync(tarballPath)) { // npm 7+ may print just the filename without .tgz extension on some platforms const found = fs.readdirSync(packDir).find((f) => f.endsWith('.tgz')); if (found) { tarballPath = path.join(packDir, found); } else { const result = { code: SMOKE.PACK_FAILED, details: { packDir, packOutput, reason: 'no .tgz in pack destination' }, }; if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); throw new ExitError(1); } } } catch (err) { if (err instanceof ExitError) throw err; const result = { code: SMOKE.PACK_FAILED, details: { error: err.message, stderr: err.stderr }, }; if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); throw new ExitError(1); } const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir }); if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); return result.code === SMOKE.OK ? 0 : 1; } function cleanup(...dirs) { for (const dir of dirs) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { // best-effort } } } // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- module.exports = { SMOKE, runSmoke, binInvocation, entrypointFixtureHome, CHILD_TIMEOUT_MS, configuredEntrypointsIn, }; if (require.main === module) { runMain(cliMain); }