From 3eaa209a10d5cf4668aadbf1a8a61e3a93325177 Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sat, 9 May 2026 08:20:25 +0000 Subject: [PATCH 1/3] fix(precommit): lockstep guard for package.json/catalog.json version (AL-25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/check-version-lockstep.sh and wires it as a pre-commit local hook. Asserts that plugin/cli/package.json::version matches plugin/catalog/catalog.json::version on every commit that touches either file. Why: scripts/build-release.sh enforces a three-way version lock at release time (TAG vs package.json vs catalog.json). Drift between package.json and catalog.json on master would surface only when the next tag push fires the build, far from the commit that introduced the drift. This hook shifts that gate from release-time to commit-time so the divergence never reaches master. Why these two files only: the AL-29 SoT migration (riding in the next commit) makes plugin/cli/package.json the canonical version. Plugin source (bash entrypoint, TS modules) and bats tests now read it dynamically — nothing left to drift in those layers. The remaining drift surface is plugin/catalog/catalog.json, which carries its own version field consumed by `agentlinux upgrade` and shipped as a sibling release artifact (CAT-05). This hook locks that one pair. Implementation: pure bash plus sed. No jq dependency on contributor laptops. Strict regex match against the conventional semver shape (plus optional pre-release suffix) catches malformed bumps loudly. Diagnostic on mismatch shows both observed values and points at the build-release.sh release-time backstop. Self-tested locally: - happy path (both at 0.3.2) -> exit 0 - injected drift (catalog -> 0.3.3) -> exit 1 with clear diagnostic - restored -> exit 0 - pre-commit run --all-files -> Passed Refs: AL-25. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pre-commit-config.yaml | 12 +++++ scripts/check-version-lockstep.sh | 85 +++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100755 scripts/check-version-lockstep.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 879a862..0470cfa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,3 +62,15 @@ repos: language: system files: ^plugin/catalog/(catalog|agents/.*/recipe)\.json$ pass_filenames: false + + # AL-25: shift the build-release.sh three-way version-lock gate from + # release-time to commit-time. Fails fast on drift between + # plugin/cli/package.json::version (SoT under AL-29) and + # plugin/catalog/catalog.json::version. Pure bash plus sed; no jq + # dependency on contributor laptops. + - id: check-version-lockstep + name: Check package.json/catalog.json version lockstep + entry: scripts/check-version-lockstep.sh + language: system + files: ^(plugin/cli/package\.json|plugin/catalog/catalog\.json)$ + pass_filenames: false diff --git a/scripts/check-version-lockstep.sh b/scripts/check-version-lockstep.sh new file mode 100755 index 0000000..8d8812a --- /dev/null +++ b/scripts/check-version-lockstep.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# scripts/check-version-lockstep.sh — AL-25 pre-commit guardrail. +# +# Asserts that plugin/cli/package.json::version (the canonical SoT under +# AL-29) matches plugin/catalog/catalog.json::version. The two files must +# agree because scripts/build-release.sh enforces a three-way version lock at +# release time (TAG vs package.json vs catalog.json) — any drift between +# package.json and catalog.json on master would block the next release. +# +# This hook shifts that gate from release-time to commit-time: a mismatch +# fails pre-commit with a precise diagnostic, so contributors cannot land a +# divergent pair into master and only discover it during the release run. +# +# Why these two files only: +# - Plugin source (plugin/bin/agentlinux-install, plugin/cli/src/*) reads +# package.json dynamically at install time / module load via the AL-29 +# SoT migration; nothing left to drift in source code. +# - Bats tests read package.json via jq for the same reason. +# - catalog.json is a JSON document that ships in the release tarball as a +# sibling artifact (CAT-05), and its `version` field is consumed +# independently by `agentlinux upgrade` for staleness detection. It can +# legitimately drift from package.json if a contributor edits one and +# forgets the other — exactly the failure mode this hook prevents. +# +# Refs: +# - .planning/quick/260503-dtx-.../260503-dtx-SUMMARY.md (AL-29 narrative) +# - scripts/build-release.sh §three-way-lock (release-time gate) +# - AL-25 (this hook), AL-29 (the SoT consolidation it backstops) + +set -euo pipefail + +PKG_JSON=plugin/cli/package.json +CAT_JSON=plugin/catalog/catalog.json + +if [[ ! -r "$PKG_JSON" ]]; then + printf 'check-version-lockstep: %s missing or unreadable\n' "$PKG_JSON" >&2 + exit 1 +fi +if [[ ! -r "$CAT_JSON" ]]; then + printf 'check-version-lockstep: %s missing or unreadable\n' "$CAT_JSON" >&2 + exit 1 +fi + +# Coreutils-only extraction so this hook does not require jq on contributor +# laptops. Format is fixed (we control both files); the regex is a strict +# parse of the conventional `"version": "X.Y.Z[-suffix]"` shape and rejects +# anything outside it loudly. +extract_version() { + local file=$1 + sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' "$file" \ + | head -n 1 +} + +PKG_V=$(extract_version "$PKG_JSON") +CAT_V=$(extract_version "$CAT_JSON") + +readonly VERSION_REGEX='^[0-9]+\.[0-9]+\.[0-9]+([-.][A-Za-z0-9.-]+)?$' +if [[ ! "$PKG_V" =~ $VERSION_REGEX ]]; then + printf 'check-version-lockstep: bad or missing version in %s: %q (expected match for %s)\n' \ + "$PKG_JSON" "$PKG_V" "$VERSION_REGEX" >&2 + exit 1 +fi +if [[ ! "$CAT_V" =~ $VERSION_REGEX ]]; then + printf 'check-version-lockstep: bad or missing version in %s: %q (expected match for %s)\n' \ + "$CAT_JSON" "$CAT_V" "$VERSION_REGEX" >&2 + exit 1 +fi + +if [[ "$PKG_V" != "$CAT_V" ]]; then + cat >&2 < ${PKG_V} + plugin/catalog/catalog.json -> ${CAT_V} + +Both files MUST carry the same version string. Bump them together. +The build-release.sh three-way version lock would have caught this +at release time; this pre-commit hook shifts that gate to commit +time so the drift never reaches master. +EOF + exit 1 +fi + +# All-clear. No stdout per pre-commit convention (silent on success). +exit 0 From 0c298e7c46c6831eac83ac3dba5188cd6383a33c Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sat, 9 May 2026 08:20:56 +0000 Subject: [PATCH 2/3] fix(version): consolidate version SoT to plugin/cli/package.json (AL-29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release version "0.3.0" / "0.3.2" was duplicated across eight sites: - plugin/bin/agentlinux-install AGENTLINUX_VERSION constant - plugin/cli/src/index.ts commander .version() arg - plugin/cli/src/catalog/loader.ts ?? fallback default - plugin/cli/src/catalog/schema.ts ?? fallback default - plugin/provisioner/10-agent-user.sh DOC-02 welcome-message string - tests/bats/10-installer.bats INST-02 fallback default - tests/bats/40-registry-cli.bats CLI-01/CLI-05 asserts + CAT-03 fixture - tests/bats/50-agents.bats CATALOG path - tests/bats/51-agt02-release-gate.bats CATALOG path Bumping a release required mechanical edits at every site, and at v0.3.2-rc1 one of those edits was missed (the AGENTLINUX_VERSION constant in the bash entrypoint stayed at 0.3.0 while package.json went to 0.3.2). The bats CAT-05 test reads the expected catalog path from package.json dynamically, so it failed CI: the staged catalog landed at /opt/agentlinux/catalog/0.3.0/ while the test expected /opt/agentlinux/catalog/0.3.2/. This change makes plugin/cli/package.json::version the single source-of-truth. Every other site reads it dynamically: TypeScript: a new plugin/cli/src/version.ts module walks up from import.meta.url looking for package.json (covers dist/, dist-test/, src/, and dev-tool layouts in one resolver — same pattern catalog/ schema.ts already uses for schema.json). Three call sites import { VERSION } from "./version.js"; the literal "0.3.x" defaults are gone. Bash entrypoint: a sed-extract from $BIN_DIR/../cli/package.json runs at script load. No jq dependency (jq isn't preinstalled on bare Ubuntu, and 30-nodejs.sh hasn't run yet at this point). The extracted value is regex-validated; a malformed package.json fails loudly before any provisioner step fires. Bats tests: every "0.3.x" literal in a path assertion or a version check is replaced with a $PKG_VERSION variable that runs jq once at file scope against the bind-mounted /opt/agentlinux-src tree (established by tests/docker/run.sh:150 before bats fires). Provisioner welcome message: dropped the version reference rather than wire it through the heredoc — the string was advisory, not behavioral, and any version reference there would rot the moment a new release shipped. Engines floor bumped from >=20 to >=22 in the same file: every CI workflow already pins setup-node@22 and the provisioner installs Node 22 LTS, so the declared minimum was ahead of reality. With the floor at 22, future TS code can rely on Node 22 features without a peer-dep warning. Verification: corepack pnpm test passes 112/112 unit tests against the dist-test layout. The bash entrypoint --version flag prints the package.json version when invoked. The new check-version-lockstep pre-commit hook (introduced in the prior commit) confirms package.json and catalog.json carry the same version on every relevant edit. Refs: AL-25 (parent — lockstep guard rode in prior commit), AL-29 (this consolidation; closing as duplicate of AL-25), AL-30 (the four-bug fix that surfaced the sprawl), AL-31 (the unpinned-resolution fix that paired with AL-30). Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/bin/agentlinux-install | 25 +++++++++++++- plugin/cli/package.json | 2 +- plugin/cli/src/catalog/loader.ts | 3 +- plugin/cli/src/catalog/schema.ts | 3 +- plugin/cli/src/index.ts | 3 +- plugin/cli/src/version.ts | 47 +++++++++++++++++++++++++++ plugin/provisioner/10-agent-user.sh | 2 +- tests/bats/10-installer.bats | 3 +- tests/bats/40-registry-cli.bats | 26 ++++++++++----- tests/bats/50-agents.bats | 4 ++- tests/bats/51-agt02-release-gate.bats | 4 ++- 11 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 plugin/cli/src/version.ts diff --git a/plugin/bin/agentlinux-install b/plugin/bin/agentlinux-install index 59bc4c2..78a0e63 100755 --- a/plugin/bin/agentlinux-install +++ b/plugin/bin/agentlinux-install @@ -9,7 +9,6 @@ # §Pattern 1, §Pitfall 6, §Example 1; 02-CONTEXT.md "Installer UX & Logging". set -euo pipefail -readonly AGENTLINUX_VERSION="0.3.2" readonly LOG_FILE="${AGENTLINUX_LOG:-/var/log/agentlinux-install.log}" # Resolve script-relative directories. Split `declare`/`assign` per SC2155 so # a cmdsub failure surfaces as a non-zero return instead of being masked by @@ -21,6 +20,30 @@ readonly LIB_DIR PROV_DIR="$(cd "$BIN_DIR/../provisioner" && pwd)" readonly PROV_DIR +# AL-29: derive AGENTLINUX_VERSION from the co-shipped plugin/cli/package.json +# rather than a hardcoded constant. Single source-of-truth: bumping +# package.json now propagates to the bash installer, the registry CLI, the +# bats test suite, and the release pipeline's three-way version lock. The +# previous hardcoded constant drifted in v0.3.2-rc1 (silently broke CAT-05 +# because the staged catalog landed at /opt/agentlinux/catalog/0.3.0/ while +# package.json said 0.3.2). +# +# Why sed-not-jq: the entrypoint runs BEFORE 30-nodejs.sh which (transitively +# via apt-get) brings jq onto bare Ubuntu. Coreutils-only extraction is +# sufficient because we control the package.json shape — it is checked into +# this repo with a fixed format. The version-regex check below catches any +# format drift loudly. +PKG_JSON="$BIN_DIR/../cli/package.json" +readonly PKG_JSON +[[ -r "$PKG_JSON" ]] \ + || { printf 'agentlinux-install: package.json not readable at %s\n' "$PKG_JSON" >&2; exit 1; } +AGENTLINUX_VERSION=$(sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' \ + "$PKG_JSON" | head -n 1) +[[ "$AGENTLINUX_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][A-Za-z0-9.-]+)?$ ]] \ + || { printf 'agentlinux-install: bad or missing version in %s: %q\n' \ + "$PKG_JSON" "$AGENTLINUX_VERSION" >&2; exit 1; } +readonly AGENTLINUX_VERSION + usage() { cat <=20" + "node": ">=22" }, "packageManager": "pnpm@10.18.2" } diff --git a/plugin/cli/src/catalog/loader.ts b/plugin/cli/src/catalog/loader.ts index 1174f6b..576032f 100644 --- a/plugin/cli/src/catalog/loader.ts +++ b/plugin/cli/src/catalog/loader.ts @@ -8,10 +8,11 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Catalog } from "../types.js"; +import { VERSION } from "../version.js"; import { formatErrors, getValidator } from "./schema.js"; function defaultCatalogDir(): string { - const ver = process.env.AGENTLINUX_VERSION ?? "0.3.2"; + const ver = process.env.AGENTLINUX_VERSION ?? VERSION; return `/opt/agentlinux/catalog/${ver}`; } diff --git a/plugin/cli/src/catalog/schema.ts b/plugin/cli/src/catalog/schema.ts index 346536f..dfa4a13 100644 --- a/plugin/cli/src/catalog/schema.ts +++ b/plugin/cli/src/catalog/schema.ts @@ -25,6 +25,7 @@ const Ajv2020: any = (AjvModule as any).default ?? (AjvModule as any).Ajv2020 ?? const addFormats: any = (AjvFormatsModule as any).default ?? AjvFormatsModule; import { access } from "node:fs/promises"; +import { VERSION } from "../version.js"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -61,7 +62,7 @@ async function resolveSchemaPath(): Promise { const candidates: string[] = []; if (envDir) candidates.push(join(envDir, "schema.json")); // Production default — matches loader.ts's defaultCatalogDir(). - const ver = process.env.AGENTLINUX_VERSION ?? "0.3.2"; + const ver = process.env.AGENTLINUX_VERSION ?? VERSION; candidates.push(`/opt/agentlinux/catalog/${ver}/schema.json`); // Walk up 6 levels; covers dist/ dist-test/src/ src/ and a couple spare // for dev/test layouts. diff --git a/plugin/cli/src/index.ts b/plugin/cli/src/index.ts index 293bc49..9e2deec 100644 --- a/plugin/cli/src/index.ts +++ b/plugin/cli/src/index.ts @@ -20,13 +20,14 @@ import { pinCmd } from "./commands/pin.js"; import { removeCmd } from "./commands/remove.js"; import { upgradeCmd } from "./commands/upgrade.js"; import { guardAgentUser } from "./guard/user.js"; +import { VERSION } from "./version.js"; const program = new Command(); program .name("agentlinux") .description("AgentLinux registry CLI — install, upgrade, remove catalog agents") - .version("0.3.2", "-V, --version"); + .version(VERSION, "-V, --version"); // Commander's `.enablePositionalOptions()` is REQUIRED so the install // subcommand's `--version ` override (CLI-03) can shadow the diff --git a/plugin/cli/src/version.ts b/plugin/cli/src/version.ts new file mode 100644 index 0000000..fa92ab8 --- /dev/null +++ b/plugin/cli/src/version.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// plugin/cli/src/version.ts — single source-of-truth for the AgentLinux release +// version, read once at module load from plugin/cli/package.json. +// +// AL-29 motivation: prior to this module the "version" string was duplicated +// across 8 sites (plugin/bin/agentlinux-install constant, three TS defaults, +// four bats test files). Bumping the release required mechanical edits in +// every site and one of those — the bash AGENTLINUX_VERSION constant — +// silently broke CAT-05 in v0.3.2-rc1 because the bats test reads the +// expected version from package.json dynamically, but the installer staged +// the catalog under the (still-0.3.0) hardcoded constant. This module +// terminates that sprawl: TS callers import VERSION; the bash entrypoint +// sed-extracts from the same package.json at install time. +// +// Layout: +// - Production: /opt/agentlinux/cli//dist/index.js → ../package.json +// - Production: /opt/agentlinux/cli//dist/catalog/loader.js → ../../package.json +// - Test build: dist-test/src/catalog/loader.js → ../../../package.json +// +// The walk-up loop accepts both layouts (and a generous spare depth for +// future dev tooling) without per-build path arithmetic. Mirrors the same +// pattern used in catalog/schema.ts §resolveSchemaPath for schema.json. + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +function findPkgVersion(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let depth = 0; depth < 6; depth++) { + try { + const text = readFileSync(resolve(dir, "package.json"), "utf8"); + const pkg = JSON.parse(text) as { version?: string }; + if (typeof pkg.version === "string" && pkg.version.length > 0) { + return pkg.version; + } + } catch { + // keep walking — package.json not present at this depth, or unreadable + } + dir = resolve(dir, ".."); + } + throw new Error( + `agentlinux: could not locate package.json within 6 levels of ${import.meta.url}`, + ); +} + +export const VERSION = findPkgVersion(); diff --git a/plugin/provisioner/10-agent-user.sh b/plugin/provisioner/10-agent-user.sh index 4fa5ee2..86899f9 100644 --- a/plugin/provisioner/10-agent-user.sh +++ b/plugin/provisioner/10-agent-user.sh @@ -82,7 +82,7 @@ ensure_marker_block /home/agent/CLAUDE.md "agentlinux-doc-02" --top <<'DOC02' ## This environment is correctly owned -This agent user was provisioned by AgentLinux v0.3.0. Your home directory, +This agent user was provisioned by AgentLinux. Your home directory, npm global prefix (arrives Phase 3), and per-tool config paths are all owned by you. You do NOT need sudo for routine agent operations. diff --git a/tests/bats/10-installer.bats b/tests/bats/10-installer.bats index 99139e4..9705c86 100644 --- a/tests/bats/10-installer.bats +++ b/tests/bats/10-installer.bats @@ -66,7 +66,8 @@ INSTALLER=/opt/agentlinux-src/plugin/bin/agentlinux-install # - readlink target: a string, byte-stable by construction. # - first line of dist/index.js: the #!/usr/bin/env node shebang — stable # regardless of any internal tsc reordering of the generated body. - local version=${AGENTLINUX_VERSION:-0.3.2} + local version + version=${AGENTLINUX_VERSION:-$(jq -r .version /opt/agentlinux-src/plugin/cli/package.json)} find \ /etc/profile.d/agentlinux.sh \ /etc/agentlinux.env \ diff --git a/tests/bats/40-registry-cli.bats b/tests/bats/40-registry-cli.bats index 67a17ea..9f478ba 100644 --- a/tests/bats/40-registry-cli.bats +++ b/tests/bats/40-registry-cli.bats @@ -21,6 +21,10 @@ load 'helpers/assertions' LOG=/var/log/agentlinux-install.log INSTALLER=/opt/agentlinux-src/plugin/bin/agentlinux-install +# AL-29: derive the expected version from package.json — single source-of-truth. +# Production layout: /opt/agentlinux-src/ is the bind-mounted source tree +# established by tests/docker/run.sh:150 BEFORE bats fires. +PKG_VERSION=$(jq -r .version /opt/agentlinux-src/plugin/cli/package.json) setup_file() { # The installer is already run by tests/docker/run.sh BEFORE bats fires, @@ -71,10 +75,12 @@ setup() { done } -# CLI-01: --version prints 0.3.2 across invocation modes — proves the symlink -# + Node shebang + dist/index.js + package.json "type":"module" chain all fire -# regardless of which shell wrapper the caller uses. -@test "CLI-01: agentlinux --version prints 0.3.2 from every invocation mode" { +# CLI-01: --version prints package.json's `version` across invocation modes — +# proves the symlink + Node shebang + dist/index.js + package.json +# "type":"module" chain all fire regardless of which shell wrapper the caller +# uses. Asserts on $PKG_VERSION (derived from package.json at file scope — +# AL-29) so a release bump in package.json propagates here without an edit. +@test "CLI-01: agentlinux --version prints package.json version from every invocation mode" { local mode for mode in "${INVOKE_MODES[@]}"; do invoke_mode "$mode" 'agentlinux --version' @@ -82,7 +88,7 @@ setup() { skip "CLI-01 (${mode}): systemd PID 1 not running" fi assert_exit_zero "CLI-01 (${mode})" - assert_path_has "CLI-01 (${mode})" "0.3.2" + assert_path_has "CLI-01 (${mode})" "$PKG_VERSION" done } @@ -244,7 +250,7 @@ setup() { @test "CLI-05: running agentlinux as agent user succeeds without sudo" { run sudo -u agent -H bash --login -c 'agentlinux --version' assert_exit_zero "CLI-05" - assert_path_has "CLI-05" "0.3.2" + assert_path_has "CLI-05" "$PKG_VERSION" } # ---------- CLI-06: upgrade detects divergence; report-only without bulk flag ---------- @@ -396,11 +402,13 @@ setup() { # the same AGENTLINUX_CATALOG_DIR env var — schema.ts §resolveSchemaPath). # Copy the production schema verbatim so the fixture catalog validates # against the SAME rules production does. - cp /opt/agentlinux/catalog/0.3.2/schema.json "$tmp/schema.json" + cp "/opt/agentlinux/catalog/${PKG_VERSION}/schema.json" "$tmp/schema.json" - cat >"$tmp/catalog.json" <<'JSON' + # Unquoted heredoc so ${PKG_VERSION} substitutes; the rest of the body + # contains no $-tokens, so this is the only expansion that fires. + cat >"$tmp/catalog.json" < Date: Sat, 9 May 2026 08:25:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(bats):=20fix=20doc-drift=20comment=20?= =?UTF-8?q?=E2=80=94=20qa-engineer=20review=20nit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 50-agents.bats:16 commented that version pins are read from a literal 0.3.2 path, but the code below was switched to PKG_VERSION-derivation in the prior commit. Update the comment to reference the dynamic path so future readers don't grep for the literal and assume drift. Refs: AL-25. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/bats/50-agents.bats | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/bats/50-agents.bats b/tests/bats/50-agents.bats index 7fb3405..f3b052b 100644 --- a/tests/bats/50-agents.bats +++ b/tests/bats/50-agents.bats @@ -13,9 +13,10 @@ # - failures emit __fail four-line TST-04 diagnostics # - setup_file installs all three agents ONCE for the whole file; teardown_file # removes them. Serial installs keep sentinel writes unambiguous. -# - version pins are read from /opt/agentlinux/catalog/0.3.2/catalog.json via -# jq — NEVER hardcoded in @test bodies (so a catalog version bump does not -# require editing this file). +# - version pins are read from /opt/agentlinux/catalog/${PKG_VERSION}/catalog.json +# via jq — NEVER hardcoded in @test bodies (so a catalog version bump +# does not require editing this file). PKG_VERSION itself is derived +# from plugin/cli/package.json under the AL-29 SoT consolidation. # # Refs: # - .claude/skills/behavior-test-contract/SKILL.md (ID-in-@test-name required)