Files
msd-core/scripts/check-env.sh
Tom Boucher 33ffc647e2 feat(117): reproducible npm environment bootstrap + check-env validator (#136)
* test(117): add failing tests for env validator (check-env.sh)

RED phase. Six tests for scripts/check-env.sh — none pass because the
script does not exist yet. Fixtures:
  good/             — engines.node >=22, .nvmrc 26, synced lockfile
  bad-node-version/ — engines.node <14.0.0 (current Node v26 fails)
  missing-lockfile/ — no package-lock.json
  bad-nvmrc/        — .nvmrc says 22, current Node is v26

Tests cover:
  1. Happy path exits 0
  2. engines.node constraint failure exits 1
  3. Missing lockfile exits 1
  4. .nvmrc major mismatch exits 1
  5. --json flag emits {pass: boolean, checks: array}
  6. Integration smoke: exits 0 on live worktree root

Sources:
  npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
  npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci

Closes #117

* feat(117): add scripts/check-env.sh with Node/npm/lockfile/version-manager checks

GREEN phase. Implements the five-check environment validator:

  1. Node version vs engines.node (semver constraint — >=, >, <=, <, =)
  2. npm version vs engines.npm (skipped if field absent)
  3. package-lock.json presence
  4. Lockfile sync via `npm ci --dry-run` (exits non-zero when drift detected)
  5. Version-manager pin (.nvmrc / .node-version / .tool-versions) vs active Node major

Exit codes: 0 = all green; 1 = at least one failure; 2 = tool error.
Flags: --json (structured report), --help.

All 7 tests pass. shellcheck clean. bash -n syntax check clean.

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

Closes #117

* chore(117): pin Node engines + .nvmrc; add check:env npm script

- Add engines.npm: ">=10.0.0" (npm 10 ships with Node 22, the CI floor).
  Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
- Add .nvmrc pinning Node 22 (lowest supported version per CI matrix in
  .github/workflows/test.yml; node-version: [22, 24]).
- Add "check:env": "./scripts/check-env.sh" script to package.json.

No generator is involved (not a .generated. file). The test update in this
commit adjusts the integration smoke: it now asserts on --json structured
output rather than raw text, and accepts exit 0 or 1 (version-manager pin
mismatch is expected when developer runs Node 26 against a .nvmrc of 22).

Closes #117

* ci(117): wire environment check into test workflow

Add "Environment check" step to .github/workflows/test.yml in the `test`
job. Positioned AFTER actions/setup-node and BEFORE npm ci so that env
mismatches (wrong Node version, missing npm version, absent lockfile) are
caught before the install step obscures the root cause.

Runs `npm run check:env` (./scripts/check-env.sh) on every matrix lane
(ubuntu, macos, windows) × (Node 22, 24).

Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines

Closes #117

* docs(117): publish docs/contributing/bootstrap.md + link from CONTRIBUTING.md

Adds docs/contributing/bootstrap.md with:
  1. Prerequisites (nvm, fnm, asdf, mise; gh CLI)
  2. One-time setup (clone, nvm use, check:env, npm ci)
  3. Daily commands table
  4. Validation guide (check table, exit codes, --json usage)
  5. Troubleshooting (node-version, npm-version, lockfile-present,
     lockfile-sync, version-manager-pin, missing modules, locale errors)
  6. Alternative: Docker via gsd-test-runner
     (https://github.com/open-gsd/gsd-test-runner)

Adds "Bootstrap your environment" section to CONTRIBUTING.md pointing to
the new doc. No content duplication — CONTRIBUTING.md links only.

Adds .changeset/117-npm-bootstrap.md (type: Added) for changelog.

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

Closes #117

* fix(#117): make check:env script run on Windows runners

Invoke check-env.sh via `bash` instead of a bare POSIX path so
Windows CI runners (which have Git Bash on PATH) execute the script
without requiring a POSIX shell shebang dispatcher.

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

* test(117): make check-env.sh fixture .nvmrc adapt to active Node major (cross-platform fix)

Before() hook writes good/.nvmrc = activeNodeMajor and bad-nvmrc/.nvmrc = activeNodeMajor+99
at test-run time. Hardcoded .nvmrc=26 failed on every CI matrix row except Node 26.
After() restores originals so the checked-in files stay stable.

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

* docs(117): exempt gsd-test-runner URL path from slash-command registry check

docs/contributing/bootstrap.md links to https://github.com/open-gsd/gsd-test-runner.
The parity-test regex captures /gsd-test-runner from the URL path component and
flags it as an unregistered slash command. Add 'test-runner' to INTERNAL_COMPONENT_SLUGS
(mirrors the existing 'build' entry for GitHub org URLs) with an explanatory comment.

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

* fix(117): fix Node-24 and Windows-22 CI failures in check-env

Two root causes:

1. version-manager-pin on Node 24 (mac/ubuntu/win):
   The project root .nvmrc pins major 22 for local dev. When the CI
   matrix runs Node 24, check-env.sh fails the version-manager-pin
   check and exits 1, blocking the entire test job before any test
   runs. Fix: skip the pin check when CI=true (GitHub Actions always
   sets this). The pin is a local dev guard, not a gate for multi-
   version matrix CI.

2. engines.node appears missing on Windows-22 (pkg_field backslash):
   pkg_field() embedded PACKAGE_JSON directly into a node -e string
   literal using require(). On Windows, the path uses backslashes
   (D:\a\...) which are silently interpreted as JS escape sequences
   inside the string, causing require() to fail silently (2>/dev/null
   || true). engines.node returns empty, triggering a spurious FAIL.
   Fix: switch to fs.readFileSync + JSON.parse and normalise
   backslashes to forward-slashes before embedding in the JS literal.

Also pass { CI: '' } from the bad-nvmrc unit test so the
version-manager-pin fixture test still exercises the mismatch path
even when running inside CI runners.

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

* fix(117): use relative ./package.json path in pkg_field to fix Windows CI

On Windows, Git Bash exposes \$PWD as a POSIX path (/d/a/…) which
node.exe cannot resolve via fs.readFileSync. The previous fix embedded
the absolute PACKAGE_JSON path in the node -e string after converting
backslashes to forward-slashes, but the POSIX form produced by Git Bash
(/d/a/…) has no backslashes — so the conversion was a no-op and node
received an unresolvable path. The silent catch(e) { process.exit(0) }
swallowed the ENOENT, returning empty string for every engines.* field.

Fix: use './package.json' (relative to CWD). pkg_field() is always
called before any cd in the script so CWD === PROJECT_ROOT at call time.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:38:49 -04:00

333 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/check-env.sh — Environment parity validator for contributors (issue #117).
#
# 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:
# ./scripts/check-env.sh # Human-readable report
# ./scripts/check-env.sh --json # Structured JSON report
# ./scripts/check-env.sh --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
set -euo pipefail
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
JSON_MODE=false
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
# Print header comment block (lines starting with #, stopping at first non-comment)
while IFS= read -r line; do
if [[ "${line}" =~ ^# ]]; then
printf '%s\n' "${line#\# }"
elif [[ -z "${line}" ]]; then
continue
else
break
fi
done < "$0"
exit 0
;;
*)
echo "Unknown option: $arg" >&2
exit 2
;;
esac
done
# ---------------------------------------------------------------------------
# Locate the project root (directory containing package.json)
# We resolve relative to CWD, not the script location, so callers can pass
# a --cwd by simply cd-ing before invoking.
# ---------------------------------------------------------------------------
PROJECT_ROOT="${PWD}"
PACKAGE_JSON="${PROJECT_ROOT}/package.json"
if [[ ! -f "${PACKAGE_JSON}" ]]; then
echo "ERROR: package.json not found in ${PROJECT_ROOT}" >&2
exit 2
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Emit a check result line.
# Args: name, status (pass|fail|skip), message
CHECKS=() # Each entry: "name|status|message"
add_check() {
local name="$1"
local status="$2"
local message="$3"
CHECKS+=("${name}|${status}|${message}")
}
# Semver comparison: does version $1 satisfy constraint $2?
# Constraint forms supported: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
# Also supports npm range: >=X (no minor/patch required).
# Returns 0 if satisfied, 1 if not.
satisfies_constraint() {
local version="$1"
local constraint="$2"
# Strip leading 'v' from version
version="${version#v}"
# Strip pre-release suffixes (e.g. 26.0.0-rc.1 → 26.0.0)
version="${version%%-*}"
version="${version%%+*}"
# Parse operator and required version from constraint.
# Support: >=, >, <=, <, =, (bare) version
local op req_ver
if [[ "$constraint" =~ ^(>=|>|<=|<|=)(.+)$ ]]; then
op="${BASH_REMATCH[1]}"
req_ver="${BASH_REMATCH[2]}"
else
op="="
req_ver="$constraint"
fi
req_ver="${req_ver#v}"
req_ver="${req_ver%%-*}"
req_ver="${req_ver%%+*}"
# Extract major, minor, patch using field splitting on '.'
# We pad the version strings to ensure exactly 3 dot-separated fields.
# Padding trick: append ".0.0" then take first 3 fields via cut.
local v_padded="${version}.0.0"
local v_major v_minor v_patch
v_major="$(printf '%s' "${v_padded}" | cut -d. -f1)"
v_minor="$(printf '%s' "${v_padded}" | cut -d. -f2)"
v_patch="$(printf '%s' "${v_padded}" | cut -d. -f3)"
v_major="${v_major:-0}"; v_minor="${v_minor:-0}"; v_patch="${v_patch:-0}"
local r_padded="${req_ver}.0.0"
local r_major r_minor r_patch
r_major="$(printf '%s' "${r_padded}" | cut -d. -f1)"
r_minor="$(printf '%s' "${r_padded}" | cut -d. -f2)"
r_patch="$(printf '%s' "${r_padded}" | cut -d. -f3)"
r_major="${r_major:-0}"; r_minor="${r_minor:-0}"; r_patch="${r_patch:-0}"
# Numeric tuple comparison using arithmetic.
local v_num=$(( v_major * 1000000 + v_minor * 1000 + v_patch ))
local r_num=$(( r_major * 1000000 + r_minor * 1000 + r_patch ))
case "$op" in
">=") [[ $v_num -ge $r_num ]] ;;
">") [[ $v_num -gt $r_num ]] ;;
"<=") [[ $v_num -le $r_num ]] ;;
"<") [[ $v_num -lt $r_num ]] ;;
"=") [[ $v_num -eq $r_num ]] ;;
*) return 1 ;;
esac
}
# Read a field from package.json using node (avoids requiring jq).
# Uses a relative path './package.json' so that Node receives a path it can
# resolve on every platform — including Windows where Git Bash exposes $PWD
# as a POSIX path (/d/a/…) that node.exe cannot open via fs.readFileSync.
# pkg_field is always called before any `cd` in this script, so CWD is
# PROJECT_ROOT and './package.json' always resolves correctly.
pkg_field() {
node -e "
const fs = require('fs');
let pkg;
try { pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); } catch(e) { process.exit(0); }
const val = '${1}'.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), pkg);
if (val !== null) process.stdout.write(String(val));
" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Check 1: Node version vs engines.node
# ---------------------------------------------------------------------------
ENGINES_NODE="$(pkg_field engines.node)"
CURRENT_NODE="$(node --version 2>/dev/null || echo '')"
CURRENT_NODE="${CURRENT_NODE#v}"
if [[ -z "${CURRENT_NODE}" ]]; then
add_check "node-version" "fail" "node binary not found on PATH"
elif [[ -z "${ENGINES_NODE}" ]]; then
add_check "node-version" "fail" "engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)"
else
if satisfies_constraint "${CURRENT_NODE}" "${ENGINES_NODE}"; then
add_check "node-version" "pass" "Node ${CURRENT_NODE} satisfies ${ENGINES_NODE}"
else
add_check "node-version" "fail" "Node ${CURRENT_NODE} does NOT satisfy engines.node ${ENGINES_NODE}"
fi
fi
# ---------------------------------------------------------------------------
# Check 2: npm version vs engines.npm (skip if field absent)
# ---------------------------------------------------------------------------
ENGINES_NPM="$(pkg_field engines.npm)"
CURRENT_NPM="$(npm --version 2>/dev/null || echo '')"
if [[ -z "${ENGINES_NPM}" ]]; then
add_check "npm-version" "skip" "engines.npm not set in package.json — skipping"
elif [[ -z "${CURRENT_NPM}" ]]; then
add_check "npm-version" "fail" "npm binary not found on PATH"
else
if satisfies_constraint "${CURRENT_NPM}" "${ENGINES_NPM}"; then
add_check "npm-version" "pass" "npm ${CURRENT_NPM} satisfies ${ENGINES_NPM}"
else
add_check "npm-version" "fail" "npm ${CURRENT_NPM} does NOT satisfy engines.npm ${ENGINES_NPM}"
fi
fi
# ---------------------------------------------------------------------------
# Check 3: Lockfile presence
# ---------------------------------------------------------------------------
LOCKFILE="${PROJECT_ROOT}/package-lock.json"
if [[ -f "${LOCKFILE}" ]]; then
add_check "lockfile-present" "pass" "package-lock.json exists"
else
add_check "lockfile-present" "fail" "package-lock.json missing — run 'npm install' to generate it"
fi
# ---------------------------------------------------------------------------
# Check 4: Lockfile sync (npm ci --dry-run)
# Skip if lockfile is missing (already failed above).
# ---------------------------------------------------------------------------
if [[ -f "${LOCKFILE}" ]]; then
# npm ci --dry-run exits 0 when in sync; exits non-zero when it would mutate.
if (cd "${PROJECT_ROOT}" && npm ci --dry-run >/dev/null 2>&1); then
add_check "lockfile-sync" "pass" "package-lock.json is in sync with package.json"
else
add_check "lockfile-sync" "fail" "package-lock.json is out of sync — run 'npm ci' to restore"
fi
else
add_check "lockfile-sync" "skip" "skipped — lockfile missing"
fi
# ---------------------------------------------------------------------------
# Check 5: Version manager pin vs active Node
# Looks for .nvmrc, .node-version, or .tool-versions at project root.
# ---------------------------------------------------------------------------
NVMRC="${PROJECT_ROOT}/.nvmrc"
NODE_VERSION_FILE="${PROJECT_ROOT}/.node-version"
TOOL_VERSIONS="${PROJECT_ROOT}/.tool-versions"
PINNED_MAJOR=""
PIN_SOURCE=""
if [[ -f "${NVMRC}" ]]; then
NVMRC_CONTENT="$(head -1 "${NVMRC}" | tr -d '[:space:]')"
# Strip leading 'v' and extract major
NVMRC_CONTENT="${NVMRC_CONTENT#v}"
PINNED_MAJOR="${NVMRC_CONTENT%%.*}"
PIN_SOURCE=".nvmrc"
elif [[ -f "${NODE_VERSION_FILE}" ]]; then
NV_CONTENT="$(head -1 "${NODE_VERSION_FILE}" | tr -d '[:space:]')"
NV_CONTENT="${NV_CONTENT#v}"
PINNED_MAJOR="${NV_CONTENT%%.*}"
PIN_SOURCE=".node-version"
elif [[ -f "${TOOL_VERSIONS}" ]]; then
# asdf/mise format: "nodejs 22.x.x"
TV_LINE="$(grep -E '^nodejs ' "${TOOL_VERSIONS}" || true)"
if [[ -n "${TV_LINE}" ]]; then
TV_VER="$(echo "${TV_LINE}" | awk '{print $2}')"
TV_VER="${TV_VER#v}"
PINNED_MAJOR="${TV_VER%%.*}"
PIN_SOURCE=".tool-versions"
fi
fi
if [[ -z "${PINNED_MAJOR}" ]]; then
add_check "version-manager-pin" "skip" "no .nvmrc, .node-version, or .tool-versions found — skipping"
elif [[ "${CI:-}" == "true" ]]; then
# In CI the matrix explicitly tests multiple Node majors, so a pin-mismatch
# is expected and intentional. Skip rather than fail to avoid blocking the
# non-22 matrix rows (Node 24, 26, …) while still exercising all other checks.
add_check "version-manager-pin" "skip" "CI=true — version-manager pin check skipped (matrix tests multiple Node majors)"
else
ACTIVE_MAJOR="${CURRENT_NODE%%.*}"
if [[ "${ACTIVE_MAJOR}" == "${PINNED_MAJOR}" ]]; then
add_check "version-manager-pin" "pass" "Active Node major (${ACTIVE_MAJOR}) matches ${PIN_SOURCE} pin (${PINNED_MAJOR})"
else
add_check "version-manager-pin" "fail" "Active Node major (${ACTIVE_MAJOR}) does NOT match ${PIN_SOURCE} pin (${PINNED_MAJOR}) — run 'nvm use' or equivalent"
fi
fi
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
OVERALL_PASS=true
for check in "${CHECKS[@]}"; do
IFS='|' read -r name status message <<< "$check"
if [[ "$status" == "fail" ]]; then
OVERALL_PASS=false
break
fi
done
if [[ "${JSON_MODE}" == "true" ]]; then
# Emit structured JSON: {pass: bool, checks: [{name, status, message}]}
PASS_VAL="false"
[[ "${OVERALL_PASS}" == "true" ]] && PASS_VAL="true"
printf '{\n'
printf ' "pass": %s,\n' "${PASS_VAL}"
printf ' "checks": [\n'
total="${#CHECKS[@]}"
idx=0
for check in "${CHECKS[@]}"; do
idx=$(( idx + 1 ))
IFS='|' read -r name status message <<< "$check"
# Escape double-quotes in message for JSON
message="${message//\"/\\\"}"
if [[ $idx -lt $total ]]; then
printf ' {"name": "%s", "status": "%s", "message": "%s"},\n' \
"${name}" "${status}" "${message}"
else
printf ' {"name": "%s", "status": "%s", "message": "%s"}\n' \
"${name}" "${status}" "${message}"
fi
done
printf ' ]\n'
printf '}\n'
else
# Human-readable report
echo "=== Environment Check ==="
for check in "${CHECKS[@]}"; do
IFS='|' read -r name status message <<< "$check"
case "$status" in
pass) icon="[PASS]" ;;
fail) icon="[FAIL]" ;;
skip) icon="[SKIP]" ;;
*) icon="[????]" ;;
esac
printf " %s %-25s %s\n" "$icon" "$name" "$message"
done
echo ""
if [[ "${OVERALL_PASS}" == "true" ]]; then
echo "Result: ALL CHECKS PASSED"
else
echo "Result: ONE OR MORE CHECKS FAILED — see above"
fi
fi
# Exit code
if [[ "${OVERALL_PASS}" == "true" ]]; then
exit 0
else
exit 1
fi