diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5886deee..4eb8c046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,12 @@ jobs: - name: Build run: pnpm turbo run build + # Drift gate (2.L · ADR-0051): the published CLI bundle inlines the @relavium/* engine and externalizes + # every third-party dep — its external import closure must equal the declared `dependencies`, or a global + # `npm i -g relavium` would fail to resolve a missing dep. Needs the build above. + - name: CLI bundle closure matches its declared dependencies + run: pnpm lint:bundle-closure + # Drift gate: the committed migration must match src/schema.ts. If a schema change # (or an upstream @relavium/shared enum change the CHECKs derive from) wasn't # regenerated via `pnpm --filter @relavium/db db:generate`, regenerating here produces diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..dd7ee4ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,147 @@ +# Relavium CLI release — pack once, smoke the exact artifact on every OS, then publish it (2.L · ADR-0051). +# +# Flow: `pack` builds the engine-inlined bundle and `pnpm pack`s it (resolving catalog:/workspace: to +# concrete versions) into a tarball artifact → `smoke` installs THAT SAME tarball globally on ubuntu / +# macOS / Windows and asserts the binary works (prebuilt native addons load, the bundled engine runs) → +# `publish` (only on a `v*` tag, gated on green smoke) publishes the very artifact that was smoked, with +# npm provenance. The actual publish needs the maintainer's `NPM_TOKEN` secret — it is maintainer-gated, +# like branch protection. Verifying cross-OS BEFORE publish is the Phase-3 go/no-go #7 gate. +# +# Third-party actions are pinned to a commit SHA (the `# vX.Y.Z` comment tracks the release), matching ci.yml. +name: Release CLI + +on: + push: + tags: ['v*'] + # Manual dry-run: build + smoke cross-OS without publishing (publish is gated on a tag below). + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +# Permissions are declared per job (least privilege) rather than workflow-wide. + +jobs: + pack: + name: build + pack the tarball + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + # On a release tag, the tag must equal the CLI's package version — else we'd publish a mismatched + # version. Fail fast (before build + the cross-OS smoke). Skipped on a manual dry-run (no tag). + - name: Tag matches the CLI package version + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./apps/cli/package.json').version")" + test "$TAG" = "$PKG" || { echo "::error::release tag v$TAG != apps/cli/package.json version $PKG"; exit 1; } + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: pnpm + - name: Install (frozen lockfile) + run: pnpm install --frozen-lockfile + # Build the engine packages first (turbo resolves the dependency order) so `pnpm pack`'s prepack can + # inline them into the bundle. + - name: Build + run: pnpm turbo run build --filter=relavium... + # `pnpm pack` (not `npm pack`) resolves catalog:/workspace: in the published manifest; its `prepack` + # rebuilds the bundle, copies the drizzle migrations beside it, AND runs the bundle-closure guard — so + # the guard validates exactly the dist that gets tarballed. The tarball is platform-independent (native + # addons resolve per-OS at install), so one tarball is smoked on, and published to, every target. + - name: Pack + working-directory: apps/cli + run: pnpm pack --pack-destination "${{ runner.temp }}/artifact" + - name: Upload tarball + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: relavium-tarball + path: ${{ runner.temp }}/artifact/*.tgz + if-no-files-found: error + + smoke: + name: install-smoke (${{ matrix.os }}) + needs: pack + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read + steps: + # Checkout only for the fixture workflows the smoke runs; no build here — we install the published tarball. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + - name: Download tarball + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: relavium-tarball + path: artifact + # One bash script across all three OSes (GitHub provides Git Bash on Windows) so the asserts are uniform. + # These commands exercise the bundle + BOTH prebuilt native addons (better-sqlite3 opens history.db with + # the shipped migrations; @napi-rs/keyring's accessor loads) WITHOUT touching the OS credential store, so + # the Windows leg is headless-safe. + - name: Install globally + smoke + shell: bash + run: | + set -euo pipefail + TGZ=$(ls artifact/*.tgz) + npm install -g "$TGZ" + echo "## relavium --help" && relavium --help >/dev/null + echo "## relavium provider list" && relavium provider list + echo "## relavium run (sequential) --json (exit 0)" + relavium run apps/cli/src/harness/fixtures/sequential.relavium.yaml --input n=21 --json + echo "## relavium run (human-gate) --json (exit 3 = gate-paused)" + set +e; relavium run apps/cli/src/harness/fixtures/human-gate.relavium.yaml --json >/dev/null; ec=$?; set -e + test "$ec" -eq 3 || { echo "expected exit 3, got $ec"; exit 1; } + echo "## relavium gate list" && relavium gate list + echo "## unknown runId → exit 2" + set +e; relavium logs nope >/dev/null 2>&1; ec=$?; set -e + test "$ec" -eq 2 || { echo "expected exit 2, got $ec"; exit 1; } + echo "✓ smoke passed on ${{ matrix.os }}" + + publish: + name: publish to npm (provenance) + needs: smoke + # Only a real release tag publishes; a manual workflow_dispatch run stops after the cross-OS smoke. + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write # npm provenance attestation + steps: + - name: Set up Node (npm registry) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + registry-url: https://registry.npmjs.org + - name: Download the smoked tarball + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: relavium-tarball + path: artifact + # Publish the EXACT artifact the smoke matrix proved — not a fresh build. catalog:/workspace: are already + # resolved in the packed manifest, so `npm publish ` is correct here (pnpm-pack did the resolving). + - name: Publish + shell: bash + run: npm publish "$(ls artifact/*.tgz)" --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 21928cf7..4822ff15 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ build/ out/ *.tsbuildinfo .turbo/ +# The CLI bundle ships @relavium/db's migrations beside its bundle; the copy is a build artifact +# (2.L / ADR-0051). The source of truth stays tracked at packages/db/drizzle. +/apps/cli/drizzle/ # Tauri / Rust target/ diff --git a/CLAUDE.md b/CLAUDE.md index 96aa3ead..1ef017cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,10 +51,11 @@ resolution (2.B) landed (PR #40), `relavium run` is wired to the engine landed (2.F — PR #42, ADR-0049), the engine regression harness (2.K — PR #43) completes M3, and durable run history landed (2.H — PR #44, ADR-0050); the provider/key commands with OS-keychain storage landed (2.C — PR #45, behind ADR-0019 + ADR-0006); the `ink` -streaming TUI landed (2.E — PR #46, behind ADR-0047); and the interactive human-gate prompt + the +streaming TUI landed (2.E — PR #46, behind ADR-0047); the interactive human-gate prompt + the out-of-band `relavium gate` cross-process resume landed (2.G — PR #47, behind ADR-0047), **fully closing -2.K's deferred gate-resume half**. The next pickup is 2.I (`list` / `logs` / `status` / `gate list` over -durable history). For live status, per-PR history, +2.K's deferred gate-resume half**; and the read commands `list` / `logs` / `status` / `gate list` over durable +history landed (2.I — PR #48, no new ADR). The next pickup is 2.L (packaging & install verification — the last +gate-closing spine PR). For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview. diff --git a/README.md b/README.md index d9222c6f..30707120 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,9 @@ local-first BYOK — workflow parsing, DAG execution, live streaming, checkpoint multi-provider failover, cost governance, and multimodal media I/O. **Phase 2 (the CLI) is underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its `--json` CI machine-output contract, the engine regression harness, durable local run history, the -provider/key commands (API keys in the OS keychain), the live `ink` streaming TUI, and the human-gate -prompt + out-of-band `relavium gate` resume have landed (milestone **M3** reached). For live status and the full roadmap, see +provider/key commands (API keys in the OS keychain), the live `ink` streaming TUI, the human-gate +prompt + out-of-band `relavium gate` resume, and the read commands (`list` / `logs` / `status` / `gate list`) +over durable history have landed (milestone **M3** reached). For live status and the full roadmap, see [docs/roadmap/current.md](docs/roadmap/current.md) and the [roadmap](docs/roadmap/README.md). diff --git a/apps/cli/README.md b/apps/cli/README.md index 2985df48..466de519 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,7 +1,54 @@ -# `apps/cli` — placeholder +# relavium -Terminal CLI (`relavium`) — the engine's first real consumer + regression harness. +> Run agent workflows from your terminal — a product of [HodeTech](https://github.com/HodeTech). -> **Not built yet.** Directory placeholder; built out in -> [Phase 2 — CLI](../../docs/roadmap/phases/phase-2-cli.md). No `package.json` yet, so -> pnpm/Turborepo do not treat it as a workspace. +`relavium` is the command-line surface of the [Relavium](https://github.com/HodeTech/Relavium) +local-first AI agent platform. It runs git-committable `.relavium.yaml` workflows on the same +pure-TypeScript engine as the desktop and VS Code surfaces — every step debuggable, every token and +dollar tracked, nothing leaving your machine unless you choose it. + +## Install + +```bash +npm install -g relavium +``` + +Requires **Node.js ≥ 20.12**. The package ships an engine-inlined bundle and installs prebuilt native +binaries, so no C/C++ toolchain is needed. + +## Quick start + +```bash +# run a workflow, streaming live progress in the terminal +relavium run ./workflows/code-review.relavium.yaml --input file=./src/index.ts + +# CI / scripting: a stable NDJSON RunEvent stream, deterministic exit codes +relavium run ./workflows/code-review.relavium.yaml --input file=src/index.ts --json + +# store a provider key in the OS keychain (read from stdin, never argv) +echo "$ANTHROPIC_API_KEY" | relavium provider set-key anthropic +``` + +## Commands + +| Command | Purpose | +|---|---| +| `relavium run [--input k=v]` | Execute a workflow; streams progress (or `--json` NDJSON). | +| `relavium list [--agents]` | List discovered workflows (or agents) with last-run status. | +| `relavium logs ` | Replay a past run's event stream. | +| `relavium status` | Show active/paused runs and their per-node status. | +| `relavium gate --approve\|--reject\|--input …` | Resolve a pending human gate. | +| `relavium gate list []` | List pending human gates. | +| `relavium provider ` | Manage providers + API keys (OS keychain). | + +**Exit codes** (CI-friendly): `0` completed · `1` failed · `2` invalid invocation · `3` paused at a +human gate. Provider keys resolve from the OS keychain → `RELAVIUM__API_KEY` env var → error. + +## Documentation + +The full command reference, the `--json` machine contract, and the CI guide live in the +[Relavium docs](https://github.com/HodeTech/Relavium/tree/main/docs/reference/cli/commands.md). + +## License + +Proprietary — © HodeTech, all rights reserved. See [LICENSE](https://github.com/HodeTech/Relavium/blob/main/LICENSE). diff --git a/apps/cli/package.json b/apps/cli/package.json index 27efbb9c..19a1bcac 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,35 +1,61 @@ { "name": "relavium", - "version": "0.0.0", - "private": true, + "version": "0.1.0", + "description": "Relavium CLI (`relavium`) — run agent workflows from the terminal; the engine's first real consumer.", + "license": "SEE LICENSE IN LICENSE", "type": "module", - "description": "Relavium CLI (`relavium`) — the terminal surface and the engine's regression harness (build phase 2).", "bin": { "relavium": "./dist/index.js" }, "files": [ - "dist" + "dist", + "drizzle" ], + "keywords": [ + "relavium", + "ai", + "agent", + "workflow", + "cli", + "llm" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/HodeTech/Relavium.git", + "directory": "apps/cli" + }, + "engines": { + "node": ">=20.12.0" + }, "scripts": { "build": "tsup", + "prepack": "tsup && pnpm -w run lint:bundle-closure", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint src", "test": "vitest run" }, "dependencies": { + "@anthropic-ai/sdk": "catalog:", "@clack/prompts": "catalog:", + "@google/genai": "catalog:", + "@jitl/quickjs-singlefile-mjs-release-sync": "catalog:", "@napi-rs/keyring": "catalog:", - "@relavium/core": "workspace:*", - "@relavium/db": "workspace:*", - "@relavium/llm": "workspace:*", - "@relavium/shared": "workspace:*", + "better-sqlite3": "catalog:", "commander": "catalog:", + "drizzle-orm": "catalog:", "ink": "catalog:", + "openai": "catalog:", + "quickjs-emscripten-core": "catalog:", "react": "catalog:", "smol-toml": "catalog:", + "yaml": "catalog:", "zod": "catalog:" }, "devDependencies": { + "@relavium/core": "workspace:*", + "@relavium/db": "workspace:*", + "@relavium/llm": "workspace:*", + "@relavium/shared": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "eslint": "catalog:", diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index 873a371e..0b4b7d15 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -1,15 +1,57 @@ +import { cpSync, rmSync } from 'node:fs'; + import { defineConfig } from 'tsup'; -// Build the CLI to a single ESM `bin` (ADR-0047). Pure-JS deps (commander; smol-toml at -// 2.B) are inlined; the native dep (@napi-rs/keyring at 2.C) is externalized in packaging -// (2.L). The shebang is prepended so the published `dist/index.js` is directly executable. +/** + * The CLI distribution bundle (2.L, [ADR-0051](../../docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md)). + * + * INLINE only the proprietary `@relavium/*` engine packages (`noExternal`); EXTERNALIZE every third-party + * dependency — they are declared in `package.json` and installed normally by npm (the prebuilt native addons + * `better-sqlite3` + `@napi-rs/keyring` cannot be bundled; the quickjs WASM + the vendor SDKs + `ink`/`react` + * are bundler-hostile). `tools/bundle-closure/check.mjs` guards that this `external` list equals the declared + * runtime `dependencies`, so the two can never drift. + * + * `sourcemap: false` + `minify: true`: the published `dist/` must ship **no** original engine source — a source + * map would embed the inlined engine's TypeScript into the tarball and defeat keeping it unpublished (ADR-0051). + * The shebang banner makes `dist/index.js` directly executable as the `relavium` bin. + */ +const THIRD_PARTY_EXTERNAL = [ + '@anthropic-ai/sdk', + '@clack/prompts', + '@google/genai', + '@jitl/quickjs-singlefile-mjs-release-sync', + '@napi-rs/keyring', + 'better-sqlite3', + 'commander', + 'drizzle-orm', + 'ink', + 'openai', + 'quickjs-emscripten-core', + 'react', + 'smol-toml', + 'yaml', + 'zod', +]; + export default defineConfig({ entry: { index: 'src/index.ts' }, format: ['esm'], target: 'node20', platform: 'node', clean: true, - sourcemap: true, + sourcemap: false, + minify: true, dts: false, + noExternal: [/^@relavium\//], + external: THIRD_PARTY_EXTERNAL, banner: { js: '#!/usr/bin/env node' }, + // The inlined `@relavium/db` resolves its drizzle migrations via `new URL('../drizzle', import.meta.url)`, + // which — once bundled — points beside THIS bundle, not the db package. So ship the migration set alongside + // `dist/` (`files: ["dist","drizzle"]`); `/drizzle` is then what the bundled db code finds at runtime. + onSuccess: async () => { + // Clean first: cpSync merges (recursive) and never deletes, so a migration removed/renamed in the source + // would otherwise linger here and ship stale. Recreate from the source of truth each build. + rmSync('./drizzle', { recursive: true, force: true }); + cpSync('../../packages/db/drizzle', './drizzle', { recursive: true }); + }, }); diff --git a/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md b/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md new file mode 100644 index 00000000..ae2d62d3 --- /dev/null +++ b/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md @@ -0,0 +1,143 @@ +# ADR-0051: CLI distribution — an engine-inlined ESM bundle that externalizes every third-party dependency + +- **Status**: Accepted +- **Date**: 2026-06-24 +- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md) (commander/ink/`@clack/prompts` + `tsup` single-bundle — this ADR finalizes the bundle boundary 0047 deferred to 2.L), [ADR-0019](0019-cli-node-keychain-library.md) (`@napi-rs/keyring`), [ADR-0021](0021-node-sqlite-driver-better-sqlite3.md) (`better-sqlite3`), [ADR-0027](0027-expression-sandbox.md) (the quickjs JS sandbox), [ADR-0011](0011-internal-llm-abstraction.md) (the in-house `@relavium/llm` this protects) + +## Context + +Workstream 2.L publishes the CLI to public npm as `relavium` so that `npm i -g relavium` +yields a working binary on macOS, Linux, and Windows — the last Phase-3 go/no-go exit +criterion. [ADR-0047](0047-cli-framework-commander-ink-clack.md) pins the bundler (`tsup`, a +single ESM `bin`) and provisionally assumed pure-JS deps would be inlined, **explicitly +deferring the exact inline-vs-external set to 2.L**; this ADR settles it. Getting the boundary +wrong is a post-publish surprise (a binary that won't start, or an IP leak), so it must be +predictable and verifiable before the first publish. + +Three hard constraints shape it: + +1. **The engine packages are proprietary and unpublished.** `@relavium/shared` / `@relavium/llm` + / `@relavium/core` / `@relavium/db` are all `private: true` (the repo's proprietary LICENSE). + They must **not** reach public npm as packages, yet the CLI needs them at runtime. A public + `npm i -g` therefore cannot resolve them as ordinary dependencies — the CLI must carry the + engine itself, inside the artifact. +2. **Native addons cannot be bundled.** `better-sqlite3` ([ADR-0021](0021-node-sqlite-driver-better-sqlite3.md)) + and `@napi-rs/keyring` ([ADR-0019](0019-cli-node-keychain-library.md)) load a platform-specific + `.node` binary via a runtime `require`; a bundler that inlines their JS wrapper breaks on that + require (verified — see Decision). Both ship **prebuilt** binaries (better-sqlite3 via a + `prebuild-install` install-script with a `node-gyp` fallback; `@napi-rs/keyring` via per-platform + `optionalDependencies`), so installed normally they need no compiler. +3. **Several third-party libraries are bundler-hostile.** `quickjs-emscripten-core` / + `@jitl/quickjs-singlefile-mjs-release-sync` (the JS sandbox, [ADR-0027](0027-expression-sandbox.md)) + load their own WASM; the vendor provider SDKs (`@anthropic-ai/sdk`, `openai`, `@google/genai`) + read their own `package.json` and use conditional/dynamic requires; `ink`/`react` are runtime UI + libraries. Inlining these is fragile and risks breakage that only surfaces at run time. + +## Decision + +**We will publish the CLI as an _engine-inlined_ ESM bundle: `tsup` inlines ONLY the proprietary +`@relavium/*` engine packages; every third-party dependency is externalized and declared in the +published `package.json` `dependencies` (the full runtime closure), installed normally by npm.** +("Thin" relative to third-party code — the *engine* is fully inlined; the bundle carries no +third-party libraries.) The native addons stay external and install their prebuilt binaries; the +proprietary engine ships transpiled-and-inlined and is never published as a separate package. + +Mechanics 2.L implements (named here so the boundary is unambiguous): + +- **Bundler.** `tsup` with `noExternal: [/^@relavium\//]` and an explicit `external` list naming the + third-party closure below; `target: node20`, ESM, shebang banner. +- **No source/sourcemap in the published artifact.** The publish build sets `sourcemap: false` and + `minify: true` — a sourcemap would embed the inlined engine's **original TypeScript source** into + the tarball (`files: ["dist"]` would ship `dist/index.js.map`), defeating the very point of not + publishing the engine. The engine therefore ships as minified JS: protected by the LICENSE and by + practical obfuscation, **not** by secrecy (a transpiled engine is inherently readable to a + determined reader — this is the accepted, deliberate posture for a proprietary CLI on public npm). +- **Engine packages move to `devDependencies`.** `@relavium/*` are `workspace:*` and `private`; left + in `dependencies` a `pnpm publish` would rewrite them to a concrete `@relavium/core@0.0.0` the + published manifest declares but npm cannot resolve (install fails). As `devDependencies` they are + available at build time for `tsup` to inline yet are **absent** from the published runtime deps. +- **The published `dependencies` are exactly the third-party closure** (15 packages): the CLI's own + `commander`, `ink`, `react`, `@clack/prompts`, `smol-toml`, `zod`, `@napi-rs/keyring` (native), plus + the engine's transitive runtime deps that 2.L hoists into the CLI manifest — `better-sqlite3` + (native), `drizzle-orm`, `yaml`, `quickjs-emscripten-core`, `@jitl/quickjs-singlefile-mjs-release-sync`, + `@anthropic-ai/sdk`, `openai`, `@google/genai`. Versions stay pinned by the pnpm `catalog:`. +- **Ship `@relavium/db`'s migrations beside the bundle.** The inlined db resolves its drizzle migrations + via `new URL('../drizzle', import.meta.url)`, which — once bundled — points at the CLI package, not the db + package; so the `tsup` build copies the migration set to `/drizzle` and `files` ships it. Without this, + every database-opening command fails on the installed CLI (`Can't find meta/_journal.json`). +- **Publish with `pnpm publish`.** Only pnpm's pack resolves `catalog:`/`workspace:*` to concrete + versions in the published manifest; `npm publish` would leave the literal protocol strings and break + the install. The CLI `package.json` (`apps/cli/package.json`) also drops `private: true` (the four + engine packages **remain** `private: true` and are never published), declares a `license` field + (the repo's proprietary terms; `pnpm pack` ships the workspace-root `LICENSE`), `engines.node >= 20.12.0` + (per [tech-stack.md](../tech-stack.md)), starts pre-1.0, and sets `files: ["dist", "drizzle"]`. + +Alternatives weighed: + +- **Fat bundle — inline everything except the native addons** (`noExternal: [/.*/]`). *Rejected, + empirically.* The build fails: esbuild inlines `@napi-rs/keyring`'s JS wrapper and then cannot + resolve its internal `require('./keyring..node')`. Even forcing the natives external, + inlining the quickjs WASM loader, the vendor SDKs (version self-reads / dynamic requires), and + `react`/`ink` is fragile and moves failures from build time to a user's machine. The only upside — + a smaller install — does not justify the unpredictability. +- **A hybrid — inline `@relavium/*` plus whatever bundles cleanly, externalize the rest.** *Rejected.* + The boundary is then decided by esbuild's heuristics (in the spike it inlined `yaml` but externalized + `drizzle-orm`), so it drifts silently and is impossible to reason about. Declaring the **whole** + third-party closure is explicit and stable. +- **Publish the engine packages to npm** (public, or a private registry). *Rejected.* A public publish + exposes the proprietary engine as a standalone package, defeating `private: true`; a private registry + breaks `npm i -g relavium` for end users, who would need registry credentials just to install the + CLI's dependencies. The engine must travel **inside** the CLI artifact. +- **`bundledDependencies` — ship the engine's built `node_modules` inside the tarball** instead of + inlining via tsup. *Rejected.* It bloats the tarball with the engine's full (transitive) `node_modules`, + keeps the per-OS native-addon problem unsolved (the bundled tree pins one platform's binaries), and + is harder to verify than a single inlined bundle plus a declared third-party closure. + +**Publish & verification model.** The actual `pnpm publish` is a **maintainer-gated** action (the +maintainer holds the `NPM_TOKEN`; publish runs with `--provenance` + 2FA, integrity pinned by the +lockfile), triggered by a `v*` release tag — mirroring the live branch-protection obligation. It is +**gated on a cross-OS install-smoke matrix** (ubuntu / macOS / Windows GitHub runners) that installs +the packed tarball globally and asserts `relavium --help`, `relavium provider list`, and a fixture +`relavium run … --json` with the correct exit code. These exercise both prebuilt native binaries — +`--help` loads the bundle (which imports `@napi-rs/keyring`), `provider list` constructs the keychain +accessor (loading `@napi-rs/keyring`) and reads the SQLite catalog, and `run --json` opens +`better-sqlite3` — **without touching the OS credential store** (no key get/set), so the Windows leg is +headless-safe in CI. The reusable release flow is **written into** +[release-a-surface.md](../runbooks/release-a-surface.md) **by 2.L** (today a stub), to serve as the +intended precedent for the desktop (`.dmg`) and VS Code (`.vsix`) surfaces. + +## Consequences + +### Positive + +- **Predictable, reproducible artifact.** The bundle's direct external import specifiers equal the + declared `dependencies` exactly — no esbuild surprises. Every third-party library (vendor SDKs, + `ink`/`react`, the quickjs WASM, the prebuilt natives) runs exactly as its authors intend. +- **The proprietary engine stays unpublished yet ships in the product** — inlined and minified in the + bundle, never a separate npm package, never accompanied by a source map. +- **`npm i -g relavium` works cross-OS with no compiler toolchain** — the native addons resolve their + prebuilt `.node` binaries (better-sqlite3's install-script / keyring's platform `optionalDependencies`); + nothing is compiled at install time (a `--ignore-scripts` install is the documented exception). +- **A reusable release precedent.** The tag → smoke-matrix → maintainer-publish flow is intended as the + template the desktop and VS Code surfaces inherit. + +### Negative + +- **The CLI `package.json` mirrors the engine's third-party runtime closure** (15 deps), duplicating + declarations that live in the engine packages — if an engine package adds a runtime dependency, the + CLI must add it too. This is the same drift the rejected hybrid had; the difference is it is **made + explicit and guarded**. *Mitigation:* 2.L adds a **build-time check** — a script (in the + `tools/engine-deps/check.mjs` family, e.g. `tools/bundle-closure/check.mjs`) that scans the built + bundle for its external (bare-specifier) imports and fails the build if that set differs from the + declared `dependencies`; the pnpm `catalog:` pins versions centrally, so only the **list** is + mirrored, never the versions. +- **A larger install footprint** than a fully-inlined single file (npm pulls the full dep trees). + Acceptable for a developer/CI tool; the win is correctness over byte count. +- **The publish and the Windows leg of verification cannot run from the dev environment** — they are a + maintainer/CI obligation, recorded by 2.L in [release-a-surface.md](../runbooks/release-a-surface.md) + and the [roadmap](../roadmap/phases/phase-2-cli.md) live obligations, gated on the smoke matrix so a + red leg blocks the release. +- **The distribution model needs propagation to its canonical homes** — 2.L updates + [tech-stack.md](../tech-stack.md), the [commands.md Install section](../reference/cli/commands.md), and + references this ADR from [phase-2-cli.md §2.L](../roadmap/phases/phase-2-cli.md) so the model is not + ADR-only. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0e1ac6eb..1abbdc23 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -94,6 +94,7 @@ flowchart TD | 0048 | [TOML parser for config files — `smol-toml`, confined to the `apps/cli` config loader](0048-toml-config-parser.md) | Accepted | 2026-06-22 | | 0049 | [CLI machine-output contract (`--json` NDJSON stream + stderr diagnostics)](0049-cli-machine-output-contract.md) | Accepted | 2026-06-22 | | 0050 | [CLI run-history `history.db` is unencrypted at rest, guarded by OS file permissions (refines 0005/0008 for the Node/CLI surface)](0050-cli-history-db-at-rest-posture.md) | Accepted | 2026-06-23 | +| 0051 | [CLI distribution — an engine-inlined ESM bundle that externalizes every third-party dependency (finalizes 0047's bundle boundary)](0051-cli-distribution-thin-bundle-private-engine.md) | Accepted | 2026-06-24 | ## Creating a new ADR diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 2b6d6fdb..06819c7e 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -11,8 +11,8 @@ The `relavium` CLI is the terminal surface of the platform and the fastest way t ## Install & distribution -- **Package**: published to npm as `relavium`, installed globally. -- **Build**: TypeScript bundled with `tsup` to a single ESM bundle. +- **Package**: published to npm as `relavium`, installed globally. The artifact is an **engine-inlined ESM bundle** — the proprietary `@relavium/*` engine is bundled in; every third-party dependency (including the prebuilt native addons) installs normally ([ADR-0051](../../decisions/0051-cli-distribution-thin-bundle-private-engine.md)). A global install needs no compiler toolchain. +- **Build**: TypeScript bundled with `tsup` to a single ESM `bin`; released via the `Release CLI` workflow (pack → cross-OS install-smoke on macOS/Linux/Windows → npm publish with provenance), see [release-a-surface.md](../../runbooks/release-a-surface.md). - **Stack**: `commander.js` for argument parsing, `ink` (React for terminals) for the interactive TUI, `@clack/prompts` for setup wizards. - **API keys**: stored in the OS keychain via `@napi-rs/keyring` (macOS Keychain / Windows Credential Manager / Linux libsecret) — never plaintext, and never the archived `keytar` (see [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) and [add-a-provider-key.md](../../runbooks/add-a-provider-key.md)). - **Workflow discovery**: reads workflows from the `.relavium/` directory in the project root, or from an explicit path argument. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 206e8901..446c0f1c 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -26,9 +26,12 @@ plumbing + inline & async output generation + generative adapters, 1.m6, 1.AD– [Phase 1 detail](phases/phase-1-engine-and-llm.md), the [decision index](../decisions/), and the [reference specs](../reference/). -> **One live maintainer obligation (carried from Phase 0):** mark the CI `ci` job a -> **required check** in GitHub branch protection (optionally add `TURBO_TOKEN`/ -> `TURBO_TEAM` secrets for the cross-runner remote cache). +> **Live maintainer obligations:** (1) mark the CI `ci` job a **required check** in GitHub branch +> protection (carried from Phase 0; optionally add `TURBO_TOKEN`/`TURBO_TEAM` secrets for the +> cross-runner remote cache); (2) once **2.L** lands, add the **`NPM_TOKEN`** repo secret + npm 2FA so the +> tag-triggered `Release CLI` workflow can publish (the actual `npm publish` is maintainer-gated, +> [ADR-0051](../decisions/0051-cli-distribution-thin-bundle-private-engine.md) / +> [release-a-surface.md](../runbooks/release-a-surface.md)). ## What is active now @@ -58,9 +61,12 @@ and **2.G** (the interactive human-gate prompt — a `@clack/prompts` card durin `relavium gate ` cross-process resume: reload snapshot → reconstruct checkpoint → `resumeFromCheckpoint`, idempotent, secret-input fail-closed), ✅ Done (PR #47, 2026-06-24) behind [ADR-0047](../decisions/0047-cli-framework-commander-ink-clack.md) (`@clack/prompts`; Node floor 20.11→20.12; -no new ADR) — **fully closing 2.K's deferred gate-resume half**. -**Next pickup:** **2.I** (`list` / `logs` / `status` / `gate list` over durable history — go/no-go #2, the read -side; surfaces the pending `gateId`s the 2.G `gate` command points at); the full status-aware +no new ADR) — **fully closing 2.K's deferred gate-resume half**; +and **2.I** (the read commands `list` / `logs` / `status` / `gate list` over durable history — go/no-go #2, the +read side; surfaces the pending `gateId`s the 2.G `gate` command points at), ✅ Done (PR #48, 2026-06-24) +(no new ADR — an additive workflow-agnostic `@relavium/db` read seam + a `@relavium/core` `parseAgent`). +**Next pickup:** **2.L** (packaging, distribution & install verification — go/no-go #7, the last gate-closing +spine PR; once it lands all seven Phase-3 exit criteria hold); the full status-aware order is the [Remaining build order](phases/phase-2-cli.md#remaining-build-order) queue. The CLI also lands the inbound MCP client (2.R, [ADR-0034](../decisions/0034-mcp-client-sdk-dependency.md)) off the M3 critical path. See the diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index 4fb3f77f..f4e4d065 100644 --- a/docs/roadmap/phases/phase-2-cli.md +++ b/docs/roadmap/phases/phase-2-cli.md @@ -1,6 +1,6 @@ # Phase 2 — CLI -> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md); and **2.C** (provider/key commands — OS keychain via `@napi-rs/keyring`) is ✅ **Done (PR #45, 2026-06-23)**, behind [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) + [ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md); and **2.E** (the `ink` streaming TUI) is ✅ **Done (PR #46, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md); and **2.G** (the interactive human-gate prompt + `relavium gate` cross-process resume) is ✅ **Done (PR #47, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) (`@clack/prompts`; no new ADR) — **fully closing 2.K's deferred gate-resume half**. The status-aware order for everything still open (next pickup: **2.I**) is the [Remaining build order](#remaining-build-order) queue. +> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md); and **2.C** (provider/key commands — OS keychain via `@napi-rs/keyring`) is ✅ **Done (PR #45, 2026-06-23)**, behind [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) + [ADR-0006](../../decisions/0006-os-keychain-for-api-keys.md); and **2.E** (the `ink` streaming TUI) is ✅ **Done (PR #46, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md); and **2.G** (the interactive human-gate prompt + `relavium gate` cross-process resume) is ✅ **Done (PR #47, 2026-06-24)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) (`@clack/prompts`; no new ADR) — **fully closing 2.K's deferred gate-resume half**; and **2.I** (the read commands `list` / `logs` / `status` / `gate list` over durable history) is ✅ **Done (PR #48, 2026-06-24)** (no new ADR — additive `@relavium/db` read seam + `@relavium/core` `parseAgent`). The status-aware order for everything still open (next pickup: **2.L**) is the [Remaining build order](#remaining-build-order) queue. - **Related**: [../README.md](../README.md), [phase-1-engine-and-llm.md](phase-1-engine-and-llm.md), [phase-3-desktop.md](phase-3-desktop.md), [../../reference/cli/commands.md](../../reference/cli/commands.md), [../../reference/contracts/config-spec.md](../../reference/contracts/config-spec.md), [../../reference/desktop/keychain-and-secrets.md](../../reference/desktop/keychain-and-secrets.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../reference/desktop/database-schema.md](../../reference/desktop/database-schema.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md) @@ -356,7 +356,7 @@ later inspection — the same tables the desktop replays. run's reported cost; a gate-paused run persists enough state for `relavium gate` to resume it in a fresh process. -### 2.I — `list`, `logs`, `status`, and `gate list` over durable history +### 2.I — `list`, `logs`, `status`, and `gate list` over durable history — ✅ **Done (PR #48)** Implement the read commands against persisted state so users can browse the catalog and inspect past and active runs. @@ -453,7 +453,9 @@ its fixture + scenario format is documented in ### 2.L — Packaging, distribution, and install verification -Make the CLI installable and verify the published artifact behaves like local dev. +Make the CLI installable and verify the published artifact behaves like local dev. Behind +[ADR-0051](../../decisions/0051-cli-distribution-thin-bundle-private-engine.md) (the bundle boundary — +an engine-inlined ESM bundle that externalizes every third-party dependency). **Tasks:** @@ -662,23 +664,22 @@ This is the status × plan view; the dependency rationale for every row lives in [Ordered waves](#ordered-waves-each-wave-is-internally-parallel-waves-gate-left-to-right) — this table does not restate them, it only sequences what remains. -> **Status (2026-06-24):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H · 2.C · 2.E · 2.G** done — **M3 reached** · next pickup: **2.I**. -> (2.K is now **fully closed** — its deferred gate-resume scenario landed with 2.G; only the agent-replay -> nightly lane remains, tracked in [deferred-tasks](../deferred-tasks.md). 2.H shipped the durable history -> substrate + read API; the consuming commands `list`/`logs`/`status`/`gate list` are 2.I.) +> **Status (2026-06-24):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H · 2.C · 2.E · 2.G · 2.I** done — **M3 reached** · next pickup: **2.L**. +> (2.I shipped the read side — `list`/`logs`/`status`/`gate list` over durable history (PR #48) — so go/no-go #2 +> now holds. The remaining gate-closing PR is **2.L** (package & publish); the four additive lanes (2.S, 2.R, +> chat, 2.J) complete in-phase but don't block Phase 3.) | Next | Lane | Why now | Blockers (all met on arrival) | |---|---|---|---| -| **1. 2.I** list / logs / status / gate list | feeder | go/no-go #2 (read side); blocks nothing | 2.H ✓ | -| **2. 2.L** package & publish | ◆ spine | go/no-go #7 — **all 7 [exit criteria](#exit-criteria-go--no-go) hold here → Phase 3 may start** | 2.K whole ✓ (via 2.G) | -| **3. 2.S** media host-wiring | additive | biggest lane + the lone SSRF security review; **first** among the additive lanes — never tailed | 2.D · 2.H ✓ | -| **4. 2.R** MCP client | additive | inbound MCP tools | 2.B ✓ · 2.C ✓ | -| **5. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C ✓ · 2.H ✓ · 2.E ✓ | -| **6. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | - -- **Gate-closing backbone — `2.I → 2.L`:** these two PRs flip the remaining exit criteria - (2.K + 2.H + 2.C + 2.E + 2.G are done). The remaining four (**2.S, 2.R, chat, 2.J**) - complete in-phase but do **not** block starting Phase 3. +| **1. 2.L** package & publish | ◆ spine | go/no-go #7 — **all 7 [exit criteria](#exit-criteria-go--no-go) hold here → Phase 3 may start** | 2.K whole ✓ (via 2.G) | +| **2. 2.S** media host-wiring | additive | biggest lane + the lone SSRF security review; **first** among the additive lanes — never tailed | 2.D · 2.H ✓ | +| **3. 2.R** MCP client | additive | inbound MCP tools | 2.B ✓ · 2.C ✓ | +| **4. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C ✓ · 2.H ✓ · 2.E ✓ | +| **5. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | + +- **Gate-closing backbone — `2.L` (last spine PR):** 2.I closed go/no-go #2, so **2.L** (the published binary) + is the only remaining exit-criteria PR (2.K + 2.H + 2.C + 2.E + 2.G + 2.I are done). The remaining four + (**2.S, 2.R, chat, 2.J**) complete in-phase but do **not** block starting Phase 3. - **2.K is fully closed (via 2.G).** Its deferred gate-resume scenario was exercised once the gate pause/resume surface shipped, so 2.L (step 2) is now unblocked on the 2.K front. - **The one judgement call — 2.S timing.** Front-load it as the *first* additive lane diff --git a/docs/runbooks/release-a-surface.md b/docs/runbooks/release-a-surface.md index 77184039..5f2202c6 100644 --- a/docs/runbooks/release-a-surface.md +++ b/docs/runbooks/release-a-surface.md @@ -40,8 +40,55 @@ artifacts referenced in the day-one DX. ## CLI (npm) -To be expanded. Will cover: building `apps/cli`, the npm publish flow, and verifying the -`relavium` binary post-publish. +The CLI publishes to public npm as **`relavium`** (`npm install -g relavium`). The artifact is an +**engine-inlined ESM bundle** — `tsup` inlines only the proprietary `@relavium/*` engine and externalizes +every third-party dependency, which install normally (prebuilt native addons included). The full rationale +and the bundle boundary are [ADR-0051](../decisions/0051-cli-distribution-thin-bundle-private-engine.md). + +**This is the first release flow; desktop and VS Code inherit its shape (pack → cross-OS smoke → publish).** + +### What the build produces + +- `apps/cli/dist/index.js` — the single ESM bin (shebang, minified, **no** source map: a map would ship the + inlined engine's TypeScript, [ADR-0051](../decisions/0051-cli-distribution-thin-bundle-private-engine.md)). +- `apps/cli/drizzle/` — `@relavium/db`'s migration set, copied beside the bundle by the `tsup` build because + the inlined db code resolves migrations relative to the bundle (`new URL('../drizzle', import.meta.url)`). +- The published `package.json` declares the third-party runtime closure only; `@relavium/*` are + `devDependencies` (build-time inputs, inlined — never published). `tools/bundle-closure/check.mjs` fails the + build if that closure and the declared `dependencies` ever drift. + +### Release steps + +1. **Pre-release gate.** A green `pnpm turbo run lint typecheck test build` on `main`; bump + `apps/cli/package.json` `version` (semver; pre-1.0 today); update the CHANGELOG. +2. **Tag.** Push a `v` tag (e.g. `v0.1.0`). This triggers the **`Release CLI`** workflow + (`.github/workflows/release.yml`): + - **`pack`** (ubuntu) — builds the engine + bundle, runs the bundle-closure guard, and `pnpm pack`s the + tarball (resolving `catalog:`/`workspace:` to concrete versions). **Use `pnpm pack`, never `npm pack`** — + only pnpm resolves those protocol strings; an `npm pack`ed manifest would keep literal `catalog:` and + break every install. + - **`smoke`** (ubuntu / macOS / Windows) — installs that exact tarball globally and asserts + `relavium --help`, `provider list`, a fixture `run … --json` (exit 0), the human-gate fixture (exit 3), + `gate list`, and an unknown `runId` (exit 2). This exercises both prebuilt native addons (better-sqlite3 + opens `history.db` against the shipped migrations; `@napi-rs/keyring`'s accessor loads) **without touching + the OS credential store**, so the Windows leg is headless-safe. + - **`publish`** (on the tag, gated on green smoke) — `npm publish --provenance --access public` + publishes the very artifact the matrix proved. + +### Maintainer obligations (not in code) + +- Add the **`NPM_TOKEN`** repo secret (an npm automation token for the `relavium` package); enable **2FA** on + the npm account. The publish job is otherwise maintainer-gated by design — like the `ci` branch-protection + obligation. +- A `workflow_dispatch` run executes `pack` + the cross-OS `smoke` **without** publishing — use it to verify a + release candidate before tagging. + +### Post-publish verification & rollback + +- Verify: `npm install -g relavium@` on a clean machine → `relavium --help` + a fixture + `run … --json`. (The cross-OS smoke matrix is the gate; this is a final manual sanity check.) +- Rollback: npm disallows un-publishing a version that others may depend on after 72h. Prefer **`npm deprecate + relavium@ "use "`** and publish a fixed patch; reserve `npm unpublish` for a same-day mistake. ## VS Code extension (Marketplace) diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 3be7ebde..4dd14a8d 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -27,7 +27,7 @@ adversarially reviewed by a 10-agent workflow before being locked. | Database (local, Phase 1) | **SQLite + Drizzle ORM** (SQLCipher) | Tauri plugin available; encrypted at rest with SQLCipher. Node-side consumers (CLI, `@relavium/db` tests) use the **`better-sqlite3`** driver — see [ADR-0021](decisions/0021-node-sqlite-driver-better-sqlite3.md). | | Database (cloud, Phase 2) | **PostgreSQL 16 + Redis 7 + BullMQ** | *Phase 2 only.* Same Drizzle schema, different driver. | | API key storage | **OS keychain**, one `KeychainStore` interface with a per-surface accessor — desktop `tauri-plugin-keychain` (Rust), **CLI `@napi-rs/keyring`** (Node; *not* the archived `keytar`, see [ADR-0019](decisions/0019-cli-node-keychain-library.md)), VS Code `vscode.SecretStorage` | macOS Keychain / Windows Credential Manager / libsecret. Never plaintext, never sent to the frontend. See [ADR-0006](decisions/0006-os-keychain-for-api-keys.md). | -| CLI | **TypeScript + commander.js + ink** (`@clack/prompts` setup wizards; bundled to a single ESM bundle with `tsup`) | Same language as the engine; React for the TUI. | +| CLI | **TypeScript + commander.js + ink** (`@clack/prompts` setup wizards; bundled to a single ESM `bin` with `tsup`) | Same language as the engine; React for the TUI. **Distribution** ([ADR-0051](decisions/0051-cli-distribution-thin-bundle-private-engine.md)): an *engine-inlined* bundle — the proprietary `@relavium/*` is inlined, every third-party dep (incl. prebuilt native addons) is externalized + declared; published to npm as `relavium` via a tag-triggered, cross-OS-smoke-gated `Release CLI` workflow. | | VS Code extension | **Standard VS Code Extension API** | Bundles `@relavium/core` in-process — no desktop app required. | | API framework (`apps/api`, Phase 2) | **Hono** | *Phase 2 only.* Lightweight, web-standard `Request`/`Response`, streaming-first; wraps `@relavium/core`, runs on Bun + Node. See [ADR-0016](decisions/0016-api-framework-hono.md). | | API runtime (`apps/api`, Phase 2) | **Bun** | *Phase 2 only.* Runtime for the cloud/gateway API; the engine's **zero platform-specific imports** guarantee must hold on Bun (no Bun-only APIs in `packages/core`/`packages/llm`). See [ADR-0017](decisions/0017-cloud-runtime-bun.md). | diff --git a/package.json b/package.json index fe0f5141..35d667e2 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,10 @@ "typecheck:tools": "tsc -p tsconfig.tools.json", "test": "turbo run test", "coverage": "vitest run --coverage", - "ci": "turbo run lint typecheck test && pnpm typecheck:tools && turbo run build format:check && pnpm lint:fence-check && pnpm lint:engine-deps", + "ci": "turbo run lint typecheck test && pnpm typecheck:tools && turbo run build format:check && pnpm lint:fence-check && pnpm lint:engine-deps && pnpm lint:bundle-closure", "lint:fence-check": "node tools/lint-fixtures/assert-fence.mjs", "lint:engine-deps": "node tools/engine-deps/check.mjs", + "lint:bundle-closure": "node tools/bundle-closure/check.mjs", "format": "prettier --write .", "format:check": "prettier --check ." }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78931ebe..4eff7dd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,40 +131,64 @@ importers: apps/cli: dependencies: + '@anthropic-ai/sdk': + specifier: 'catalog:' + version: 0.101.0(zod@3.25.76) '@clack/prompts': specifier: 'catalog:' version: 1.6.0 + '@google/genai': + specifier: 'catalog:' + version: 2.8.0 + '@jitl/quickjs-singlefile-mjs-release-sync': + specifier: 'catalog:' + version: 0.32.0 '@napi-rs/keyring': specifier: 'catalog:' version: 1.3.0 - '@relavium/core': - specifier: workspace:* - version: link:../../packages/core - '@relavium/db': - specifier: workspace:* - version: link:../../packages/db - '@relavium/llm': - specifier: workspace:* - version: link:../../packages/llm - '@relavium/shared': - specifier: workspace:* - version: link:../../packages/shared + better-sqlite3: + specifier: 'catalog:' + version: 12.10.0 commander: specifier: 'catalog:' version: 12.1.0 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0) ink: specifier: 'catalog:' version: 6.8.0(@types/react@19.2.17)(react@19.2.7) + openai: + specifier: 'catalog:' + version: 6.42.0(ws@8.21.0)(zod@3.25.76) + quickjs-emscripten-core: + specifier: 'catalog:' + version: 0.32.0 react: specifier: 'catalog:' version: 19.2.7 smol-toml: specifier: 'catalog:' version: 1.7.0 + yaml: + specifier: 'catalog:' + version: 2.9.0 zod: specifier: 'catalog:' version: 3.25.76 devDependencies: + '@relavium/core': + specifier: workspace:* + version: link:../../packages/core + '@relavium/db': + specifier: workspace:* + version: link:../../packages/db + '@relavium/llm': + specifier: workspace:* + version: link:../../packages/llm + '@relavium/shared': + specifier: workspace:* + version: link:../../packages/shared '@types/node': specifier: 'catalog:' version: 20.19.41 diff --git a/tools/bundle-closure/check.mjs b/tools/bundle-closure/check.mjs new file mode 100644 index 00000000..8c56448f --- /dev/null +++ b/tools/bundle-closure/check.mjs @@ -0,0 +1,84 @@ +/** + * CLI bundle-closure guard (2.L, [ADR-0051](../../docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md)). + * + * The published `relavium` bundle inlines ONLY the proprietary `@relavium/*` engine and externalizes every + * third-party dependency, which must be DECLARED in `apps/cli/package.json` `dependencies` so a global install + * resolves them. The danger is drift: an engine package adds a runtime dep, the bundle imports it, but the CLI + * manifest is not updated — and a user's `npm i -g relavium` fails with `Cannot find module …`. + * + * This guard reads the BUILT bundle, extracts every external (bare-specifier) import it still carries, and + * asserts that set equals the declared runtime `dependencies` exactly: + * - a bundle import NOT declared → ERROR (a broken install — the dangerous direction); + * - a declared dep NOT imported → ERROR (dead dependency / stale manifest). + * `@relavium/*` must NOT appear (they are inlined); node builtins and relative imports are ignored. + * + * Build the CLI first, then run from the repo root: + * pnpm --filter relavium build && node tools/bundle-closure/check.mjs + */ +import { existsSync, readFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { join } from 'node:path'; + +const BUNDLE = 'apps/cli/dist/index.js'; +const MANIFEST = 'apps/cli/package.json'; + +const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); + +/** Collapse a specifier to its package name: `@scope/n/sub` → `@scope/n`; `name/sub` → `name`. */ +function toPackageName(spec) { + const parts = spec.split('/'); + return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +} + +if (!existsSync(BUNDLE)) { + console.error(`✗ ${BUNDLE} not found — build the CLI first (pnpm --filter relavium build).`); + process.exit(1); +} + +const code = readFileSync(BUNDLE, 'utf8'); +// Every external-import form esbuild can emit — minified (no spaces) or not: `from "x"`, side-effect +// `import "x"`, dynamic `import("x")`, and CJS-interop `require("x")`. `import\s*\(` precedes the bare +// `import\s*` branch so a dynamic import is matched by the former, not mis-split by the latter. +const SPEC_RE = /(?:from\s*|import\s*\(\s*|require\s*\(\s*|import\s*)["']([^"']+)["']/g; +const imported = new Set(); +for (const match of code.matchAll(SPEC_RE)) { + const spec = match[1]; + if (spec.startsWith('.') || spec.startsWith('/') || BUILTINS.has(spec)) continue; + imported.add(toPackageName(spec)); +} + +const declared = new Set( + Object.keys(JSON.parse(readFileSync(join(MANIFEST), 'utf8')).dependencies ?? {}), +); + +const leakedEngine = [...imported].filter((p) => p.startsWith('@relavium/')); +const missing = [...imported].filter((p) => !p.startsWith('@relavium/') && !declared.has(p)); +const dead = [...declared].filter((p) => !imported.has(p)); + +let failed = false; +if (leakedEngine.length > 0) { + failed = true; + console.error( + `✗ engine package(s) NOT inlined (imported at runtime, but must be bundled): ${leakedEngine.join(', ')}`, + ); +} +if (missing.length > 0) { + failed = true; + console.error( + `✗ bundle imports undeclared dependenc(ies) — a published install would fail: ${missing.join(', ')}\n` + + ` Add them to ${MANIFEST} "dependencies" (catalog:), per ADR-0051.`, + ); +} +if (dead.length > 0) { + failed = true; + console.error( + `✗ declared dependenc(ies) the bundle never imports — stale manifest: ${dead.join(', ')}\n` + + ` Remove them from ${MANIFEST} "dependencies" (or confirm they are runtime-required).`, + ); +} + +if (failed) process.exit(1); +console.log( + `✓ CLI bundle closure matches the declared dependencies (${declared.size} third-party dep(s); ` + + `the @relavium/* engine is inlined).`, +);