From 6a54a915c20f78bb04e3a0d0d66ceab8cecbbf55 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 08:38:38 +0000 Subject: [PATCH 01/12] feat(ci): update CI to support CLI 'update' subcommand --- .github/workflows/release-rune-cli.yml | 17 +++++++++++++++++ .release-pins.yaml | 3 +++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/release-rune-cli.yml b/.github/workflows/release-rune-cli.yml index f7b0a06..3a1c98e 100644 --- a/.github/workflows/release-rune-cli.yml +++ b/.github/workflows/release-rune-cli.yml @@ -227,6 +227,21 @@ jobs: fi echo "Pinning rune-mcp=${RUNE_MCP_VERSION}, runed=${RUNED_VERSION}" + PLUGIN_VERSION=$(jq -r '.version // empty' .claude-plugin/plugin.json) + MIN_PLUGIN_VERSION=$(awk '/^min_plugin_version:/ {print $2}' .release-pins.yaml) + if [ -z "${PLUGIN_VERSION:-}" ] || [ -z "${MIN_PLUGIN_VERSION:-}" ]; then + echo "::error::missing plugin version (plugin.json version=${PLUGIN_VERSION}, .release-pins.yaml min_plugin_version=${MIN_PLUGIN_VERSION})" >&2 + exit 1 + fi + + if [[ "${MIN_PLUGIN_VERSION#v}" =~ ^[0-9]+(\.[0-9]+)*$ && "${PLUGIN_VERSION#v}" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then + if [ "$(printf '%s\n%s\n' "${MIN_PLUGIN_VERSION#v}" "${PLUGIN_VERSION#v}" | sort -V | head -1)" != "${MIN_PLUGIN_VERSION#v}" ]; then + echo "::error::min_plugin_version ${MIN_PLUGIN_VERSION} is above the shipped plugin version ${PLUGIN_VERSION}" >&2 + exit 1 + fi + fi + echo "Plugin: plugin_version=${PLUGIN_VERSION}, min_plugin_version=${MIN_PLUGIN_VERSION}" + mkdir -p _downstream/runed _downstream/rune-mcp # runed @@ -297,6 +312,8 @@ jobs: "version": 1, "rune_mcp_version": "${RUNE_MCP_VERSION}", "runed_version": "${RUNED_VERSION}", + "plugin_version": "${PLUGIN_VERSION}", + "min_plugin_version": "${MIN_PLUGIN_VERSION}", "platforms": { "darwin-arm64": { "runed": { diff --git a/.release-pins.yaml b/.release-pins.yaml index 3af9c5d..87a9842 100644 --- a/.release-pins.yaml +++ b/.release-pins.yaml @@ -1,2 +1,5 @@ rune_mcp_version: v1.0.0 runed_version: v1.0.0 + +# Hard floor for `rune update` +min_plugin_version: 1.0.0 From b4141e10a89bc734ad3755e7ac9460e52465ef79 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:26:28 +0000 Subject: [PATCH 02/12] feat(bootstrap): tolerate unknown manifest fields --- internal/bootstrap/manifest.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/bootstrap/manifest.go b/internal/bootstrap/manifest.go index ef9e331..44bf119 100644 --- a/internal/bootstrap/manifest.go +++ b/internal/bootstrap/manifest.go @@ -1,7 +1,6 @@ package bootstrap import ( - "bytes" "context" "encoding/json" "errors" @@ -78,10 +77,11 @@ func FetchManifest(ctx context.Context, manifestURL string, logf func(string, .. return nil, err } + // Unknown fields are tolerated: every deployed binary polls the shared + // "latest" release channel, so a future manifest field must degrade to + // a no-op on older binaries instead of bricking their update path. var m Manifest - dec := json.NewDecoder(bytes.NewReader(body)) - dec.DisallowUnknownFields() - if err := dec.Decode(&m); err != nil { + if err := json.Unmarshal(body, &m); err != nil { return nil, fmt.Errorf("manifest: parse JSON: %w", err) } if m.Version != ManifestVersion { From aa7df9ca2a541cde8446360b6add8ad915e0a317 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:28:36 +0000 Subject: [PATCH 03/12] feat(cli): add rune CLI self-update --- cmd/rune/update.go | 33 ++++++++++-- internal/bootstrap/install.go | 1 + internal/bootstrap/manifest.go | 10 ++-- internal/bootstrap/update.go | 96 +++++++++++++++++++++++++++++++--- 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/cmd/rune/update.go b/cmd/rune/update.go index d80de6a..bdf284f 100644 --- a/cmd/rune/update.go +++ b/cmd/rune/update.go @@ -23,7 +23,7 @@ func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int manifest := fs.String("manifest-url", manifestURL, "override manifest URL") pluginRoot := fs.String("plugin-root", "", "plugin root for the plugin version check (defaults to $CLAUDE_PLUGIN_ROOT)") allowOutdated := fs.Bool("allow-plugin-outdated", false, "apply even if the plugin package is older than the binaries require") - only := fs.String("only", "", "restrict to a comma-separated set of artifacts: rune_mcp, runed") + only := fs.String("only", "", "restrict to a comma-separated set of artifacts: rune_mcp, runed, rune_cli") if err := fs.Parse(args); err != nil { return 2 } @@ -55,7 +55,7 @@ func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int return 1 } - plan, err := bootstrap.PlanFromManifest(mf) + plan, err := bootstrap.PlanFromManifest(mf, runeVersion) if err != nil { fmt.Fprintf(stderr, "rune update: %v\n", err) return 1 @@ -216,6 +216,31 @@ func applyUpdate(ctx context.Context, manifest string, plan *bootstrap.UpdateLis fmt.Fprintf(stdout, "updated %s: %s -> %s (staged; applies on next daemon start)\n", a.Step, a.Installed, to) } } + case bootstrap.StepRuneCLI: + to, err := bootstrap.UpdateCLI(ctx, manifest, runeVersion, logf) + if errors.Is(err, bootstrap.ErrCLIOutdated) { + out.Deferred = append(out.Deferred, a.Step) + if !jsonOut { + fmt.Fprintf(stderr, "rune update: %s: skipped (%v)\n", a.Step, err) + } + + continue + } + if err != nil { + out.Error = err.Error() + exit = 1 + + if !jsonOut { + fmt.Fprintf(stderr, "rune update: %s: %v\n", a.Step, err) + } + + continue + } + + out.Applied = append(out.Applied, appliedUpdate{Step: a.Step, From: a.Installed, To: to}) + if !jsonOut { + fmt.Fprintf(stdout, "updated %s: %s -> %s (apply on the next rune execution)\n", a.Step, a.Installed, to) + } } } @@ -246,8 +271,8 @@ func parseOnly(only string) (map[string]bool, error) { if tok == "" { continue } - if tok != bootstrap.StepRuneMCP && tok != bootstrap.StepRuned { - return nil, fmt.Errorf("--only: unknown artifact %q (want %s or %s)", tok, bootstrap.StepRuneMCP, bootstrap.StepRuned) + if tok != bootstrap.StepRuneMCP && tok != bootstrap.StepRuned && tok != bootstrap.StepRuneCLI { + return nil, fmt.Errorf("--only: unknown artifact %q (want %s, %s or %s)", tok, bootstrap.StepRuneMCP, bootstrap.StepRuned, bootstrap.StepRuneCLI) } set[tok] = true diff --git a/internal/bootstrap/install.go b/internal/bootstrap/install.go index 0086ffd..2f70dbe 100644 --- a/internal/bootstrap/install.go +++ b/internal/bootstrap/install.go @@ -40,6 +40,7 @@ const ( StepManifest = "manifest" StepRuned = "runed" StepRuneMCP = "rune_mcp" + StepRuneCLI = "rune_cli" // CLI self-update; updated via UpdateCLI, never via Install ) const binaryMode = 0o755 // executable diff --git a/internal/bootstrap/manifest.go b/internal/bootstrap/manifest.go index 44bf119..0110ad1 100644 --- a/internal/bootstrap/manifest.go +++ b/internal/bootstrap/manifest.go @@ -20,10 +20,12 @@ const defaultManifestFetchTimeout = 30 * time.Second // "version": 1, // "rune_mcp_version": "v0.1.0", // "runed_version": "v0.1.0-alpha.1", +// "cli_version": "v1.0.0", // "platforms": { // "linux-amd64": { // "runed": {"url": "...", "sha256": "...", "size": 8123456}, -// "rune_mcp": {"url": "...", "sha256": "...", "size": 16234567} +// "rune_mcp": {"url": "...", "sha256": "...", "size": 16234567}, +// "rune_cli": {"url": "...", "sha256": "...", "size": 12345678} // }, // "darwin-arm64": { ... } // } @@ -36,6 +38,7 @@ type Manifest struct { PluginVersion string `json:"plugin_version,omitempty"` // plugin package version; optional MinPluginVersion string `json:"min_plugin_version,omitempty"` // hard floor for `rune update`; optional + CLIVersion string `json:"cli_version,omitempty"` // rune CLI release tag; optional Platforms map[string]PlatformArtifacts `json:"platforms"` } @@ -43,6 +46,7 @@ type Manifest struct { type PlatformArtifacts struct { Runed ArtifactSpec `json:"runed"` // ~/.runed/bin RuneMCP ArtifactSpec `json:"rune_mcp"` // ~/.rune/bin + RuneCLI ArtifactSpec `json:"rune_cli"` // ~/.rune/bin/rune; optional } type ArtifactSpec struct { @@ -77,9 +81,7 @@ func FetchManifest(ctx context.Context, manifestURL string, logf func(string, .. return nil, err } - // Unknown fields are tolerated: every deployed binary polls the shared - // "latest" release channel, so a future manifest field must degrade to - // a no-op on older binaries instead of bricking their update path. + // No-op for unknown fields on older binaries rather than block update var m Manifest if err := json.Unmarshal(body, &m); err != nil { return nil, fmt.Errorf("manifest: parse JSON: %w", err) diff --git a/internal/bootstrap/update.go b/internal/bootstrap/update.go index 9b31bd9..07ecc18 100644 --- a/internal/bootstrap/update.go +++ b/internal/bootstrap/update.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "errors" "fmt" "os" "strings" @@ -56,14 +57,15 @@ func normalizeVersion(v string) string { return v } -func planUpdate(installed *InstalledManifest, manifest *Manifest) UpdateList { +func planUpdate(installed *InstalledManifest, manifest *Manifest, cliVersion string) UpdateList { plan := UpdateList{} if manifest == nil { return plan // no updates } - for _, step := range []string{StepRuned, StepRuneMCP} { + for _, step := range []string{StepRuned, StepRuneMCP, StepRuneCLI} { var inst, avail string + var outdated bool switch step { case StepRuned: @@ -71,24 +73,30 @@ func planUpdate(installed *InstalledManifest, manifest *Manifest) UpdateList { if installed != nil { inst = installed.RunedVersion } + outdated = avail != "" && inst != "" && normalizeVersion(inst) != normalizeVersion(avail) case StepRuneMCP: avail = manifest.RuneMCPVersion if installed != nil { inst = installed.RuneMCPVersion } + outdated = avail != "" && inst != "" && normalizeVersion(inst) != normalizeVersion(avail) + case StepRuneCLI: + avail = manifest.CLIVersion + inst = cliVersion + outdated = avail != "" && inst != "" && compareVersions(inst, avail) < 0 } plan.Artifacts = append(plan.Artifacts, ArtifactVersion{ Step: step, Installed: inst, Available: avail, - Outdated: avail != "" && inst != "" && normalizeVersion(inst) != normalizeVersion(avail), + Outdated: outdated, }) } return plan } -func CheckUpdate(ctx context.Context, manifestURL string, logf func(format string, args ...any)) (*UpdateList, error) { +func CheckUpdate(ctx context.Context, manifestURL, cliVersion string, logf func(format string, args ...any)) (*UpdateList, error) { if logf == nil { logf = func(string, ...any) {} } @@ -99,18 +107,18 @@ func CheckUpdate(ctx context.Context, manifestURL string, logf func(format strin return nil, err } - return PlanFromManifest(manifest) + return PlanFromManifest(manifest, cliVersion) } -func PlanFromManifest(manifest *Manifest) (*UpdateList, error) { +func PlanFromManifest(manifest *Manifest, cliVersion string) (*UpdateList, error) { paths, err := Resolve() if err != nil { return nil, err } // Get local installed info - installed, _ := ReadInstalledManifest(paths) // nil: unknown version - plan := planUpdate(installed, manifest) // build update plan + installed, _ := ReadInstalledManifest(paths) // nil: unknown version + plan := planUpdate(installed, manifest, cliVersion) // build update plan return &plan, nil } @@ -208,3 +216,75 @@ func UpdateArtifact(ctx context.Context, manifestURL, step string, afterInstall return version, writeManifest(paths, rec) } + +var ErrCLIOutdated = errors.New("update: CLI is not newer than exsiting binary") + +// cliVersion is the running binary's own version. The strictly-newer gate is +// re-checked here against the manifest actually being installed from: the +// caller planned against an earlier fetch and the channel can move in +// between, so trusting the plan alone would let a stale plan swap an older +// binary over a newer one. +func UpdateCLI(ctx context.Context, manifestURL, cliVersion string, logf func(format string, args ...any)) (string, error) { + if logf == nil { + logf = func(string, ...any) {} + } + + manifest, err := FetchManifest(ctx, manifestURL, logf) + if err != nil { + return "", err + } + if manifest.CLIVersion == "" { + return "", fmt.Errorf("update: manifest declares no cli_version") + } + if cliVersion != "" && compareVersions(cliVersion, manifest.CLIVersion) >= 0 { + return "", fmt.Errorf("%w: channel %s, running %s", ErrCLIOutdated, manifest.CLIVersion, cliVersion) + } + + tuple := PlatformTuple() + arts, ok := manifest.Platforms[tuple] + if !ok { + return "", fmt.Errorf("%w: %s", ErrNoArtifactForPlatform, tuple) + } + spec := arts.RuneCLI + if spec.URL == "" || spec.SHA256 == "" { + return "", fmt.Errorf("manifest: rune_cli artifact for %s missing url or sha256", tuple) + } + + paths, err := Resolve() + if err != nil { + return "", err + } + if err := paths.EnsureDirs(); err != nil { + return "", err + } + + unlock, err := acquireInstallLock(ctx, paths.InstallLock, InstallLockTimeout) + if err != nil { + return "", fmt.Errorf("update: acquire lock: %w", err) + } + defer unlock() + + if err := installArtifact(ctx, paths, spec, paths.RuneCLIBinary, nil, logf); err != nil { + return "", err + } + + rec, _ := ReadInstalledManifest(paths) + if rec == nil { + rec = &InstalledManifest{ManifestVersion: manifest.Version, Platform: tuple} + } + if rec.Artifacts == nil { + rec.Artifacts = map[string]InstalledArtifact{} + } + + entry := InstalledArtifact{URL: spec.URL, SHA256: spec.SHA256, DestSHA256: spec.SHA256, Path: paths.RuneCLIBinary, Size: spec.Size} + if info, statErr := os.Stat(paths.RuneCLIBinary); statErr == nil { + entry.Size = info.Size() + } + rec.Artifacts[StepRuneCLI] = entry + + rec.ManifestURL = manifestURL + rec.ManifestVersion = manifest.Version + rec.InstalledAt = time.Now().UTC().Format(time.RFC3339) + + return manifest.CLIVersion, writeManifest(paths, rec) // return new CLI version +} From d53c03f6ba52c5f569b792c2289875fdc33c8439 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:29:04 +0000 Subject: [PATCH 04/12] feat(cli): poll the promoted release channel for updates --- cmd/rune/main.go | 11 +++++++++++ cmd/rune/mcpserver.go | 2 +- cmd/rune/update.go | 15 ++++++++++++++- cmd/rune/version.go | 3 +++ internal/bootstrap/update.go | 11 +++++++++++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/cmd/rune/main.go b/cmd/rune/main.go index c737bb5..9e4fba6 100644 --- a/cmd/rune/main.go +++ b/cmd/rune/main.go @@ -44,6 +44,17 @@ var runeVersion = "v0.4.0-dev" // Configurable at build time via `-ldflags -X main.manifestURL=...` var manifestURL = "" +// Configurable at build time via `-ldflags -X main.updateManifestURL=...` +var updateManifestURL = "" + +func updateChannel() string { + if updateManifestURL != "" { + return updateManifestURL + } + + return manifestURL +} + func main() { if len(os.Args) < 2 { printHelp(os.Stderr) diff --git a/cmd/rune/mcpserver.go b/cmd/rune/mcpserver.go index 3c2ee82..0cd929f 100644 --- a/cmd/rune/mcpserver.go +++ b/cmd/rune/mcpserver.go @@ -76,7 +76,7 @@ var spawnUpdateFn = spawnDetachedUpdate // background update launcher var errBackgroundUnsupported = errors.New("detached background update not supported on this platform") func resolvedManifest() string { - m := manifestURL + m := updateChannel() if env := os.Getenv("RUNE_MANIFEST"); env != "" { m = env } diff --git a/cmd/rune/update.go b/cmd/rune/update.go index bdf284f..b378ba6 100644 --- a/cmd/rune/update.go +++ b/cmd/rune/update.go @@ -20,7 +20,7 @@ func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int check := fs.Bool("check", false, "report available updates without applying") jsonOut := fs.Bool("json", false, "emit JSON") - manifest := fs.String("manifest-url", manifestURL, "override manifest URL") + manifest := fs.String("manifest-url", updateChannel(), "override manifest URL") pluginRoot := fs.String("plugin-root", "", "plugin root for the plugin version check (defaults to $CLAUDE_PLUGIN_ROOT)") allowOutdated := fs.Bool("allow-plugin-outdated", false, "apply even if the plugin package is older than the binaries require") only := fs.String("only", "", "restrict to a comma-separated set of artifacts: rune_mcp, runed, rune_cli") @@ -38,6 +38,9 @@ func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int return 2 } + // Override with explicit --manifest-url or RUNE_MANIFEST + onChannel := updateManifestURL != "" && *manifest == updateManifestURL && os.Getenv("RUNE_MANIFEST") == "" + // Fall-back if *manifest == "" { if env := os.Getenv("RUNE_MANIFEST"); env != "" { @@ -55,6 +58,16 @@ func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int return 1 } + if onChannel && bootstrap.ChannelBehind(mf, runeVersion) { + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(updateSummary{Applied: []appliedUpdate{}}) + } else { + fmt.Fprintf(stdout, "rune: update channel is behind this build (channel CLI %q, running %s)\n", mf.CLIVersion, runeVersion) + fmt.Fprintln(stdout, " This version might not promoted yet") + } + return 0 + } + plan, err := bootstrap.PlanFromManifest(mf, runeVersion) if err != nil { fmt.Fprintf(stderr, "rune update: %v\n", err) diff --git a/cmd/rune/version.go b/cmd/rune/version.go index 8a83ca5..9c21a56 100644 --- a/cmd/rune/version.go +++ b/cmd/rune/version.go @@ -23,6 +23,9 @@ func runVersion(w io.Writer) int { } else { fmt.Fprintln(w, "manifest missing: supply --manifest-url or RUNE_MANIFEST") } + if updateManifestURL != "" { + fmt.Fprintf(w, "update channel: %s\n", updateManifestURL) + } // Show latest installed rune-mcp/runed (skip if not installed yet) if paths, err := bootstrap.Resolve(); err == nil { diff --git a/internal/bootstrap/update.go b/internal/bootstrap/update.go index 07ecc18..ba335d2 100644 --- a/internal/bootstrap/update.go +++ b/internal/bootstrap/update.go @@ -217,6 +217,17 @@ func UpdateArtifact(ctx context.Context, manifestURL, step string, afterInstall return version, writeManifest(paths, rec) } +func ChannelBehind(m *Manifest, cliVersion string) bool { + if m == nil || cliVersion == "" { + return false // unknown running version: leave the plan alone + } + if m.CLIVersion == "" { + return true // predates self-update + } + + return compareVersions(m.CLIVersion, cliVersion) < 0 +} + var ErrCLIOutdated = errors.New("update: CLI is not newer than exsiting binary") // cliVersion is the running binary's own version. The strictly-newer gate is From e64762d20217af3647598cb9ca10acccafd08cfb Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:29:29 +0000 Subject: [PATCH 05/12] feat(ci): publish rune_cli artifacts and bake the update channel --- .github/workflows/release-rune-cli.yml | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-rune-cli.yml b/.github/workflows/release-rune-cli.yml index 3a1c98e..ea0dbcb 100644 --- a/.github/workflows/release-rune-cli.yml +++ b/.github/workflows/release-rune-cli.yml @@ -71,6 +71,7 @@ jobs: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} MANIFEST_URL: https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/manifest.json + UPDATE_MANIFEST_URL: https://github.com/${{ github.repository }}/releases/latest/download/manifest.json run: | # EXT="" # if [ "${{ matrix.os }}" = "windows" ]; then EXT=".exe"; fi @@ -78,7 +79,7 @@ jobs: ASSET="rune-${{ matrix.os }}-${{ matrix.arch }}" mkdir -p dist go build -trimpath \ - -ldflags "-s -w -X main.runeVersion=${VERSION} -X main.manifestURL=${MANIFEST_URL}" \ + -ldflags "-s -w -X main.runeVersion=${VERSION} -X main.manifestURL=${MANIFEST_URL} -X main.updateManifestURL=${UPDATE_MANIFEST_URL}" \ -o "dist/${ASSET}" \ ./cmd/rune ls -lh "dist/${ASSET}" @@ -306,6 +307,21 @@ jobs: RUNED_BASE="https://github.com/CryptoLabInc/runed/releases/download/${RUNED_VERSION}" MCP_BASE="https://github.com/CryptoLabInc/rune-mcp/releases/download/${RUNE_MCP_VERSION}" + # rune CLI self-update artifacts + CLI_BASE="https://github.com/${{ github.repository }}/releases/download/${VERSION}" + cli_sha() { + local sha + sha=$(awk -v f="$1" '$2 == f {print $1}' dist/checksums.txt) + if [ -z "$sha" ]; then + echo "::error::no checksum for $1 in dist/checksums.txt" >&2 + return 1 + fi + echo "$sha" + } + CLI_DARWIN_ARM64_SHA=$(cli_sha rune-darwin-arm64) + CLI_LINUX_AMD64_SHA=$(cli_sha rune-linux-amd64) + CLI_LINUX_ARM64_SHA=$(cli_sha rune-linux-arm64) + mkdir -p dist cat > dist/manifest.json < Date: Fri, 17 Jul 2026 10:29:29 +0000 Subject: [PATCH 06/12] fix(plugin): resolve only CLI release tags when bootstrapping --- bin/rune | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/rune b/bin/rune index 3b19297..c1f022c 100755 --- a/bin/rune +++ b/bin/rune @@ -208,9 +208,13 @@ if [ -z "$RUNE_VERSION" ]; then body="$(fetch --max-time "$NET_API_MAXTIME" "$api" || true)" fi + # Newest release whose tag looks like a CLI version (v...): other + # release trains in this repo (e.g. plugin/*) carry no rune-* assets and + # must never be picked up here. RUNE_VERSION="$(printf '%s' "$body" \ - | grep -m1 '"tag_name"' \ + | grep -o '"tag_name":[[:space:]]*"[^"]*"' \ | sed 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/' \ + | grep -m1 '^v[0-9]' \ || true)" fi From d31ee6dbd044eae339733775bc9f1eda90316a59 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:29:29 +0000 Subject: [PATCH 07/12] feat(plugin): re-bootstrap CLIs below the required floor --- bin/rune | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/bin/rune b/bin/rune index c1f022c..4a45662 100755 --- a/bin/rune +++ b/bin/rune @@ -20,9 +20,62 @@ set -euo pipefail RUNE_HOME="${RUNE_HOME:-$HOME/.rune}" TARGET="$RUNE_HOME/bin/rune" -# Delegate to ~/.rune/bin/rune immediately +# Minimum CLI version rune plugin require +RUNE_CLI_MIN="${RUNE_CLI_MIN-v1.0.0}" + +# Installed CLI version: "" when absent or unparseable (treat as old) +installed_cli_version() { + [ -x "$TARGET" ] || return 0 + "$TARGET" version 2>/dev/null | head -1 | awk '$1 == "rune" {print $2}' +} + +# True when semver(A) > semver(B) +ver_lt() { + local a="${1#v}" b="${2#v}" + if [ "$a" = "$b" ]; then + return 1 + fi + + local ar="${a%%-*}" br="${b%%-*}" + local ap="" bp="" + case "$a" in *-*) ap="${a#*-}" ;; esac + case "$b" in *-*) bp="${b#*-}" ;; esac + + if [ "$ar" != "$br" ]; then + [ "$(printf '%s\n%s\n' "$ar" "$br" | sort -V | head -1)" = "$ar" ] + return + fi + if [ -z "$ap" ]; then + return 1 # A: release > B: pre-release + fi + if [ -z "$bp" ]; then + return 0 # A: pre-release < B: release + fi + + [ "$(printf '%s\n%s\n' "$ap" "$bp" | sort -V | head -1)" = "$ap" ] +} + +target_ready() { + [ -x "$TARGET" ] || return 1 + [ -n "$RUNE_CLI_MIN" ] || return 0 + local v + v="$(installed_cli_version || true)" + [ -n "$v" ] && ! ver_lt "$v" "$RUNE_CLI_MIN" +} + +# ~/.rune/bin/rune exist if [ -x "$TARGET" ]; then - exec "$TARGET" "$@" + case "${1:-}" in + install|mcp-server) + if target_ready; then + exec "$TARGET" "$@" + fi + echo "rune: installed CLI $(installed_cli_version || true) is older than this plugin needs (min $RUNE_CLI_MIN); upgrading..." >&2 + ;; + *) + exec "$TARGET" "$@" + ;; + esac fi # Bootstrap path (~/.rune/bin/rune missing) @@ -122,7 +175,7 @@ while true; do fi # Wait for another process as we failed to claim lock - if [ -x "$TARGET" ]; then + if target_ready; then exec "$TARGET" "$@" # bootstrap finished fi @@ -173,7 +226,7 @@ while true; do done # Double-check: install is completed right before we won the lock -if [ -x "$TARGET" ]; then +if target_ready; then cleanup trap - EXIT INT TERM exec "$TARGET" "$@" @@ -208,9 +261,6 @@ if [ -z "$RUNE_VERSION" ]; then body="$(fetch --max-time "$NET_API_MAXTIME" "$api" || true)" fi - # Newest release whose tag looks like a CLI version (v...): other - # release trains in this repo (e.g. plugin/*) carry no rune-* assets and - # must never be picked up here. RUNE_VERSION="$(printf '%s' "$body" \ | grep -o '"tag_name":[[:space:]]*"[^"]*"' \ | sed 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/' \ @@ -224,6 +274,14 @@ if [ -z "$RUNE_VERSION" ]; then exit 1 fi +INSTALLED_VERSION="$(installed_cli_version || true)" +if [ -x "$TARGET" ] && [ -n "$INSTALLED_VERSION" ] && ! ver_lt "$INSTALLED_VERSION" "$RUNE_VERSION"; then + echo "rune: no new release after $INSTALLED_VERSION (latest $RUNE_VERSION)" >&2 + cleanup + trap - EXIT INT TERM + exec "$TARGET" "$@" +fi + # Map (OS, arch) to release asset name case "$(uname -s)-$(uname -m)" in Linux-x86_64) ASSET="rune-linux-amd64" ;; From 66c190aff46b7c51ffac3254680217aee0de21b3 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:29:41 +0000 Subject: [PATCH 08/12] feat(mcp-server): auto-update the CLI in the background --- .claude-plugin/plugin.json | 1 - cmd/rune/mcpserver.go | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9f171e0..107c0fc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -11,7 +11,6 @@ "rune": { "command": "${CLAUDE_PLUGIN_ROOT}/bin/rune", "args": ["mcp-server"], - "env": { "RUNE_NO_AUTO_UPDATE": "1" }, "timeout": 30000, "description": "Rune MCP server (talks to Console + embedder). The command is the plugin's bash wrapper, which always exists at session start; on first run it self-install rune-mcp, then exec - MCP server comes online in the same session with no restart." } diff --git a/cmd/rune/mcpserver.go b/cmd/rune/mcpserver.go index 0cd929f..e0f42b7 100644 --- a/cmd/rune/mcpserver.go +++ b/cmd/rune/mcpserver.go @@ -105,6 +105,15 @@ func tryAutoCheck(paths *bootstrap.Paths, stderr io.Writer) { } } +// Background update check argv +func detachedUpdateArgs(manifest string) []string { + return []string{ + "update", + "--only", bootstrap.StepRuneCLI, + "--manifest-url", manifest, + } +} + func spawnDetachedUpdate(paths *bootstrap.Paths, manifest string) error { exe, err := os.Executable() if err != nil { @@ -120,8 +129,7 @@ func spawnDetachedUpdate(paths *bootstrap.Paths, manifest string) error { } defer logFile.Close() - // Runed is excluded since mcp server does not handle its lifecycle - cmd := exec.Command(exe, "update", "--only", bootstrap.StepRuneMCP, "--manifest-url", manifest) + cmd := exec.Command(exe, detachedUpdateArgs(manifest)...) cmd.Stdin = nil cmd.Stdout = logFile cmd.Stderr = logFile From dbeee42b4352ad4cde26ecc1f460711ba6ac0c58 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:33:04 +0000 Subject: [PATCH 09/12] feat(test): update end-to-end 'update' test --- test/update-e2e.sh | 123 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/test/update-e2e.sh b/test/update-e2e.sh index 4a06837..5aa8686 100755 --- a/test/update-e2e.sh +++ b/test/update-e2e.sh @@ -9,6 +9,8 @@ # mcp - rune-mcp raw binary update test # runed - runed update test as real tar.gz with live supervisor # plugin - version outdated guard test +# cli - CLI self-update test (swap, integrity, downgrade guard) +# wrapper - plugin wrapper CLI floor / migration test # autocheck - automatic update test # noauto - RUNE_NO_AUTO_UPDATE disable auto check # lock - concurrent install test @@ -287,8 +289,102 @@ scenario_plugin() { assert_contains "$(cat "$RUNE_HOME/bin/rune-mcp")" "v0.3.0 ADVISORY" "advisory update swapped" } +scenario_cli() { + echo "=== scenario: CLI self-update ===" + reset_home + raw_binary rune-mcp "rune-mcp v0.1.0" + raw_binary runed "runed v0.1.0" + MCP_URL="$BASE/rune-mcp" MCP_FILE="$WORK/release/rune-mcp" MCP_VER=v0.1.0 MCP_EXTRACT="" \ + RUNED_URL="$BASE/runed" RUNED_FILE="$WORK/release/runed" RUNED_VER=v0.1.0 RUNED_EXTRACT="" \ + write_manifest + + run "$RUNE" install + assert_eq "$RC" 0 "seed install" + + # New CLI release + raw_binary rune-cli-new "rune CLI v9.9.9 UPDATE TEST" + MCP_URL="$BASE/rune-mcp" MCP_FILE="$WORK/release/rune-mcp" MCP_VER=v0.1.0 MCP_EXTRACT="" \ + RUNED_URL="$BASE/runed" RUNED_FILE="$WORK/release/runed" RUNED_VER=v0.1.0 RUNED_EXTRACT="" \ + CLI_VER=v9.9.9 CLI_URL="$BASE/rune-cli-new" CLI_FILE="$WORK/release/rune-cli-new" \ + write_manifest + + run "$RUNE" update --check + assert_contains "$OUT" "rune_cli: v0.4.0-dev -> v9.9.9" "--check reports CLI outdated" + + run "$RUNE" update --only rune_cli + assert_eq "$RC" 0 "update --only rune_cli exits 0" + assert_contains "$OUT" "updated rune_cli: v0.4.0-dev -> v9.9.9" "reports the apply" + assert_contains "$(cat "$RUNE_HOME/bin/rune")" "v9.9.9 UPDATE TEST" "canonical ~/.rune/bin/rune swapped" + + # Integrity: corrupted CLI download ignored + raw_binary rune-cli-new "rune CLI v10.0.0 CORRUPTED" + MCP_URL="$BASE/rune-mcp" MCP_FILE="$WORK/release/rune-mcp" MCP_VER=v0.1.0 MCP_EXTRACT="" \ + RUNED_URL="$BASE/runed" RUNED_FILE="$WORK/release/runed" RUNED_VER=v0.1.0 RUNED_EXTRACT="" \ + CLI_VER=v10.0.0 CLI_URL="$BASE/rune-cli-new" CLI_FILE="$WORK/release/rune-cli-new" \ + CLI_SHA_OVERRIDE="$(printf '0%.0s' {1..64})" write_manifest + + run "$RUNE" update --only rune_cli + assert_eq "$RC" 1 "checksum mismatch fails the update" + assert_contains "$OUT" "checksum mismatch" "reports the mismatch" + assert_contains "$(cat "$RUNE_HOME/bin/rune")" "v9.9.9 UPDATE TEST" "bad download did NOT swap the CLI" + + # Downgarde guard: older release than running binary is not updated + MCP_URL="$BASE/rune-mcp" MCP_FILE="$WORK/release/rune-mcp" MCP_VER=v0.1.0 MCP_EXTRACT="" \ + RUNED_URL="$BASE/runed" RUNED_FILE="$WORK/release/runed" RUNED_VER=v0.1.0 RUNED_EXTRACT="" \ + CLI_VER=v0.0.1 CLI_URL="$BASE/rune-cli-new" CLI_FILE="$WORK/release/rune-cli-new" \ + write_manifest + + run "$RUNE" update --check + assert_missing "$OUT" "rune_cli:" "--check does not offer a downgrade" + assert_contains "$OUT" "up to date" "downgrade counts as up to date" +} + +scenario_wrapper() { + echo "=== scenario: wrapper CLI floor ===" + reset_home + mkdir -p "$RUNE_HOME/bin" + local wrapper="$REPO/bin/rune" + + fake_cli() { + printf '#!/bin/sh\nif [ "$1" = version ]; then echo "rune %s"; exit 0; fi\necho "RAN(%s): $*"\n' "$1" "$1" >"$RUNE_HOME/bin/rune" + chmod +x "$RUNE_HOME/bin/rune" + } + + # At/above the floor: no upgrade + fake_cli v1.0.0 + run env RUNE_HOME="$RUNE_HOME" RUNE_CLI_MIN=v1.0.0 bash "$wrapper" mcp-server + assert_eq "$RC" 0 "at-floor CLI delegates" + assert_contains "$OUT" "RAN(v1.0.0): mcp-server" "at-floor CLI actually ran" + assert_missing "$OUT" "upgrading" "at-floor CLI is not upgraded" + + # Below the floor without new release: keep it, do NOT re-download + fake_cli v0.4.1 + run env RUNE_HOME="$RUNE_HOME" RUNE_CLI_MIN=v1.0.0 RUNE_VERSION=v0.4.1 bash "$wrapper" mcp-server + assert_eq "$RC" 0 "CLI under floor but still rune since there is no new release" + assert_contains "$OUT" "is older than this plugin needs" "floor gap is reported" + assert_contains "$OUT" "no new release after v0.4.1" "no re-download" + assert_contains "$OUT" "RAN(v0.4.1): mcp-server" "old CLI still serves the session" + + # Non-bootstrap subcommand + fake_cli v0.4.1 + run env RUNE_HOME="$RUNE_HOME" RUNE_CLI_MIN=v1.0.0 bash "$wrapper" verify + assert_eq "$RC" 0 "non-bootstrap subcommand delegates" + assert_contains "$OUT" "RAN(v0.4.1): verify" "old CLI handled it" + assert_missing "$OUT" "upgrading" "no upgrade attempt off the bootstrap path" + + fake_cli v0.1.0 + run env RUNE_HOME="$RUNE_HOME" RUNE_CLI_MIN= bash "$wrapper" mcp-server + assert_eq "$RC" 0 "empty floor disable check" + assert_contains "$OUT" "RAN(v0.1.0): mcp-server" "old CLI delegate when floor is off" + + # Broken CLI regard as old + printf '#!/bin/sh\nexit 3\n' >"$RUNE_HOME/bin/rune"; chmod +x "$RUNE_HOME/bin/rune" + run env RUNE_HOME="$RUNE_HOME" RUNE_CLI_MIN=v1.0.0 RUNE_VERSION=v0.4.1 bash "$wrapper" mcp-server + assert_contains "$OUT" "is older than this plugin needs" "unparseable version treated as below floor" +} + scenario_autocheck() { - echo "=== scenario: auto-check ===" + echo "=== scenario: auto-check (CLI only) ===" need timeout reset_home raw_binary rune-mcp "rune-mcp v0.1.0" @@ -300,10 +396,12 @@ scenario_autocheck() { run "$RUNE" install assert_eq "$RC" 0 "seed install v0.1.0" - # Publish new rune-mcp to trigger auto-check + # Publish new rune-mcp and CLI raw_binary rune-mcp "rune-mcp v0.2.0 AUTO" + raw_binary rune-cli-new "rune CLI v9.9.9 AUTO" MCP_URL="$BASE/rune-mcp" MCP_FILE="$WORK/release/rune-mcp" MCP_VER=v0.2.0 MCP_EXTRACT="" \ RUNED_URL="$BASE/runed" RUNED_FILE="$WORK/release/runed" RUNED_VER=v0.1.0 RUNED_EXTRACT="" \ + CLI_VER=v9.9.9 CLI_URL="$BASE/rune-cli-new" CLI_FILE="$WORK/release/rune-cli-new" \ write_manifest # Run auto check @@ -314,11 +412,13 @@ scenario_autocheck() { else fail "spawn did not run the auto-check (no stamp)" fi - if poll_contains "$RUNE_HOME/bin/rune-mcp" "v0.2.0 AUTO" 30 || poll_contains "$UPDATE_LOG" "rune_mcp" 5; then - pass "spawn fired a detached rune update (rune-mcp swapped to v0.2.0)" + if poll_contains "$RUNE_HOME/bin/rune" "v9.9.9 AUTO" 30; then + pass "spawn fired a detached rune update (CLI swapped to v9.9.9)" else - fail "spawn did not fire the detached rune update (no swap and no update.log within timeout)" + fail "spawn did not self-update the CLI (no swap within timeout)" fi + assert_contains "$(cat "$RUNE_HOME/bin/rune-mcp")" "v0.1.0" "auto-check do not update rune-mcp" + assert_missing "$(cat "$UPDATE_LOG" 2>/dev/null || true)" "updated rune_mcp" "auto-check did not apply rune_mcp" stop_mcp # Initial setup -> no auto check @@ -415,9 +515,14 @@ if os.environ.get("PLUGIN_VER"): m["plugin_version"] = os.environ["PLUGIN_VER"] if os.environ.get("MIN_PLUGIN_VER"): m["min_plugin_version"] = os.environ["MIN_PLUGIN_VER"] -# optional integrity-test override: advertise a wrong rune-mcp hash +if os.environ.get("CLI_VER"): + m["cli_version"] = os.environ["CLI_VER"] + m["platforms"][tuple_]["rune_cli"] = art(os.environ["CLI_URL"], os.environ["CLI_FILE"], "") +# optional integrity-test overrides: advertise a wrong hash if os.environ.get("MCP_SHA_OVERRIDE"): m["platforms"][tuple_]["rune_mcp"]["sha256"] = os.environ["MCP_SHA_OVERRIDE"] +if os.environ.get("CLI_SHA_OVERRIDE"): + m["platforms"][tuple_]["rune_cli"]["sha256"] = os.environ["CLI_SHA_OVERRIDE"] json.dump(m, open(os.environ["OUT_MANIFEST"], "w"), indent=2) PY @@ -436,11 +541,13 @@ case "$SCENARIO" in mcp) scenario_mcp ;; runed) scenario_runed ;; plugin) scenario_plugin ;; + cli) scenario_cli ;; + wrapper) scenario_wrapper ;; autocheck) scenario_autocheck ;; noauto) scenario_noauto ;; lock) scenario_lock ;; - all) scenario_mcp; echo; scenario_runed; echo; scenario_plugin; echo; scenario_autocheck; echo; scenario_noauto; echo; scenario_lock ;; - *) echo "unknown scenario: $SCENARIO (want: mcp|runed|plugin|autocheck|noauto|lock|all)" >&2; exit 2 ;; + all) scenario_mcp; echo; scenario_runed; echo; scenario_plugin; echo; scenario_cli; echo; scenario_wrapper; echo; scenario_autocheck; echo; scenario_noauto; echo; scenario_lock ;; + *) echo "unknown scenario: $SCENARIO (want: mcp|runed|plugin|cli|wrapper|autocheck|noauto|lock|all)" >&2; exit 2 ;; esac echo From 02e92983a76e4e27a4b3512064cdfbf55c6603eb Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 10:33:44 +0000 Subject: [PATCH 10/12] test: update tests Co-Authored-By: Claude Sonnet 5 --- cmd/rune/mcpserver_test.go | 60 ++++++++ cmd/rune/update_test.go | 210 +++++++++++++++++++++++++++- commands/claude/update.md | 10 +- internal/bootstrap/install_test.go | 31 +++- internal/bootstrap/manifest_test.go | 15 +- internal/bootstrap/update_test.go | 198 ++++++++++++++++++++++++-- 6 files changed, 497 insertions(+), 27 deletions(-) diff --git a/cmd/rune/mcpserver_test.go b/cmd/rune/mcpserver_test.go index 9f7ee9a..59957de 100644 --- a/cmd/rune/mcpserver_test.go +++ b/cmd/rune/mcpserver_test.go @@ -168,6 +168,66 @@ func stubSpawn(t *testing.T) *int { return &n } +// The background check is the only path that carries a CLI update to an +// installed fleet unattended, so it must carry rune_cli - and ONLY rune_cli: +// rune-mcp/runed swaps change what a live session is talking to and must stay +// behind an explicit `rune update`. +func TestDetachedUpdateArgs_CLIOnly(t *testing.T) { + args := detachedUpdateArgs("http://example.test/manifest.json") + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "update --only ") { + t.Fatalf("argv is not an `update --only` invocation: %q", joined) + } + + var only string + for i, a := range args { + if a == "--only" && i+1 < len(args) { + only = args[i+1] + } + } + set, err := parseOnly(only) + if err != nil { + t.Fatalf("--only %q does not parse: %v", only, err) + } + if !set[bootstrap.StepRuneCLI] { + t.Errorf("--only %q must include %s (unattended CLI self-update)", only, bootstrap.StepRuneCLI) + } + for _, step := range []string{bootstrap.StepRuneMCP, bootstrap.StepRuned} { + if set[step] { + t.Errorf("--only %q must not include %s: swapping it disrupts a live session", only, step) + } + } + if !strings.Contains(joined, "--manifest-url http://example.test/manifest.json") { + t.Errorf("manifest URL not forwarded: %q", joined) + } +} + +func TestResolvedManifest_PrefersChannelThenEnv(t *testing.T) { + savedPinned, savedChannel := manifestURL, updateManifestURL + t.Cleanup(func() { manifestURL, updateManifestURL = savedPinned, savedChannel }) + + manifestURL = "http://pinned.test/manifest.json" + updateManifestURL = "http://channel.test/manifest.json" + + t.Setenv("RUNE_MANIFEST", "") + if got := resolvedManifest(); got != updateManifestURL { + t.Errorf("auto-check must poll the channel, not the pinned manifest: got %q", got) + } + + t.Setenv("RUNE_MANIFEST", "http://env.test/manifest.json") + if got := resolvedManifest(); got != "http://env.test/manifest.json" { + t.Errorf("RUNE_MANIFEST must win: got %q", got) + } + + // Dev build: no channel baked, fall back to the pinned manifest + t.Setenv("RUNE_MANIFEST", "") + updateManifestURL = "" + if got := resolvedManifest(); got != manifestURL { + t.Errorf("without a baked channel, fall back to pinned: got %q", got) + } +} + func TestTryAutoCheck_Due(t *testing.T) { paths := setTestEnv(t) t.Setenv("RUNE_MANIFEST", "http://example.invalid/manifest.json") diff --git a/cmd/rune/update_test.go b/cmd/rune/update_test.go index e8c3765..2d960e6 100644 --- a/cmd/rune/update_test.go +++ b/cmd/rune/update_test.go @@ -201,6 +201,214 @@ func dummyUpdateServer(t *testing.T, mcpBytes []byte, mcpVer, runedVer string) s return srv.URL + "/manifest.json" } +// Manifest server whose only outdated artifact is the CLI itself +func cliUpdateServer(t *testing.T, cliBytes []byte, cliVer string) string { + t.Helper() + return cliUpdateServerWithPlugin(t, cliBytes, cliVer, "", "") +} + +// As cliUpdateServer, plus optional plugin_version / min_plugin_version. +func cliUpdateServerWithPlugin(t *testing.T, cliBytes []byte, cliVer, pluginVer, minPluginVer string) string { + t.Helper() + + sum := sha256.Sum256(cliBytes) + cliSHA := hex.EncodeToString(sum[:]) + + var srv *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { + m := map[string]any{ + "version": 1, + "rune_mcp_version": "v0.1.0", + "runed_version": "v0.1.0", + "cli_version": cliVer, + "platforms": map[string]any{ + bootstrap.PlatformTuple(): map[string]any{ + "runed": map[string]any{"url": "http://example.test/runed", "sha256": "aa", "size": 1}, + "rune_mcp": map[string]any{"url": "http://example.test/rune-mcp", "sha256": "bb", "size": 1}, + "rune_cli": map[string]any{"url": srv.URL + "/rune-cli", "sha256": cliSHA, "size": len(cliBytes)}, + }, + }, + } + if pluginVer != "" { + m["plugin_version"] = pluginVer + } + if minPluginVer != "" { + m["min_plugin_version"] = minPluginVer + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m) + }) + mux.HandleFunc("/rune-cli", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(cliBytes))) + _, _ = w.Write(cliBytes) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv.URL + "/manifest.json" +} + +func TestRunUpdate_ApplyRuneCLI(t *testing.T) { + paths := setTestEnv(t) + cli := []byte("freshly-updated-rune-cli") + url := cliUpdateServer(t, cli, "v9.9.9") // newer than runeVersion (v0.4.0-dev) + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.1.0") // runed/rune_mcp current + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), nil, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + if b, _ := os.ReadFile(paths.RuneCLIBinary); string(b) != string(cli) { + t.Errorf("CLI not swapped on disk: got %q", b) + } + if !strings.Contains(stdout.String(), "updated rune_cli") { + t.Errorf("expected a rune_cli applied message, got %q", stdout.String()) + } +} + +func TestRunUpdate_CheckNeverOffersCLIDowngrade(t *testing.T) { + paths := setTestEnv(t) + url := cliUpdateServer(t, []byte("older-cli"), "v0.0.1") // older than runeVersion + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.1.0") + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--check"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + if strings.Contains(stdout.String(), "rune_cli") { + t.Errorf("downgrade must not be offered: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "up to date") { + t.Errorf("expected up-to-date message, got %q", stdout.String()) + } +} + +// A released binary must poll the shared channel, not its own pinned +// manifest - the pinned manifest always matches what is installed, so +// polling it would report "up to date" forever. +func TestRunUpdate_DefaultsToChannelNotPinnedManifest(t *testing.T) { + paths := setTestEnv(t) + channel := cliUpdateServer(t, []byte("cli-from-channel"), "v9.9.9") + pinned := updateManifestServer(t, "v0.1.0", "v0.1.0") + + saved, savedChannel := manifestURL, updateManifestURL + t.Cleanup(func() { manifestURL, updateManifestURL = saved, savedChannel }) + manifestURL, updateManifestURL = pinned, channel + t.Setenv("RUNE_MANIFEST", "") + writeAudit(t, paths, pinned, "v0.1.0", "v0.1.0") // current per the PINNED manifest + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--check"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + // Only the channel advertises a newer CLI; the pinned manifest has none. + if !strings.Contains(stdout.String(), "rune_cli") { + t.Errorf("expected the channel's CLI update to be reported, got %q", stdout.String()) + } +} + +// An explicit --manifest-url overrides the baked channel. +func TestRunUpdate_ExplicitManifestURLBeatsChannel(t *testing.T) { + paths := setTestEnv(t) + channel := cliUpdateServer(t, []byte("cli-from-channel"), "v9.9.9") + explicit := updateManifestServer(t, "v0.2.0", "v0.2.0") + + saved, savedChannel := manifestURL, updateManifestURL + t.Cleanup(func() { manifestURL, updateManifestURL = saved, savedChannel }) + manifestURL, updateManifestURL = "", channel + t.Setenv("RUNE_MANIFEST", "") + writeAudit(t, paths, explicit, "v0.1.0", "v0.2.0") + + var stdout, stderr bytes.Buffer + code := runUpdate(context.Background(), []string{"--check", "--manifest-url", explicit}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "rune_mcp: v0.1.0 -> v0.2.0") { + t.Errorf("expected the explicit manifest to be used, got %q", out) + } + if strings.Contains(out, "rune_cli") { + t.Errorf("channel must not leak in when --manifest-url is given: %q", out) + } +} + +// A channel behind this build (here: predating self-update, so no +// cli_version) must never drag the runtime pair backwards. +func TestRunUpdate_IgnoresChannelBehindThisBuild(t *testing.T) { + paths := setTestEnv(t) + // Old-schema channel manifest: no cli_version, older runtime pins + channel := updateManifestServer(t, "v0.1.0", "v0.1.0") + + saved, savedChannel := manifestURL, updateManifestURL + t.Cleanup(func() { manifestURL, updateManifestURL = saved, savedChannel }) + manifestURL, updateManifestURL = "", channel + t.Setenv("RUNE_MANIFEST", "") + // Bootstrapped from a newer, not-yet-promoted release + writeAudit(t, paths, channel, "v1.0.0-alpha", "v1.0.0-alpha") + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), nil, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "channel is behind this build") { + t.Errorf("expected the behind-channel notice, got %q", out) + } + // The audit must still show the newer pair: nothing was rolled back. + rec, err := bootstrap.ReadInstalledManifest(paths) + if err != nil { + t.Fatalf("ReadInstalledManifest: %v", err) + } + if rec.RuneMCPVersion != "v1.0.0-alpha" || rec.RunedVersion != "v1.0.0-alpha" { + t.Errorf("stale channel downgraded the runtime pair: rune_mcp=%q runed=%q", rec.RuneMCPVersion, rec.RunedVersion) + } +} + +// The plugin floor gates every step, the CLI included. +func TestRunUpdate_PluginFloorBlocksCLI(t *testing.T) { + paths := setTestEnv(t) + cli := []byte("cli-that-must-not-land") + url := cliUpdateServerWithPlugin(t, cli, "v9.9.9", "1.0.0", "1.0.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.1.0") + + proot := filepath.Join(shortTempDir(t), "plugin") + if err := os.MkdirAll(filepath.Join(proot, ".claude-plugin"), 0o755); err != nil { + t.Fatal(err) + } + // 0.4.1 is below the manifest's 1.0.0 floor + if err := os.WriteFile(filepath.Join(proot, ".claude-plugin", "plugin.json"), []byte(`{"version":"0.4.1"}`), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := runUpdate(context.Background(), []string{"--plugin-root", proot}, &stdout, &stderr) + if code != 1 { + t.Fatalf("exit = %d, want 1 (stderr=%q)", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "refusing to apply") { + t.Errorf("expected a refusal, got %q", stderr.String()) + } + if _, err := os.Stat(paths.RuneCLIBinary); !os.IsNotExist(err) { + t.Errorf("CLI must not be swapped while the plugin is below the floor (err=%v)", err) + } +} + +func TestParseOnly_AcceptsRuneCLI(t *testing.T) { + set, err := parseOnly("rune_mcp,rune_cli") + if err != nil { + t.Fatalf("parseOnly: %v", err) + } + if !set[bootstrap.StepRuneCLI] || !set[bootstrap.StepRuneMCP] { + t.Errorf("set = %+v, want rune_mcp and rune_cli", set) + } +} + func TestRunUpdate_ApplyRuneMCP(t *testing.T) { paths := setTestEnv(t) mcp := []byte("freshly-updated-rune-mcp") @@ -219,7 +427,7 @@ func TestRunUpdate_ApplyRuneMCP(t *testing.T) { t.Errorf("expected an applied message, got %q", stdout.String()) } - plan, err := bootstrap.CheckUpdate(context.Background(), url, nil) + plan, err := bootstrap.CheckUpdate(context.Background(), url, runeVersion, nil) if err != nil { t.Fatalf("CheckUpdate: %v", err) } diff --git a/commands/claude/update.md b/commands/claude/update.md index b079a9e..2ae4a9b 100644 --- a/commands/claude/update.md +++ b/commands/claude/update.md @@ -1,13 +1,13 @@ --- -description: Update Rune's runtime binaries (rune-mcp, runed) in place to the published manifest versions +description: Update Rune's runtime binaries (rune-mcp, runed, the rune CLI itself) in place to the published manifest versions allowed-tools: Bash(~/.rune/bin/rune update:*), Bash(${CLAUDE_PLUGIN_ROOT}/bin/rune update:*) --- # /rune:update — Update Runtime Binaries -Refresh the runtime binaries (rune-mcp, runed) in place — no plugin reinstall. -Non-destructive; works whether Rune is active or dormant. The user never runs -`rune update` themselves — the agent runs it. +Refresh the runtime binaries (rune-mcp, runed, and the rune CLI itself) in +place — no plugin reinstall. Non-destructive; works whether Rune is active or +dormant. The user never runs `rune update` themselves — the agent runs it. ## Run @@ -31,6 +31,8 @@ The two that need a nudge beyond what it prints: `/mcp` to pick it up immediately. - `updated runed: … (staged; applies on next daemon start)` → run `/rune:activate` to start the daemon on the new binary now. +- `updated rune_cli: … (applies on the next rune invocation)` → nothing to do; + the next `rune` command already runs the new binary. (`daemon reloaded`, `all binaries are up to date`, `refusing to apply …`, and the plugin-version notes are self-explanatory — relay them as-is; the refusal diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go index d347420..ae1dfb6 100644 --- a/internal/bootstrap/install_test.go +++ b/internal/bootstrap/install_test.go @@ -19,8 +19,13 @@ type installFixture struct { runedSHA string mcpSHA string + // CLI self-update artifact; the manifest advertises it only while + // cliVersion is set (assign after newFixture, like mismatchStep) + cli []byte + cliVersion string + // Override SHA in the manifest to simulate a checksum mismatch - mismatchStep string // "" | StepRuned | StepRuneMCP + mismatchStep string // "" | StepRuned | StepRuneMCP | StepRuneCLI hits map[string]int } @@ -39,24 +44,30 @@ func newFixture(t *testing.T) *installFixture { mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { runedSHA := fx.runedSHA mcpSHA := fx.mcpSHA + cliSHA := sha256Hex(fx.cli) switch fx.mismatchStep { case StepRuned: runedSHA = "00" + runedSHA[2:] case StepRuneMCP: mcpSHA = "00" + mcpSHA[2:] + case StepRuneCLI: + cliSHA = "00" + cliSHA[2:] } + plat := map[string]any{ + "runed": map[string]any{"url": fx.srv.URL + "/runed", "sha256": runedSHA, "size": len(fx.runed)}, + "rune_mcp": map[string]any{"url": fx.srv.URL + "/rune-mcp", "sha256": mcpSHA, "size": len(fx.runeMCP)}, + } manifest := map[string]any{ "version": 1, "rune_mcp_version": "v0.1.0-test", "runed_version": "v0.1.0-test", - "platforms": map[string]any{ - PlatformTuple(): map[string]any{ - "runed": map[string]any{"url": fx.srv.URL + "/runed", "sha256": runedSHA, "size": len(fx.runed)}, - "rune_mcp": map[string]any{"url": fx.srv.URL + "/rune-mcp", "sha256": mcpSHA, "size": len(fx.runeMCP)}, - }, - }, + "platforms": map[string]any{PlatformTuple(): plat}, + } + if fx.cliVersion != "" { + manifest["cli_version"] = fx.cliVersion + plat["rune_cli"] = map[string]any{"url": fx.srv.URL + "/rune-cli", "sha256": cliSHA, "size": len(fx.cli)} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(manifest) @@ -72,6 +83,12 @@ func newFixture(t *testing.T) *installFixture { mux.HandleFunc("/runed", serveBinary("/runed", fx.runed)) mux.HandleFunc("/rune-mcp", serveBinary("/rune-mcp", fx.runeMCP)) + // fx.cli is assigned after newFixture, so serve it per request + mux.HandleFunc("/rune-cli", func(w http.ResponseWriter, r *http.Request) { + fx.hits["/rune-cli"]++ + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(fx.cli))) + _, _ = w.Write(fx.cli) + }) fx.srv = httptest.NewServer(mux) t.Cleanup(fx.srv.Close) diff --git a/internal/bootstrap/manifest_test.go b/internal/bootstrap/manifest_test.go index 972efbb..9f57029 100644 --- a/internal/bootstrap/manifest_test.go +++ b/internal/bootstrap/manifest_test.go @@ -110,15 +110,20 @@ func TestFetchManifest_RejectsEmptyPlatforms(t *testing.T) { } } -func TestFetchManifest_RejectsUnknownFields(t *testing.T) { +// Unknown fields must be tolerated: deployed binaries poll the shared +// "latest" channel, whose manifest gains fields over time. +func TestFetchManifest_ToleratesUnknownFields(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(fullManifestJSON(`, "extra": "field"`))) + _, _ = w.Write([]byte(fullManifestJSON(`, "future_field": "ignored"`))) })) defer srv.Close() - _, err := FetchManifest(context.Background(), srv.URL, nil) - if err == nil || !strings.Contains(err.Error(), "unknown field") { - t.Errorf("want unknown-field rejection, got %v", err) + m, err := FetchManifest(context.Background(), srv.URL, nil) + if err != nil { + t.Fatalf("unknown fields must not fail the parse: %v", err) + } + if m.RuneMCPVersion != "v0.1.0" { + t.Errorf("known fields must still decode: %+v", m) } } diff --git a/internal/bootstrap/update_test.go b/internal/bootstrap/update_test.go index b313c65..15f9780 100644 --- a/internal/bootstrap/update_test.go +++ b/internal/bootstrap/update_test.go @@ -36,7 +36,7 @@ func TestPlanUpdate(t *testing.T) { t.Run("outdated when versions differ", func(t *testing.T) { installed := &InstalledManifest{RuneMCPVersion: "v0.1.0", RunedVersion: "v0.2.0"} - plan := planUpdate(installed, manifest) + plan := planUpdate(installed, manifest, "") if !plan.HasUpdates() { t.Fatal("expected an update") } @@ -52,7 +52,7 @@ func TestPlanUpdate(t *testing.T) { t.Run("not outdated when v-prefix and build metadata", func(t *testing.T) { installed := &InstalledManifest{RuneMCPVersion: "0.2.0+build.3", RunedVersion: "v0.2.0"} - if planUpdate(installed, manifest).HasUpdates() { + if planUpdate(installed, manifest, "").HasUpdates() { t.Error("v-prefix / build-metadata differences must not count as update") } }) @@ -60,20 +60,20 @@ func TestPlanUpdate(t *testing.T) { t.Run("prerelease difference is update", func(t *testing.T) { m := &Manifest{Version: 1, RuneMCPVersion: "v0.2.0-alpha.5", RunedVersion: "v0.2.0"} installed := &InstalledManifest{RuneMCPVersion: "v0.2.0-alpha.4", RunedVersion: "v0.2.0"} - if !planUpdate(installed, m).HasUpdates() { + if !planUpdate(installed, m, "").HasUpdates() { t.Error("pre-release tags must count as update") } }) t.Run("unknown installed is not flagged", func(t *testing.T) { - if planUpdate(nil, manifest).HasUpdates() { + if planUpdate(nil, manifest, "").HasUpdates() { t.Error("unknown installed versions must not be flagged") } }) t.Run("empty installed version is not flagged", func(t *testing.T) { installed := &InstalledManifest{} // blank version - if planUpdate(installed, manifest).HasUpdates() { + if planUpdate(installed, manifest, "").HasUpdates() { t.Error("blank installed version is unknown, not outdated") } }) @@ -81,18 +81,62 @@ func TestPlanUpdate(t *testing.T) { t.Run("empty manifest version gives nothing", func(t *testing.T) { m := &Manifest{Version: 1} installed := &InstalledManifest{RuneMCPVersion: "v0.1.0", RunedVersion: "v0.1.0"} - if planUpdate(installed, m).HasUpdates() { + if planUpdate(installed, m, "").HasUpdates() { t.Error("empty manifest versions must not be flagged") } }) t.Run("nil manifest (no panic, no updates)", func(t *testing.T) { installed := &InstalledManifest{RuneMCPVersion: "v0.1.0", RunedVersion: "v0.1.0"} - plan := planUpdate(installed, nil) + plan := planUpdate(installed, nil, "") if plan.HasUpdates() || len(plan.Artifacts) != 0 { t.Errorf("nil manifest should be empty plan, got %+v", plan) } }) + + t.Run("cli outdated when channel is newer", func(t *testing.T) { + m := &Manifest{Version: 1, CLIVersion: "v0.5.0"} + out := planUpdate(nil, m, "v0.4.1").Outdated() + if len(out) != 1 || out[0].Step != StepRuneCLI { + t.Fatalf("expected only rune_cli outdated, got %+v", out) + } + if out[0].Installed != "v0.4.1" || out[0].Available != "v0.5.0" { + t.Errorf("version fields wrong: %+v", out[0]) + } + }) + + t.Run("cli equal (v-prefix aside) is not outdated", func(t *testing.T) { + m := &Manifest{Version: 1, CLIVersion: "v0.5.0"} + if planUpdate(nil, m, "0.5.0").HasUpdates() { + t.Error("equal CLI versions must not count as update") + } + }) + + t.Run("cli newer than channel is not a downgrade", func(t *testing.T) { + // Unlike runed/rune_mcp (inequality), the CLI check is ordered: the + // shared latest channel lagging a fresh binary must not roll it back. + m := &Manifest{Version: 1, CLIVersion: "v0.5.0"} + if planUpdate(nil, m, "v0.6.0").HasUpdates() { + t.Error("older channel CLI must not be offered as update") + } + }) + + t.Run("cli dev prerelease is older than its release", func(t *testing.T) { + m := &Manifest{Version: 1, CLIVersion: "v0.5.0"} + if !planUpdate(nil, m, "v0.5.0-dev").HasUpdates() { + t.Error("prerelease build must count as older than the release") + } + }) + + t.Run("cli skipped without cli_version or running version", func(t *testing.T) { + if planUpdate(nil, manifest, "v0.4.1").HasUpdates() { + t.Error("manifest without cli_version must not flag the CLI") + } + m := &Manifest{Version: 1, CLIVersion: "v9.9.9"} + if planUpdate(nil, m, "").HasUpdates() { + t.Error("unknown running version must not be flagged") + } + }) } func TestCheckUpdate(t *testing.T) { @@ -118,7 +162,7 @@ func TestCheckUpdate(t *testing.T) { t.Fatalf("WriteInstalledManifest: %v", err) } - plan, err := CheckUpdate(context.Background(), fx.manifestURL(), nil) + plan, err := CheckUpdate(context.Background(), fx.manifestURL(), "", nil) if err != nil { t.Fatalf("CheckUpdate: %v", err) } @@ -137,7 +181,7 @@ func TestCheckUpdate_NotInstalled(t *testing.T) { t.Setenv("RUNE_MANIFEST", "") fx := newFixture(t) - plan, err := CheckUpdate(context.Background(), fx.manifestURL(), nil) + plan, err := CheckUpdate(context.Background(), fx.manifestURL(), "", nil) if err != nil { t.Fatalf("CheckUpdate: %v", err) } @@ -202,7 +246,7 @@ func TestUpdateArtifact_UpdateSingleArtifact(t *testing.T) { } // Check after update - plan, err := CheckUpdate(context.Background(), fx.manifestURL(), nil) + plan, err := CheckUpdate(context.Background(), fx.manifestURL(), "", nil) if err != nil { t.Fatalf("CheckUpdate: %v", err) } @@ -217,6 +261,140 @@ func TestUpdateArtifact_UnknownArtifact(t *testing.T) { } } +func TestUpdateCLI_SwapsCanonicalBinary(t *testing.T) { + rune, _ := setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fx := newFixture(t) + fx.cli = []byte("rune-cli-binary-v9") + fx.cliVersion = "v9.9.9" + + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + // Simulate the currently installed CLI + if err := os.WriteFile(paths.RuneCLIBinary, []byte("current-cli"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := UpdateCLI(context.Background(), fx.manifestURL(), "v1.0.0", nil) + if err != nil { + t.Fatalf("UpdateCLI: %v", err) + } + if got != "v9.9.9" { + t.Errorf("returned version = %q, want v9.9.9", got) + } + if b, _ := os.ReadFile(filepath.Join(rune, "bin", "rune")); string(b) != string(fx.cli) { + t.Errorf("CLI not swapped on disk: got %q", b) + } + + after, err := ReadInstalledManifest(paths) + if err != nil { + t.Fatalf("ReadInstalledManifest: %v", err) + } + if after.Artifacts[StepRuneCLI].Path != paths.RuneCLIBinary { + t.Errorf("audit entry missing or wrong path: %+v", after.Artifacts[StepRuneCLI]) + } +} + +// The channel can move between plan time and apply time; UpdateCLI must +// re-check rather than trust the plan. +func TestUpdateCLI_RefusesNonNewerChannel(t *testing.T) { + setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fx := newFixture(t) + fx.cli = []byte("older-cli-bytes") + fx.cliVersion = "v1.0.0" + + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + if err := os.WriteFile(paths.RuneCLIBinary, []byte("current-cli"), 0o755); err != nil { + t.Fatal(err) + } + + // Running v1.1.0 while the channel serves v1.0.0 (demoted mid-run) + _, err = UpdateCLI(context.Background(), fx.manifestURL(), "v1.1.0", nil) + if !errors.Is(err, ErrCLIOutdated) { + t.Fatalf("want ErrCLIOutdated, got %v", err) + } + if b, _ := os.ReadFile(paths.RuneCLIBinary); string(b) != "current-cli" { + t.Errorf("must not swap an older CLI over the running one: got %q", b) + } + + // Equal versions are likewise nothing to do + if _, err := UpdateCLI(context.Background(), fx.manifestURL(), "v1.0.0", nil); !errors.Is(err, ErrCLIOutdated) { + t.Errorf("equal version: want ErrCLIOutdated, got %v", err) + } +} + +func TestChannelBehind(t *testing.T) { + cases := []struct { + name string + cliVersion string + manifest *Manifest + want bool + }{ + {"channel older than build", "v1.0.0", &Manifest{CLIVersion: "v0.9.0"}, true}, + {"channel predates self-update", "v1.0.0", &Manifest{}, true}, + {"channel equal", "v1.0.0", &Manifest{CLIVersion: "v1.0.0"}, false}, + {"channel newer", "v1.0.0", &Manifest{CLIVersion: "v1.1.0"}, false}, + {"unknown running version", "", &Manifest{}, false}, + {"nil manifest", "v1.0.0", nil, false}, + } + for _, c := range cases { + if got := ChannelBehind(c.manifest, c.cliVersion); got != c.want { + t.Errorf("%s: ChannelBehind = %v, want %v", c.name, got, c.want) + } + } +} + +func TestUpdateCLI_NoCLIInManifest(t *testing.T) { + setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fx := newFixture(t) // fixture without cliVersion omits cli_version + + _, err := UpdateCLI(context.Background(), fx.manifestURL(), "v1.0.0", nil) + if err == nil || !strings.Contains(err.Error(), "cli_version") { + t.Fatalf("want no-cli_version error, got %v", err) + } +} + +func TestUpdateCLI_ChecksumMismatchDoesNotSwap(t *testing.T) { + setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fastRetry(t) + fx := newFixture(t) + fx.cli = []byte("good-cli-bytes") + fx.cliVersion = "v9.9.9" + fx.mismatchStep = StepRuneCLI + + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + if err := os.WriteFile(paths.RuneCLIBinary, []byte("current-cli"), 0o755); err != nil { + t.Fatal(err) + } + + if _, err := UpdateCLI(context.Background(), fx.manifestURL(), "v1.0.0", nil); err == nil { + t.Fatal("expected checksum mismatch error") + } + if b, _ := os.ReadFile(paths.RuneCLIBinary); string(b) != "current-cli" { + t.Errorf("binary must not swap on checksum mismatch: got %q", b) + } +} + func outdatedRuneMCP(t *testing.T) (*Paths, string) { t.Helper() setRealms(t) From 8c3ae846e99aace7b04e33119fdf5453e78b41fe Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 17 Jul 2026 11:00:53 +0000 Subject: [PATCH 11/12] fix: fix go.mod to refer 'rune' rather than 'rune-cli' --- cmd/rune/install.go | 2 +- cmd/rune/install_test.go | 2 +- cmd/rune/launch.go | 2 +- cmd/rune/main_test.go | 2 +- cmd/rune/mcpserver.go | 2 +- cmd/rune/mcpserver_test.go | 2 +- cmd/rune/runed.go | 4 ++-- cmd/rune/runed_test.go | 2 +- cmd/rune/update.go | 4 ++-- cmd/rune/update_test.go | 2 +- cmd/rune/verify.go | 2 +- cmd/rune/version.go | 2 +- go.mod | 4 ++-- 13 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cmd/rune/install.go b/cmd/rune/install.go index d7fabe6..bd07ffd 100644 --- a/cmd/rune/install.go +++ b/cmd/rune/install.go @@ -8,7 +8,7 @@ import ( "io" "os" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func runInstall(ctx context.Context, args []string, stdout, stderr io.Writer) int { diff --git a/cmd/rune/install_test.go b/cmd/rune/install_test.go index 6b47f1c..57c5827 100644 --- a/cmd/rune/install_test.go +++ b/cmd/rune/install_test.go @@ -12,7 +12,7 @@ import ( "path/filepath" "testing" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func TestRunInstall_JSONHappyPath(t *testing.T) { diff --git a/cmd/rune/launch.go b/cmd/rune/launch.go index 7991708..8856e8b 100644 --- a/cmd/rune/launch.go +++ b/cmd/rune/launch.go @@ -10,7 +10,7 @@ import ( "syscall" "time" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) const gracefulShutdownGrace = 5 * time.Second diff --git a/cmd/rune/main_test.go b/cmd/rune/main_test.go index a3b439f..fb36b01 100644 --- a/cmd/rune/main_test.go +++ b/cmd/rune/main_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func TestRunVersion_PrintConstants(t *testing.T) { diff --git a/cmd/rune/mcpserver.go b/cmd/rune/mcpserver.go index e0f42b7..e8d17bc 100644 --- a/cmd/rune/mcpserver.go +++ b/cmd/rune/mcpserver.go @@ -10,7 +10,7 @@ import ( "path/filepath" "time" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) const mcpSelfhealBudget = 25 * time.Second // (download:25s + (exec + MCP handshake):5s) < plugin manifest 30s MCP connection timeout diff --git a/cmd/rune/mcpserver_test.go b/cmd/rune/mcpserver_test.go index 59957de..d2a5636 100644 --- a/cmd/rune/mcpserver_test.go +++ b/cmd/rune/mcpserver_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func TestRunMCPServer_InstallErrorFailFast(t *testing.T) { diff --git a/cmd/rune/runed.go b/cmd/rune/runed.go index 9e959b5..0c73376 100644 --- a/cmd/rune/runed.go +++ b/cmd/rune/runed.go @@ -6,8 +6,8 @@ import ( "io" "os" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" - "github.com/CryptoLabInc/rune-cli/internal/supervisor" + "github.com/CryptoLabInc/rune/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/supervisor" ) // runRuned dispatches `rune runed [args...]`: diff --git a/cmd/rune/runed_test.go b/cmd/rune/runed_test.go index c164d96..30b7ea0 100644 --- a/cmd/rune/runed_test.go +++ b/cmd/rune/runed_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func TestExtractDetachFlag(t *testing.T) { diff --git a/cmd/rune/update.go b/cmd/rune/update.go index b378ba6..8dbd853 100644 --- a/cmd/rune/update.go +++ b/cmd/rune/update.go @@ -10,8 +10,8 @@ import ( "os" "strings" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" - "github.com/CryptoLabInc/rune-cli/internal/supervisor" + "github.com/CryptoLabInc/rune/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/supervisor" ) func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int { diff --git a/cmd/rune/update_test.go b/cmd/rune/update_test.go index 2d960e6..9b5af60 100644 --- a/cmd/rune/update_test.go +++ b/cmd/rune/update_test.go @@ -15,7 +15,7 @@ import ( "strings" "testing" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func updateManifestServer(t *testing.T, runeMCPVer, runedVer string) string { diff --git a/cmd/rune/verify.go b/cmd/rune/verify.go index e84f382..ccd3c9d 100644 --- a/cmd/rune/verify.go +++ b/cmd/rune/verify.go @@ -7,7 +7,7 @@ import ( "fmt" "io" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func runVerify(ctx context.Context, args []string, stdout, stderr io.Writer) int { diff --git a/cmd/rune/version.go b/cmd/rune/version.go index 9c21a56..65fd990 100644 --- a/cmd/rune/version.go +++ b/cmd/rune/version.go @@ -5,7 +5,7 @@ import ( "io" "os" - "github.com/CryptoLabInc/rune-cli/internal/bootstrap" + "github.com/CryptoLabInc/rune/internal/bootstrap" ) func runVersion(w io.Writer) int { diff --git a/go.mod b/go.mod index f82b5db..a34983a 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module github.com/CryptoLabInc/rune-cli +module github.com/CryptoLabInc/rune go 1.26.2 -require golang.org/x/sys v0.45.0 // indirect +require golang.org/x/sys v0.45.0 From abeb22ef43559c6ca5e6a07b4d6acc03739f39c0 Mon Sep 17 00:00:00 2001 From: shpark Date: Mon, 20 Jul 2026 10:22:24 +0000 Subject: [PATCH 12/12] chore: bump up plugin version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d0bdfbe..5850018 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cryptolab/rune", - "version": "0.4.0", + "version": "1.0.0", "description": "FHE-encrypted organizational memory for teams", "private": true, "license": "Apache-2.0",