* fix: address orthogonal-review findings on the new work in this PR Isolated code-review + security-review of everything added to this PR since its original review (hono override, check-env.cjs rewrite/revert, new lib file, its test, installer enumeration). Security review: clean, no findings. Code review found: - BLOCKER: .changeset/silly-hens-relax.md described a hono override this PR no longer actually makes -- PR #4560 landed the identical fix on next first, and this branch's own hono commit became a genuine no-op the moment it was rebased onto that updated next (git diff origin/next -- package.json package-lock.json is empty). Deleted the orphaned changeset; next already carries #4560's equivalent one (.changeset/zesty-seals-click.md). - HIGH: .changeset/tame-hens-jump.md's body still described the execNpm-routing approach that was tried and reverted -- stale text from before that revert, would have shipped a release note for code that isn't actually in the diff. Rewritten to describe what actually shipped (self-contained spawnSync, 15s timeout, accurate ENOENT vs. timeout vs. non-zero-exit diagnosis). - LOW: no comment explaining why the spawnSync call has no try/catch (safe -- its documented contract routes failures through the returned result, never a throw -- but worth stating given this file's whole purpose is graceful degradation). Added one. - nit: exitCode 0 + empty stdout fell through to "npm binary not found on PATH", misdescribing a real npm binary that simply printed nothing. Gave it its own message; updated the corresponding test. Manually re-verified describeNpmVersionCheckFailure's branches and the real check:env success path before re-running gsd-test, since this repo blocks local node --test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: rest of the orthogonal-review fixes (previous commit only caught the deletion) Tooling mistake in the previous commit: a git add with the already-staged deleted changeset mixed into the same pathspec list errored out and silently skipped staging the other four files, so only the changeset deletion actually committed. This commit carries the rest of that same change: tame-hens-jump.md's rewritten body, check-env.cjs's no-try/catch comment, npm-version-check-diagnosis.cjs's exitCode-0-empty-stdout fix, and the corresponding test update. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4460): fix changeset pr field to point at this PR, not the original .changeset/tame-hens-jump.md's pr field still said 4552 (the PR its original text was authored under), but this PR (#4572) is what's actually landing the corrected body -- changeset-lint's own DEFECT.CHANGESET-PR-FIELD-DRIFT check caught it: "pr: 4552, expected pr: 4572". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
14 KiB
JavaScript
341 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
// scripts/check-env.cjs — Environment parity validator for contributors (issue #117).
|
|
//
|
|
// Node.js port of scripts/check-env.sh. Behaviorally identical output and
|
|
// exit codes; shell-agnostic so it runs on Windows, macOS, and Linux.
|
|
//
|
|
// Checks that the developer's environment matches project requirements before
|
|
// running tests or audits. Designed to catch mismatches early rather than
|
|
// through cryptic test failures.
|
|
//
|
|
// Exit codes:
|
|
// 0 All checks passed
|
|
// 1 One or more checks failed
|
|
// 2 Tool error (missing required tool, corrupt package.json, etc.)
|
|
//
|
|
// Usage:
|
|
// node scripts/check-env.cjs # Human-readable report
|
|
// node scripts/check-env.cjs --json # Structured JSON report
|
|
// node scripts/check-env.cjs --help # This message
|
|
//
|
|
// Sources:
|
|
// npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
|
|
// Reproducible builds: https://reproducible-builds.org/docs/source-tree/
|
|
// npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
|
|
// gsd-test-runner: https://github.com/open-gsd/gsd-test-runner
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
const { describeNpmVersionCheckFailure } = require('./lib/npm-version-check-diagnosis.cjs');
|
|
|
|
// #4460 follow-up: check-env.cjs runs as its own standalone CI step BEFORE
|
|
// `npm ci` / `npm run build:lib` (a deliberate pre-flight, run before there
|
|
// is even a node_modules to build with) -- confirmed the hard way, by a
|
|
// MODULE_NOT_FOUND crash on every real CI platform after a first attempt at
|
|
// this fix routed the npm-version check through the canonical execNpm seam
|
|
// (gsd-core/bin/lib/shell-command-projection.cjs), a tsc-compiled artifact
|
|
// that plain does not exist yet at that point in the pipeline. This file
|
|
// must stay self-contained: no requires reaching into gsd-core/bin/lib.
|
|
//
|
|
// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without
|
|
// shell:true requires the exact filename including extension.
|
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Semver comparison: does `version` satisfy `constraint`?
|
|
* Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
|
|
* Returns true if satisfied, false otherwise.
|
|
*/
|
|
function satisfiesConstraint(version, constraint) {
|
|
// Strip leading 'v' and pre-release/build suffixes
|
|
version = version.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
|
|
|
|
let op, reqVer;
|
|
const opMatch = constraint.match(/^(>=|>|<=|<|=)(.+)$/);
|
|
if (opMatch) {
|
|
op = opMatch[1];
|
|
reqVer = opMatch[2];
|
|
} else {
|
|
op = '=';
|
|
reqVer = constraint;
|
|
}
|
|
reqVer = reqVer.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
|
|
|
|
function parseTuple(v) {
|
|
const parts = (v + '.0.0').split('.');
|
|
return [
|
|
parseInt(parts[0], 10) || 0,
|
|
parseInt(parts[1], 10) || 0,
|
|
parseInt(parts[2], 10) || 0,
|
|
];
|
|
}
|
|
|
|
const [vMaj, vMin, vPat] = parseTuple(version);
|
|
const [rMaj, rMin, rPat] = parseTuple(reqVer);
|
|
|
|
const vNum = vMaj * 1_000_000 + vMin * 1_000 + vPat;
|
|
const rNum = rMaj * 1_000_000 + rMin * 1_000 + rPat;
|
|
|
|
switch (op) {
|
|
case '>=': return vNum >= rNum;
|
|
case '>': return vNum > rNum;
|
|
case '<=': return vNum <= rNum;
|
|
case '<': return vNum < rNum;
|
|
case '=': return vNum === rNum;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read a field from package.json using dot-notation (e.g. 'engines.node').
|
|
* Returns the string value or empty string if absent.
|
|
* Uses './package.json' so Node resolves relative to CWD on all platforms.
|
|
*/
|
|
function pkgField(fieldPath, PROJECT_ROOT) {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
|
|
let val = pkg;
|
|
for (const key of fieldPath.split('.')) {
|
|
if (val == null || typeof val !== 'object') return '';
|
|
val = val[key];
|
|
}
|
|
return val != null ? String(val) : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
// ---------------------------------------------------------------------------
|
|
// Argument parsing
|
|
// ---------------------------------------------------------------------------
|
|
let jsonMode = false;
|
|
|
|
for (const arg of process.argv.slice(2)) {
|
|
if (arg === '--json') {
|
|
jsonMode = true;
|
|
} else if (arg === '--help' || arg === '-h') {
|
|
process.stdout.write(
|
|
'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
|
|
'\n' +
|
|
'Checks that the developer\'s environment matches project requirements before\n' +
|
|
'running tests or audits. Designed to catch mismatches early rather than\n' +
|
|
'through cryptic test failures.\n' +
|
|
'\n' +
|
|
'Exit codes:\n' +
|
|
' 0 All checks passed\n' +
|
|
' 1 One or more checks failed\n' +
|
|
' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
|
|
'\n' +
|
|
'Usage:\n' +
|
|
' node scripts/check-env.cjs # Human-readable report\n' +
|
|
' node scripts/check-env.cjs --json # Structured JSON report\n' +
|
|
' node scripts/check-env.cjs --help # This message\n'
|
|
);
|
|
return 0;
|
|
} else {
|
|
process.stderr.write(`Unknown option: ${arg}\n`);
|
|
throw new ExitError(2);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Locate the project root (directory containing package.json)
|
|
// ---------------------------------------------------------------------------
|
|
const PROJECT_ROOT = process.cwd();
|
|
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
|
|
|
|
if (!fs.existsSync(PACKAGE_JSON)) {
|
|
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
|
|
throw new ExitError(2);
|
|
}
|
|
|
|
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
|
|
const checks = [];
|
|
|
|
function addCheck(name, status, message) {
|
|
checks.push({ name, status, message });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 1: Node version vs engines.node
|
|
// ---------------------------------------------------------------------------
|
|
const enginesNode = pkgField('engines.node', PROJECT_ROOT);
|
|
let currentNode = '';
|
|
try {
|
|
currentNode = process.version.replace(/^v/, '');
|
|
} catch { /* ignore */ }
|
|
|
|
if (!currentNode) {
|
|
addCheck('node-version', 'fail', 'node binary not found on PATH');
|
|
} else if (!enginesNode) {
|
|
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)');
|
|
} else {
|
|
if (satisfiesConstraint(currentNode, enginesNode)) {
|
|
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`);
|
|
} else {
|
|
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 2: npm version vs engines.npm (skip if field absent)
|
|
// ---------------------------------------------------------------------------
|
|
const enginesNpm = pkgField('engines.npm', PROJECT_ROOT);
|
|
// #4460: 15_000ms, not the original 10_000 -- matches the default this
|
|
// repo's canonical (but here unusable, see the file-header note above)
|
|
// execNpm seam already uses for npm subprocess calls generally, rather
|
|
// than inventing a new number. Confirmed via real Windows CI: a 10s
|
|
// window was insufficient twice under ~51-file concurrent test load.
|
|
const NPM_VERSION_TIMEOUT_MS = 15_000;
|
|
// No try/catch needed: spawnSync's documented contract routes ENOENT and a
|
|
// timeout-triggered kill through the RETURNED result's `.error` field, not
|
|
// a thrown exception -- there is nothing here for a catch to intercept.
|
|
const npmVersionSpawn = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: NPM_VERSION_TIMEOUT_MS, shell: process.platform === 'win32' });
|
|
const npmVersionResult = {
|
|
exitCode: npmVersionSpawn.status ?? 1,
|
|
stdout: (npmVersionSpawn.stdout || '').toString().trim(),
|
|
signal: npmVersionSpawn.signal ?? null,
|
|
error: npmVersionSpawn.error ?? null,
|
|
// Canonical cross-platform timeout predicate (matches this repo's
|
|
// execNpm/isSpawnTimeout convention, src/shell-command-projection.cts):
|
|
// error.code === 'ETIMEDOUT', which Node's spawnSync guarantees when its
|
|
// own `timeout` option fires. Checking `signal === 'SIGTERM'` instead
|
|
// (what an earlier version of this fix did) is platform-fragile -- that
|
|
// module's own docstring flags a Windows-specific false-negative risk,
|
|
// the exact platform this bug was discovered on.
|
|
timedOut: (npmVersionSpawn.error && npmVersionSpawn.error.code === 'ETIMEDOUT') === true,
|
|
};
|
|
const currentNpm = npmVersionResult.exitCode === 0 && npmVersionResult.stdout ? npmVersionResult.stdout : '';
|
|
|
|
if (!enginesNpm) {
|
|
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
|
|
} else if (!currentNpm) {
|
|
addCheck('npm-version', 'fail', describeNpmVersionCheckFailure(npmVersionResult));
|
|
} else {
|
|
if (satisfiesConstraint(currentNpm, enginesNpm)) {
|
|
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
|
|
} else {
|
|
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 3: Lockfile presence
|
|
// ---------------------------------------------------------------------------
|
|
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
|
|
if (fs.existsSync(LOCKFILE)) {
|
|
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
|
|
} else {
|
|
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 4: Lockfile sync (npm ci --dry-run)
|
|
// ---------------------------------------------------------------------------
|
|
if (fs.existsSync(LOCKFILE)) {
|
|
try {
|
|
// --ignore-scripts: this is a lockfile-vs-package.json sync check, not a
|
|
// build. Without it, npm would run the `prepare` lifecycle (build:lib via
|
|
// tsc) — which fails when check:env runs before deps are installed (tsc
|
|
// absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish.
|
|
const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], {
|
|
cwd: PROJECT_ROOT,
|
|
encoding: 'utf8',
|
|
shell: process.platform === 'win32',
|
|
});
|
|
if (res.status === 0) {
|
|
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
|
|
} else {
|
|
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
|
|
}
|
|
} catch {
|
|
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
|
|
}
|
|
} else {
|
|
addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check 5: Version manager pin vs active Node
|
|
// Looks for .nvmrc, .node-version, or .tool-versions at project root.
|
|
// ---------------------------------------------------------------------------
|
|
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
|
|
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
|
|
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
|
|
|
|
let pinnedMajor = '';
|
|
let pinSource = '';
|
|
|
|
if (fs.existsSync(NVMRC)) {
|
|
const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
|
|
pinnedMajor = content.split('.')[0];
|
|
pinSource = '.nvmrc';
|
|
} else if (fs.existsSync(NODE_VERSION_FILE)) {
|
|
const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
|
|
pinnedMajor = content.split('.')[0];
|
|
pinSource = '.node-version';
|
|
} else if (fs.existsSync(TOOL_VERSIONS)) {
|
|
const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
|
|
const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
|
|
if (nodeLine) {
|
|
const ver = nodeLine.split(/\s+/)[1] || '';
|
|
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
|
|
pinSource = '.tool-versions';
|
|
}
|
|
}
|
|
|
|
if (!pinnedMajor) {
|
|
addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
|
|
} else if (process.env.CI === 'true') {
|
|
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
|
|
} else {
|
|
const activeMajor = process.version.replace(/^v/, '').split('.')[0];
|
|
if (activeMajor === pinnedMajor) {
|
|
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
|
|
} else {
|
|
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output
|
|
// ---------------------------------------------------------------------------
|
|
const overallPass = checks.every(c => c.status !== 'fail');
|
|
|
|
if (jsonMode) {
|
|
// Structured JSON: {pass: bool, checks: [{name, status, message}]}
|
|
const out = {
|
|
pass: overallPass,
|
|
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })),
|
|
};
|
|
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
} else {
|
|
// Human-readable report
|
|
process.stdout.write('=== Environment Check ===\n');
|
|
for (const { name, status, message } of checks) {
|
|
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]';
|
|
const namePadded = name.padEnd(25);
|
|
process.stdout.write(` ${icon} ${namePadded} ${message}\n`);
|
|
}
|
|
process.stdout.write('\n');
|
|
if (overallPass) {
|
|
process.stdout.write('Result: ALL CHECKS PASSED\n');
|
|
} else {
|
|
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n');
|
|
}
|
|
}
|
|
|
|
return overallPass ? 0 : 1;
|
|
}
|
|
|
|
runMain(main);
|