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/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/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 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" <