diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3cf51..96697e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ --- -latest_version: 3.2.16 +latest_version: 3.2.17 released: 2026-05-29 --- @@ -12,6 +12,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [3.2.17] — 2026-05-29 — `onebrain update`: refresh Homebrew tap before upgrade + dedicated npm channel + +- **Fix: `onebrain update` on a Homebrew install now refreshes the `onebrain-ai/onebrain` tap before `brew upgrade`.** `brew upgrade` does not fetch new formulae, so running `onebrain update` right after a release found a stale local formula, no-op'd ("already installed"), and the post-install version guard then reported the upgrade "may not have taken effect" (a confusing false-ish failure that forced a manual `brew update && brew upgrade onebrain`). The fix git-pulls **only** our tap (not a full `brew update` — stays fast) so the freshly-published formula is visible and the upgrade applies in one `onebrain update`. Best-effort + non-fatal: a refresh failure falls through to `brew upgrade` exactly as before. +- **Feat: `onebrain update` now has a dedicated npm channel.** A binary installed via `npm i -g @onebrain-ai/cli` previously fell through to the Direct path, which swaps the file in place and desyncs npm's metadata (the same divergence the Homebrew path avoids). It's now detected by the `@onebrain-ai` `node_modules` scope and updated via `npm install -g @onebrain-ai/cli@`. This completes the "delegate to the package manager, never swap a managed binary" model across all three channels — **Homebrew · npm · Direct download**. + ## [3.2.16] — 2026-05-29 — plugin-cache doctor check + orphan cleanup + post-update reload hint - **Fix: stale plugin-cache orphans no longer silently shadow the vault-local plugin.** A leftover `~/.claude/plugins/cache//onebrain//` (orphaned by a marketplace install that predated a vault-local update) could make Claude Code load OLD skills while `INSTRUCTIONS.md` came from the current copy. vault-sync Step 9 already pruned these during a sync; this adds proactive detection so an orphan created outside an update is caught on the next `doctor` run. diff --git a/Cargo.lock b/Cargo.lock index 2292101..532c66a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,7 +1166,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onebrain-cache" -version = "3.2.16" +version = "3.2.17" dependencies = [ "chrono", "onebrain-core", @@ -1179,7 +1179,7 @@ dependencies = [ [[package]] name = "onebrain-cli" -version = "3.2.16" +version = "3.2.17" dependencies = [ "anyhow", "assert_cmd", @@ -1208,7 +1208,7 @@ dependencies = [ [[package]] name = "onebrain-core" -version = "3.2.16" +version = "3.2.17" dependencies = [ "chrono", "indexmap", @@ -1224,7 +1224,7 @@ dependencies = [ [[package]] name = "onebrain-fs" -version = "3.2.16" +version = "3.2.17" dependencies = [ "chrono", "dirs", diff --git a/Cargo.toml b/Cargo.toml index d58bfa4..d2e7ec2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/*"] resolver = "2" [workspace.package] -version = "3.2.16" +version = "3.2.17" edition = "2021" license = "AGPL-3.0-only" authors = ["OneBrain Contributors"] diff --git a/README.md b/README.md index bbe2689..39acf5b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The CLI is **cross-harness**: paired with the [OneBrain plugin](https://github.c Point an AI agent at a vault and it improvises — a different pile of `grep` / `ls` / `find` / `sed` each time, behaving differently on each harness and re-derived every session: slow, token-hungry, non-portable, sometimes wrong. **OneBrain CLI replaces that improvisation with one deterministic binary.** - **Same behavior on every harness & model** — Claude Code and Gemini CLI both run `onebrain ` and get identical output; switch harness without re-testing how your vault gets touched. +- **Cross-platform, one command** — the *same* `onebrain ` runs on macOS, Linux, and Windows (Apple Silicon & Intel, x86_64 & ARM down to a Pi Zero) and returns the *same* typed result on every OS. Write a hook or script once; it behaves identically everywhere — no per-platform shell quirks (`sed`/`find`/path-separator differences) to work around. - **Yours to extend, no waiting** — add a capability the harness/LLM doesn't have yet and every agent can use it immediately; they only learn the command, not implement the feature. - **No re-deriving solved workflows** — search, capture, consolidate, checkpoint live in the binary, so the agent calls one command instead of re-reasoning the recipe each session. Fewer tokens, no drift. - **Deterministic & safe** — a typed command with a frozen `Envelope` can't half-finish or quietly differ like an ad-hoc `rm` / `sed` pipeline. Same input → same output, scriptable by hooks. @@ -61,7 +62,7 @@ brew install onebrain-ai/onebrain/onebrain # 2. Verify onebrain --version -# → onebrain 3.1.5 +# → onebrain 3.2.17 # 3. Scaffold a vault and let init pull the OneBrain plugin mkdir my-vault && cd my-vault @@ -122,9 +123,13 @@ onebrain update --plan # machine-readable JSON plan On an interactive terminal, `update` shows a framed `🧠 OneBrain Update` header and a braille spinner while it checks for (and downloads) a new version; piped / `--json` / `--plan` runs stay plain. -The install path resolves the current target triple at runtime, downloads the matching GitHub Release tarball over HTTPS (rustls TLS), and atomically swaps the running binary (Unix single-rename; Windows rustup-style two-step with rollback on failure). No package-manager middleware. +`onebrain update` auto-detects how the binary was installed and uses the right path so package-manager metadata never desyncs: -> **Homebrew users:** since v3.1.4, `onebrain update` auto-detects a brew-managed install (binary under the Cellar) and delegates to `brew upgrade onebrain`, so it stays in sync with brew's metadata — no manual step needed. +- **Homebrew** (binary under the Cellar) — refreshes the `onebrain-ai/onebrain` tap, then runs `brew upgrade onebrain`. The tap refresh (added v3.2.17) means a freshly-released version applies in one `onebrain update` with no manual `brew update`. +- **npm** (under `node_modules/@onebrain-ai/`) — runs `npm install -g @onebrain-ai/cli@` (added v3.2.17). +- **Direct download** (a plain file we own) — resolves the current target triple, downloads the matching GitHub Release tarball over HTTPS (rustls TLS), verifies its SHA-256, and atomically swaps the running binary (Unix single-rename; Windows rustup-style two-step with rollback on failure). + +After any path, a post-install guard runs `onebrain --version` from PATH and confirms the upgrade actually took effect. ### Build from source diff --git a/crates/onebrain-fs/src/update/install.rs b/crates/onebrain-fs/src/update/install.rs index 1521ce8..c3fa497 100644 --- a/crates/onebrain-fs/src/update/install.rs +++ b/crates/onebrain-fs/src/update/install.rs @@ -432,6 +432,11 @@ fn download_text(url: &str) -> Result { pub(crate) enum InstallChannel { /// Homebrew-managed: lives in the Cellar behind a `brew` symlink. Homebrew, + /// npm-managed: the `@onebrain-ai/cli` global package — the binary lives + /// under `node_modules/@onebrain-ai/cli`. Hand off to `npm install -g` so + /// npm's metadata stays in sync; swapping the file in place would desync it + /// (the same divergence the Homebrew path avoids). + Npm, /// Direct download / `cargo-binstall` / manual — a file we own and can /// safely swap in place. Direct, @@ -460,20 +465,69 @@ pub(crate) fn detect_install_channel(current_exe: &Path) -> InstallChannel { /// never matches and always resolves to `Direct` (the Windows update path is /// unwired anyway — see `AssetInfo::extract_binary`). fn classify_path(resolved: &Path) -> InstallChannel { - if resolved.to_string_lossy().contains("/Cellar/onebrain/") { + let s = resolved.to_string_lossy(); + if s.contains("/Cellar/onebrain/") { InstallChannel::Homebrew + } else if s.contains("/node_modules/@onebrain-ai/") { + // The npm wrapper's native binary canonicalizes to + // `/lib/node_modules/@onebrain-ai/cli/bin/onebrain`; pnpm/yarn + // and `bun add -g` nest it under `.../node_modules/@onebrain-ai/cli` + // too (bun via its bin symlink). The scoped `@onebrain-ai` segment is + // the precise signal — a bare `node_modules` check would over-match + // unrelated binaries. Unix path separator: Windows npm globals use + // backslashes and fall through to Direct (Windows update is unwired — + // see `AssetInfo::extract_binary`). + InstallChannel::Npm } else { InstallChannel::Direct } } -/// Delegate the update to Homebrew. `brew upgrade onebrain` refreshes the tap -/// formula and is idempotent (a no-op when already current); stdio is -/// inherited so the user sees brew's own output. We never swap a Cellar -/// binary in place — that would leave brew's metadata pointing at a version -/// it no longer manages. +/// The Homebrew tap that ships the `onebrain` formula. +const ONEBRAIN_TAP: &str = "onebrain-ai/onebrain"; + +/// Best-effort, quiet refresh of the `onebrain` Homebrew tap so a +/// freshly-published formula is visible to `brew upgrade`. +/// +/// `brew upgrade` does NOT fetch new formulae — that's `brew update`, which +/// refreshes EVERY tap and can be slow. We scope the refresh to just our tap by +/// git-pulling its checkout (resolved via `brew --repository `). All +/// failures are swallowed: this is a convenience that removes the need for a +/// manual `brew update`, never a hard requirement — `brew_upgrade` proceeds and +/// the post-install version guard still catches a genuine no-op. +fn refresh_onebrain_tap() { + use std::process::Command; + let Ok(out) = Command::new("brew") + .args(["--repository", ONEBRAIN_TAP]) + .output() + else { + return; + }; + if !out.status.success() { + return; + } + let tap_dir = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if tap_dir.is_empty() { + return; + } + // `--ff-only` keeps it safe (never a merge commit on a read-only tap); + // `--quiet` keeps the framed update report clean. + let _ = Command::new("git") + .args(["-C", &tap_dir, "pull", "--ff-only", "--quiet"]) + .status(); +} + +/// Delegate the update to Homebrew. We refresh the onebrain tap FIRST (see +/// [`refresh_onebrain_tap`]) so `brew upgrade` sees a just-published formula — +/// without it, running `onebrain update` right after a release found a stale +/// local formula, no-op'd ("already installed"), and the post-install version +/// guard then flagged the mismatch. `brew upgrade` itself is idempotent (a +/// no-op when already current); stdio is inherited so the user sees brew's own +/// output. We never swap a Cellar binary in place — that would leave brew's +/// metadata pointing at a version it no longer manages. pub(crate) fn brew_upgrade() -> Result<(), UpdateError> { use std::process::Command; + refresh_onebrain_tap(); let status = Command::new("brew") .args(["upgrade", "onebrain"]) .status() @@ -494,6 +548,52 @@ pub(crate) fn brew_upgrade() -> Result<(), UpdateError> { } } +/// Build the npm install spec for a target version. Pins to the exact version +/// the update flow resolved (not `@latest`) so the install matches what was +/// already decided, and strips any leading `v` — npm specs are bare semver, so +/// an unstripped tag would yield `@onebrain-ai/cli@v3.2.17`, which npm rejects. +/// Extracted as a pure fn because it's the one silently-breakable bit of +/// [`npm_update`] (the rest just shells out). +fn npm_spec(version: &str) -> String { + format!("@onebrain-ai/cli@{}", version.trim_start_matches('v')) +} + +/// Delegate the update to npm for the `@onebrain-ai/cli` global package. +/// `npm install -g @onebrain-ai/cli@` re-runs the wrapper's binary +/// download and keeps npm's metadata in sync. We never swap the file in place — +/// that would desync npm (the same divergence the Homebrew path avoids). stdio +/// is inherited so the user sees npm's own progress. +/// +/// `npm` is resolved from the ambient PATH, so the install targets whatever +/// node prefix is active in this process (relevant under nvm / Volta / fnm — a +/// different active node could install into a different prefix than the running +/// binary, surfacing as a no-op caught by the post-install version guard, not a +/// corruption). Bun users (`bun add -g`) canonicalize into this same Npm arm +/// via their `node_modules` symlink, but would want `bun add -g` instead — Bun +/// is not a v3 canonical install channel, so the npm path + guard is the +/// safe-enough fallback. +pub(crate) fn npm_update(version: &str) -> Result<(), UpdateError> { + use std::process::Command; + let spec = npm_spec(version); + let status = Command::new("npm") + .args(["install", "-g", &spec]) + .status() + .map_err(|e| { + UpdateError::Install(format!( + "npm install detected but `npm` is not runnable ({e}). \ + Run `npm install -g {spec}` manually." + )) + })?; + if status.success() { + Ok(()) + } else { + Err(UpdateError::Install(format!( + "`npm install -g {spec}` failed (exit {}).", + status.code().unwrap_or(-1) + ))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -675,4 +775,45 @@ mod tests { InstallChannel::Direct ); } + + #[test] + fn classify_path_detects_npm_global() { + use std::path::Path; + // npm global: the native binary canonicalizes to the package's `bin/` + // (per the wrapper's postinstall) — the REAL production path. + assert_eq!( + classify_path(Path::new( + "/opt/homebrew/lib/node_modules/@onebrain-ai/cli/bin/onebrain" + )), + InstallChannel::Npm + ); + // system-node npm prefix layout, same scope. + assert_eq!( + classify_path(Path::new( + "/usr/local/lib/node_modules/@onebrain-ai/cli/bin/onebrain" + )), + InstallChannel::Npm + ); + // pnpm nests it under the @onebrain-ai scope too. + assert_eq!( + classify_path(Path::new( + "/Users/x/Library/pnpm/global/5/.pnpm/@onebrain-ai+cli@3.2.17/node_modules/@onebrain-ai/cli/onebrain" + )), + InstallChannel::Npm + ); + // An UNSCOPED node_modules binary must NOT match — the `@onebrain-ai` + // scope is required, so a bare node_modules path stays Direct. + assert_eq!( + classify_path(Path::new("/proj/node_modules/.bin/onebrain")), + InstallChannel::Direct + ); + } + + #[test] + fn npm_spec_strips_v_prefix() { + // The one silently-breakable bit of npm_update: a stray `v` would yield + // `@onebrain-ai/cli@v3.2.17`, which npm rejects. + assert_eq!(npm_spec("3.2.17"), "@onebrain-ai/cli@3.2.17"); + assert_eq!(npm_spec("v3.2.17"), "@onebrain-ai/cli@3.2.17"); + } } diff --git a/crates/onebrain-fs/src/update/mod.rs b/crates/onebrain-fs/src/update/mod.rs index ce942b3..46306b7 100644 --- a/crates/onebrain-fs/src/update/mod.rs +++ b/crates/onebrain-fs/src/update/mod.rs @@ -389,10 +389,12 @@ pub fn default_install_binary(version: &str) -> Result<(), UpdateError> { let current_exe = std::env::current_exe() .map_err(|e| UpdateError::Install(format!("could not resolve current binary path: {e}")))?; match install::detect_install_channel(¤t_exe) { - // Homebrew-managed installs live in the Cellar behind a `brew` symlink. - // Swapping that file in place would desync brew's metadata from disk - // (the dual-install divergence), so hand off to `brew upgrade` instead. + // Homebrew- and npm-managed installs live behind their package + // manager's metadata; swapping the file in place would desync it (the + // dual-install divergence). Hand off to the package manager instead. + // Only a Direct install is a plain file we own and can safely swap. install::InstallChannel::Homebrew => install::brew_upgrade(), + install::InstallChannel::Npm => install::npm_update(version), install::InstallChannel::Direct => install::fetch_and_swap_binary(version, ¤t_exe), } }