From b6e558225613144b12049f379a668131b0a867d5 Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 26 Aug 2026 22:24:54 -0400 Subject: [PATCH] feat(toolchain): add devenv-cli source resolver tool (RIG-2546) Build tools/toolchain/devenv-cli/, the single place that resolves the devenv CLI source from a named devenv.lock. Parity-gate shape: pure resolution in core.ts (lock JSON -> validated owner/repo/rev -> `github://#devenv` flakeref; argv -> parsed request), a thin exec shell in index.ts, unit tests in core.test.ts. Two modes: `--mode flakeref` prints the flakeref (pure, no build/network) for the caller to `nix run`; `--mode bin-dir` runs `nix build --no-link --print-out-paths` and exposes a single-`devenv`-symlink shim dir (RD-3: never the raw closure bin dir, which could shadow the parity-pinned toolchain on PATH). devenvSource() ignores the `dir` field some locks carry (the `#devenv` flake attribute is selected, not a source subdir) and throws loudly on missing node / non-github type / non-40-hex rev, the same fail-loud posture as refresh-devenv-nixpkgs.core.ts. This is RIG-2546 T1, the prerequisite tool; T2/T3 rewire renovate.yml and ci.yml onto it. Register the project in .moon/workspace.yml (explicit map, no glob). Dependency-free (bun/node builtins + ./core only, enforced by a static import-hygiene test). Verified: moon run devenv-cli:ci green (17 tests), tsc clean, biome clean; smoke-tested against both real locks (root -> cachix flakeref, agent-image -> RigelBuild fork flakeref) and error paths (missing lock / bad mode exit 1). Spec-impact: none Co-authored-by: Matt Wilkinson --- .moon/workspace.yml | 5 + tools/toolchain/devenv-cli/core.test.ts | 253 +++++++++++++++++++++++ tools/toolchain/devenv-cli/core.ts | 157 ++++++++++++++ tools/toolchain/devenv-cli/index.ts | 66 ++++++ tools/toolchain/devenv-cli/moon.yml | 32 +++ tools/toolchain/devenv-cli/package.json | 12 ++ tools/toolchain/devenv-cli/tsconfig.json | 14 ++ 7 files changed, 539 insertions(+) create mode 100644 tools/toolchain/devenv-cli/core.test.ts create mode 100644 tools/toolchain/devenv-cli/core.ts create mode 100755 tools/toolchain/devenv-cli/index.ts create mode 100644 tools/toolchain/devenv-cli/moon.yml create mode 100644 tools/toolchain/devenv-cli/package.json create mode 100644 tools/toolchain/devenv-cli/tsconfig.json diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 8060e696a..d0de3f608 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -63,6 +63,11 @@ projects: # The toolchain version-parity gate: asserts CI's PATH holds the dev shell's # toolchain, and carries the unit tests for its own comparison logic. toolchain-parity: 'tools/toolchain' + # The devenv-CLI source tool: resolves the devenv CLI source (flakeref or a + # single-binary PATH shim) from a named devenv.lock, so renovate.yml and + # ci.yml share one lock-tracking resolver instead of hand-pinning a rev + # (RIG-2546). Carries its own unit tests for the pure resolution half. + devenv-cli: 'tools/toolchain/devenv-cli' # The generator-stamp gate: asserts the checked-in gen trees' `@generated by` # headers agree with each other and with the nixpkgs protoc-gen-es on PATH # (SEA-1405). Separate from compass-proto because its subject is the plugin diff --git a/tools/toolchain/devenv-cli/core.test.ts b/tools/toolchain/devenv-cli/core.test.ts new file mode 100644 index 000000000..c6abcf29b --- /dev/null +++ b/tools/toolchain/devenv-cli/core.test.ts @@ -0,0 +1,253 @@ +// Tests for the pure half of the devenv-CLI source tool (RIG-2546 §T1). +// +// The properties under test: the tool is SOURCE-AGNOSTIC (it reads whatever +// owner/repo/rev the named lock names — cachix upstream or the RigelBuild fork), +// it FAILS LOUD on any shape drift (missing node, short rev, non-github type) +// rather than resolving a stale/wrong source, and its dependency-free +// convention is a CHECKED property, not a comment. The bin-dir shim's +// single-binary invariant (RD-3) is unit-checked via the pure shimPlan helper. + +import { describe, expect, test } from "bun:test"; +import { devenvSource, flakeref, parseArgs, shimPlan } from "./core.ts"; + +// A cachix-shaped node (the root lock today) — WITH a `dir: src/modules` field, +// which the tool must ignore. +const CACHIX_LOCK = JSON.stringify({ + nodes: { + devenv: { + locked: { + dir: "src/modules", + owner: "cachix", + repo: "devenv", + rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", + type: "github", + }, + }, + }, +}); + +// A RigelBuild-shaped node (the agent-image lock) — no `dir` field. +const RIGELBUILD_LOCK = JSON.stringify({ + nodes: { + devenv: { + locked: { + owner: "RigelBuild", + repo: "devenv", + rev: "15a81f3e15619187fcbe10c2eac40878e0b4ce28", + type: "github", + }, + }, + }, +}); + +describe("devenvSource", () => { + test("parses a cachix-shaped node, ignoring the dir field", () => { + expect(devenvSource(CACHIX_LOCK)).toEqual({ + owner: "cachix", + repo: "devenv", + rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", + }); + }); + + test("parses a RigelBuild-shaped node — the tool is source-agnostic", () => { + expect(devenvSource(RIGELBUILD_LOCK)).toEqual({ + owner: "RigelBuild", + repo: "devenv", + rev: "15a81f3e15619187fcbe10c2eac40878e0b4ce28", + }); + }); + + test("throws when the devenv node is absent", () => { + const lock = JSON.stringify({ nodes: { root: { locked: {} } } }); + expect(() => devenvSource(lock)).toThrow(/nodes\.devenv\.locked absent/); + }); + + test("throws on a rev shorter than 40 hex", () => { + const lock = JSON.stringify({ + nodes: { + devenv: { + locked: { + owner: "cachix", + repo: "devenv", + rev: "0bf6765c", + type: "github", + }, + }, + }, + }); + expect(() => devenvSource(lock)).toThrow(/40-hex devenv rev/); + }); + + test("throws on a non-github node type", () => { + const lock = JSON.stringify({ + nodes: { + devenv: { + locked: { + owner: "cachix", + repo: "devenv", + rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", + type: "git", + }, + }, + }, + }); + expect(() => devenvSource(lock)).toThrow(/expected "github"/); + }); + + test("throws when the devenv node has no owner", () => { + const lock = JSON.stringify({ + nodes: { + devenv: { + locked: { + repo: "devenv", + rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", + type: "github", + }, + }, + }, + }); + expect(() => devenvSource(lock)).toThrow(/no owner/); + }); + + test("throws when the devenv node has no repo", () => { + const lock = JSON.stringify({ + nodes: { + devenv: { + locked: { + owner: "cachix", + rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", + type: "github", + }, + }, + }, + }); + expect(() => devenvSource(lock)).toThrow(/no repo/); + }); + + test("throws on an owner with flakeref-reshaping characters", () => { + const lock = JSON.stringify({ + nodes: { + devenv: { + locked: { + owner: "a/b#x", + repo: "devenv", + rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", + type: "github", + }, + }, + }, + }); + expect(() => devenvSource(lock)).toThrow(/bare github owner/); + }); + + test("throws on invalid JSON", () => { + expect(() => devenvSource("not json")).toThrow(/not valid JSON/); + }); +}); + +describe("flakeref", () => { + test("composes the exact cachix flakeref", () => { + expect(flakeref(devenvSource(CACHIX_LOCK))).toBe( + "github:cachix/devenv/0bf6765ce7071d98ed137ecfe02d1e435007c971#devenv", + ); + }); + + test("composes the exact RigelBuild flakeref", () => { + expect(flakeref(devenvSource(RIGELBUILD_LOCK))).toBe( + "github:RigelBuild/devenv/15a81f3e15619187fcbe10c2eac40878e0b4ce28#devenv", + ); + }); +}); + +describe("parseArgs", () => { + test("parses --lock/--mode in order", () => { + expect(parseArgs(["--lock", "devenv.lock", "--mode", "bin-dir"])).toEqual({ + lockPath: "devenv.lock", + mode: "bin-dir", + }); + }); + + test("parses --mode/--lock in either order", () => { + expect( + parseArgs(["--mode", "flakeref", "--lock", "agent-image/devenv.lock"]), + ).toEqual({ lockPath: "agent-image/devenv.lock", mode: "flakeref" }); + }); + + test("throws on an unknown flag", () => { + expect(() => + parseArgs(["--lock", "devenv.lock", "--mode", "flakeref", "--extra"]), + ).toThrow(/unknown argument/); + }); + + test("throws when --lock is missing", () => { + expect(() => parseArgs(["--mode", "flakeref"])).toThrow( + /--lock is required/, + ); + }); + + test("throws when --mode is missing", () => { + expect(() => parseArgs(["--lock", "devenv.lock"])).toThrow( + /--mode is required/, + ); + }); + + test("throws on an invalid --mode value", () => { + expect(() => parseArgs(["--lock", "devenv.lock", "--mode", "wat"])).toThrow( + /invalid --mode/, + ); + }); + + test("throws when a flag is missing its value", () => { + expect(() => parseArgs(["--lock", "--mode", "flakeref"])).toThrow( + /--lock requires a value/, + ); + }); +}); + +describe("shimPlan (RD-3 single-binary invariant)", () => { + test("plans exactly one entry named devenv pointing at the out-path bin", () => { + const plan = shimPlan("/nix/store/abc-devenv-1.0"); + expect(plan).toEqual([ + { link: "devenv", target: "/nix/store/abc-devenv-1.0/bin/devenv" }, + ]); + // The load-bearing property: exactly one entry, named `devenv`, so the + // printed dir cannot put devenv's whole closure bin dir on $GITHUB_PATH. + expect(plan).toHaveLength(1); + expect(plan.map((l) => l.link)).toEqual(["devenv"]); + }); +}); + +describe("import hygiene (dependency-free convention as a checked property)", () => { + test("core.ts and index.ts import only node:/bun: builtins or ./core", async () => { + const root = new URL(".", import.meta.url).pathname; + const sources = await Promise.all( + ["core.ts", "index.ts"].map((f) => Bun.file(`${root}${f}`).text()), + ); + // Static `import ... from "x"`, side-effect `import "x"`, dynamic + // `import("x")` / `require("x")`, and re-export `export ... from "x"` — + // all specifier forms must resolve to a builtin or ./core, since the + // tool runs before `bun install`. + const specifierRes = [ + /import\s+(?:type\s+)?[^"']*?from\s+["']([^"']+)["']/g, + /import\s+["']([^"']+)["']/g, + /(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g, + /export\s+(?:type\s+)?(?:\*|\{[^}]*\}|[^;]*?)\s+from\s+["']([^"']+)["']/g, + ]; + const isAllowed = (specifier: string): boolean => + specifier.startsWith("node:") || + specifier.startsWith("bun:") || + specifier === "./core" || + specifier === "./core.ts"; + for (const source of sources) { + for (const importRe of specifierRes) { + for (const match of source.matchAll(importRe)) { + const specifier = match[1]; + expect( + isAllowed(specifier), + `disallowed import specifier: ${specifier}`, + ).toBe(true); + } + } + } + }); +}); diff --git a/tools/toolchain/devenv-cli/core.ts b/tools/toolchain/devenv-cli/core.ts new file mode 100644 index 000000000..0f6ef2c44 --- /dev/null +++ b/tools/toolchain/devenv-cli/core.ts @@ -0,0 +1,157 @@ +// Pure resolution for the devenv-CLI source tool (RIG-2546). No I/O, no process +// exec — everything here is a total function over strings, so the load-bearing +// half (lock JSON → validated coordinates → flakeref; argv → parsed request) is +// unit-testable (core.test.ts) and the executable shell (index.ts) stays thin. +// This mirrors the tools/toolchain/parity.ts / parity-core.ts split, and the +// lock-parse posture of tools/renovate/refresh-devenv-nixpkgs.core.ts:25 — a +// shape change must fail the caller loudly, never resolve a stale/wrong source. + +/** The devenv node's locked coordinates, as a nix flakeref fragment. */ +export interface DevenvSource { + readonly owner: string; + readonly repo: string; + readonly rev: string; // 40-hex, validated +} + +/** + * Parse `.nodes.devenv.locked` out of a devenv.lock's text. Throws loudly on + * missing node, missing/short rev, or non-github type — a shape change must + * fail the caller, never resolve a stale or wrong source (the same posture as + * refresh-devenv-nixpkgs.core.ts's innerNixpkgsRev). + * + * The `dir` field some locks carry (e.g. the root lock's `src/modules`) is + * deliberately IGNORED: the `#devenv` flake attribute is what the flakeref + * selects, not a source subdir, so DevenvSource carries only owner/repo/rev. + */ +export function devenvSource(lockText: string): DevenvSource { + let lock: unknown; + try { + lock = JSON.parse(lockText); + } catch (error) { + throw new Error(`devenv-cli: devenv.lock is not valid JSON: ${error}`); + } + // Narrow with `in`/`typeof` at each level so every access is actually + // checked (devenv.lock is external-boundary data; no schema validator is in + // the repo). A shape change surfaces as a loud throw, never a silent read. + const isObj = (v: unknown): v is Record => + typeof v === "object" && v !== null; + let locked: Record | undefined; + if (isObj(lock) && "nodes" in lock && isObj(lock.nodes)) { + const node = lock.nodes.devenv; + if (isObj(node) && "locked" in node && isObj(node.locked)) { + locked = node.locked; + } + } + if (locked === undefined) { + throw new Error( + "devenv-cli: could not read the devenv node from devenv.lock " + + "(nodes.devenv.locked absent) — devenv lock shape may have changed.", + ); + } + const { type, owner, repo, rev } = locked; + if (type !== "github") { + throw new Error( + `devenv-cli: devenv node type is ${JSON.stringify(type)}, expected "github".`, + ); + } + if (typeof owner !== "string" || owner === "") { + throw new Error("devenv-cli: devenv node has no owner in devenv.lock."); + } + if (!/^[A-Za-z0-9-]+$/.test(owner)) { + throw new Error( + "devenv-cli: devenv node owner is not a bare github owner " + + "(nodes.devenv.locked.owner) — devenv lock shape may be malformed.", + ); + } + if (typeof repo !== "string" || repo === "") { + throw new Error("devenv-cli: devenv node has no repo in devenv.lock."); + } + if (!/^[A-Za-z0-9._-]+$/.test(repo)) { + throw new Error( + "devenv-cli: devenv node repo is not a bare github repo " + + "(nodes.devenv.locked.repo) — devenv lock shape may be malformed.", + ); + } + if (typeof rev !== "string" || !/^[a-f0-9]{40}$/.test(rev)) { + throw new Error( + "devenv-cli: could not read a 40-hex devenv rev from devenv.lock " + + "(nodes.devenv.locked.rev) — devenv lock shape may have changed.", + ); + } + return { owner, repo, rev }; +} + +/** `github://#devenv` for the parsed node. */ +export function flakeref(src: DevenvSource): string { + return `github:${src.owner}/${src.repo}/${src.rev}#devenv`; +} + +/** What the caller wants printed. */ +export type Mode = "flakeref" | "bin-dir"; + +export interface Request { + readonly lockPath: string; // e.g. "devenv.lock" | "agent-image/devenv.lock" + readonly mode: Mode; +} + +const MODES: readonly Mode[] = ["flakeref", "bin-dir"]; + +function isMode(value: string): value is Mode { + return (MODES as readonly string[]).includes(value); +} + +/** + * Parse argv (`--lock --mode `, either order); throws + * on an unknown flag, a missing flag value, a missing required flag, or an + * invalid mode. Fail loud rather than defaulting — a mistyped invocation must + * not silently resolve the wrong lock or mode. + */ +export function parseArgs(argv: readonly string[]): Request { + let lockPath: string | undefined; + let mode: Mode | undefined; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag === "--lock" || flag === "--mode") { + const value = argv[i + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`devenv-cli: ${flag} requires a value.`); + } + i++; + if (flag === "--lock") { + lockPath = value; + } else if (isMode(value)) { + mode = value; + } else { + throw new Error( + `devenv-cli: invalid --mode ${JSON.stringify(value)}, expected one of ${MODES.join(", ")}.`, + ); + } + continue; + } + throw new Error(`devenv-cli: unknown argument ${JSON.stringify(flag)}.`); + } + if (lockPath === undefined) { + throw new Error("devenv-cli: --lock is required."); + } + if (mode === undefined) { + throw new Error("devenv-cli: --mode is required."); + } + return { lockPath, mode }; +} + +/** One symlink to create in the bin-dir shim: `link` (a name) → `target`. */ +export interface ShimLink { + readonly link: string; + readonly target: string; +} + +/** + * The single-binary shim plan for a `nix build` out-path: exactly one symlink + * named `devenv` pointing at `/bin/devenv`. Extracted as a pure helper + * so the load-bearing RD-3 invariant — the printed dir exposes ONE binary, not + * devenv's whole closure bin dir, so appending it to $GITHUB_PATH cannot shadow + * the parity-pinned toolchain — is unit-checked without a nix build. + */ +export function shimPlan(outPath: string): readonly ShimLink[] { + return [{ link: "devenv", target: `${outPath}/bin/devenv` }]; +} diff --git a/tools/toolchain/devenv-cli/index.ts b/tools/toolchain/devenv-cli/index.ts new file mode 100755 index 000000000..8e92d83a9 --- /dev/null +++ b/tools/toolchain/devenv-cli/index.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env bun +// The devenv-CLI source tool (RIG-2546): the single place that turns "the +// devenv node of a named devenv.lock" into a usable devenv CLI. Shared by +// .github/workflows/renovate.yml (mode=bin-dir → PATH) and ci.yml (mode=flakeref +// → `nix run`), so neither carries a hand-pinned rev or its own jq/nix blob. +// +// This is the thin execution shell — parse argv, read the lock, resolve, maybe +// build, print one line. All parsing and validation lives in ./core.ts, which +// is pure and unit-tested (./core.test.ts). +// +// bun tools/toolchain/devenv-cli/index.ts --lock --mode +// mode=flakeref → print `github://#devenv` (no build, no network) +// mode=bin-dir → `nix build --no-link --print-out-paths `, create a +// temp dir holding a single `devenv` symlink → its bin, print that dir +// +// stdout: exactly one line (the value); all diagnostics to stderr; exit 1 on +// any failure (bad args, missing/invalid lock, failed build). + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { devenvSource, flakeref, parseArgs, shimPlan } from "./core.ts"; + +async function main(): Promise { + const request = parseArgs(Bun.argv.slice(2)); + const lockText = await Bun.file(request.lockPath).text(); + const ref = flakeref(devenvSource(lockText)); + + if (request.mode === "flakeref") { + console.log(ref); + return; + } + + // mode=bin-dir: realize the store path and expose a single `devenv` binary. + const out = execFileSync( + "nix", + ["build", "--no-link", "--print-out-paths", ref], + // stdout stays 'pipe' (we read the out-path below); nix's stderr is + // inherited so its real build diagnostic streams straight through + // instead of being swallowed into error.stderr and lost to the + // generic "Command failed" message the outer catch would print. + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + ).trim(); + if (out === "") { + throw new Error(`devenv-cli: nix build produced no out-path for ${ref}.`); + } + // One symlink named `devenv`, not the raw `/bin` — appending the whole + // closure bin dir to $GITHUB_PATH could shadow the parity-pinned toolchain + // (RD-3). shimPlan encodes that single-binary invariant. + // Intentionally never removed: the caller appends this dir to $GITHUB_PATH + // and needs it after this process exits (CI runners are ephemeral, so no + // unlink is wanted — cleaning it up would break the PATH contract). + const shimDir = mkdtempSync(join(tmpdir(), "devenv-shim-")); + for (const { link, target } of shimPlan(out)) { + symlinkSync(target, join(shimDir, link)); + } + console.log(shimDir); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/tools/toolchain/devenv-cli/moon.yml b/tools/toolchain/devenv-cli/moon.yml new file mode 100644 index 000000000..62c96acd6 --- /dev/null +++ b/tools/toolchain/devenv-cli/moon.yml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# The devenv-CLI source tool (RIG-2546). The single place that resolves the +# devenv CLI source from a named devenv.lock: parse `.nodes.devenv.locked` → +# `github://#devenv`, and (mode=bin-dir) realize it and expose +# a single-binary shim dir. Shared by .github/workflows/renovate.yml (PATH) and +# ci.yml (nix run) so neither carries a hand-pinned rev or its own jq/nix blob. +# +# A moon project, not a bare workflow step, for the same reason as the parity +# gate: its pure half is real logic that must itself be gated (`test`, +# `typecheck` below) — an unverified helper both workflows depend on is a +# liability. The parity task is intentionally absent; this tool is not the +# parity gate. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'oss', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', '/bun.lock'] + + test: + command: 'bun test' + deps: ['install'] + inputs: ['*.ts', '/bun.lock'] + + ci: + deps: ['typecheck', 'test'] + options: + cache: false diff --git a/tools/toolchain/devenv-cli/package.json b/tools/toolchain/devenv-cli/package.json new file mode 100644 index 000000000..4cceb444a --- /dev/null +++ b/tools/toolchain/devenv-cli/package.json @@ -0,0 +1,12 @@ +{ + "name": "@compass/devenv-cli", + "version": "0.1.0", + "private": true, + "description": "Resolves the devenv CLI source from a named devenv.lock (parses .nodes.devenv.locked into a github://#devenv flakeref, and optionally realizes it into a single-binary shim dir). Shared by renovate.yml and ci.yml so neither hand-pins a devenv rev. Dependency-free by design — it runs in renovate.yml before `bun install` has, so it may import only bun/node builtins.", + "license": "MIT OR Apache-2.0", + "type": "module", + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/toolchain/devenv-cli/tsconfig.json b/tools/toolchain/devenv-cli/tsconfig.json new file mode 100644 index 000000000..db8363cde --- /dev/null +++ b/tools/toolchain/devenv-cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "types": ["bun"] + }, + "include": ["*.ts"] +}