Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 24 additions & 1 deletion plugin/bin/agentlinux-install
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <<EOF
agentlinux-install v${AGENTLINUX_VERSION} — provision an agent user and environment
Expand Down
2 changes: 1 addition & 1 deletion plugin/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"typescript": "^5.6.3"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"packageManager": "pnpm@10.18.2"
}
3 changes: 2 additions & 1 deletion plugin/cli/src/catalog/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down
3 changes: 2 additions & 1 deletion plugin/cli/src/catalog/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -61,7 +62,7 @@ async function resolveSchemaPath(): Promise<string> {
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.
Expand Down
3 changes: 2 additions & 1 deletion plugin/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <semver>` override (CLI-03) can shadow the
Expand Down
47 changes: 47 additions & 0 deletions plugin/cli/src/version.ts
Original file line number Diff line number Diff line change
@@ -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/<ver>/dist/index.js → ../package.json
// - Production: /opt/agentlinux/cli/<ver>/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();
2 changes: 1 addition & 1 deletion plugin/provisioner/10-agent-user.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
85 changes: 85 additions & 0 deletions scripts/check-version-lockstep.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
check-version-lockstep: version drift between SoT and catalog
plugin/cli/package.json -> ${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
3 changes: 2 additions & 1 deletion tests/bats/10-installer.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
26 changes: 17 additions & 9 deletions tests/bats/40-registry-cli.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,18 +75,20 @@ 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'
if [[ "${output:-}" == *SKIP_SYSTEMD_UNAVAILABLE* ]]; then
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
}

Expand Down Expand Up @@ -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 ----------
Expand Down Expand Up @@ -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" <<JSON
{
"version": "0.3.2",
"version": "${PKG_VERSION}",
"agents": [
{
"id": "fake-42",
Expand Down
11 changes: 7 additions & 4 deletions tests/bats/50-agents.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -27,7 +28,9 @@ load 'helpers/invoke_modes'
load 'helpers/assertions'

LOG=/var/log/agentlinux-install.log
CATALOG=/opt/agentlinux/catalog/0.3.2/catalog.json
# AL-29: derive the catalog version from package.json — single SoT.
PKG_VERSION=$(jq -r .version /opt/agentlinux-src/plugin/cli/package.json)
CATALOG=/opt/agentlinux/catalog/${PKG_VERSION}/catalog.json

setup_file() {
# 40-registry-cli.bats's INST-04 --purge @tests run earlier in filename sort
Expand Down
4 changes: 3 additions & 1 deletion tests/bats/51-agt02-release-gate.bats
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
load 'helpers/invoke_modes'
load 'helpers/assertions'

CATALOG=/opt/agentlinux/catalog/0.3.2/catalog.json
# AL-29: derive the catalog version from package.json — single SoT.
PKG_VERSION=$(jq -r .version /opt/agentlinux-src/plugin/cli/package.json)
CATALOG=/opt/agentlinux/catalog/${PKG_VERSION}/catalog.json

setup_file() {
# 40-registry-cli.bats's INST-04 @tests run --purge at the end of that file
Expand Down
Loading