From df91d0e909c94f2dcfdfcdd5d645f32c51cee8b9 Mon Sep 17 00:00:00 2001 From: Irwan Setiawan Date: Mon, 13 Jul 2026 21:25:59 +0800 Subject: [PATCH] feat(update): add `perchd update` and opt-in background auto-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `perchd update` updates perchd in place using the package manager that installed it (npm/pnpm/bun), detected from the running binary's path — so it never installs a second, shadowing copy in a different global prefix. Foreground + inherited stdio, so a sudo-needing system-node install surfaces its own error. Opt-in `PERCHD_AUTO_UPDATE=1` (default off): the existing once-a-day detached __update-check, after caching, installs a newer version silently — applied on the next run, never hot-swapping the live process. Fail-silent, so an unwritable/sudo install just no-ops and the notice persists. The notice now points at `perchd update` (or says it's auto-updating when the flag is on) instead of a raw npm string. The current version rides along in the __update-check argv so the child can compare without re-reading package.json. --- CHANGELOG.md | 18 ++++++++ CLAUDE.md | 13 ++++++ README.md | 1 + src/cli.ts | 13 +++++- src/commands/update.ts | 33 ++++++++++++++ src/core/update.ts | 101 ++++++++++++++++++++++++++++++++++++----- test/update.test.ts | 91 ++++++++++++++++++++++++++++++++++++- 7 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 src/commands/update.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4b209..4bccb5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`perchd update`** — update perchd in place. It uses the **same package manager that + installed it** (npm/pnpm/bun, sniffed from the running binary's path), so it never + creates a second, shadowing copy in a different global prefix. Runs in the foreground, + so a sudo-needing (system-node) install surfaces its own error to re-run. +- **Opt-in background auto-update (`PERCHD_AUTO_UPDATE=1`, default off).** When enabled, + the existing once-a-day background check also installs a newer version silently; it + applies on your **next** run (never hot-swaps the running process). Fail-silent — an + unwritable/sudo-needing install just no-ops and the notice keeps showing, so it can't + brick or block anything. The update notice reflects the mode: `Run: perchd update` + normally, or `Auto-updating in the background …` when the flag is on. + +### Changed + +- The update notice now points at **`perchd update`** instead of a raw + `npm i -g perchd@latest` string. + ### Fixed - **Port detection now looks in every place a dev port is actually pinned**, instead diff --git a/CLAUDE.md b/CLAUDE.md index 38b3784..1b0249d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,19 @@ Flow for a switch: `cli.ts` → `core/context.ts` (git worktrees + common dir) `stdout` isn't a TTY, because `perchd path` is read via `$(...)` in the shell `cd` function; a notice on stdout would corrupt it. Also skipped under `CI` / `NO_UPDATE_NOTIFIER` / `pnpm dev` (argv[1] ends `.ts`). + - **Updating uses the manager that installed perchd**, sniffed from the running + binary's path (`detectManager`: `/.bun/`→bun, `/pnpm/`|`$PNPM_HOME`→pnpm, else npm). + Never hard-code `npm i -g` — a pnpm/bun install lives in a different global prefix, + so `npm i -g` would create a second, shadowing copy. `perchd update` runs it in the + foreground (inherited stdio, so a sudo-needing system-node install surfaces its + error). The current version rides along in the `__update-check` argv so the detached + child can compare without re-reading `package.json`. + - **Opt-in auto-update (`PERCHD_AUTO_UPDATE=1`, default off).** When set, the detached + `__update-check`, after caching, installs the newer version **silently** (applies on + the *next* run — never hot-swaps the running process). Fail-silent by design: an + unwritable/sudo-needing install just no-ops and the notice keeps showing, so it can + never brick or block. Off by default because silently mutating a user-installed binary + is surprising and the install method can be ambiguous (symlinked dev checkouts, etc.). - **`docs/superpowers/` and `perchd-prd.md` are gitignored and must NEVER be committed.** They were deliberately purged from git history (filter-repo + force-push, 2026-06-14). Design/spec/plan docs live there on disk, local-only. Don't reference them from tracked diff --git a/README.md b/README.md index 793e85d..3f25b68 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ perchd stop # stop the active server | `perchd doctor` | Diagnose stale pids, dead ports, undetected worktrees, foreign port holders. | | `perchd config` | Print the resolved config and detected runner per worktree. | | `perchd watch` | Foreground watcher: auto-stops the active server the instant its worktree is deleted. | +| `perchd update` | Update perchd to the latest version, using the package manager that installed it (npm/pnpm/bun). | | `perchd dev [target]` | **Deprecated** — an alias for `perchd [target]`. See [migrating](#migrating-from-perchd-dev). | **Flags on a switch:** `-d`/`--detach` (don't attach), `--cmd ` and `--port ` diff --git a/src/cli.ts b/src/cli.ts index e7414d6..d701ee9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,8 @@ import { runGc } from "./commands/gc.js"; import { runDoctor } from "./commands/doctor.js"; import { runConfig } from "./commands/config.js"; import { runWatch } from "./commands/watch.js"; -import { HIDDEN_COMMAND, maybeNotifyUpdate, runUpdateCheck } from "./core/update.js"; +import { runUpdate } from "./commands/update.js"; +import { HIDDEN_COMMAND, autoUpdateEnabled, maybeNotifyUpdate, runUpdateCheck } from "./core/update.js"; // Read our own version from package.json. `../package.json` resolves the same // from the entry whether it runs as src/cli.ts (tsx) or the bundled dist/cli.js @@ -151,13 +152,21 @@ cli.command("config", "print resolved config + detected runner per worktree") cli.command("watch", "watch for worktree deletion and auto-stop the active server") .action(async () => { try { await runWatch(cwd); } catch (e) { fail(e); } }); +cli.command("update", "update perchd to the latest version (via the manager that installed it)") + .action(async () => { try { process.exit(await runUpdate()); } catch (e) { fail(e); } }); + cli.version(version); // adds `-v, --version` cli.help(); // Hidden: the detached background refresh (spawned by maybeNotifyUpdate). Handled // before cac so it never appears in help and never triggers its own notice. if (process.argv[2] === HIDDEN_COMMAND) { - void runUpdateCheck().finally(() => process.exit(0)); + // `perchd __update-check `: refresh the cache and, when opted in, + // auto-update. The version rides in argv so we needn't re-read package.json. + void runUpdateCheck({ + current: process.argv[3] ?? version, + autoUpdate: autoUpdateEnabled(), + }).finally(() => process.exit(0)); } else { // Notify (from cache) + kick off a background refresh; never blocks the command. maybeNotifyUpdate(version); diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..e2e2d05 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,33 @@ +import pc from "picocolors"; +import { + detectManager, updateCommand, execCommand, type PackageManager, +} from "../core/update.js"; + +export interface UpdateDeps { + binPath?: string; + env?: NodeJS.ProcessEnv; + log?: (m: string) => void; + exec?: (cmd: string[]) => Promise; +} + +/** + * `perchd update` — update perchd in place using the package manager that + * installed it (npm/pnpm/bun), so we never create a second, shadowing copy in a + * different global prefix. Foreground and explicit: output is inherited, and if + * the install needs sudo (system node) the user sees the error and can re-run. + */ +export async function runUpdate(deps: UpdateDeps = {}): Promise { + const binPath = deps.binPath ?? process.argv[1] ?? ""; + const env = deps.env ?? process.env; + const log = deps.log ?? ((m: string) => console.log(m)); + const exec = deps.exec ?? ((cmd: string[]) => execCommand(cmd)); + + const manager: PackageManager = detectManager(binPath, env); + const cmd = updateCommand(manager); + log(pc.dim(`Updating perchd via ${manager}: ${cmd.join(" ")}`)); + + const code = await exec(cmd); + if (code === 0) log(pc.green("✓ perchd updated — the new version applies on your next run.")); + else log(pc.red(`Update failed (exit ${code}). Try re-running it yourself: ${cmd.join(" ")}`)); + return code; +} diff --git a/src/core/update.ts b/src/core/update.ts index bfb8434..aaa4ea3 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -27,13 +27,52 @@ export function isNewer(current: string, latest: string): boolean { return false; } +export type PackageManager = "npm" | "pnpm" | "bun"; + +/** + * Which package manager owns this install, sniffed from the running binary's + * path (and PNPM_HOME). Each manager keeps globals in its own prefix with its + * own update command, so `npm i -g` against a pnpm/bun install would create a + * second, shadowing copy instead of updating the real one. Best-effort; npm is + * the default. Pure. + */ +export function detectManager(binPath: string, env: NodeJS.ProcessEnv = process.env): PackageManager { + const p = binPath.replace(/\\/g, "/"); + if (p.includes("/.bun/") || p.includes("/bun/install/")) return "bun"; + const home = (env.PNPM_HOME ?? "").replace(/\\/g, "/"); + if (p.includes("/pnpm/") || p.includes("/.pnpm/") || (home && p.startsWith(home))) return "pnpm"; + return "npm"; +} + +/** The global-install argv for a manager. Pure. */ +export function updateCommand(manager: PackageManager, pkg = "perchd@latest"): string[] { + if (manager === "pnpm") return ["pnpm", "add", "-g", pkg]; + if (manager === "bun") return ["bun", "add", "-g", pkg]; + return ["npm", "install", "-g", pkg]; +} + +/** Opt-in background auto-update, off unless `PERCHD_AUTO_UPDATE` is truthy. Pure. */ +export function autoUpdateEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const v = (env.PERCHD_AUTO_UPDATE ?? "").toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +export interface NoticeOpts { + autoUpdate?: boolean; +} + /** The two-line notice, or null when up to date / no cache. Pure. */ -export function updateNotice(current: string, cache: UpdateCache | null): string | null { +export function updateNotice( + current: string, + cache: UpdateCache | null, + opts: NoticeOpts = {}, +): string | null { if (!cache?.latest || !isNewer(current, cache.latest)) return null; - return [ - pc.yellow(`⚠ Your perchd version ${current} is outdated. Latest is ${cache.latest}.`), - pc.dim(" Update with: npm i -g perchd@latest"), - ].join("\n"); + const head = pc.yellow(`⚠ Your perchd version ${current} is outdated. Latest is ${cache.latest}.`); + const action = opts.autoUpdate + ? pc.dim(" Auto-updating in the background — restart perchd to use it.") + : pc.dim(" Run: perchd update"); + return [head, action].join("\n"); } export function cachePath(): string { @@ -70,6 +109,28 @@ export interface UpdateCheckDeps { fetchLatest?: () => Promise; now?: () => number; path?: string; + /** Our own version, so the background check can decide whether to auto-update. */ + current?: string; + autoUpdate?: boolean; + binPath?: string; + /** Injectable install runner (returns an exit code); defaults to spawning the manager. */ + exec?: (cmd: string[]) => Promise; +} + +/** Run a command to completion, resolving to its exit code. stdio inherited. */ +export function execCommand(cmd: string[], opts: { silent?: boolean } = {}): Promise { + return new Promise((resolve) => { + try { + const child = spawn(cmd[0], cmd.slice(1), { + stdio: opts.silent ? "ignore" : "inherit", + shell: false, + }); + child.on("error", () => resolve(1)); + child.on("close", (code) => resolve(code ?? 1)); + } catch { + resolve(1); + } + }); } async function defaultFetchLatest(): Promise { @@ -95,16 +156,34 @@ export async function runUpdateCheck(deps: UpdateCheckDeps = {}): Promise const path = deps.path ?? cachePath(); try { const latest = await fetchLatest(); - if (latest) writeCache(path, { checkedAt: now(), latest }); + if (!latest) return; + writeCache(path, { checkedAt: now(), latest }); + + // Opt-in auto-update: if enabled and we're behind, install the new version in + // this already-detached process. It applies to the *next* run. Fail-silent — + // a sudo-needing (system-node) or otherwise unwritable install just no-ops + // and the notice keeps showing, so we never brick or block anything. + if (deps.autoUpdate && deps.current && isNewer(deps.current, latest)) { + const binPath = deps.binPath ?? process.argv[1] ?? ""; + const cmd = updateCommand(detectManager(binPath)); + const exec = deps.exec ?? ((c: string[]) => execCommand(c, { silent: true })); + await exec(cmd); + } } catch { /* fail-silent */ } } -/** Spawn a detached, unref'd refresh so it outlives — and never delays — this call. */ -export function refreshInBackground(): void { +/** + * Spawn a detached, unref'd refresh so it outlives — and never delays — this + * call. The current version rides along as an argv so the child can auto-update + * (when opted in) without re-reading package.json. + */ +export function refreshInBackground(current?: string): void { try { - const child = spawn(process.execPath, [process.argv[1], HIDDEN_COMMAND], { + const args = [process.argv[1], HIDDEN_COMMAND]; + if (current) args.push(current); + const child = spawn(process.execPath, args, { detached: true, stdio: "ignore", }); @@ -131,13 +210,13 @@ export function maybeNotifyUpdate(current: string): void { if (updateCheckSuppressed()) return; const path = cachePath(); const cache = readCache(path); - const notice = updateNotice(current, cache); + const notice = updateNotice(current, cache, { autoUpdate: autoUpdateEnabled() }); if (notice) { process.on("exit", () => { try { process.stderr.write(notice + "\n"); } catch { /* stream gone */ } }); } - if (isStale(cache, Date.now())) refreshInBackground(); + if (isStale(cache, Date.now())) refreshInBackground(current); } export { HIDDEN_COMMAND }; diff --git a/test/update.test.ts b/test/update.test.ts index 14bb958..c6cfc83 100644 --- a/test/update.test.ts +++ b/test/update.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { isNewer, updateNotice, readCache, writeCache, isStale, runUpdateCheck, + detectManager, updateCommand, autoUpdateEnabled, } from "../src/core/update.js"; +import { runUpdate } from "../src/commands/update.js"; describe("isNewer", () => { it("detects a newer latest", () => { @@ -29,13 +31,98 @@ describe("updateNotice", () => { expect(updateNotice("0.5.0", { checkedAt: 1, latest: "0.5.0" })).toBeNull(); expect(updateNotice("0.6.0", { checkedAt: 1, latest: "0.5.0" })).toBeNull(); }); - it("names both versions and the update command when outdated", () => { + it("names both versions and points at `perchd update` when outdated", () => { const n = updateNotice("0.5.0", { checkedAt: 1, latest: "0.6.0" })!; expect(n).toContain("0.5.0"); expect(n).toContain("0.6.0"); - expect(n).toContain("npm i -g perchd"); + expect(n).toContain("perchd update"); expect(n.toLowerCase()).toContain("outdated"); }); + it("says it's auto-updating instead when auto-update is on", () => { + const n = updateNotice("0.5.0", { checkedAt: 1, latest: "0.6.0" }, { autoUpdate: true })!; + expect(n).toContain("0.6.0"); + expect(n.toLowerCase()).toContain("background"); + expect(n).not.toContain("perchd update"); + }); +}); + +describe("detectManager", () => { + it("defaults to npm", () => { + expect(detectManager("/usr/local/lib/node_modules/perchd/dist/cli.js", {})).toBe("npm"); + expect(detectManager("/Users/x/.nvm/versions/node/v20/lib/node_modules/perchd/dist/cli.js", {})).toBe("npm"); + }); + it("detects pnpm from the path or PNPM_HOME", () => { + expect(detectManager("/Users/x/Library/pnpm/global/5/.pnpm/perchd@0.6.0/node_modules/perchd/dist/cli.js", {})).toBe("pnpm"); + expect(detectManager("/opt/perchd/dist/cli.js", { PNPM_HOME: "/opt" })).toBe("pnpm"); + }); + it("detects bun from the path", () => { + expect(detectManager("/Users/x/.bun/install/global/node_modules/perchd/dist/cli.js", {})).toBe("bun"); + }); +}); + +describe("updateCommand", () => { + it("builds the global-install command per manager", () => { + expect(updateCommand("npm")).toEqual(["npm", "install", "-g", "perchd@latest"]); + expect(updateCommand("pnpm")).toEqual(["pnpm", "add", "-g", "perchd@latest"]); + expect(updateCommand("bun")).toEqual(["bun", "add", "-g", "perchd@latest"]); + }); +}); + +describe("autoUpdateEnabled", () => { + it("is off by default and on for truthy values", () => { + expect(autoUpdateEnabled({})).toBe(false); + expect(autoUpdateEnabled({ PERCHD_AUTO_UPDATE: "0" })).toBe(false); + expect(autoUpdateEnabled({ PERCHD_AUTO_UPDATE: "1" })).toBe(true); + expect(autoUpdateEnabled({ PERCHD_AUTO_UPDATE: "true" })).toBe(true); + }); +}); + +describe("runUpdateCheck auto-update", () => { + const p = () => join(mkdtempSync(join(tmpdir(), "perchd-au-")), "u.json"); + it("installs in the background when enabled and outdated", async () => { + const calls: string[][] = []; + await runUpdateCheck({ + fetchLatest: async () => "0.7.0", now: () => 1, path: p(), + current: "0.6.0", autoUpdate: true, binPath: "/usr/local/lib/node_modules/perchd/dist/cli.js", + exec: async (cmd) => { calls.push(cmd); return 0; }, + }); + expect(calls).toEqual([["npm", "install", "-g", "perchd@latest"]]); + }); + it("does not install when already up to date", async () => { + const calls: string[][] = []; + await runUpdateCheck({ + fetchLatest: async () => "0.6.0", now: () => 1, path: p(), + current: "0.6.0", autoUpdate: true, exec: async (cmd) => { calls.push(cmd); return 0; }, + }); + expect(calls).toEqual([]); + }); + it("does not install when auto-update is off", async () => { + const calls: string[][] = []; + await runUpdateCheck({ + fetchLatest: async () => "0.7.0", now: () => 1, path: p(), + current: "0.6.0", autoUpdate: false, exec: async (cmd) => { calls.push(cmd); return 0; }, + }); + expect(calls).toEqual([]); + }); +}); + +describe("runUpdate command", () => { + it("runs the detected manager's global-install and returns its exit code", async () => { + const calls: string[][] = []; + const code = await runUpdate({ + binPath: "/Users/x/.bun/install/global/node_modules/perchd/dist/cli.js", + env: {}, log: () => {}, exec: async (cmd) => { calls.push(cmd); return 0; }, + }); + expect(calls).toEqual([["bun", "add", "-g", "perchd@latest"]]); + expect(code).toBe(0); + }); + it("returns a non-zero exit code on failure", async () => { + const code = await runUpdate({ + binPath: "/usr/local/lib/node_modules/perchd/dist/cli.js", + env: {}, log: () => {}, exec: async () => 1, + }); + expect(code).toBe(1); + }); }); describe("cache read/write", () => {