diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6e2201b..877db660 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,92 +221,40 @@ jobs: rt-darwin-arm64-*.tar.gz rt-darwin-x64-*.tar.gz - # ── Update Homebrew formula ────────────────────────────────────────────── - - name: Update Homebrew tap - env: - TAP_TOKEN: ${{ secrets.TAP_TOKEN }} - run: | - VERSION="${GITHUB_REF_NAME}" - VERSION_NUM="${VERSION#v}" - SHA_ARM=$(shasum -a 256 rt-darwin-arm64-${VERSION}.tar.gz | cut -d' ' -f1) - SHA_X64=$(shasum -a 256 rt-darwin-x64-${VERSION}.tar.gz | cut -d' ' -f1) - - git clone "https://x-access-token:${TAP_TOKEN}@github.com/m4ttheweric/homebrew-tap.git" /tmp/homebrew-tap - cd /tmp/homebrew-tap - - cat > Formula/rt.rb << FORMULA - class Rt < Formula - desc "Developer CLI for branch management, service runner, daemon, and notifications" - homepage "https://github.com/m4ttstack/rt" - version "${VERSION_NUM}" - license "MIT" - - on_macos do - on_arm do - url "https://github.com/m4ttstack/rt/releases/download/${VERSION}/rt-darwin-arm64-${VERSION}.tar.gz" - sha256 "${SHA_ARM}" - end - on_intel do - url "https://github.com/m4ttstack/rt/releases/download/${VERSION}/rt-darwin-x64-${VERSION}.tar.gz" - sha256 "${SHA_X64}" - end - end - - depends_on :macos - depends_on "fzf" - depends_on "tmux" - - def install - bin.install "rt" - prefix.install "mattstack.app" - prefix.install "rt-context.vsix" - end - - # No post_install hook: Homebrew runs hooks in a sandbox that can't - # write to ~/Applications, ~/.rt, or shell rc files. Setup is handled - # by the binary on first invocation (or via \`rt verify\`). - - def caveats - <<~EOS - Complete setup + verify the install: - rt verify - - Then configure your API tokens: - rt settings linear token - rt settings gitlab token - EOS - end - end - FORMULA - - # Dedent the heredoc (remove leading spaces) - sed -i '' 's/^ //' Formula/rt.rb - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/rt.rb - git commit -m "rt ${VERSION}" - git push - # ── Post-release install test ───────────────────────────────────────────── + # Installs from the published tarball on a fresh runner, the same artifact + # mattstack.app's installer and `rt update` consume. test-install: needs: release runs-on: macos-latest steps: - - name: Install from tap + - name: Download release tarball + env: + GH_TOKEN: ${{ github.token }} run: | - brew tap m4ttheweric/tap - brew install m4ttheweric/tap/rt + gh release download "${GITHUB_REF_NAME}" -R "${GITHUB_REPOSITORY}" \ + -p "rt-darwin-arm64-*.tar.gz" -D "$RUNNER_TEMP" + mkdir -p "$RUNNER_TEMP/release" + tar -xzf "$RUNNER_TEMP"/rt-darwin-arm64-*.tar.gz -C "$RUNNER_TEMP/release" + ls -la "$RUNNER_TEMP/release" + + - name: Install runtime dependencies + run: brew install fzf tmux - name: Verify binary run: | - rt --version - echo "Binary OK: $(rt --version)" + "$RUNNER_TEMP/release/rt" --version + echo "Binary OK: $("$RUNNER_TEMP/release/rt" --version)" + + - name: Run the installer + run: | + "$RUNNER_TEMP/release/rt" --post-install + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Run post-install setup + - name: Installed binary on PATH run: | - # Run explicitly outside the Homebrew sandbox for full user-env access - rt --post-install + test -x "$HOME/.local/bin/rt" + rt --version - name: Install and start daemon run: | @@ -315,7 +263,3 @@ jobs: - name: Verify installation run: rt verify --ci - - - name: Smoke test - run: rt --version - diff --git a/CLAUDE.md b/CLAUDE.md index aeb5a22c..0705f8ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # rt — repo tools -Personal developer CLI built with Bun. Compiled to a standalone binary via `bun build --compile` and distributed through Homebrew. +Personal developer CLI built with Bun. Compiled to a standalone binary via `bun build --compile` and distributed as a GitHub Release tarball that mattstack.app (and `rt --post-install`) installs. ## Architecture docs live in Linear, not this repo diff --git a/README.md b/README.md index f7312030..a361d39d 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,21 @@ Full documentation: **https://rt.cool** ## Install +Download the latest `rt-darwin--.tar.gz` from +[GitHub Releases](https://github.com/m4ttstack/rt/releases), extract it, and run +the installer it contains: + ```bash -brew install m4ttheweric/tap/rt -rt verify # completes first-run setup, then verifies everything +tar -xzf rt-darwin-arm64-*.tar.gz -C rt-release && cd rt-release +./rt --post-install # installs rt, mattstack.app, the editor extension, the daemon, shell integration +rt verify # verifies everything ``` -`rt verify` runs setup on first use (tray app, daemon, editor extensions, shell -integration) and then reports the health of each piece: use it any time you -want to confirm the install is in good shape. +(The mattstack.app installer + onboarding UI that replaces this step is in +progress; `rt update` already upgrades in place from GitHub Releases.) + +`rt verify` runs setup on first use and then reports the health of each piece: +use it any time you want to confirm the install is in good shape. Then configure your API tokens: @@ -232,7 +239,7 @@ rt settings linear team # Set default Linear team rt settings gitlab token # Set GitLab personal access token rt settings extension # Install RT Context extension into local editors rt settings notifications # Toggle notification preferences -rt settings dev-mode # Toggle between local source and Homebrew binary +rt settings dev-mode # Toggle between local source and the installed binary ``` ### Other @@ -243,7 +250,7 @@ rt build # Interactive turbo build selector rt hooks # Toggle git hooks on/off rt verify # Installation verification rt version # Print version + mode (dev/prod) -rt update # Upgrade to the latest release via Homebrew +rt update # Upgrade to the latest GitHub release rt --version # Print version (short) ``` @@ -326,7 +333,7 @@ A resource missing from the file just shows its raw name and connects with Postg ## RT Context Extension -The `rt-context` VS Code/Cursor extension shows your current worktree, branch, and linked Linear ticket in the status bar. It's installed automatically by Homebrew. +The `rt-context` VS Code/Cursor extension shows your current worktree, branch, and linked Linear ticket in the status bar. The installer (`rt --post-install`) installs it into every detected editor. To reinstall or install into additional editors: @@ -364,8 +371,8 @@ From the menu you can restart the daemon, stop it, toggle launch-at-login, and c | Dependency | Notes | |---|---| | macOS | Required (Apple Silicon or Intel) | -| `fzf` | 0.71.0 or newer (`--listen` and `--id-nth`, used by `rt nav`'s live refresh); auto-installed by Homebrew | -| `tmux` | Auto-installed by Homebrew | +| `fzf` | 0.71.0 or newer (`--listen` and `--id-nth`, used by `rt nav`'s live refresh); `brew install fzf` | +| `tmux` | `brew install tmux` | | `zellij` | Optional, only needed for `rt x --zellij` mode (`brew install zellij`) | | `chafa` | Optional, renders image previews in `rt nav` as colored character art (`brew install chafa`) | | `kitten` | Optional, upgrades `rt nav` image previews to true pixels on Kitty-protocol terminals such as Ghostty. Ships with Kitty (`brew install --cask kitty`) | @@ -393,19 +400,19 @@ bun run cli.ts verify # run any subcommand the same way ### Switching between dev and production -Once you have a Homebrew install alongside your source checkout, use the built-in toggle: +Once mattstack.app is installed alongside your source checkout, use the built-in toggle: ```bash rt settings dev-mode # interactive picker: dev ↔ prod rt settings dev-mode dev # switch to local source -rt settings dev-mode prod # switch back to Homebrew binary +rt settings dev-mode prod # switch back to the installed binary ``` **How it works:** -- `dev` mode writes a wrapper script at `~/.local/bin/rt` that calls `bun run /path/to/cli.ts "$@"` -- `prod` mode removes that wrapper, letting `/opt/homebrew/bin/rt` take over -- `~/.local/bin` is added to your PATH automatically (in your shell rc file) during `brew install` and on first `dev-mode dev` -- The source path is remembered in `~/.rt/dev-mode.json`, so there's no re-entry needed when toggling back +- `dev` mode writes a wrapper script at `~/.local/bin/rt` that calls `bun run /path/to/cli.ts "$@"` and hands the tray over to `mattstack-dev.app` +- `prod` mode installs the compiled binary carried inside `mattstack.app` at that same path and hands the tray back to `mattstack.app` +- `~/.local/bin` is added to your PATH automatically (in your shell rc file) by the installer and on first `dev-mode dev` +- The source path is remembered in `~/.mattstack/rt/dev-mode.json`, so there's no re-entry needed when toggling back `rt version` tells you which is active (and the source path in dev mode); `rt --version` is the short form that just prints the version string. @@ -419,11 +426,13 @@ rt --post-install ``` This is the same code that `rt` auto-runs on its first invocation (and that -`rt update` re-runs after a Homebrew upgrade). It: -1. Copies `rt-tray.app` to `~/Applications` -2. Installs `rt-context.vsix` into all detected editors -3. Installs the daemon as a launchd agent -4. Writes shell integration to your rc file (PATH + rtcd, idempotent, supports zsh, bash, fish) +`rt update` runs from the freshly downloaded release). Run from an extracted +release tarball it: +1. Installs the `rt` binary at `~/.local/bin/rt` +2. Copies `mattstack.app` to `~/Applications` +3. Installs `rt-context.vsix` into all detected editors +4. Installs the daemon as a launchd agent +5. Writes shell integration to your rc file (PATH + rtcd, idempotent, supports zsh, bash, fish) ### Verifying an installation @@ -484,15 +493,12 @@ git push --tags GitHub Actions will: 1. Compile `rt` for arm64 + x64 -2. Build `rt-tray.app` with version baked into `Info.plist` +2. Build `mattstack.app` with version baked into `Info.plist` 3. Package `rt-context.vsix` 4. Create a GitHub Release with bundled tarballs -5. Update `m4ttheweric/homebrew-tap` formula with real URLs + SHA256s -6. Run `rt verify --ci` on a fresh `macos-latest` runner to confirm the install works - -The formula has **no** `post_install` hook: Homebrew runs formula hooks in a -sandbox that can't write to `~/Applications`, `~/.rt`, or shell rc files, so -setup is deferred to the binary itself. `cli.ts` detects a missing -`~/.rt/daemon.json` on first invocation and transparently runs -`commands/post-install.ts`, which is also what `rt --post-install` and the -post-upgrade step of `rt update` invoke. +5. Install from the published tarball on a fresh `macos-latest` runner and run `rt verify --ci` + +Setup lives in the binary itself: `cli.ts` detects a missing +`~/.mattstack/rt/daemon.json` on first invocation and transparently runs +`commands/post-install.ts`, which is also what `rt --post-install` and +`rt update` invoke. diff --git a/cli.ts b/cli.ts index fe5e693b..c59e4480 100755 --- a/cli.ts +++ b/cli.ts @@ -94,8 +94,9 @@ if (args[0] === "--version" || args[0] === "-V") { const { interceptRun } = await import("./commands/intercept.ts"); await interceptRun(args.slice(2)); } else if (args[0] === "--post-install") { - // Hidden entry point: called by the Homebrew formula's post_install hook. - // Handles tray app, extension install, daemon setup, and shell integration. + // Hidden entry point: the installer. Run from an extracted release tarball + // it installs the binary, the app, the extension, the daemon, and shell + // integration; `rt update` re-execs it from the freshly downloaded release. const { runPostInstall } = await import("./commands/post-install.ts"); await runPostInstall(); } else if (args[0] === "--grant-fda") { @@ -121,10 +122,10 @@ if (args[0] === "--version" || args[0] === "-V") { process.exit(0); } else { // ── First-run auto-setup ────────────────────────────────────────────────── - // If daemon.json doesn't exist, post-install hasn't completed outside the - // Homebrew sandbox. Run it now transparently before the requested command. - // This also applies to `rt verify`, which is the command we recommend users - // run after `brew install` — it should set up + then verify in one shot. + // If daemon.json doesn't exist, post-install has never completed. Run it + // now transparently before the requested command. This also applies to + // `rt verify`, the command we recommend after installing — it should set + // up + then verify in one shot. if (process.env.CI !== "true" && process.env.RT_SKIP_SETUP !== "1") { const { existsSync } = await import("fs"); const { join } = await import("path"); diff --git a/commands/cd.ts b/commands/cd.ts index e463671a..d0762b27 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -38,7 +38,7 @@ const SHELL_FUNCTION = [ `rt() {`, ` # Resolve rt by absolute path when the dev-mode wrapper exists. This bypasses`, ` # zsh's command-hash cache, which otherwise pins rt to whichever binary it`, - ` # first found (often Homebrew's compiled rt) and ignores later dev-mode`, + ` # first found and ignores later dev-mode`, ` # swaps until the shell calls 'hash -r' — leading to surprising "I'm in dev`, ` # mode but my changes don't show up" behaviour across shells.`, ` local rt_bin="$HOME/.local/bin/rt"`, diff --git a/commands/extension.ts b/commands/extension.ts index 56c05b79..b932c7a1 100644 --- a/commands/extension.ts +++ b/commands/extension.ts @@ -72,15 +72,10 @@ function detectEditors(): DetectedEditor[] { // ─── VSIX Finder ───────────────────────────────────────────────────────────── function findVsix(): string | null { - // 1. Check Homebrew formula prefix (compiled binary install) + // 1. Next to the binary (extracted release tarball layout) const execPath = process.execPath; - const brewPrefix = resolve(execPath, ".."); - const brewVsix = join(brewPrefix, "rt-context.vsix"); - if (existsSync(brewVsix)) return brewVsix; - - // Also check one level up (Homebrew Cellar structure: Cellar/rt/version/bin/rt) - const cellarVsix = resolve(execPath, "../../rt-context.vsix"); - if (existsSync(cellarVsix)) return cellarVsix; + const bundledVsix = resolve(execPath, "../rt-context.vsix"); + if (existsSync(bundledVsix)) return bundledVsix; // 2. Check relative to source repo (development mode only — skip in compiled binary) const metaUrl = new URL(import.meta.url).pathname; @@ -118,7 +113,7 @@ export async function installExtension(): Promise { const vsixPath = findVsix(); if (!vsixPath) { console.log(` ${red}✗${reset} rt-context.vsix not found`); - console.log(` ${dim}expected in Homebrew prefix or extensions/vscode/rt-context/${reset}\n`); + console.log(` ${dim}expected next to the rt binary or in extensions/vscode/rt-context/${reset}\n`); return; } diff --git a/commands/post-install.ts b/commands/post-install.ts index 9d04d000..180adb7a 100644 --- a/commands/post-install.ts +++ b/commands/post-install.ts @@ -1,24 +1,25 @@ /** - * rt --post-install — First-run setup, auto-triggered on the first `rt` invocation. + * rt --post-install — the installer. Auto-triggered on the first `rt` + * invocation, re-run by `rt update` from the freshly extracted release. * - * Handles all setup steps: - * 1. Copy mattstack.app → ~/Applications (remove quarantine) - * 2. Install rt-context.vsix into all detected editors (best-effort, non-interactive) - * 3. Install daemon as a launchd agent (auto-starts on login) - * 4. Write shell integration to the user's rc file (PATH + rtcd, idempotent) + * Run from an extracted release tarball (rt + mattstack.app + rt-context.vsix + * side by side) it is a complete install: + * 1. Install this binary at ~/.local/bin/rt (unless dev mode owns that path) + * 2. Copy mattstack.app → ~/Applications (remove quarantine) + * 3. Install rt-context.vsix into all detected editors (best-effort, non-interactive) + * 4. Install daemon as a launchd agent (auto-starts on login) + * 5. Write shell integration to the user's rc file (PATH + rtcd, idempotent) * - * Keeping this in the binary (not the formula) means: - * - Setup logic is versioned alongside the binary - * - Easy to test locally: rt --post-install - * - No Ruby needed to understand or change setup behaviour - * - Works correctly for both fresh install and upgrade + * Run from an already-installed binary, steps 1-3 find nothing beside it and + * skip; the rest still reconcile. */ import { spawnSync } from "child_process"; -import { existsSync, readFileSync, readdirSync, mkdirSync, rmSync, cpSync, writeFileSync } from "fs"; +import { existsSync, readFileSync, readdirSync, mkdirSync, rmSync, cpSync, writeFileSync, realpathSync } from "fs"; import { join, resolve, dirname } from "path"; import { homedir } from "os"; -import { rtDir, TRAY_APP_NAME, TRAY_APP_BUNDLE, trayAppPath, legacyTrayAppPaths } from "../lib/rt-paths.ts"; +import { TRAY_APP_NAME, TRAY_APP_BUNDLE, trayAppPath, legacyTrayAppPaths } from "../lib/rt-paths.ts"; +import { currentMode, installRtBinary, rtBinaryPath } from "../lib/dev-mode.ts"; import { installShellIntegration, detectShell, shellRcPath } from "../lib/shell-integration.ts"; const HOME = homedir(); @@ -32,20 +33,38 @@ function ok(label: string, detail = "") { log("✓", label, detail); } function fail(label: string, detail = "") { log("✗", label, detail); } function info(label: string, detail = "") { log("·", label, detail); } -// ─── 1. Tray app ────────────────────────────────────────────────────────────── +// ─── 1. rt binary ───────────────────────────────────────────────────────────── + +function installRtBinaryStep(): void { + const dest = rtBinaryPath(); + const src = process.execPath; + let alreadyThere = false; + try { alreadyThere = existsSync(dest) && realpathSync(dest) === realpathSync(src); } catch { /* unreadable dest: install over it */ } + if (alreadyThere) { + info("rt", `already installed at ${dest}`); + return; + } + if (currentMode() === "dev") { + info("rt", `dev mode owns ${dest} — leaving the wrapper in place`); + return; + } + if (!existsSync(resolve(src, `../${TRAY_APP_BUNDLE}`))) { + info("rt", "not running from an extracted release — skipping binary install"); + return; + } + try { + installRtBinary(src); + ok("rt", `→ ${dest}`); + } catch (err: any) { + fail("rt", err?.message ?? String(err)); + } +} + +// ─── 2. Tray app ────────────────────────────────────────────────────────────── function installTrayApp(): void { - // The tray app is bundled alongside the rt binary in the Homebrew prefix. - // Homebrew Cellar structure: .../cellar/rt//bin/rt - // /../mattstack.app - const rtPath = process.execPath; - const candidates = [ - resolve(rtPath, `../${TRAY_APP_BUNDLE}`), // same dir as binary - resolve(rtPath, `../../${TRAY_APP_BUNDLE}`), // one level up (Cellar layout) - ]; - - const srcTray = candidates.find(existsSync); - if (!srcTray) { + const srcTray = resolve(process.execPath, `../${TRAY_APP_BUNDLE}`); + if (!existsSync(srcTray)) { fail(TRAY_APP_BUNDLE, "not found alongside binary — skipping"); return; } @@ -76,7 +95,7 @@ function installTrayApp(): void { } } -// ─── 2. Extension ───────────────────────────────────────────────────────────── +// ─── 3. Extension ───────────────────────────────────────────────────────────── const EDITOR_PATTERNS = [ { appName: "Cursor.app", cliBinary: "cursor", displayName: "Cursor" }, @@ -89,20 +108,14 @@ const EDITOR_PATTERNS = [ ]; function findVsix(): string | null { - const rtPath = process.execPath; - for (const candidate of [ - resolve(rtPath, "../rt-context.vsix"), - resolve(rtPath, "../../rt-context.vsix"), - ]) { - if (existsSync(candidate)) return candidate; - } - return null; + const candidate = resolve(process.execPath, "../rt-context.vsix"); + return existsSync(candidate) ? candidate : null; } function installExtensions(): void { const vsix = findVsix(); if (!vsix) { - info("rt-context.vsix", "not found in Homebrew prefix — skipping"); + info("rt-context.vsix", "not found alongside binary — skipping"); return; } @@ -139,7 +152,7 @@ function installExtensions(): void { } } -// ─── 3. Daemon ──────────────────────────────────────────────────────────────── +// ─── 4. Daemon ──────────────────────────────────────────────────────────────── async function installDaemon(): Promise { try { @@ -169,7 +182,7 @@ async function installDaemon(): Promise { } -// ─── 4. Shell integration ───────────────────────────────────────────────────────── +// ─── 5. Shell integration ───────────────────────────────────────────────────────── function installShellIntegrationStep(): void { const result = installShellIntegration(); @@ -182,7 +195,7 @@ function installShellIntegrationStep(): void { } } -// ─── 5. TCC / Full Disk Access check ───────────────────────────────────────── +// ─── 6. TCC / Full Disk Access check ───────────────────────────────────────── async function checkTccAccess(): Promise { try { @@ -210,7 +223,7 @@ async function checkTccAccess(): Promise { } catch { /* daemon not reachable — skip silently */ } } -// ─── 6. Shell wrapper repair ───────────────────────────────────────────────── +// ─── 7. Shell wrapper repair ───────────────────────────────────────────────── /** * The v0.x shell wrapper used `command -v rt` to resolve the binary path. @@ -245,20 +258,15 @@ function repairShellWrapper(): void { // ─── 0. One-shot legacy migration sweep (MAT-383 §4) ───────────────────────── // -// `rt --post-install` runs on every fresh install AND re-execs on every -// `rt update` (commands/update.ts:73-76) and every first-run-without- -// daemon.json path (cli.ts). An unguarded sweep would therefore `launchctl -// bootout` `com.rt.daemon` — the label prod KEEPS — on every routine update, -// killing the live daemon forever. So: GUARDED, fires only when a legacy -// rt-tray.app bundle is still found on disk, and this can NEVER move into -// `rt daemon install` — post-install.ts already spawns that AFTER the new -// app registers (see installDaemon()/runPostInstall() below), so a sweep -// there would boot out the daemon it just started. +// `rt --post-install` runs on every fresh install, every `rt update`, and +// every first-run-without-daemon.json path (cli.ts). An unguarded sweep would +// `launchctl bootout` on every routine run, so it is GUARDED: it fires only +// while a legacy rt-tray.app bundle is still on disk, and it can NEVER move +// into `rt daemon install` (runPostInstall spawns that AFTER the new app +// registers; a sweep there would boot out the daemon it just started). // -// Order (spec §4, all before installing the new app): quit the OLD app by -// its own never-changing name → stop its launchd job → delete its bundle(s). -// The caller (runPostInstall) then runs the existing install+launch flow -// and reports migration success/failure loudly afterward. +// Order (all before installing the new app): quit the OLD app by its own +// never-changing name → stop its old launchd job → delete its bundle(s). /** True only when at least one legacy rt-tray.app candidate still exists. */ function legacySweepNeeded(): boolean { @@ -320,52 +328,18 @@ async function reportMigrationOutcome(): Promise { // ─── Entry ─────────────────────────────────────────────────────────────────── -/** - * Returns true when running inside the Homebrew post_install sandbox. - * The sandbox blocks writes to ~/Applications, ~/.mattstack/rt/, ~/.zshrc, etc. - * Extensions still work because they're installed via signed app subprocesses. - */ -function isHomebrewSandboxed(): boolean { - // Homebrew sets these env vars during formula post_install - return !!(process.env.HOMEBREW_CELLAR || process.env.HOMEBREW_PREFIX) && - process.env.HOME !== undefined && - // If we can't write to HOME, we're sandboxed - (() => { - try { - const testPath = rtDir(); - mkdirSync(testPath, { recursive: true }); - return false; // write worked — not sandboxed - } catch { - return true; // EPERM — sandboxed - } - })(); -} - export async function runPostInstall(): Promise { console.log(""); console.log(" rt post-install"); console.log(""); - const sandboxed = isHomebrewSandboxed(); - - if (sandboxed) { - // Only install extensions — they work via signed app subprocesses. - // Home-directory writes (tray, daemon, shell) are blocked by the sandbox. - info("sandbox", "Homebrew sandbox detected — installing extensions only"); - installExtensions(); - console.log(""); - console.log(" Run the following to complete setup:"); - console.log(" rt --post-install"); - console.log(""); - return; - } - // One-shot legacy migration sweep (spec §4) — GUARDED and MUST run before // the new app is installed/launched below. See runLegacySweep()'s docblock // for why this can never move into `rt daemon install`. const migrating = legacySweepNeeded(); if (migrating) runLegacySweep(); + installRtBinaryStep(); installTrayApp(); installExtensions(); diff --git a/commands/settings.ts b/commands/settings.ts index ed0a2f9b..da11945e 100644 --- a/commands/settings.ts +++ b/commands/settings.ts @@ -7,14 +7,14 @@ * settings gitlab token — set GitLab personal access token */ -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { homedir } from "os"; import { rtDir, TRAY_APP_NAME, DEV_TRAY_APP_NAME, TRAY_APP_BUNDLE, trayAppPath, devTrayAppPath, } from "../lib/rt-paths.ts"; -import { currentMode } from "../lib/dev-mode.ts"; +import { currentMode, installRtBinary } from "../lib/dev-mode.ts"; import { spawnSync } from "child_process"; import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; import { @@ -458,13 +458,10 @@ export function renderDevModePreload(): string { } /** - * Leaving dev mode must leave a WORKING `rt` behind (MAT-383 smoke, Matt's - * ruling 2026-08-20). Deleting the wrapper used to be enough because Homebrew - * owned the prod binary; with brew retired, the only compiled rt on this - * machine is the one mattstack.app carries at Contents/MacOS/rt-daemon (the - * daemon and the CLI are the same binary). So prod mode installs THAT over the - * wrapper path — the same "the app provides the binary" model the installer - * generalizes in phase 2. + * Leaving dev mode must leave a WORKING `rt` behind. The only compiled rt on + * this machine is the one mattstack.app carries at Contents/MacOS/rt-daemon + * (the daemon and the CLI are the same binary), so prod mode installs THAT + * over the wrapper path: the app provides the binary. * * Throws when the prod app is absent: stranding the CLI with no rt on PATH is * worse than refusing the switch. @@ -477,10 +474,7 @@ function disableDevMode(): void { ); } - mkdirSync(dirname(DEV_MODE_WRAPPER), { recursive: true }); - if (existsSync(DEV_MODE_WRAPPER)) rmSync(DEV_MODE_WRAPPER); - copyFileSync(prodBinary, DEV_MODE_WRAPPER); - chmodSync(DEV_MODE_WRAPPER, 0o755); + installRtBinary(prodBinary); if (existsSync(DEV_MODE_PRELOAD)) { rmSync(DEV_MODE_PRELOAD); diff --git a/commands/update.ts b/commands/update.ts index 8d67953d..5bf0e120 100644 --- a/commands/update.ts +++ b/commands/update.ts @@ -1,80 +1,109 @@ #!/usr/bin/env bun /** - * rt update — Upgrade rt to the latest version via Homebrew. + * rt update — install the latest GitHub release. * - * Equivalent to: brew upgrade m4ttheweric/tap/rt + * Downloads the release tarball (the same artifact mattstack.app ships in), + * then runs the EXTRACTED binary's --post-install so the rt binary, the app + * bundle, and the editor extension are installed by the one code path a fresh + * install uses. Nothing here knows how to install; post-install does. */ -import { spawnSync, execSync } from "child_process"; -import { existsSync } from "fs"; -import { homedir } from "os"; +import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, rmSync } from "fs"; import { join } from "path"; -import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; +import { currentMode } from "../lib/dev-mode.ts"; +import { rtDir } from "../lib/rt-paths.ts"; +import { bold, dim, green, red, reset, yellow } from "../lib/tui.ts"; declare const RT_VERSION: string; +export const RELEASES_API = "https://api.github.com/repos/m4ttstack/rt/releases/latest"; + +interface ReleaseAsset { name: string; browser_download_url: string } +interface Release { tag_name: string; assets: ReleaseAsset[] } + +export function releaseAssetName(tag: string, arch: string = process.arch): string { + return `rt-darwin-${arch === "arm64" ? "arm64" : "x64"}-${tag}.tar.gz`; +} + +function stripV(v: string): string { + return v.startsWith("v") ? v.slice(1) : v; +} + export async function runUpdate(_args: string[]): Promise { - // Detect dev mode — updating makes no sense when running from source. - // Use the wrapper script as the authoritative signal (same as settings.ts:currentMode()). - // dev-mode.json persists even after switching to prod, so it's not reliable. - const devModeWrapper = join(homedir(), ".local/bin/rt"); - if (existsSync(devModeWrapper)) { + if (currentMode() === "dev") { console.log(`\n ${yellow}⚠${reset} dev mode is active — you're running from local source.`); console.log(` ${dim}Switch to prod first: rt settings dev-mode prod${reset}\n`); process.exit(1); } - // Check brew is available - const brew = spawnSync("which", ["brew"], { encoding: "utf8" }).stdout.trim(); - if (!brew) { - console.log(`\n ${red}✗${reset} Homebrew not found — rt was not installed via Homebrew.`); - console.log(` ${dim}Install from: https://brew.sh${reset}\n`); - process.exit(1); - } - - // Show current version — RT_VERSION is injected at compile time via bun build --define. + // RT_VERSION is injected at compile time via bun build --define. const current = (typeof RT_VERSION !== "undefined" ? RT_VERSION : null) ?? process.env.RT_VERSION ?? "dev"; console.log(` ${dim}current: ${current}${reset}`); - // Check if there's an update available first (non-blocking) + let release: Release; try { - execSync("brew update --quiet", { stdio: "pipe", timeout: 30_000 }); - const outdated = execSync("brew outdated m4ttheweric/tap/rt 2>/dev/null || brew outdated rt 2>/dev/null", { - encoding: "utf8", stdio: "pipe", - }).trim(); - - if (!outdated) { - console.log(` ${green}✓${reset} already up to date\n`); - return; - } - } catch { - // brew update can fail (no network etc.) — attempt upgrade anyway + const res = await fetch(RELEASES_API, { headers: { Accept: "application/vnd.github+json" } }); + if (!res.ok) throw new Error(`GitHub API ${res.status}`); + release = (await res.json()) as Release; + } catch (err: any) { + console.log(`\n ${red}✗${reset} could not check releases: ${err?.message ?? err}\n`); + process.exit(1); } - console.log(` upgrading via Homebrew…\n`); + const tag = release.tag_name; + if (stripV(tag) === stripV(current)) { + console.log(` ${green}✓${reset} already up to date\n`); + return; + } - const result = spawnSync(brew, ["upgrade", "m4ttheweric/tap/rt"], { - stdio: "inherit", - timeout: 5 * 60_000, - }); + const assetName = releaseAssetName(tag); + const asset = release.assets.find((a) => a.name === assetName); + if (!asset) { + console.log(`\n ${red}✗${reset} release ${tag} has no ${assetName}\n`); + process.exit(1); + } + + console.log(` ${dim}latest: ${tag}${reset}\n`); + console.log(` downloading ${bold}${assetName}${reset}…`); + + const stage = join(rtDir(), "updates", tag); + rmSync(stage, { recursive: true, force: true }); + mkdirSync(stage, { recursive: true }); + const tarball = join(stage, assetName); + + try { + const res = await fetch(asset.browser_download_url); + if (!res.ok) throw new Error(`download ${res.status}`); + await Bun.write(tarball, res); + } catch (err: any) { + console.log(`\n ${red}✗${reset} download failed: ${err?.message ?? err}\n`); + process.exit(1); + } - if (result.status !== 0) { - // Try without the tap prefix in case it's already linked differently - const fallback = spawnSync(brew, ["upgrade", "rt"], { stdio: "inherit", timeout: 5 * 60_000 }); - if (fallback.status !== 0) { - console.log(`\n ${red}✗${reset} upgrade failed — run manually: brew upgrade m4ttheweric/tap/rt\n`); - process.exit(1); - } + const untar = spawnSync("tar", ["-xzf", tarball, "-C", stage], { stdio: "pipe", env: process.env }); + if (untar.status !== 0) { + console.log(`\n ${red}✗${reset} extract failed: ${untar.stderr?.toString().trim()}\n`); + process.exit(1); + } + + const newRt = join(stage, "rt"); + if (!existsSync(newRt)) { + console.log(`\n ${red}✗${reset} tarball has no rt binary\n`); + process.exit(1); } - // Run post-install using the newly installed binary so it finds the new - // tray app / extension relative to its own path, and restarts the daemon. - const newRt = spawnSync("which", ["rt"], { encoding: "utf8" }).stdout.trim(); - if (newRt) { - console.log(`\n running post-install setup…\n`); - spawnSync(newRt, ["--post-install"], { stdio: "inherit", env: { ...process.env, RT_SKIP_SETUP: "1" } }); + console.log(`\n running post-install from ${tag}…\n`); + const post = spawnSync(newRt, ["--post-install"], { + stdio: "inherit", + env: { ...process.env, RT_SKIP_SETUP: "1" }, + }); + if (post.status !== 0) { + console.log(`\n ${red}✗${reset} post-install failed — the extracted release is at ${stage}\n`); + process.exit(1); } - console.log(`\n ${green}✓${reset} rt updated — restart your terminal for the new version\n`); + rmSync(stage, { recursive: true, force: true }); + console.log(`\n ${green}✓${reset} rt updated to ${tag} — restart your terminal for the new version\n`); } diff --git a/commands/version.ts b/commands/version.ts index 9b90561a..185d5ed4 100644 --- a/commands/version.ts +++ b/commands/version.ts @@ -1,20 +1,17 @@ -import { existsSync } from "fs"; -import { homedir } from "os"; -import { join } from "path"; +import { currentMode } from "../lib/dev-mode.ts"; import { bold, cyan, dim, green, reset, yellow } from "../lib/tui.ts"; declare const RT_VERSION: string; export async function runVersion(_args: string[]): Promise { const version = (typeof RT_VERSION !== "undefined" ? RT_VERSION : null) ?? process.env.RT_VERSION ?? "dev"; - const isDevMode = existsSync(join(homedir(), ".local/bin/rt")); console.log(`\n ${bold}${cyan}rt${reset} ${version}`); - if (isDevMode) { + if (currentMode() === "dev") { console.log(` ${yellow}dev mode${reset} ${dim}running from local source — switch with: rt settings dev-mode${reset}`); } else { - console.log(` ${green}prod${reset} ${dim}Homebrew install${reset}`); + console.log(` ${green}prod${reset} ${dim}${process.execPath}${reset}`); } console.log(""); diff --git a/extensions/vscode/rt-context/README.md b/extensions/vscode/rt-context/README.md index 55e08f36..6d535d0b 100644 --- a/extensions/vscode/rt-context/README.md +++ b/extensions/vscode/rt-context/README.md @@ -44,7 +44,7 @@ For branches without a recognizable Linear ID in the name, the extension can loo ## Install -Installed automatically with `brew install m4ttheweric/tap/rt`. To reinstall manually: +Installed automatically by the rt installer (`rt --post-install`). To reinstall manually: ```bash rt extension install diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 63f0d165..64e5b32b 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -492,7 +492,7 @@ export const TREE: Record = { }, update: { - description: "Update rt to the latest version via Homebrew", + description: "Update rt to the latest GitHub release", module: "./commands/update.ts", fn: "runUpdate", args: [], @@ -506,7 +506,7 @@ export const TREE: Record = { }, verify: { - description: "Verify an rt installation end-to-end (run after brew install)", + description: "Verify an rt installation end-to-end (run after installing)", module: "./commands/verify.ts", fn: "runVerify", args: [ @@ -916,12 +916,12 @@ export const TREE: Record = { args: [], }, "dev-mode": { - description: "Toggle between local dev source and Homebrew production binary", + description: "Toggle between local dev source and the installed production binary", module: "./commands/settings.ts", fn: "toggleDevMode", requiresTTY: true, args: [ - { name: "Target", type: "select", hint: "Omit to be prompted interactively", options: [{ value: "dev", label: "dev", hint: "Run from local source" }, { value: "prod", label: "prod", hint: "Run the Homebrew binary" }] }, + { name: "Target", type: "select", hint: "Omit to be prompted interactively", options: [{ value: "dev", label: "dev", hint: "Run from local source" }, { value: "prod", label: "prod", hint: "Run the installed binary (from mattstack.app)" }] }, ], }, llm: { diff --git a/lib/dev-mode.ts b/lib/dev-mode.ts index 58e96a6b..25d72df5 100644 --- a/lib/dev-mode.ts +++ b/lib/dev-mode.ts @@ -13,7 +13,8 @@ * flavor (dev vs prod); dev-mode.json's existence is NOT a flavor signal. */ -import { closeSync, existsSync, openSync, readSync } from "fs"; +import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, renameSync } from "fs"; +import { dirname } from "path"; import { homedir } from "os"; // Call-time HOME (mirrors lib/rt-paths.ts's home()): resolved on every call, @@ -24,10 +25,30 @@ function home(): string { return process.env.HOME ?? homedir(); } -function devModeWrapperPath(): string { +/** Where the CLI lives in both modes: the wrapper script (dev) or the compiled binary (prod). */ +export function rtBinaryPath(): string { return `${home()}/.local/bin/rt`; } +function devModeWrapperPath(): string { + return rtBinaryPath(); +} + +/** + * Install a compiled rt at rtBinaryPath(). Copy-then-rename so a process + * currently executing the old binary (rt update running from it, say) keeps + * its mapped pages instead of having them overwritten underneath it. + */ +export function installRtBinary(src: string): string { + const dest = rtBinaryPath(); + mkdirSync(dirname(dest), { recursive: true }); + const tmp = `${dest}.new`; + copyFileSync(src, tmp); + chmodSync(tmp, 0o755); + renameSync(tmp, dest); + return dest; +} + /** * Dev mode is signalled by the dev-mode WRAPPER SCRIPT at ~/.local/bin/rt -- * not by any file existing there. Prod mode installs the compiled binary at diff --git a/lib/fzf.ts b/lib/fzf.ts index 8a4511ed..7b23ef38 100644 --- a/lib/fzf.ts +++ b/lib/fzf.ts @@ -1,10 +1,10 @@ /** - * fzf is a hard dependency of rt. The Homebrew formula installs it alongside - * rt, and every interactive picker (run, nav, commit, branch, …) shells out to - * it. If it goes missing — e.g. `brew autoremove`/`brew cleanup` reaps it while - * installing an unrelated formula — we want a single, actionable error rather - * than silently degrading to a non-fuzzy picker or crashing with an opaque - * spawn ENOENT. Every fzf spawn site calls ensureFzf() before spawning. + * fzf is a hard dependency of rt: every interactive picker (run, nav, commit, + * …) shells out to it, and nothing installs it for the user. If it is missing + * — never installed, or reaped by `brew autoremove`/`brew cleanup` — we want a + * single, actionable error rather than silently degrading to a non-fuzzy + * picker or crashing with an opaque spawn ENOENT. Every fzf spawn site calls + * ensureFzf() before spawning. */ import { bold, dim, yellow, reset } from "./tui.ts"; diff --git a/lib/rt-paths.ts b/lib/rt-paths.ts index b23e2c8d..4336169e 100644 --- a/lib/rt-paths.ts +++ b/lib/rt-paths.ts @@ -132,8 +132,9 @@ export function devTrayAppPath(): string { * * Mirrors the candidate list scattered across commands/verify.ts and * commands/post-install.ts today: the installed location under - * ~/Applications, plus the two Homebrew-prefix-relative locations next to - * the running binary (same dir, and one level up for the Cellar layout). + * ~/Applications, plus the two locations the old Homebrew install put it at + * relative to the running binary (same dir, and one level up for the Cellar + * layout). */ export function legacyTrayAppPaths(): string[] { const rtExec = process.execPath; diff --git a/packages/rt-client/README.md b/packages/rt-client/README.md index 69c491da..1f2fce1f 100644 --- a/packages/rt-client/README.md +++ b/packages/rt-client/README.md @@ -17,8 +17,8 @@ const repos = await rtCommand(['repos', 'list']); const mrs = await readProjectMRs('group/repo'); ``` -Requires a running rt daemon. Install rt with `brew install m4ttheweric/tap/rt`, -then `rt verify`. +Requires a running rt daemon. Install rt from the latest GitHub Release +(`./rt --post-install`), then `rt verify`. `@mattstack/glance` is a peer dependency: rt-client returns glance's forge types so merge request shapes stay identical across rt, gitq, and mr-board. diff --git a/rt-tray/Sources/AppDelegate.swift b/rt-tray/Sources/AppDelegate.swift index 1427ebbd..5614dc6a 100644 --- a/rt-tray/Sources/AppDelegate.swift +++ b/rt-tray/Sources/AppDelegate.swift @@ -300,10 +300,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - /// Spawn `rt daemon logs` detached. Prefers the dev wrapper at - /// ~/.local/bin/rt over the brew-installed binary (which may lag the - /// source tree by a release cycle). Falls back to a login-shell PATH - /// lookup if neither exists. + /// Spawn `rt daemon logs` detached. Uses the installed CLI at + /// ~/.local/bin/rt (wrapper script in dev mode, binary in prod), else this + /// bundle's own embedded daemon binary, else a login-shell PATH lookup. /// /// Logdy stays running in the background after this method returns; /// it's killed only if the user Ctrl-Cs the spawned rt OR the rt-tray @@ -312,8 +311,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let home = NSHomeDirectory() let candidates = [ "\(home)/.local/bin/rt", - "/opt/homebrew/bin/rt", - "/usr/local/bin/rt", + Bundle.main.bundlePath + "/Contents/MacOS/rt-daemon", ] let rtBin = candidates.first { FileManager.default.isExecutableFile(atPath: $0) } diff --git a/rt-tray/Sources/UpdateChecker.swift b/rt-tray/Sources/UpdateChecker.swift index aff1094f..f19163be 100644 --- a/rt-tray/Sources/UpdateChecker.swift +++ b/rt-tray/Sources/UpdateChecker.swift @@ -39,10 +39,18 @@ class UpdateChecker { #endif } - /// The CLI is in dev mode when ~/.local/bin/rt exists (the dev-mode wrapper). + /// The installed CLI path. Both modes use it: dev mode writes a wrapper + /// script here, prod mode installs the compiled binary here. + private static var cliPath: String { NSHomeDirectory() + "/.local/bin/rt" } + + /// Dev mode is signalled by the wrapper SCRIPT at the CLI path, not by a + /// file existing there (prod installs the binary at the same path). Same + /// rule as lib/dev-mode.ts currentMode(): a script starts with "#!". private static var isCliDevMode: Bool { - let home = NSHomeDirectory() - return FileManager.default.fileExists(atPath: home + "/.local/bin/rt") + guard let handle = FileHandle(forReadingAtPath: cliPath) else { return false } + defer { try? handle.close() } + let head = handle.readData(ofLength: 2) + return head == Data("#!".utf8) } func startPeriodicChecks() { @@ -149,15 +157,17 @@ class UpdateChecker { alert.runModal() } - /// Shells out to the Homebrew-installed rt binary for its version string. + /// Version of the installed rt: the CLI at ~/.local/bin/rt, else this + /// bundle's own embedded daemon (the same binary). `rt --version` prints + /// "rt vX.Y.Z"; keep only the version. private static func resolveCliVersion() -> String? { - let paths = ["/opt/homebrew/bin/rt", "/usr/local/bin/rt"] - guard let rtPath = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else { return nil } + let paths = [cliPath, Bundle.main.bundlePath + "/Contents/MacOS/rt-daemon"] + guard let rtPath = paths.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { return nil } guard let data = TrayLog.runLogged(rtPath, ["--version"], label: "rt --version") else { return nil } guard let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty else { return nil } - let version = raw.hasPrefix("v") ? String(raw.dropFirst()) : raw + let last = raw.split(separator: " ").last, !last.isEmpty else { return nil } + let version = last.hasPrefix("v") ? String(last.dropFirst()) : String(last) return version } diff --git a/skills/rt-release/SKILL.md b/skills/rt-release/SKILL.md index 0d07b2fd..1ab79b56 100644 --- a/skills/rt-release/SKILL.md +++ b/skills/rt-release/SKILL.md @@ -9,7 +9,7 @@ Update the rt.cool docs, write the GitHub release notes from the previous tag to HEAD, commit them, and push a version tag. Pushing the tag is what publishes: the `.github/workflows/release.yml` workflow (trigger `on: push: tags: v*`) builds the macOS binaries, creates the GitHub release from the committed `RELEASE_NOTES.md`, -attaches the tarballs, and updates the Homebrew tap. You never create the GitHub +attaches the tarballs, and smoke-installs from them. You never create the GitHub release yourself; CI owns the release object. Your job is docs, notes, the tag, verifying CI, and deploying rt.cool. @@ -59,8 +59,8 @@ left as-is or reduced to a pointer here. git push origin ``` Do NOT run `gh release create`. The tag push triggers `release.yml`, which - creates the release from `RELEASE_NOTES.md`, attaches the binaries, and updates - Homebrew. + creates the release from `RELEASE_NOTES.md`, attaches the binaries, and + smoke-installs from them on a fresh runner. 8. **Verify the publish.** Find the run (`gh run list --workflow=release.yml`) and watch it to completion (`gh run watch --exit-status`), then confirm with