Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <str>` and `--port <n>`
Expand Down
13 changes: 11 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <version>`: 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);
Expand Down
33 changes: 33 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
}

/**
* `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<number> {
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;
}
101 changes: 90 additions & 11 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -70,6 +109,28 @@ export interface UpdateCheckDeps {
fetchLatest?: () => Promise<string>;
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<number>;
}

/** Run a command to completion, resolving to its exit code. stdio inherited. */
export function execCommand(cmd: string[], opts: { silent?: boolean } = {}): Promise<number> {
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<string> {
Expand All @@ -95,16 +156,34 @@ export async function runUpdateCheck(deps: UpdateCheckDeps = {}): Promise<void>
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",
});
Expand All @@ -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 };
91 changes: 89 additions & 2 deletions test/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading