From 972244a4dbd21b2a377ad7a39c710ec17c8e6aff Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 04:46:50 +0000 Subject: [PATCH 1/3] fix(upgrade): upgrade privacycode with its installer, not its own updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode upgrade` asked privacycode to update itself, and it always refused: ■ opencode is installed to /home/anthony/.privacycode/bin/privacycode ● Using method: unknown ■ Upgrade failed — Unknown installation method: unknown privacycode is an opencode derivative, so `privacycode upgrade` is opencode's updater, and that updater picks its method by recognising where the binary was installed. It knows opencode's own locations; it does not know this fork's ~/.privacycode/bin, so it resolves `unknown` and stops before doing anything. Nothing about the machine makes this intermittent — it can never upgrade an install made by that installer. Drop the native updater from the engine and let upgradeSpec fall through to the installer, which is idempotent and fetches the latest. Plain opencode keeps its updater: run against a real opencode install it reports `Using method: curl` and upgrades cleanly. Co-Authored-By: Claude Opus 5 (1M context) --- src/engines.mjs | 7 ++++++- test/upgrade.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/engines.mjs b/src/engines.mjs index 59ea517..0167eb0 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -33,7 +33,12 @@ export const ENGINES = { agentArgs: ["--auto"], agentsView: ["agent", "list"], install: { cmd: "sh", args: ["-c", "curl -fsSL https://getprivacycode.com/install | sh"] }, - upgrade: { cmd: "privacycode", args: ["upgrade"] }, + // Deliberately no native updater. `privacycode upgrade` is opencode's, and + // it works out how to update itself by recognising where it was installed — + // it knows opencode's own locations, not this fork's ~/.privacycode/bin. It + // reports `Using method: unknown` and aborts with "Unknown installation + // method", every time, so it can never upgrade an install we made. Falling + // through to the installer above is what actually moves the version. }, claude: { desc: "Claude Code — Anthropic's agentic CLI", diff --git a/test/upgrade.test.mjs b/test/upgrade.test.mjs index 1458726..4db0102 100644 --- a/test/upgrade.test.mjs +++ b/test/upgrade.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { ENGINES, upgradeSpec } from "../src/engines.mjs"; import { planUpgrade, selfSpec } from "../src/upgrade.mjs"; function withFakeTools(fn) { @@ -63,6 +64,33 @@ test("default upgrade includes self and every installed tool", () => { }); }); +test("privacycode upgrades by re-running its installer, not its own updater", () => { + // `privacycode upgrade` is opencode's updater, and it decides how to update + // by recognising where the binary was installed. It knows opencode's + // locations, not this fork's ~/.privacycode/bin, so against an install of + // ours it reports `Using method: unknown` and aborts every time. Re-running + // the installer is what actually moves the version. + assert.equal(ENGINES.privacycode.upgrade, undefined, "privacycode must not carry a native updater"); + assert.deepEqual(upgradeSpec(ENGINES.privacycode), ENGINES.privacycode.install); + + // The same call on plain opencode keeps its updater: that one does know + // where opencode puts itself. + assert.deepEqual(upgradeSpec(ENGINES.opencode), { cmd: "opencode", args: ["upgrade"] }); +}); + +test("an explicit privacycode upgrade plans the installer", () => { + const plan = planUpgrade(["privacycode"]); + + assert.equal(plan.self, false); + assert.deepEqual(plan.items.map(({ key, kind, spec }) => ({ key, kind, spec })), [ + { + key: "privacycode", + kind: "engine", + spec: { cmd: "sh", args: ["-c", "curl -fsSL https://getprivacycode.com/install | sh"] }, + }, + ]); +}); + test("unknown upgrade targets remain visible to the caller", () => { const plan = planUpgrade(["not-a-tool"]); assert.deepEqual(plan.unknown, ["not-a-tool"]); From 7c16a4a6b6144e74737f90da0ade962dd154e4df Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 04:49:32 +0000 Subject: [PATCH 2/3] fix(upgrade): fall back to the installer when a native updater refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode upgrade` left targets stranded on old versions: ● Using method: unknown ■ Upgrade failed — Unknown installation method: unknown opencode-family updaters choose how to update by recognising where the binary was installed. When they don't recognise the location they resolve `unknown` and stop — the same on every run, so the target never moves. It is not machine-specific luck: the same `opencode upgrade` reports `Using method: curl` and succeeds where the install is one it knows, and fails where it isn't. A fork living under its own directory hits this every time. Try the installer when the native updater fails. Installers are idempotent and fetch the latest, which is why they are already what an uninstalled target runs. The fallback only exists where the updater is a different command, so it can never repeat the one that just failed, and it says so rather than retrying silently. privacycode loses its native updater outright: it is opencode's, pointed at ~/.privacycode/bin, so it cannot ever work — no reason to spend a failed run discovering that every time. Adds an injectable runner to runUpgrade so the retry is testable without spawning real installers. Co-Authored-By: Claude Opus 5 (1M context) --- src/upgrade.mjs | 34 ++++++++++++++++++++++---- test/upgrade.test.mjs | 56 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/upgrade.mjs b/src/upgrade.mjs index 2858475..faf6848 100644 --- a/src/upgrade.mjs +++ b/src/upgrade.mjs @@ -80,6 +80,10 @@ export function planUpgrade(targets = []) { label: key, kind: "engine", spec: installed ? upgradeSpec(ENGINES[key]) : ENGINES[key].install, + // Where to turn when a native updater refuses. Only set when the updater + // is something other than the installer, so a fallback can never repeat + // the command that just failed. + fallback: installed && upgradeSpec(ENGINES[key]) !== ENGINES[key].install ? ENGINES[key].install : null, installed, }); }; @@ -93,6 +97,7 @@ export function planUpgrade(targets = []) { label: key, kind: "tool", spec: installed ? toolUpgradeSpec(TOOLS[key]) : TOOLS[key].install, + fallback: installed && toolUpgradeSpec(TOOLS[key]) !== TOOLS[key].install ? TOOLS[key].install : null, installed, }); }; @@ -133,16 +138,22 @@ export async function runUpgrade(targets = [], io = {}) { return []; } + const exec = io.runCmd || runCmd; + const results = []; - const run = async (name, spec, note) => { + const attempt = async (name, spec, note) => { log(`\n⬆ upgrading ${name}${note ? ` ${note}` : ""} — ${spec.cmd} ${spec.args.join(" ")}`); rule(); - const r = await runCmd(spec.cmd, spec.args); + const r = await exec(spec.cmd, spec.args); rule(); const ok = ranOk(r); log(ok ? `✓ ${name} up to date` : `✗ ${name} upgrade failed (${exitReason(r)})`); - results.push({ name, ok, code: r.code, signal: r.signal ?? null }); - return ok; + return { name, ok, code: r.code, signal: r.signal ?? null }; + }; + const run = async (name, spec, note) => { + const result = await attempt(name, spec, note); + results.push(result); + return result.ok; }; if (self) { @@ -158,7 +169,20 @@ export async function runUpgrade(targets = [], io = {}) { } } } - for (const it of items) await run(it.label, it.spec, it.installed ? "" : "(installing — not present)"); + for (const it of items) { + const result = await attempt(it.label, it.spec, it.installed ? "" : "(installing — not present)"); + // A native updater that can't tell how the binary got there fails the same + // way on every run — an opencode fork living under its own directory, a + // binary someone moved, a machine where the installer left no marker. The + // installer is idempotent and fetches the latest, so reach for it rather + // than leaving the target stranded on an old version. + if (!result.ok && it.fallback) { + log(`· ${it.label}'s own updater could not do it — falling back to its installer`); + results.push(await attempt(it.label, it.fallback, "(installer)")); + continue; + } + results.push(result); + } const failed = results.filter((r) => !r.ok); log(`\n${failed.length ? "✗" : "✓"} upgraded ${results.length - failed.length}/${results.length}${failed.length ? ` — failed: ${failed.map((r) => r.name).join(", ")}` : "."} 🤘`); diff --git a/test/upgrade.test.mjs b/test/upgrade.test.mjs index 4db0102..483f3f1 100644 --- a/test/upgrade.test.mjs +++ b/test/upgrade.test.mjs @@ -5,7 +5,23 @@ import path from "node:path"; import test from "node:test"; import { ENGINES, upgradeSpec } from "../src/engines.mjs"; -import { planUpgrade, selfSpec } from "../src/upgrade.mjs"; +import { planUpgrade, runUpgrade, selfSpec } from "../src/upgrade.mjs"; + +// Same trick as withFakeTools, for the cases that turn on a target being +// installed: only an installed target is asked to update itself, so the +// fallback path can't be reached without one on PATH. +async function withFakeBins(names, fn) { + const dir = mkdtempSync(path.join(tmpdir(), "moshcode-upgrade-bins-")); + for (const name of names) { + const file = path.join(dir, name); + writeFileSync(file, "#!/bin/sh\nexit 0\n"); + chmodSync(file, 0o755); + } + const before = process.env.PATH; + process.env.PATH = dir; + try { return await fn(); } + finally { process.env.PATH = before; } +} function withFakeTools(fn) { const dir = mkdtempSync(path.join(tmpdir(), "moshcode-upgrade-")); @@ -91,6 +107,44 @@ test("an explicit privacycode upgrade plans the installer", () => { ]); }); +test("a refused native updater falls back to the installer", async () => { + // opencode's updater picks its method from where the binary was installed, + // and reports `Using method: unknown` and gives up when it doesn't + // recognise the location — the same on every run, so the target would stay + // stranded on an old version. The installer is idempotent; use it. + const calls = []; + const runCmd = async (cmd, args) => { + calls.push(`${cmd} ${args.join(" ")}`); + return cmd === "opencode" ? { ok: true, code: 1, signal: null } : { ok: true, code: 0, signal: null }; + }; + const logs = []; + const results = await withFakeBins(["opencode"], () => runUpgrade(["opencode"], { + runCmd, log: (s) => logs.push(s), rule: () => {}, + })); + + assert.deepEqual(calls, [ + "opencode upgrade", + "bash -c curl -fsSL https://opencode.ai/install | bash", + ], "the installer runs only after its own updater refuses"); + assert.deepEqual(results.map((r) => r.ok), [true], "the fallback result is what counts, not the refusal"); + assert.ok(logs.some((l) => l.includes("falling back to its installer")), "the fallback must be visible, not silent"); +}); + +test("a target with no separate updater is not retried", async () => { + // claude upgrades with `npm install -g`, which is already the installer. + // Retrying it would just run the identical command a second time. + const calls = []; + const runCmd = async (cmd, args) => { + calls.push(`${cmd} ${args.join(" ")}`); + return { ok: true, code: 1, signal: null }; + }; + const results = await withFakeBins(["claude"], () => + runUpgrade(["claude"], { runCmd, log: () => {}, rule: () => {} })); + + assert.deepEqual(calls, ["npm install -g @anthropic-ai/claude-code"]); + assert.deepEqual(results.map((r) => r.ok), [false]); +}); + test("unknown upgrade targets remain visible to the caller", () => { const plan = planUpgrade(["not-a-tool"]); assert.deepEqual(plan.unknown, ["not-a-tool"]); From a54495acd8a0c84db4c0e2e381da22d120f78f65 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 04:59:34 +0000 Subject: [PATCH 3/3] test(upgrade): assert the sweep covers both kinds, not a count of five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep over every entry with a native updater guarded itself with `length >= 5`, which is a number that moves whenever an entry gains or loses an updater — privacycode just lost one, and the sweep failed despite the invariant it exists to protect still holding for all four that remain. Assert what the guard is for instead: that at least one engine and at least one tool are in the sweep, so neither code path can silently drop out of it. Co-Authored-By: Claude Opus 5 (1M context) --- test/upgrade-install-missing.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/upgrade-install-missing.test.mjs b/test/upgrade-install-missing.test.mjs index 69b6cf0..f83f7c1 100644 --- a/test/upgrade-install-missing.test.mjs +++ b/test/upgrade-install-missing.test.mjs @@ -78,7 +78,11 @@ test("a missing engine reached through an alias is planned with its installer", test("no missing target is ever planned to run the binary it is missing", () => { // The sweep that matters: for EVERY entry with a native updater, the planned // command for a not-installed target must not be the absent binary itself. - assert.ok(withNativeUpdater.length >= 5, "expected several native updaters"); + // Cover both code paths rather than counting to a number that moves whenever + // an entry gains or loses an updater — privacycode dropped its own once it + // turned out it could never update an install of ours. + assert.ok(withNativeUpdater.some(([, , kind]) => kind === "engine"), "no engine has a native updater to sweep"); + assert.ok(withNativeUpdater.some(([, , kind]) => kind === "tool"), "no tool has a native updater to sweep"); for (const [key, entry, kind] of withNativeUpdater) { const item = specOf(key); assert.equal(item.installed, false, `${kind} ${key} should not be installed`);