* fix(#437): restore defaults.run.shell at job level (step-level matrix expr rejected by GHA) Per actions/runner workflow-v1.0.json schema, `jobs.<job_id>.defaults.run.shell` allows `matrix` context (job-defaults-run has context:[matrix,...]); step-level `shell:` does not (run-step's shell field is plain string with no context array). PR #434 used step-level shell:${{matrix.shell}}, which GHA's parser rejects with "Unrecognized named-value: 'matrix'" — blocking every push to next and every release.yml dispatch. This commit: - Removes step-level `shell: ${{ matrix.shell }}` from test-full (test.yml) and smoke (install-smoke.yml) jobs (17 directives). - Adds `defaults.run.shell: ${{ matrix.shell }}` at job level in those two jobs. - Fixes pre-existing shellcheck SC2129 in test.yml (individual >> redirects → grouped brace form) and SC2010 in install-smoke.yml (ls|grep → glob loop). Verified locally with actionlint 1.7.12 (exit 0). Policy linter still 0 violations (matrix.shell now resolves via job.defaults.run.shell which the linter already handles per workflow-policy.cjs:effectiveShell). Refs: actions/runner#444 (open since 2020), GHA contexts page section "Context availability". * fix(#439): inline ci-smoke-skip back to shell (Node port required pre-checkout file resolution) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#440): use platform-correct npm.cmd on Windows for spawn (and surface-check other Node ports) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#437): use 'zsh {0}' format string in matrix.shell for macOS (zsh not in GHA built-ins) Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions (jobs.<job_id>.defaults.run.shell section): "You can use built-in shell keywords like bash, pwsh, python, sh, cmd, and powershell, or define a custom set of shell options." zsh is not in the built-ins list. GHA accepts custom shells via a format string containing '{0}', which it replaces with the temporary script file path at runtime (same pattern as the perl {0} example in the docs). Bare `shell: zsh` triggers: "Invalid shell option. Shell must be a valid built-in or a format string containing '{0}'". Precursor: 514cb429 introduced the matrix shell-pinning pattern; this completes it by switching the macOS rows from the bare value to the required format string. Also updates scripts/workflow-policy.cjs to normalise 'zsh {0}' to 'zsh' before the policy comparison, so the repo-baseline test continues to pass (the linter was correctly treating 'zsh {0}' as a distinct value from the policy 'zsh'). Affects: - .github/workflows/test.yml: test-full matrix (node 22 + node 24 macOS rows) - .github/workflows/install-smoke.yml: smoke matrix (macOS node 24 row) - scripts/workflow-policy.cjs: detectViolation strips ' {0}' format suffix * fix(#440): add shell:true to spawnSync on Windows for .cmd files (Node docs requirement) Per https://nodejs.org/docs/latest-v22.x/api/child_process.html: ".bat and .cmd files require a terminal to run and cannot be launched directly with execFile(). To run these scripts on Windows, use child_process.spawn() with the shell option, child_process.exec(), or spawn cmd.exe with the script as an argument." "On Windows, .bat and .cmd files require a shell to execute. Use child_process.exec() or child_process.spawn() with the shell: true option." On Windows, npm is installed as npm.cmd (a batch wrapper). Without shell: true, spawnSync resolves the binary directly and fails with ENOENT / "npm binary not found on PATH" because the OS cannot execute a .cmd file without cmd.exe as the intermediary. The fix uses `shell: process.platform === 'win32'` so the shell spawning is only activated on Windows; macOS/Linux continue to resolve the plain npm binary directly with shell: false, preserving the existing behaviour on non-Windows platforms. Updated both spawnSync(npmCmd, ...) call sites: - npm --version check (line 182) - npm ci --dry-run lockfile-sync check (line 215) * fix(#437): bug-410 defaults test — set USERPROFILE for Windows os.homedir() redirect On Windows, os.homedir() reads USERPROFILE (not HOME), so the test's process.env.HOME = FAKE_HOME redirect was silently ignored. finishInstall's path.join(os.homedir(), '.gsd') resolved to the real user home and the defaults.json write either failed (permissions) or landed outside the temp dir, causing the existsSync assertion to return false. Fix: also set process.env.USERPROFILE = FAKE_HOME so os.homedir() returns the sandboxed directory on Windows. Node.js docs (os.homedir): https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir Refs: #437 (fix/437-restore-defaults-run-shell), Windows pwsh compat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#437): precommit-alias-drift hook test — use path.delimiter for PATH Hardcoded ':' PATH separator breaks Windows where process.env.PATH uses ';'. The malformed PATH passed to bash caused the mock git/npm stubs in binDir to be invisible to the hook script; npm was never called and the marker file never written. Fix: replace ':' with path.delimiter in both PATH constructions so the env var is well-formed on Windows (';') and POSIX (':') alike. Refs: #437 (fix/437-restore-defaults-run-shell), Windows pwsh compat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#437): prepush-enterprise-email hook test — use path.delimiter for PATH Same root cause as precommit-alias-drift: hardcoded ':' PATH separator is invalid on Windows (';' required). The malformed PATH meant bash ran the real git binary instead of the mock stub, which rejected the placeholder SHAs 'refs-local-sha' / 'refs-remote-sha' with a fatal ambiguous-argument error rather than returning the fixture commit list. Fix: replace ':' with path.delimiter in both execFileSync PATH env values. Refs: #437 (fix/437-restore-defaults-run-shell), Windows pwsh compat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#437): set MSYS2_PATH_TYPE=inherit so mock stubs take precedence in Git Bash PATH Root cause: Git Bash (MSYS2) on Windows prepends its own system directories (/mingw64/bin, /usr/bin, /bin) to the PATH at process startup before the user-supplied Windows PATH entries. This placed the real git/npm binaries ahead of the mock stubs in binDir even though binDir was first in the Windows PATH passed to execFileSync. The path.delimiter fix (0042fe0d) made the PATH syntactically correct for Windows (semicolons) but did not change the MSYS2 system-dir prepend order. The real git rejected placeholder SHAs (refs-local-sha, refs-remote-sha) with "fatal: ambiguous argument", producing the observed Windows CI failure. For the pre-commit test, the real git output nothing (no staged files on a fresh checkout), so the grep match failed and npm was never called. Fix: set MSYS2_PATH_TYPE=inherit in the env passed to both bash spawns. With inherit, MSYS2 uses only the converted Windows PATH without prepending system directories, so binDir (converted from Windows to POSIX) is first in the search path and the mock stubs are found. grep/tr/printf remain available: the GHA Windows runner PATH includes C:\Program Files\Git\usr\bin which contains these utilities; MSYS2 converts that Windows entry to a POSIX path on startup. The /usr/bin/env shebang in mock stubs resolves through MSYS2's virtual filesystem mount (not via PATH) and is always accessible regardless of MSYS2_PATH_TYPE. On macOS/Linux this variable is ignored; no behaviour change on those platforms. Source: https://www.msys2.org/wiki/MSYS2-introduction/#path (MSYS2_PATH_TYPE controls whether system dirs are prepended to converted PATH) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#437): hook test mocks — use cmd-shim pattern for Windows bin resolution On Windows, bash (Git Bash / MSYS2) resolves PATH commands by scanning for extensionless files, but cmd.exe and Win32 process creation resolve via PATHEXT (.CMD, .BAT, .EXE). When execFileSync('bash', [hookPath]) runs a hook that calls `git` or `npm`, both resolution paths may fire. The previous approach set MSYS2_PATH_TYPE=inherit in the child env, but that variable is only read in /etc/profile (login-shell path) — bash launched without --login never sources /etc/profile, so the variable had no effect: https://github.com/msys2/MSYS2-packages/blob/master/filesystem/profile Fix: adopt the cmd-shim three-file pattern used by npm itself: https://github.com/npm/cmd-shim For each mock binary, write: <name> extensionless bash script (bash PATH scan) <name>.cmd batch wrapper delegating to bash (PATHEXT / cmd.exe) <name>.ps1 PowerShell wrapper (completeness) This is the same approach used by stevemao/mock-bin for test mocking with Windows CI green on AppVeyor: https://github.com/stevemao/mock-bin The .cmd and .ps1 files are only written on process.platform === 'win32'. MSYS2_PATH_TYPE is removed from the child env — it was ineffective and is no longer needed with the shim files in place. * fix(#437): tarball-smoke — raise CHILD_TIMEOUT_MS on Windows to 600 s The CI failure showed a test duration of 120003.1812 ms — matching the previous CHILD_TIMEOUT_MS = 120_000 exactly. When spawnSync hits its timeout, it sends SIGTERM and returns { status: null, stdout: '', stderr: '' } per the Node.js docs: https://nodejs.org/docs/latest-v22.x/api/child_process.html "status: <number> | <null> — The exit code of the subprocess, or null if the subprocess terminated due to a signal." The installResult check is `status !== 0`; null !== 0 is true, so the timeout fired the INSTALL_FAILED path with empty stdout/stderr, which made the root cause invisible in CI logs. GitHub-hosted Windows runners are slower than Linux/macOS for filesystem-heavy operations (npm install -g of a 1499-file tarball): 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 Fix: use 600_000 ms (10 min) on Windows, keeping 120_000 ms on POSIX. 600 s matches the SLOW_HOST_TIMEOUT already used in the test before() helper for the pack + install fixture step. Also expose `signal` and `installError` in the INSTALL_FAILED details object so a future timeout (status=null, signal='SIGTERM', stdout='') is immediately diagnosable in CI logs without guesswork. * fix(#437): chmod +x via bash on Windows for hook test mocks (root cause: fs.writeFileSync mode=0o755 no-op on NTFS) Root cause: Node's fs.writeFileSync mode=0o755 is a no-op for the execute bit on Windows NTFS. Per https://nodejs.org/docs/latest-v22.x/api/fs.html: "on Windows only the write permission can be changed." Bash's access(X_OK) therefore skips the mock file; the real git/npm binary is found later in PATH and the hook runs against real state instead of the test double. Fix: after writeFileSync, invoke Git Bash's chmod via the POSIX emulation layer (Cygwin/MSYS2), which sets the NTFS execute ACL that Node cannot reach: const posixPath = filePath.replace(/\\/g, '/'); execFileSync('bash', ['-c', `chmod +x "${posixPath}"`], { stdio: 'pipe' }); execFileSync('bash', ...) works because Git for Windows ships bash on PATH in all GHA Windows runners. Forward-slash conversion is required because MSYS2 bash auto-converts /c/foo paths but not mixed-separator paths. Why prior approaches didn't take effect: - MSYS2_PATH_TYPE=inherit: only read in /etc/profile (login-shell path); execFileSync('bash', ...) launches non-interactively without --login, so /etc/profile is never sourced. Ref: https://github.com/msys2/MSYS2-packages/blob/master/filesystem/profile - .cmd/.ps1 cmd-shim wrappers: bash does POSIX command resolution and does not honor PATHEXT, so wrappers are not found by bash's own PATH scan. They are not wrong (kept for non-bash callers) but do not fix bash's X_OK. Files changed: tests/precommit-alias-drift-hook.test.cjs, tests/prepush-enterprise-email-hook.test.cjs * refactor(#437): hooks use GIT_OVERRIDE/NPM_OVERRIDE env-var DI; tests drop PATH-mocking Four prior rounds (path.delimiter join, MSYS2_PATH_TYPE=inherit, cmd-shim .cmd/.ps1 wrappers, chmod-via-bash post-write) all failed to make MSYS2 bash's PATH-lookup find the mock executables. The root cause is that none of those approaches can reliably override bash's own command-resolution on NTFS without fighting NTFS execute-ACLs or login-shell profile sourcing. The simplest robust solution is to bypass PATH entirely: Hooks: each hook now binds GIT_CMD="${GIT_OVERRIDE:-git}" (and NPM_CMD for pre-commit) at the top. When env vars are unset the hooks invoke bare `git`/`npm` exactly as before — zero behavior change for users. Tests: writeMockBin/binDir/PATH manipulation replaced by writeMock(), which writes a .sh mock to a tmpDir and passes its absolute path via GIT_OVERRIDE / NPM_OVERRIDE in the execFileSync env. Bash inside the hook executes the path directly via the seam — no PATH scan, no NTFS ACL check, no MSYS2 profile dependency. Test-rigor principle: the new seam (env-var injection) is platform- independent and doesn't rely on bash's command-resolution mechanism on the host OS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
625 lines
24 KiB
JavaScript
625 lines
24 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)
|
|
*
|
|
* 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):
|
|
* - Scans all installed get-shit-done/workflows/*.md for /gsd:<known-cmd>
|
|
* 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.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { execFileSync, spawnSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
// 120 s proved too tight on Windows GitHub-hosted runners: cold-cache
|
|
// `npm install -g` with a 1499-file tarball took ~120 s exactly, causing
|
|
// spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' }
|
|
// (Node docs: status is null when subprocess terminated due to a signal).
|
|
// The INSTALL_FAILED branch checks `status !== 0`, which null satisfies, so the
|
|
// test saw empty stdout/stderr and a spurious INSTALL_FAILED. Windows runners
|
|
// are slower than Linux/macOS for filesystem-heavy operations (
|
|
// 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
|
|
// ). Raise to 600 s (the same ceiling the before() helper uses for pack+install).
|
|
const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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',
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (findGsdToolsBin / 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/@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;
|
|
}
|
|
|
|
/**
|
|
* 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 <prefix>/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 gsd-tools binary (symlink in <prefix>/bin/).
|
|
*/
|
|
function findGsdToolsBin(installPrefix) {
|
|
for (const c of binCandidates(installPrefix, 'gsd-tools')) {
|
|
if (fs.existsSync(c)) return c;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Locate the get-shit-done-redux installer binary (the symlink in <prefix>/bin/).
|
|
*/
|
|
function findInstallerBin(installPrefix) {
|
|
for (const c of binCandidates(installPrefix, 'get-shit-done-redux')) {
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
* @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'],
|
|
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;
|
|
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 gsd-tools binary --------------------------------
|
|
const actualBin = findGsdToolsBin(installPrefix);
|
|
|
|
if (!actualBin) {
|
|
const searched = binCandidates(installPrefix, 'gsd-tools');
|
|
return {
|
|
code: SMOKE.BIN_NOT_CALLABLE,
|
|
details: { ...details, searched },
|
|
};
|
|
}
|
|
|
|
// --- Invoke `gsd-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: '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 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', '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: workflow-body validation (informational)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// --- 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;
|
|
// 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.
|
|
|
|
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, binInvocation };
|
|
|
|
if (require.main === module) {
|
|
cliMain();
|
|
}
|