Files
msd-core/scripts/release-tarball-smoke.cjs
Tom Boucher 334a64168e chore(npm): rebrand packages to @opengsd scope (#127)
* chore(npm): rebrand packages to @opengsd scope

Rename:
- get-shit-done-redux → @opengsd/get-shit-done-redux
- @gsd-redux/sdk → @opengsd/gsd-sdk

Add publishConfig.access=public for first-time scoped publish.
CLI binary names (get-shit-done-redux, gsd-sdk, gsd-tools) unchanged.

Sweeps install commands, npx invocations, CI publish/version-check
workflows, tests, docs, READMEs (all translations), and the
PACKAGE_NAME constant in check-latest-version.

Bumps qs 6.15.1 → 6.15.2 to clear a moderate advisory surfaced by
the audit-clean test (GHSA-q8mj-m7cp-5q26).

Closes #126

* chore: pin 2.0.0 release + remove canary workflow

- Bump both packages 1.50.0-canary.0 → 2.0.0 for first @opengsd publish
- Remove .github/workflows/canary.yml and canary dist-tag handling in
  release.yml / release-sdk.yml
- Drop canary section from VERSIONING.md

Refs #126

* chore: address review findings + harden tarball-smoke timeout

- .changeset/opengsd-org-rename.md: match project's custom
  parse.cjs frontmatter (type: Changed / pr: 127); the scoped
  @changesets/cli keys were silently rejected.
- CONTEXT.md: drop two canary-stream policy lines and a dangling
  DEFECT.CANARY-VERSION-LEAK.cross-ref now that canary.yml is gone.
- tests/release-tarball-smoke.install.test.cjs: pass
  timeout: 600_000 for npm pack + global install; the 3-minute
  runNpm default was timing out on slower Docker hosts (cartographer).

Refs #126

* fix(sdk): add missing type/runtime devDependencies for build

prepublishOnly invokes tsc which couldn't resolve @types/node,
@types/ws, or synckit. They had been hoisted from root but were
not declared in sdk/'s own package.json — first publish from a
clean SDK tree failed.

Refs #126

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ci): use npm pack stdout instead of glob to find tarball

`npm pack --silent` for a scoped package (@opengsd/get-shit-done-redux)
produces `opengsd-get-shit-done-redux-*.tgz`, not `get-shit-done-redux-*.tgz`.
Capture the filename from stdout instead of a hardcoded glob so the step
works regardless of package name format.

Fixes smoke (ubuntu-latest, 22, false) CI failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: treat workflow-file changes as test-skip eligible

`.github/workflows/install-smoke.yml` (and other workflow files)
were in neither `test.yml` paths nor `test-skip.yml` paths-ignore,
so neither workflow ran on a workflow-only commit — leaving the
required test-skip check perpetually missing.

Refs #126

* chore: reset version to 1.0.0 for first @opengsd publish

Nothing has been published yet under the @opengsd scope, so the
inaugural release uses 1.0.0 rather than 2.0.0. The "major bump"
in the changeset reflects the breaking install-command change for
users migrating from the prior unscoped `get-shit-done-redux`, not
a numeric continuation from a 1.x line under the new identity.

Refs #126

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:22:41 -04:00

678 lines
23 KiB
JavaScript

#!/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)
* - #3668: 75/78 workflows call bare `gsd-sdk` without fallback; --local users
* see `command not found`
*
* 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/gsd/<cmd>.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 `get-shit-done-redux --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 until #3668 is fixed):
* - Calls `gsd-sdk "query" state.json --project-dir <fixtureDir>` to verify
* the SDK binary is callable and produces parseable JSON (SDK_BINARY_NOT_CALLABLE).
* - Scans all installed get-shit-done/workflows/*.md for:
* (a) /gsd:<known-cmd> colon-namespace leaks (WORKFLOW_BODY_COLON_LEAK)
* (b) bare `gsd-sdk` query invocations in shell fences without a `command -v gsd-sdk`
* guard in the same fence (WORKFLOW_MISSING_SDK_FALLBACK — #3668).
* Both checks populate result.details with counters but do NOT return a failure
* code by default; they are informational until the upstream fixes land.
*/
'use strict';
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const CHILD_TIMEOUT_MS = 120000;
// ---------------------------------------------------------------------------
// 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 codes
SDK_BINARY_NOT_CALLABLE: 'sdk_binary_not_callable',
WORKFLOW_BODY_COLON_LEAK: 'workflow_body_colon_leak',
WORKFLOW_MISSING_SDK_FALLBACK: 'workflow_missing_sdk_fallback',
});
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Locate the lib/node_modules/@opengsd/get-shit-done-redux package root inside
* an npm --prefix install directory.
*/
function pkgRoot(installPrefix) {
// POSIX: <prefix>/lib/node_modules/@opengsd/get-shit-done-redux
// Windows: <prefix>/node_modules/@opengsd/get-shit-done-redux
const posix = path.join(installPrefix, 'lib', 'node_modules', '@opengsd', 'get-shit-done-redux');
const win = path.join(installPrefix, 'node_modules', '@opengsd', 'get-shit-done-redux');
return fs.existsSync(posix) ? posix : win;
}
/**
* Locate the installed gsd-sdk binary (symlink in <prefix>/bin/).
*/
function findGsdSdkBin(installPrefix) {
const binDir = process.platform === 'win32'
? path.join(installPrefix, 'node_modules', '.bin')
: path.join(installPrefix, 'bin');
const candidates = process.platform === 'win32'
? [path.join(binDir, 'gsd-sdk.cmd'), path.join(binDir, 'gsd-sdk')]
: [path.join(binDir, 'gsd-sdk')];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return null;
}
/**
* Locate the get-shit-done-redux installer binary (the symlink in <prefix>/bin/).
*/
function findInstallerBin(installPrefix) {
const binDir = process.platform === 'win32'
? path.join(installPrefix, 'node_modules', '.bin')
: path.join(installPrefix, 'bin');
const candidates = process.platform === 'win32'
? [path.join(binDir, 'get-shit-done-redux.cmd'), path.join(binDir, 'get-shit-done-redux')]
: [path.join(binDir, 'get-shit-done-redux')];
for (const c of candidates) {
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/get-shit-done/workflows/<name>.md`
* 2. Inline mention: any line containing `~/.claude/get-shit-done/workflows/<name>.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 = 'get-shit-done/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 GSD command names from the installed package.
* Returns an array of strings like `['init', 'discuss-phase', ...]`.
*/
function readInstalledCmdNames(pkg) {
const commandsDir = path.join(pkg, 'commands', 'gsd');
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 /gsd:<cmd> colon-namespace leaks.
*
* Uses the word-boundary-safe regex shape from scripts/fix-slash-commands.cjs:
* /gsd-(<cmd1>|<cmd2>|...)(?=[^a-zA-Z0-9_-]|$)/g — forward
* We check the colon form: /gsd:<cmd> 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(`/gsd:(${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;
}
/**
* Scan a single workflow .md file for bare `gsd-sdk` query invocations inside
* shell fences that lack a `command -v gsd-sdk` guard in the same fence.
*
* Structured check: walks lines, tracks open/close shell fences (```bash /
* ```sh / ``` alone), collects `gsd-sdk` query lines and the fence's guard
* state, then emits findings per-fence.
*
* Returns the first unguarded { line, lineNumber } or null.
*/
function scanWorkflowMissingSdkFallback(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split(/\r?\n/);
const FENCE_OPEN = /^```(?:bash|sh)?\s*$/;
const FENCE_CLOSE = /^```\s*$/;
const SDK_QUERY = /\bgsd-sdk\s+query\b/;
const COMMAND_V = /\bcommand\s+-v\s+gsd-sdk\b/;
let inFence = false;
let fenceHasGuard = false;
let firstSdkQueryLineInFence = null;
let firstSdkQueryLineNumInFence = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!inFence) {
if (FENCE_OPEN.test(trimmed)) {
inFence = true;
fenceHasGuard = false;
firstSdkQueryLineInFence = null;
firstSdkQueryLineNumInFence = null;
}
} else {
if (FENCE_CLOSE.test(trimmed)) {
// Closing the fence — check if there were bare sdk query calls without a guard
if (firstSdkQueryLineInFence !== null && !fenceHasGuard) {
return { line: firstSdkQueryLineNumInFence, content: firstSdkQueryLineInFence.trim() };
}
inFence = false;
fenceHasGuard = false;
firstSdkQueryLineInFence = null;
firstSdkQueryLineNumInFence = null;
} else {
if (COMMAND_V.test(line)) {
fenceHasGuard = true;
}
if (SDK_QUERY.test(line) && firstSdkQueryLineInFence === null) {
firstSdkQueryLineInFence = line;
firstSdkQueryLineNumInFence = i + 1;
}
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// 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 {boolean} [opts.dryRun=false] - If true, skip actual npm install; validate input only
* @returns {{ code: string, details: object }}
*/
function runSmoke({
tarballPath,
installPrefix,
expectedVersion,
fixtureDir,
lifecycleCommands = ['init', 'discuss-phase', 'plan-phase', 'execute-phase'],
dryRun = false,
}) {
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';
const installResult = spawnSync(
npmCmd,
['install', '-g', '--prefix', installPrefix, tarballPath],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: CHILD_TIMEOUT_MS },
);
if (installResult.status !== 0) {
return {
code: SMOKE.INSTALL_FAILED,
details: {
...details,
stderr: installResult.stderr,
stdout: installResult.stdout,
},
};
}
// --- Locate the installed gsd-sdk binary ---------------------------------
const actualBin = findGsdSdkBin(installPrefix);
if (!actualBin) {
const binDir = process.platform === 'win32'
? path.join(installPrefix, 'node_modules', '.bin')
: path.join(installPrefix, 'bin');
return {
code: SMOKE.BIN_NOT_CALLABLE,
details: { ...details, binDir, searched: [] },
};
}
// --- Invoke `gsd-sdk --version` ------------------------------------------
const versionResult = spawnSync(
process.execPath,
[actualBin, '--version'],
{ encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS },
);
if (versionResult.status !== 0) {
return {
code: SMOKE.BIN_NOT_CALLABLE,
details: {
...details,
bin: actualBin,
stderr: versionResult.stderr,
stdout: versionResult.stdout,
},
};
}
// Output format: "gsd-sdk v1.50.0-canary.0\n"
const rawOutput = (versionResult.stdout || '').trim();
const versionMatch = rawOutput.match(/v(.+)$/);
const installedVersion = versionMatch ? versionMatch[1] : rawOutput;
details.version = installedVersion;
details.rawVersionOutput = rawOutput;
details.bin = actualBin;
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: 'get-shit-done-redux binary not found in installPrefix',
installPrefix,
},
};
}
// Non-interactive: --local --claude installs to .claude/ in cwd (fixtureDir).
// GSD_TEST_MODE must be cleared — install.js skips its main() block when
// GSD_TEST_MODE is set, which would cause the installer to exit 0 silently
// without actually creating any files.
const initEnv = { ...process.env };
delete initEnv.GSD_TEST_MODE;
const initResult = spawnSync(
process.execPath,
[installerBin, '--local', '--claude'],
{
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,
},
);
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', 'get-shit-done'),
];
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', 'gsd', `${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 get-shit-done/workflows/<name> in the package.
// Some live in subdirectories; try flat first then scan once.
const flat = path.join(pkg, 'get-shit-done', '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: SDK binary callable + workflow-body validation (informational)
// ─────────────────────────────────────────────────────────────────────────
// --- Verify `gsd-sdk` query is callable and returns parseable JSON -------
const sdkQueryDir = fixtureDir || os.tmpdir();
const sdkQueryResult = spawnSync(
process.execPath,
[actualBin, 'query', 'state.json', '--project-dir', sdkQueryDir],
{ encoding: 'utf-8', timeout: CHILD_TIMEOUT_MS },
);
if (sdkQueryResult.status !== 0) {
return {
code: SMOKE.SDK_BINARY_NOT_CALLABLE,
details: {
...details,
sdkBin: actualBin,
sdkQueryStderr: sdkQueryResult.stderr,
sdkQueryStdout: sdkQueryResult.stdout,
},
};
}
let sdkQueryParsed = false;
try {
JSON.parse(sdkQueryResult.stdout);
sdkQueryParsed = true;
} catch {
// leave sdkQueryParsed = false
}
if (!sdkQueryParsed) {
return {
code: SMOKE.SDK_BINARY_NOT_CALLABLE,
details: {
...details,
sdkBin: actualBin,
reason: 'gsd-sdk query-state output is not valid JSON',
sdkQueryStdout: sdkQueryResult.stdout,
},
};
}
details.sdkQueryResult = sdkQueryResult.stdout;
details.sdkQueryParsed = true;
// --- Workflow-body checks (informational — #3668 not yet fixed) ----------
const workflowsDir = path.join(pkg, 'get-shit-done', 'workflows');
const installedCmdNames = readInstalledCmdNames(pkg);
let workflowsScanned = 0;
let colonLeakCount = 0;
let missingFallbackCount = 0;
// Store first finding per check type (for future enforcement mode)
let firstColonLeak = null;
let firstMissingFallback = 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 };
}
}
const missingFallback = scanWorkflowMissingSdkFallback(filePath);
if (missingFallback) {
missingFallbackCount++;
if (!firstMissingFallback) {
firstMissingFallback = { file: filePath, line: missingFallback.line };
}
}
}
}
details.workflowsScanned = workflowsScanned;
details.colonLeakCount = colonLeakCount;
details.missingFallbackCount = missingFallbackCount;
if (firstColonLeak) details.firstColonLeak = firstColonLeak;
if (firstMissingFallback) details.firstMissingFallback = firstMissingFallback;
// NOTE: colonLeakCount and missingFallbackCount are informational here.
// They will be non-zero against current main per #3668 and the /gsd: leak
// backlog. Once those issues are fixed, a future enforcement mode can be
// enabled (e.g. SMOKE_ENFORCE_WORKFLOW_BODY=1) to fail here.
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(), 'gsd-smoke-pack-'));
const installPrefix = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-smoke-prefix-'));
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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,
},
).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);
process.exit(1);
}
}
} catch (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);
process.exit(1);
}
const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir });
if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
cleanup(packDir, installPrefix, fixtureDir);
process.exit(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 };
if (require.main === module) {
cliMain();
}