diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f7d8e..8efd430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,6 @@ jobs: - name: Test run: npm test + + - name: Install/update contract smoke + run: bash scripts/smoke-install-update.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b40023c..0081633 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,13 @@ -name: Release +name: Release Metadata -# GitHub Releases are cut from package.json's version only. +# GitHub Releases are optional metadata cut from package.json's version only. +# They are not the CLI/application release authority. The app release boundary +# is the reviewed dev -> main merge consumed by scripts/install.sh and +# `rlmx update`. # Do not derive ad-hoc hotfix release tags in this workflow: npm publishing -# uses the same package version in version.yml, and rlmx --version reads +# uses the same SDK package version in version.yml, and rlmx --version reads # src/version.ts generated from that package version. If the exact release -# already exists, skip; a new release requires an explicit version bump. +# already exists, skip; a new metadata release requires an explicit version bump. on: push: @@ -85,7 +88,7 @@ jobs: run: | TAG="${{ steps.ver.outputs.tag }}" if [ -z "$RELEASE_NOTES" ]; then - RELEASE_NOTES="Initial release of rlmx." + RELEASE_NOTES="RLMX release metadata for the main branch application boundary and SDK package version." fi printf '%s' "$RELEASE_NOTES" > /tmp/release-notes.md gh release create "${TAG}" \ diff --git a/.github/workflows/rolling-pr.yml b/.github/workflows/rolling-pr.yml index cf81743..4a04d87 100644 --- a/.github/workflows/rolling-pr.yml +++ b/.github/workflows/rolling-pr.yml @@ -33,6 +33,10 @@ jobs: Auto-maintained rolling promotion PR from \`dev\` to \`main\`. + Merging this PR is the canonical RLMX application release boundary. + Git-installed CLIs receive this commit through \`scripts/install.sh\` + or \`rlmx update\`. npm remains SDK-only. + **Process:** - This PR is automatically created and kept open - Human reviews and merges when ready diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 9224977..d7e37eb 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -1,21 +1,26 @@ -name: Version +name: SDK Package -# Single source of npm publishing for @automagik/rlmx. +# Single source of SDK-only npm publishing for @automagik/rlmx. +# +# npm is NOT the canonical CLI/application release channel. End-user CLI +# installs and updates are git-managed through scripts/install.sh and +# `rlmx update`, with public `main` as the release boundary after a reviewed +# dev -> main merge. # # Trigger Context npm tag Bump? # ----------------------------------- ------- -------- ------ -# workflow_run (CI success on dev) dev @next yes -# workflow_run (merge PR to main) main @latest no -# workflow_dispatch (manual) dev @next yes +# workflow_run (CI success on dev) SDK dev @next yes +# workflow_run (merge PR to main) SDK main @latest no +# workflow_dispatch (manual) SDK dev @next yes # # On dev triggers we DERIVE a fresh version (today + build-count), # commit + tag + push back to dev, then publish as @next. # -# On main triggers we DO NOT bump — the version that -# dev already tagged is in package.json. We just publish that exact -# version as @latest, so npm and GitHub Release agree. Bumping on main -# caused historical drift between release tag and npm latest. Any green -# main push is eligible; protected main is the release boundary. +# On main triggers we DO NOT bump — the version that dev already tagged is in +# package.json. We just publish that exact SDK artifact as @latest, so npm and +# GitHub Release metadata agree. The CLI/application release already happened +# when the protected dev -> main merge made the commit reachable to +# scripts/install.sh and `rlmx update`. # # Auth: npm OIDC Trusted Publishing. The package's only Trusted # Publisher entry on npmjs.com is THIS file (version.yml). All other @@ -133,9 +138,9 @@ jobs: git tag "v${VERSION}" git push --atomic origin "HEAD:refs/heads/${BRANCH}" "refs/tags/v${VERSION}" - # On main context we publish whatever package.json currently holds — - # that's the version dev tagged on the same chain, which is exactly - # what @latest should point at. + # On main context we publish whatever package.json currently holds as + # SDK metadata/artifacts only. This must not be treated as the CLI app + # release authority. - name: Resolve publish version id: pubver run: | @@ -169,9 +174,9 @@ jobs: # but disable it explicitly to keep the contract identical to genie # and avoid surprise failures. OIDC token exchange still happens. NPM_CONFIG_PROVENANCE: "false" - # On main context: publish package.json's current version as @latest - # (no bump, matches what dev already tagged). - # On dev context: publish the version we just bumped to as @next. + # On main context: publish package.json's current SDK version as + # @latest (no bump, matches what dev already tagged). + # On dev context: publish the SDK version we just bumped to as @next. run: | VERSION="${{ steps.pubver.outputs.version }}" TAG="${{ steps.context.outputs.npm_tag }}" diff --git a/dist/src/cli.js b/dist/src/cli.js index d0dd4ff..efe7f83 100755 --- a/dist/src/cli.js +++ b/dist/src/cli.js @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { mkdir } from "node:fs/promises"; import { parseArgs } from "node:util"; +import { execFileSync } from "node:child_process"; import { loadConfig } from "./config.js"; import { isValidThinkingLevel, checkFutureFlags } from "./gemini.js"; import { scaffold, needsScaffold } from "./scaffold.js"; @@ -57,6 +58,7 @@ Usage: rlmx benchmark [options] Run benchmarks (cost or oolong) rlmx stats [options] Query run history and cost breakdowns rlmx doctor Health check: providers, RTK, config + rlmx update [--force] Fetch latest main commit for a git install Options: --context Path to context (directory or file) @@ -174,7 +176,8 @@ function parseCliArgs(args) { : positionals[0] === "benchmark" ? "benchmark" : positionals[0] === "stats" ? "stats" : positionals[0] === "doctor" ? "doctor" - : "query"; + : positionals[0] === "update" ? "update" + : "query"; const query = command === "query" ? positionals[0] ?? null : null; const batchFile = command === "batch" ? positionals[1] ?? null : null; const dir = values.dir || process.cwd(); @@ -795,6 +798,47 @@ async function runDoctor() { } // Exit 0 — nominal } +function runGit(root, args) { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} +function runCommand(root, command, args) { + execFileSync(command, args, { cwd: root, stdio: "inherit" }); +} +async function runUpdate(args) { + const force = args.includes("--force") || args.includes("-f"); + const { dirname, resolve: resolvePath } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const root = resolvePath(dirname(fileURLToPath(import.meta.url)), "../.."); + try { + runGit(root, ["rev-parse", "--is-inside-work-tree"]); + } + catch { + throw new Error("rlmx update requires a git-installed checkout. Reinstall with scripts/install.sh."); + } + const before = runGit(root, ["rev-parse", "HEAD"]); + const dirty = runGit(root, ["status", "--porcelain"]); + if (dirty && !force) { + throw new Error("Refusing to update with local changes. Commit/stash them or rerun with --force for managed installs."); + } + console.log(`rlmx update: ${root}`); + console.log(`before: ${before}`); + runGit(root, ["fetch", "origin", "main", "--tags"]); + const target = runGit(root, ["rev-parse", "origin/main"]); + console.log(`target: ${target}`); + if (before === target && !dirty) { + console.log("Already up to date."); + return; + } + runGit(root, ["reset", "--hard", "origin/main"]); + runCommand(root, "npm", ["ci"]); + runCommand(root, "npm", ["run", "build"]); + const after = runGit(root, ["rev-parse", "HEAD"]); + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const pkg = require("../../package.json"); + console.log(`after: ${after}`); + console.log(`rlmx v${pkg.version}`); +} async function main() { const opts = parseCliArgs(process.argv.slice(2)); // Load global settings and inject API keys before any command @@ -839,6 +883,9 @@ async function main() { case "doctor": await runDoctor(); break; + case "update": + await runUpdate(process.argv.slice(3)); + break; case "query": if (!opts.query && process.stdin.isTTY) { console.log(HELP); diff --git a/dist/src/schema.js b/dist/src/schema.js index 3095968..44562af 100644 --- a/dist/src/schema.js +++ b/dist/src/schema.js @@ -164,6 +164,14 @@ export const RLMX_CLI_SCHEMA = { description: "Template used by the init command.", appliesTo: ["init"], }, + { + name: "--force", + aliases: ["-f"], + type: "boolean", + default: false, + description: "Allow rlmx update to reset a dirty managed checkout to origin/main.", + appliesTo: ["update"], + }, ], output: { $schema: "https://json-schema.org/draft/2020-12/schema", diff --git a/docs/release-contract.md b/docs/release-contract.md new file mode 100644 index 0000000..616cfee --- /dev/null +++ b/docs/release-contract.md @@ -0,0 +1,73 @@ +# RLMX Release Contract + +RLMX follows the Hermes/Genie install/update shape: + +- `scripts/install.sh` is the canonical installer. +- `rlmx update` updates an installed checkout by fetching the latest public `main` commits. +- npm is SDK-only distribution. npm is not the canonical end-user CLI release channel. +- The canonical release boundary is a PR merge from `dev` to `main`. + +## Channels + +### CLI / application channel + +The CLI is git-installed: + +1. `install.sh` clones or refreshes the public repository. +2. It checks out `main`. +3. It installs dependencies. +4. It builds local `dist/`. +5. It links the `rlmx` executable into the user's bin directory. + +After install, `rlmx update` performs the same update path in-place against `origin/main`. + +### npm channel + +The npm package is SDK-only: + +- npm publishes library/SDK artifacts for programmatic consumers. +- npm dist-tags are not the canonical CLI release signal. +- `rlmx --version` reports the package/runtime version embedded in the checkout, but CLI freshness is primarily determined by the git commit on `main`. + +## Main release boundary + +`main` is the canonical release branch. + +A release happens when a PR merges from `dev` to `main`: + +1. CI passes on the PR. +2. The merge lands on `main`. +3. `main` becomes the install/update target. +4. `install.sh` and `rlmx update` fetch that commit. +5. GitHub release metadata may be created from the package version, but it is not the install authority. + +## Coherence invariants + +- Public git tags and GitHub Releases must not lie about package contents. +- If a tag is named `vX`, the target commit's `package.json` and `src/version.ts` must also be `X`. +- `rlmx update` must never use npm to update the CLI application. +- npm publishing must not create or imply a CLI release. +- Deployment-specific private policy must stay outside public RLMX. + +## Update semantics + +`rlmx update`: + +- Runs from the installed repository root. +- Fetches `origin main --tags`. +- Refuses to overwrite local changes unless explicitly forced. +- Resets the checkout to `origin/main` for managed installs. +- Runs dependency install and build. +- Reports old commit, new commit, and version. + +This mirrors the practical Hermes model: the installer owns the app checkout; the app update command refreshes that checkout. + +## Workflow implementation + +The release system follows this contract: + +- `CI` builds, typechecks, tests, and runs an install/update smoke against a temporary git `main` remote. +- `SDK Package` publishes npm artifacts for programmatic consumers only. +- The npm manifest does not expose a `bin`, so npm does not act as the canonical CLI installer. +- `Release Metadata` may create GitHub Releases from the package version, but only as coherent metadata. +- The rolling `dev` → `main` PR states that merging it is the application release boundary. diff --git a/package-lock.json b/package-lock.json index edf787c..118d9d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,6 @@ "pg": "^8.20.0", "pgserve": "^1.1.10" }, - "bin": { - "rlmx": "dist/src/cli.js" - }, "devDependencies": { "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", diff --git a/package.json b/package.json index e3130ab..5c92248 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,11 @@ { "name": "@automagik/rlmx", "version": "0.260528.2", - "description": "RLM algorithm CLI for coding agents — prompt externalization, Python REPL with symbolic recursion, code-driven navigation", + "description": "RLM algorithm SDK for coding agents \u2014 prompt externalization, Python REPL with symbolic recursion, code-driven navigation", "type": "module", "publishConfig": { "access": "public" }, - "bin": { - "rlmx": "dist/src/cli.js" - }, "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "files": [ @@ -24,7 +21,8 @@ "test": "node --test dist/tests/*.test.js", "prepare": "node scripts/prepare.mjs", "bump-version": "node scripts/version.mjs", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "install:local": "bash scripts/install.sh" }, "dependencies": { "@earendil-works/pi-ai": "0.77.0", @@ -49,7 +47,6 @@ "llm", "repl", "research", - "cli", "agent" ], "license": "MIT", diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..93aaa6e --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +RLMX_REPO_URL="${RLMX_REPO_URL:-https://github.com/automagik-dev/rlmx.git}" +RLMX_BRANCH="${RLMX_BRANCH:-main}" +RLMX_INSTALL_DIR="${RLMX_INSTALL_DIR:-$HOME/.rlmx/rlmx}" +RLMX_BIN_DIR="${RLMX_BIN_DIR:-$HOME/.local/bin}" + +echo "==> Installing RLMX" +echo "repo: $RLMX_REPO_URL" +echo "branch: $RLMX_BRANCH" +echo "dir: $RLMX_INSTALL_DIR" +echo "bin: $RLMX_BIN_DIR" + +mkdir -p "$RLMX_BIN_DIR" "$(dirname "$RLMX_INSTALL_DIR")" + +if [ -d "$RLMX_INSTALL_DIR/.git" ]; then + echo "==> Existing checkout found; refreshing" + git -C "$RLMX_INSTALL_DIR" fetch origin "$RLMX_BRANCH" --tags + git -C "$RLMX_INSTALL_DIR" checkout "$RLMX_BRANCH" + git -C "$RLMX_INSTALL_DIR" reset --hard "origin/$RLMX_BRANCH" +else + if [ -e "$RLMX_INSTALL_DIR" ]; then + echo "error: $RLMX_INSTALL_DIR exists but is not a git checkout" >&2 + exit 1 + fi + echo "==> Cloning" + git clone --branch "$RLMX_BRANCH" "$RLMX_REPO_URL" "$RLMX_INSTALL_DIR" +fi + +cd "$RLMX_INSTALL_DIR" + +echo "==> Installing dependencies" +npm ci + +echo "==> Building" +npm run build + +ln -sfn "$RLMX_INSTALL_DIR/dist/src/cli.js" "$RLMX_BIN_DIR/rlmx" +chmod +x "$RLMX_INSTALL_DIR/dist/src/cli.js" + +echo "==> Installed" +"$RLMX_BIN_DIR/rlmx" --version diff --git a/scripts/smoke-install-update.sh b/scripts/smoke-install-update.sh new file mode 100755 index 0000000..d5cc471 --- /dev/null +++ b/scripts/smoke-install-update.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +SOURCE="$TMP/source" +INSTALL_DIR="$TMP/install" +BIN_DIR="$TMP/bin" + +copy_worktree() { + mkdir -p "$SOURCE" + tar \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='.DS_Store' \ + -C "$ROOT" \ + -cf - . | tar -C "$SOURCE" -xf - +} + +commit_source() { + git -C "$SOURCE" add -A + git -C "$SOURCE" commit -m "$1" >/dev/null +} + +copy_worktree + +git init -b main "$SOURCE" >/dev/null +git -C "$SOURCE" config user.name "rlmx-ci" +git -C "$SOURCE" config user.email "rlmx-ci@example.invalid" +commit_source "test: seed install smoke source" +INITIAL_HEAD="$(git -C "$SOURCE" rev-parse HEAD)" + +echo "==> Smoke install from local main checkout" +RLMX_REPO_URL="$SOURCE" \ +RLMX_BRANCH=main \ +RLMX_INSTALL_DIR="$INSTALL_DIR" \ +RLMX_BIN_DIR="$BIN_DIR" \ + bash "$ROOT/scripts/install.sh" + +INSTALLED_HEAD="$(git -C "$INSTALL_DIR" rev-parse HEAD)" +if [ "$INSTALLED_HEAD" != "$INITIAL_HEAD" ]; then + echo "::error::install head mismatch: installed=$INSTALLED_HEAD source=$INITIAL_HEAD" >&2 + exit 1 +fi + +"$BIN_DIR/rlmx" --version || { + echo "::error::installed rlmx binary failed" >&2 + exit 1 +} + +npm pack --json --dry-run | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{ const p=JSON.parse(s)[0]; if (p.bin) { console.error("npm package exposes bin unexpectedly", p.bin); process.exit(1); } });' || { + echo "::error::npm package must remain SDK-only and expose no bin" >&2 + exit 1 +} +echo "==> npm package is SDK-only: no bin exposed" + +echo "==> Smoke update dirty-check refusal" +printf '\n# dirty smoke\n' >> "$INSTALL_DIR/docs/release-contract.md" +if "$BIN_DIR/rlmx" update >"$TMP/dirty.out" 2>"$TMP/dirty.err"; then + echo "::error::rlmx update succeeded despite dirty checkout" >&2 + cat "$TMP/dirty.out" >&2 + cat "$TMP/dirty.err" >&2 + exit 1 +fi +if ! grep -q 'Refusing to update with local changes' "$TMP/dirty.err"; then + echo "::error::dirty-check error message missing" >&2 + cat "$TMP/dirty.err" >&2 + exit 1 +fi + +git -C "$INSTALL_DIR" reset --hard HEAD >/dev/null +git -C "$INSTALL_DIR" clean -fd >/dev/null + +echo "==> Smoke update happy path from advanced main" +printf '\nupdate-smoke=%s\n' "$(date -u +%Y%m%dT%H%M%SZ)" >> "$SOURCE/.rlmx-update-smoke" +commit_source "test: advance main for update smoke" +TARGET_HEAD="$(git -C "$SOURCE" rev-parse HEAD)" + +"$BIN_DIR/rlmx" update +UPDATED_HEAD="$(git -C "$INSTALL_DIR" rev-parse HEAD)" +if [ "$UPDATED_HEAD" != "$TARGET_HEAD" ]; then + echo "::error::update head mismatch: installed=$UPDATED_HEAD source=$TARGET_HEAD" >&2 + exit 1 +fi + +echo "==> Install/update smoke passed" +echo "initial: $INITIAL_HEAD" +echo "target: $TARGET_HEAD" diff --git a/src/cli.ts b/src/cli.ts index 1ae23e1..05d6a91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { mkdir } from "node:fs/promises"; import { parseArgs } from "node:util"; +import { execFileSync } from "node:child_process"; import { loadConfig, type ToolsLevel } from "./config.js"; import { isValidThinkingLevel, checkFutureFlags, type ThinkingLevel } from "./gemini.js"; import { scaffold, needsScaffold } from "./scaffold.js"; @@ -62,6 +63,7 @@ Usage: rlmx benchmark [options] Run benchmarks (cost or oolong) rlmx stats [options] Query run history and cost breakdowns rlmx doctor Health check: providers, RTK, config + rlmx update [--force] Fetch latest main commit for a git install Options: --context Path to context (directory or file) @@ -117,7 +119,7 @@ Examples: interface CliOptions { query: string | null; - command: "query" | "init" | "help" | "version" | "schema" | "cache" | "batch" | "config" | "benchmark" | "stats" | "doctor"; + command: "query" | "init" | "help" | "version" | "schema" | "cache" | "batch" | "config" | "benchmark" | "stats" | "doctor" | "update"; context: string | null; output: "text" | "json" | "stream"; verbose: boolean; @@ -210,6 +212,7 @@ function parseCliArgs(args: string[]): CliOptions { : positionals[0] === "benchmark" ? "benchmark" : positionals[0] === "stats" ? "stats" : positionals[0] === "doctor" ? "doctor" + : positionals[0] === "update" ? "update" : "query"; const query = command === "query" ? positionals[0] ?? null : null; const batchFile = command === "batch" ? positionals[1] ?? null : null; @@ -901,6 +904,55 @@ async function runDoctor(): Promise { // Exit 0 — nominal } +function runGit(root: string, args: string[]): string { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +function runCommand(root: string, command: string, args: string[]): void { + execFileSync(command, args, { cwd: root, stdio: "inherit" }); +} + +async function runUpdate(args: string[]): Promise { + const force = args.includes("--force") || args.includes("-f"); + const { dirname, resolve: resolvePath } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const root = resolvePath(dirname(fileURLToPath(import.meta.url)), "../.."); + + try { + runGit(root, ["rev-parse", "--is-inside-work-tree"]); + } catch { + throw new Error("rlmx update requires a git-installed checkout. Reinstall with scripts/install.sh."); + } + + const before = runGit(root, ["rev-parse", "HEAD"]); + const dirty = runGit(root, ["status", "--porcelain"]); + if (dirty && !force) { + throw new Error("Refusing to update with local changes. Commit/stash them or rerun with --force for managed installs."); + } + + console.log(`rlmx update: ${root}`); + console.log(`before: ${before}`); + runGit(root, ["fetch", "origin", "main", "--tags"]); + const target = runGit(root, ["rev-parse", "origin/main"]); + console.log(`target: ${target}`); + + if (before === target && !dirty) { + console.log("Already up to date."); + return; + } + + runGit(root, ["reset", "--hard", "origin/main"]); + runCommand(root, "npm", ["ci"]); + runCommand(root, "npm", ["run", "build"]); + + const after = runGit(root, ["rev-parse", "HEAD"]); + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const pkg = require("../../package.json") as { version: string }; + console.log(`after: ${after}`); + console.log(`rlmx v${pkg.version}`); +} + async function main(): Promise { const opts = parseCliArgs(process.argv.slice(2)); @@ -957,6 +1009,10 @@ async function main(): Promise { await runDoctor(); break; + case "update": + await runUpdate(process.argv.slice(3)); + break; + case "query": if (!opts.query && process.stdin.isTTY) { console.log(HELP); diff --git a/src/schema.ts b/src/schema.ts index 1b73189..8ccdb21 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -196,6 +196,14 @@ export const RLMX_CLI_SCHEMA: RlmxCliSchema = { description: "Template used by the init command.", appliesTo: ["init"], }, + { + name: "--force", + aliases: ["-f"], + type: "boolean", + default: false, + description: "Allow rlmx update to reset a dirty managed checkout to origin/main.", + appliesTo: ["update"], + }, ], output: { $schema: "https://json-schema.org/draft/2020-12/schema",