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>
This commit is contained in:
Tom Boucher
2026-05-23 18:38:49 -04:00
committed by GitHub
parent d8b432da1e
commit 33ffc647e2
18 changed files with 905 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 117
---
**Environment bootstrap validator (`npm run check:env`)** — adds `scripts/check-env.sh` which validates Node version, npm version, lockfile presence, lockfile sync, and version-manager pin before test or audit runs. Catches environment mismatches early with structured `--json` output. Companion docs in `docs/contributing/bootstrap.md` cover prerequisites, one-time setup, daily commands, and troubleshooting. CI now runs the environment check after `setup-node` and before `npm ci` on every matrix lane. (#117)

View File

@@ -148,6 +148,14 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: 'npm'
# Environment check - validates Node/npm version against engines constraints
# and confirms package-lock.json is present before npm ci runs.
# Catches env drift in CI itself (contributor parity check, issue #117).
# Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
- name: Environment check
shell: bash
run: npm run check:env
- name: Install dependencies
shell: bash
run: npm ci

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -16,6 +16,23 @@ npm test
---
## Bootstrap your environment
For a step-by-step setup guide covering Node version managers, `npm ci`, the environment
validator, daily commands, and troubleshooting, see:
**[docs/contributing/bootstrap.md](docs/contributing/bootstrap.md)**
Quick start:
```bash
nvm use # activate the pinned Node version from .nvmrc
npm run check:env # validate your environment
npm ci # install from lockfile
```
---
## Types of Contributions
GSD accepts three types of contributions. Each type has a different process and a different bar for acceptance. **Read this section before opening anything.**

View File

@@ -0,0 +1,221 @@
# Bootstrap your environment
This guide gets a new contributor from a fresh checkout to a passing baseline in one session.
Sources:
- npm engines field: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
- Reproducible builds: https://reproducible-builds.org/docs/source-tree/
- npm ci: https://docs.npmjs.com/cli/v10/commands/npm-ci
- Docker-based Linux verification: https://github.com/open-gsd/gsd-test-runner
---
## Prerequisites
### Node version manager (pick one)
| Tool | Install | Docs |
|---|---|---|
| **nvm** (recommended) | `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh \| bash` | https://github.com/nvm-sh/nvm |
| **fnm** (fast, Rust) | `curl -fsSL https://fnm.vercel.app/install \| bash` | https://github.com/Schniz/fnm |
| **asdf** | `brew install asdf` then `asdf plugin add nodejs` | https://asdf-vm.com |
| **mise** | `curl https://mise.run \| sh` | https://mise.jdx.dev |
A version manager ensures you can switch Node versions per project without polluting your
global install. This project ships a `.nvmrc` file at the root — any of the tools above
will read it.
### Other required tools
- **gh** (GitHub CLI) — https://cli.github.com — used by contribution workflows and CI
- **git** — any recent version
---
## One-time setup
```bash
# 1. Clone
git clone https://github.com/open-gsd/get-shit-done-redux.git
cd get-shit-done-redux
# 2. Activate the pinned Node version
nvm use # nvm
# fnm use # fnm
# asdf install # asdf / mise
# 3. Verify the environment (see Validation below)
npm run check:env
# 4. Install dependencies (reproducible, lockfile-driven)
npm ci
```
`npm ci` is required over `npm install`. It installs exactly what `package-lock.json`
specifies and fails fast if the lockfile is out of sync — this is intentional.
See https://docs.npmjs.com/cli/v10/commands/npm-ci
---
## Daily commands
| Command | Purpose |
|---|---|
| `npm run check:env` | Validate your environment before running tests |
| `npm test` | Run the full test suite (unit + integration + security) |
| `npm run test:unit` | Unit tests only (fastest) |
| `npm run test:integration` | Integration tests |
| `npm run build:sdk` | Rebuild the SDK dist (required before first test run) |
> `npm run check:integrity` — available once [#114](https://github.com/open-gsd/get-shit-done-redux/issues/114) merges.
---
## Validation
Run the environment validator before any test or audit run:
```bash
npm run check:env
```
This runs `scripts/check-env.sh` and reports pass/fail for each check:
| Check | What it verifies |
|---|---|
| `node-version` | Active Node satisfies `engines.node` (`>=22.0.0`) |
| `npm-version` | Active npm satisfies `engines.npm` (`>=10.0.0`) |
| `lockfile-present` | `package-lock.json` exists at root |
| `lockfile-sync` | `npm ci --dry-run` exits 0 (lockfile matches installed state) |
| `version-manager-pin` | Active Node major matches `.nvmrc` / `.node-version` / `.tool-versions` |
**Exit codes:**
- `0` — all checks passed, safe to proceed
- `1` — one or more checks failed — see the report
- `2` — tool error (e.g., `node` not found, corrupt `package.json`)
For structured output (useful in scripts):
```bash
npm run check:env -- --json
```
---
## Troubleshooting
### `node-version` FAIL — Node X does NOT satisfy `>=22.0.0`
**Cause:** The system Node is too old, or the version manager hasn't activated the correct version.
**Fix:**
```bash
nvm use # activates version from .nvmrc
node --version # confirm
```
If `nvm` reports the version is not installed:
```bash
nvm install # installs the version in .nvmrc
nvm use
```
---
### `npm-version` FAIL — npm X does NOT satisfy `>=10.0.0`
**Cause:** npm bundled with an old Node version.
**Fix:**
```bash
npm install -g npm@latest
npm --version
```
---
### `lockfile-present` FAIL — `package-lock.json` missing
**Cause:** The lockfile was deleted or was never generated.
**Fix:**
```bash
npm install # generates package-lock.json
```
Do NOT commit a regenerated lockfile without verifying no unexpected packages changed.
Run `git diff package-lock.json` to inspect the diff.
---
### `lockfile-sync` FAIL — `package-lock.json` is out of sync
**Cause:** `package.json` was edited (dependency added/changed) without updating the lockfile,
or the lockfile was hand-edited.
**Fix:**
```bash
npm ci # restores node_modules to match lockfile exactly
# or, if the sync failure is intentional (you updated package.json):
npm install # updates lockfile to match package.json
```
---
### `version-manager-pin` FAIL — Active Node major does not match `.nvmrc`
**Cause:** The shell is using a globally-installed Node rather than the version manager's
activation. Common on fresh shell sessions.
**Fix:**
```bash
nvm use # re-activate from .nvmrc
# or add to your shell profile:
# echo 'nvm use --silent' >> ~/.zshrc
```
---
### Tests fail with `Error: Cannot find module ...`
**Cause:** `node_modules` is missing or stale (common after a branch switch that changed `package.json`).
**Fix:**
```bash
npm ci # clean install from lockfile
npm run build:sdk
```
---
### Locale / encoding errors on non-UTF-8 systems
**Cause:** Some test fixtures contain non-ASCII characters. Node requires a UTF-8 locale.
**Fix:**
```bash
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
```
Add these to your shell profile to make them permanent.
---
## Alternative: Docker via gsd-test-runner
For canonical Linux verification from a macOS dev box, use
[gsd-test-runner](https://github.com/open-gsd/gsd-test-runner).
This is the same path CI uses for cross-platform coverage. It is the authoritative way
to confirm your change passes on Linux before opening a PR:
```bash
# One-time: install gsd-test-runner (see repo README)
# Then, from the project root:
gsd-test-summary
```
`gsd-test-summary` runs the full suite in a Docker container and emits a concise
`Mac: N failed / Docker: N failed` summary. Both lines must show `0 failed` before
a PR is opened. See [CLAUDE.md](../../CLAUDE.md) for the required PR-flow ordering.

View File

@@ -48,7 +48,8 @@
"access": "public"
},
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
@@ -61,6 +62,7 @@
"fallow": "^2.70.0"
},
"scripts": {
"check:env": "bash scripts/check-env.sh",
"check:integrity": "./scripts/check-npm-integrity.sh",
"build:hooks": "node scripts/build-hooks.js",
"build:sdk": "cd sdk && npm ci && npm run build",

332
scripts/check-env.sh Executable file
View File

@@ -0,0 +1,332 @@
#!/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

234
tests/check-env.test.cjs Normal file
View File

@@ -0,0 +1,234 @@
/**
* Tests for scripts/check-env.sh (issue #117).
*
* Verifies the environment validator exits correctly and emits
* structured output for every documented check:
* 1. Node version vs engines.node constraint
* 2. npm version vs engines.npm constraint (if present)
* 3. Lockfile presence
* 4. Lockfile sync (npm ci --dry-run)
* 5. Version-manager pin file matches active Node major
* 6. --json flag produces parseable JSON with documented shape
* 7. Integration smoke: exits 0 on the live worktree root
*
* Sources:
* npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
* npm ci: https://docs.npmjs.com/cli/v10/commands/npm-ci
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.sh');
const FIXTURE_ROOT = path.resolve(__dirname, 'fixtures', 'check-env');
const LIVE_ROOT = path.resolve(__dirname, '..');
/**
* Run check-env.sh synchronously in `cwd` with optional extra args.
* Returns { status, stdout, stderr }.
* @param {string} cwd
* @param {string[]} args
* @param {Record<string,string>} [envOverrides] - optional env vars to overlay
* on process.env. Pass { CI: '' } to suppress GitHub Actions CI detection
* so that version-manager-pin is exercised even inside CI runners.
*/
function runScript(cwd, args = [], envOverrides = {}) {
const result = spawnSync('bash', [SCRIPT, ...args], {
cwd,
encoding: 'utf8',
timeout: 30_000,
env: { ...process.env, ...envOverrides },
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
describe('check-env.sh', () => {
// -------------------------------------------------------------------------
// Dynamic .nvmrc setup: write fixture .nvmrc files at test-run time so the
// tests are correct across all Node major versions in the CI matrix (Node 22,
// 24, 26, …). A hardcoded value like "26" passes on Node 26 but fails on
// every other matrix row; using the active major makes the fixture portable.
//
// good/ → .nvmrc = active Node major (should match → exit 0)
// bad-nvmrc/ → .nvmrc = active+99 (guaranteed mismatch → exit 1)
// -------------------------------------------------------------------------
const activeNodeMajor = parseInt(process.version.match(/^v(\d+)/)[1], 10);
const goodNvmrc = path.join(FIXTURE_ROOT, 'good', '.nvmrc');
const badNvmrc = path.join(FIXTURE_ROOT, 'bad-nvmrc', '.nvmrc');
let originalGoodNvmrc;
let originalBadNvmrc;
before(() => {
originalGoodNvmrc = fs.existsSync(goodNvmrc) ? fs.readFileSync(goodNvmrc, 'utf8') : null;
originalBadNvmrc = fs.existsSync(badNvmrc) ? fs.readFileSync(badNvmrc, 'utf8') : null;
fs.writeFileSync(goodNvmrc, `${activeNodeMajor}\n`);
fs.writeFileSync(badNvmrc, `${activeNodeMajor + 99}\n`);
});
after(() => {
if (originalGoodNvmrc !== null) {
fs.writeFileSync(goodNvmrc, originalGoodNvmrc);
}
if (originalBadNvmrc !== null) {
fs.writeFileSync(badNvmrc, originalBadNvmrc);
}
});
// -------------------------------------------------------------------------
// Test 1: Happy path — all checks green
// -------------------------------------------------------------------------
test('exits 0 in a fixture directory with engines, .nvmrc, and matching lockfile', () => {
const cwd = path.join(FIXTURE_ROOT, 'good');
const { status, stdout } = runScript(cwd);
assert.equal(
status, 0,
`Expected exit 0, got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 2: engines.node constraint not satisfied
// -------------------------------------------------------------------------
test('exits 1 when engines.node constraint is not satisfied by current Node', () => {
const cwd = path.join(FIXTURE_ROOT, 'bad-node-version');
// Fixture has engines.node: "<14.0.0"; current Node is much higher.
const { status, stdout } = runScript(cwd);
assert.equal(
status, 1,
`Expected exit 1 (bad node version), got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 3: Missing lockfile
// -------------------------------------------------------------------------
test('exits 1 when package-lock.json is missing', () => {
const cwd = path.join(FIXTURE_ROOT, 'missing-lockfile');
const { status, stdout } = runScript(cwd);
assert.equal(
status, 1,
`Expected exit 1 (missing lockfile), got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 4: .nvmrc major doesn't match active Node major
// -------------------------------------------------------------------------
test('exits 1 when .nvmrc major version does not match active Node major', () => {
const cwd = path.join(FIXTURE_ROOT, 'bad-nvmrc');
// Fixture .nvmrc is set to (activeNodeMajor + 99) by the before() hook above,
// guaranteeing a mismatch regardless of the CI matrix Node version.
// Override CI='' so the version-manager-pin check is not skipped even when
// this test runs inside a CI runner (GitHub Actions sets CI=true, which
// would otherwise turn the pin check into a skip and exit 0).
const { status, stdout } = runScript(cwd, [], { CI: '' });
assert.equal(
status, 1,
`Expected exit 1 (nvmrc mismatch), got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 5: --json flag produces parseable JSON with documented shape
// -------------------------------------------------------------------------
test('--json emits parseable JSON with pass and checks keys', () => {
const cwd = path.join(FIXTURE_ROOT, 'good');
const { status, stdout } = runScript(cwd, ['--json']);
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (err) {
assert.fail(`--json output was not valid JSON: ${err.message}\nstdout: ${stdout}`);
}
// Top-level shape
assert.equal(typeof parsed.pass, 'boolean', 'JSON must have boolean `pass` key');
assert.ok(Array.isArray(parsed.checks), 'JSON must have array `checks` key');
// The good fixture has engines.node, .nvmrc, and package-lock.json — expect
// at least the node-version, lockfile-present, lockfile-sync, and
// version-manager-pin checks to appear.
const checkNames = parsed.checks.map((c) => c.name);
assert.ok(
checkNames.includes('node-version'),
`Expected 'node-version' check in JSON, got: ${checkNames.join(', ')}`
);
assert.ok(
checkNames.includes('lockfile-present'),
`Expected 'lockfile-present' check in JSON, got: ${checkNames.join(', ')}`
);
// Every check item must have name, status, message fields with expected types
for (const check of parsed.checks) {
assert.equal(typeof check.name, 'string', `check.name must be string in ${JSON.stringify(check)}`);
assert.ok(
['pass', 'fail', 'skip'].includes(check.status),
`check.status must be pass|fail|skip in ${JSON.stringify(check)}`
);
assert.equal(typeof check.message, 'string', `check.message must be string in ${JSON.stringify(check)}`);
}
// Good fixture: overall result must be pass:true
assert.equal(parsed.pass, true, 'good fixture must report pass:true');
assert.equal(
status, 0,
`Expected exit 0 in good fixture with --json, got ${status}`
);
});
// -------------------------------------------------------------------------
// Test 5b: --json reports pass:false on failure fixtures (counter-test for 5)
// -------------------------------------------------------------------------
test('--json reports pass:false when a check fails', () => {
const cwd = path.join(FIXTURE_ROOT, 'bad-node-version');
const { status, stdout } = runScript(cwd, ['--json']);
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (err) {
assert.fail(`--json output was not valid JSON: ${err.message}\nstdout: ${stdout}`);
}
assert.equal(parsed.pass, false, 'failure fixture must report pass:false');
assert.equal(status, 1, `Expected exit 1 with --json on failure fixture, got ${status}`);
// The node-version check must be present and marked fail
const nodeCheck = parsed.checks.find((c) => c.name === 'node-version');
assert.ok(nodeCheck, 'node-version check must appear in JSON output');
assert.equal(nodeCheck.status, 'fail', `Expected node-version status=fail, got ${nodeCheck.status}`);
});
// -------------------------------------------------------------------------
// Test 6: Integration smoke — script runs without tool-error on live root
//
// Verifies the script executes against a real repo without a tool error (exit 2).
// Exit 0 or 1 are acceptable — local Node may differ from the .nvmrc pin (22).
// Uses --json for structured assertion, avoiding raw output-grep.
// -------------------------------------------------------------------------
test('script runs without tool error on the live worktree root (--json)', () => {
const { status, stdout, stderr } = runScript(LIVE_ROOT, ['--json']);
assert.notEqual(
status, 2,
`Expected exit 0 or 1 on live repo, got exit 2 (tool error).\nstdout: ${stdout}\nstderr: ${stderr}`
);
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (err) {
assert.fail(`Live repo --json was not valid JSON: ${err.message}\nstdout: ${stdout}`);
}
assert.equal(typeof parsed.pass, 'boolean', 'Live repo JSON must have boolean pass');
assert.ok(Array.isArray(parsed.checks), 'Live repo JSON must have checks array');
// Node version check must be present and pass (Node >=22 is installed)
const nodeCheck = parsed.checks.find((c) => c.name === 'node-version');
assert.ok(nodeCheck, 'node-version check must be present in live repo output');
assert.equal(nodeCheck.status, 'pass', `node-version should pass on live repo, got: ${nodeCheck.status} — ${nodeCheck.message}`);
// Lockfile checks must pass on the live repo
const lockfileCheck = parsed.checks.find((c) => c.name === 'lockfile-present');
assert.ok(lockfileCheck, 'lockfile-present check must appear in live output');
assert.equal(lockfileCheck.status, 'pass', `lockfile-present should pass on live repo`);
});
});

View File

@@ -153,6 +153,13 @@ const INTERNAL_COMPONENT_SLUGS = new Set([
// docs/discussions/grok-build-support-2026-05.md. The regex captures
// "/gsd-sync-skills" from the path. Invoked via Skill(skill="gsd-sync-skills").
'sync-skills',
// gsd-test-runner — GitHub repository name: "github.com/open-gsd/gsd-test-runner".
// docs/contributing/bootstrap.md references it as a hyperlink target:
// [gsd-test-runner](https://github.com/open-gsd/gsd-test-runner)
// The regex captures "/gsd-test-runner" from the URL path component. This is
// an external tool repo, not a user-typable slash command in this product.
'test-runner',
]);
/**

View File

@@ -0,0 +1,15 @@
{
"name": "check-env-fixture-bad-node",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "check-env-fixture-bad-node",
"version": "1.0.0",
"engines": {
"node": "<14.0.0"
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "check-env-fixture-bad-node",
"version": "1.0.0",
"engines": {
"node": "<14.0.0"
}
}

View File

@@ -0,0 +1 @@
22

15
tests/fixtures/check-env/bad-nvmrc/package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "check-env-fixture-bad-nvmrc",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "check-env-fixture-bad-nvmrc",
"version": "1.0.0",
"engines": {
"node": ">=22.0.0"
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "check-env-fixture-bad-nvmrc",
"version": "1.0.0",
"engines": {
"node": ">=22.0.0"
}
}

1
tests/fixtures/check-env/good/.nvmrc vendored Normal file
View File

@@ -0,0 +1 @@
26

16
tests/fixtures/check-env/good/package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "check-env-fixture-good",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "check-env-fixture-good",
"version": "1.0.0",
"engines": {
"node": ">=22.0.0",
"npm": ">=9.0.0"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "check-env-fixture-good",
"version": "1.0.0",
"engines": {
"node": ">=22.0.0",
"npm": ">=9.0.0"
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "check-env-fixture-missing-lockfile",
"version": "1.0.0",
"engines": {
"node": ">=22.0.0"
}
}