* 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>
333 lines
12 KiB
Bash
Executable File
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
|