From 58db8b77dd0d0ac2873e35d2904ac9db2dfdcf54 Mon Sep 17 00:00:00 2001
From: Oliver Mee <102673257+oliver-mee@users.noreply.github.com>
Date: Thu, 10 Sep 2026 20:21:47 +0800
Subject: [PATCH 1/2] feat(update): support native pnpm self-updates
Bind pnpm updates to the global installation that owns OpenCodex, verify package trees and generated shims during recovery, and launch through the verified active package. Add manager-aware integrity preflight, focused tests, and documentation while keeping the npm transactional path intact.
---
README.md | 9 +-
bin/ocx.mjs | 293 +++++++--
.../docs/getting-started/for-agents.md | 1 +
.../docs/getting-started/installation.md | 8 +-
.../content/docs/reference/cli/lifecycle.md | 13 +-
scripts/test-layout/layout.json | 1 +
src/cli.ts | 4 +-
src/cli/launcher-context.ts | 2 +-
src/config/pending-teardown.ts | 2 +-
src/lib/bun-runtime.ts | 2 +-
src/lib/package-tree-integrity.ts | 2 +-
src/service.ts | 2 +-
src/update/badge.ts | 5 +-
src/update/index.ts | 381 +++++++++--
src/update/install-detection.d.mts | 6 +
src/update/install-detection.mjs | 73 +++
src/update/job.ts | 150 +++--
src/update/pnpm-global-install.d.mts | 144 ++++
src/update/pnpm-global-install.mjs | 591 +++++++++++++++++
src/update/pnpm-invocation.d.mts | 43 ++
src/update/pnpm-invocation.mjs | 141 ++++
src/update/registry-integrity.d.mts | 16 +
src/update/registry-integrity.mjs | 37 ++
src/update/transactional-install.d.mts | 2 +-
src/update/transactional-install.mjs | 43 +-
src/update/tray-update-plan.mjs | 2 +-
structure/01_runtime.md | 2 +-
structure/06_docs-and-release.md | 22 +-
tests/ci-workflows/install-scripts.test.ts | 6 +-
tests/cli/ocx-launcher-runtime.test.ts | 4 +-
tests/cli/ocx-launcher-source.test.ts | 11 +-
tests/fixtures/test-layout-expected.json | 1 +
tests/update/update-badge.test.ts | 2 +-
tests/update/update-job.test.ts | 100 ++-
tests/update/update-pnpm.test.ts | 617 ++++++++++++++++++
tests/update/update-stop-first.test.ts | 16 +-
36 files changed, 2517 insertions(+), 237 deletions(-)
create mode 100644 src/update/install-detection.d.mts
create mode 100644 src/update/install-detection.mjs
create mode 100644 src/update/pnpm-global-install.d.mts
create mode 100644 src/update/pnpm-global-install.mjs
create mode 100644 src/update/pnpm-invocation.d.mts
create mode 100644 src/update/pnpm-invocation.mjs
create mode 100644 src/update/registry-integrity.d.mts
create mode 100644 src/update/registry-integrity.mjs
create mode 100644 tests/update/update-pnpm.test.ts
diff --git a/README.md b/README.md
index c7ce31acce..4493c1d6ed 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Two commands, and every one of them runs any LLM you point it at.
```bash
npm install -g @bitkyc08/opencodex
+# Or with pnpm 10.4+: pnpm add -g --allow-build=bun @bitkyc08/opencodex
ocx start
```
@@ -82,6 +83,7 @@ account while existing threads stay pinned to the account that started them.
```bash
npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
+# Or with pnpm 10.4+: pnpm add -g --allow-build=bun @bitkyc08/opencodex
ocx start # proxy + dashboard on localhost:10100
```
@@ -188,6 +190,7 @@ they reach the npm package.
```bash
npm install -g @bitkyc08/opencodex
+# Or with pnpm 10.4+: pnpm add -g --allow-build=bun @bitkyc08/opencodex
ocx start # or `ocx service`
ocx init # interactive setup: writes ~/.opencodex/config.json and wires Codex
```
@@ -211,9 +214,9 @@ when it is unreachable). `ocx status` / `ocx doctor` / `ocx health` report the r
| Linux (x64 / arm64) | Fully supported | systemd (user unit) |
| Windows (x64) | Fully supported | Task Scheduler (hidden) / opt-in native service (`--native`, WinSW) |
-Requires [Node](https://nodejs.org) 18+. The Bun runtime is bundled on `npm install` — no separate
-Bun install needed, no WSL needed on Windows. If npm blocked the bundled runtime's install scripts,
-see the [installation docs](https://opencodex.me/getting-started/installation/).
+Requires [Node](https://nodejs.org) 18+. The Bun runtime is bundled on npm or pnpm install — no
+separate Bun install needed, no WSL needed on Windows. If npm blocked the bundled runtime's install
+scripts, see the [installation docs](https://opencodex.me/getting-started/installation/).
## Highlights
diff --git a/bin/ocx.mjs b/bin/ocx.mjs
index 59818de2f5..ef3aa80cd8 100755
--- a/bin/ocx.mjs
+++ b/bin/ocx.mjs
@@ -1,12 +1,12 @@
#!/usr/bin/env node
/**
- * opencodex npm bin launcher.
+ * opencodex published-package bin launcher.
*
* The package source is TypeScript that runs on the Bun runtime. To let
- * `npm install -g @bitkyc08/opencodex` work without a separately-installed Bun,
+ * global npm and pnpm installs of `@bitkyc08/opencodex` work without a separately-installed Bun,
* we bundle the runtime via the `bun` npm dependency and exec it from this
* Node shim. (Dev still runs `bun run src/cli/index.ts` directly via the shebang on
- * src/cli/index.ts — only the published npm `bin` routes through here.)
+ * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.)
*/
import { spawn, spawnSync } from "node:child_process";
import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs";
@@ -20,6 +20,14 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs";
import { npmInvocation } from "../src/update/npm-invocation.mjs";
+import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs";
+import { detectInstallFromPath } from "../src/update/install-detection.mjs";
+import {
+ pnpmOwnerInvocation,
+ resolvePnpmGlobalOwner,
+ runPnpmGlobalUpdate,
+} from "../src/update/pnpm-global-install.mjs";
+import { checkRegistryPackageIntegrity } from "../src/update/registry-integrity.mjs";
import { hasPendingTeardownIn } from "../src/config/pending-teardown-names.mjs";
import {
npmCachePreflightFailureMessage,
@@ -44,6 +52,7 @@ try {
}
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
+const installMethod = detectInstallFromPath(here, { exists: existsSync });
const cliPath = join(here, "..", "src", "cli", "index.ts");
const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT";
const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof=";
@@ -53,7 +62,7 @@ function isNodeModulesInstall() {
}
function isBunGlobalInstall() {
- return /[\\/]\.bun[\\/]/.test(here);
+ return installMethod === "bun";
}
function currentPackageVersion() {
@@ -102,10 +111,9 @@ function historyRestoreIncomplete() {
}
}
-function repairCodexShimIfNeeded() {
+function repairCodexShimIfNeeded(launcherPath = fileURLToPath(import.meta.url)) {
if (!shouldRepairCodexShim()) return;
- const launcher = fileURLToPath(import.meta.url);
- const res = spawnSync(process.execPath, [launcher, "codex-shim", "install"], {
+ const res = spawnSync(process.execPath, [launcherPath, "codex-shim", "install"], {
stdio: "inherit",
windowsHide: true,
});
@@ -135,34 +143,116 @@ function runTrayLifecycle(launcher, action) {
});
}
+function shellQuote(value) {
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`;
+ return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+function launcherStartHint(launcher, port) {
+ return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`;
+}
+
function runNpmSelfUpdate() {
+ return runPackageManagerSelfUpdate("npm");
+}
+
+function runPnpmSelfUpdate() {
+ return runPackageManagerSelfUpdate("pnpm");
+}
+
+function runningPnpmShimPath() {
+ const invoked = process.argv[1];
+ if (!invoked) return undefined;
+ const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
+ if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) {
+ return undefined;
+ }
+ return resolve(invoked);
+}
+
+function runPackageManagerSelfUpdate(manager) {
const current = currentPackageVersion();
const tag = updateTag(current);
- const latestInvocation = npmInvocation(["view", `${PKG}@${tag}`, "version"]);
- const installInvocation = npmInvocation(["install", "-g", `${PKG}@${tag}`]);
+ let owner;
+ if (manager === "pnpm") {
+ const ownerResult = resolvePnpmGlobalOwner({
+ packageName: PKG,
+ packagePath: resolve(here, ".."),
+ commandPaths: resolvePnpmCommands(),
+ runningShimPath: runningPnpmShimPath(),
+ runPnpm: (commandPath, args, capture = false) => {
+ const invocation = pnpmInvocationForPath(commandPath, args);
+ if (!invocation) return { status: 1 };
+ return spawnSync(invocation.file, invocation.args, {
+ stdio: capture ? "pipe" : "ignore",
+ encoding: "utf8",
+ timeout: 20_000,
+ windowsHide: true,
+ ...invocation.options,
+ });
+ },
+ });
+ if (!ownerResult.ok) {
+ console.error(`opencodex: ${ownerResult.reason}; aborting before stopping the proxy.`);
+ process.exit(1);
+ }
+ owner = ownerResult.owner;
+ }
+ const managerInvocation = args => manager === "pnpm"
+ ? pnpmOwnerInvocation(owner, args)
+ : npmInvocation(args);
+ const latestInvocation = managerInvocation(["view", `${PKG}@${tag}`, "version"]);
+ const installArgs = manager === "pnpm"
+ ? ["add", "-g", "--allow-build=bun", `${PKG}@${tag}`]
+ : ["install", "-g", `${PKG}@${tag}`];
+ const installInvocation = managerInvocation(installArgs);
if (!latestInvocation || !installInvocation) {
- console.error("opencodex: could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
+ console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
process.exit(1);
}
const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, {
encoding: "utf8",
timeout: 12000,
windowsHide: true,
+ ...(latestInvocation.env ? { env: latestInvocation.env } : {}),
...latestInvocation.options,
});
- const latest = latestResult.status === 0 ? latestResult.stdout.trim() : "";
+ const latest = latestResult.status === 0 && typeof latestResult.stdout === "string" ? latestResult.stdout.trim() : "";
- console.log(`opencodex v${current} (installed via npm, tag ${tag})`);
+ console.log(`opencodex v${current} (installed via ${manager}, tag ${tag})`);
if (latest && latest === current) {
console.log(`Already on the latest ${tag} version (v${latest}).`);
process.exit(0);
}
- const cachePreflight = runNpmCachePreflight();
- if (!cachePreflight.ok) {
- console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`);
+ const integrity = checkRegistryPackageIntegrity(PKG, latest || null, args => {
+ const invocation = managerInvocation(args);
+ if (!invocation) return { status: 1 };
+ return spawnSync(invocation.file, invocation.args, {
+ encoding: "utf8",
+ timeout: 12000,
+ windowsHide: true,
+ ...(invocation.env ? { env: invocation.env } : {}),
+ ...invocation.options,
+ });
+ });
+ if (integrity.ok === false) {
+ console.error(`opencodex: ${integrity.reason}; aborting before stopping the proxy.`);
process.exit(1);
}
+ if (integrity.ok === "skipped") {
+ console.warn(`opencodex: integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`);
+ } else {
+ console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`);
+ }
+
+ if (manager === "npm") {
+ const cachePreflight = runNpmCachePreflight();
+ if (!cachePreflight.ok) {
+ console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`);
+ process.exit(1);
+ }
+ }
// Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
// unloads it, so a successful update must refresh and restart it afterwards.
@@ -177,15 +267,15 @@ function runNpmSelfUpdate() {
* may be re-registered and require elevation.
*/
function serviceRefreshArgs() {
- return [launcher, "service", "repair"];
+ return [postUpdateLauncher, "service", "repair"];
}
/** Register from scratch, preserving the recorded backend. Only for a genuinely absent service. */
function serviceInstallArgs() {
try {
const state = JSON.parse(readFileSync(serviceStatePath, "utf8"));
- if (state.backend === "native") return [launcher, "service", "install", "--native"];
+ if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"];
} catch { /* missing or corrupt — fall through to default */ }
- return [launcher, "service", "install"];
+ return [postUpdateLauncher, "service", "install"];
}
/**
* Structured "is a service actually registered?" answer.
@@ -262,16 +352,23 @@ function runNpmSelfUpdate() {
// get it from one place.
const launcher = fileURLToPath(import.meta.url);
+ // The pnpm owner preflight has verified this package tree and global group. Keep that exact
+ // package path as the recovery starting point; a path returned by the pnpm transaction
+ // replaces it only after the new tree and shims have been verified.
+ let postUpdateLauncher = manager === "pnpm" && owner
+ ? join(owner.packagePath, "bin", "ocx.mjs")
+ : launcher;
+ let postUpdateLauncherUsable = true;
function startProxyDirectly() {
- if (!existsSync(launcher)) {
+ if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) {
console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually.");
return;
}
const env = { ...process.env };
delete env.OCX_SERVICE;
console.log(`Attempting to restart the proxy on port ${bakePort}.`);
- const child = spawn(process.execPath, [launcher, "start", "--port", String(bakePort)], {
+ const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -296,7 +393,7 @@ function runNpmSelfUpdate() {
// diagnostic says the service is genuinely absent. Installing after ANY repair
// failure would resurrect the elevation prompt this change exists to avoid, and
// could re-register a service the user just uninstalled.
- if (svc.status !== 0 && readServiceInstalledFromStatus(launcher) === false) {
+ if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) {
console.log("No registered service found — installing it instead.");
svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true });
}
@@ -305,7 +402,7 @@ function runNpmSelfUpdate() {
// Exit 0 can still leave stale/missing assets that never bring the proxy
// back — match the GUI/CLI fallthrough so /healthz is not left dead.
try {
- const st = spawnSync(process.execPath, [launcher, "status", "--json"], {
+ const st = spawnSync(process.execPath, [postUpdateLauncher, "status", "--json"], {
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
@@ -365,6 +462,10 @@ function runNpmSelfUpdate() {
existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json"));
function recoverStoppedRuntimeAfterFailure() {
+ if (!postUpdateLauncherUsable) {
+ console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually.");
+ return;
+ }
if (serviceWasInstalled) {
console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service.");
refreshBackgroundServiceOrStartDirect();
@@ -423,62 +524,105 @@ function runNpmSelfUpdate() {
}
}
- // #1942/#1849: stage -> verify -> swap -> rollback instead of installing straight
- // into the live tree. A failure at any point leaves either the old or the new tree
- // complete — never a file-less skeleton. Falls back to the legacy in-place install
- // only when the transactional module cannot run at all.
- const packageDir = resolve(here, "..");
- console.log(`Updating${latest ? ` to v${latest}` : ""} (transactional)...`);
+ // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a
+ // content-addressable store and generated global shims, so its path uses pnpm's own
+ // global update operation and verifies the active group instead of renaming files.
+ console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`);
let res;
try {
- const tx = transactionalNpmUpdate({
- packageDir,
- pkgName: PKG,
- targetVersion: latest || undefined,
- tag,
- runNpm: (args) => {
- const invocation = npmInvocation(args);
- if (!invocation) return { status: 1 };
- return spawnSync(invocation.file, invocation.args, {
- stdio: "inherit",
- timeout: 180000,
- windowsHide: true,
- ...invocation.options,
- });
- },
- log: (line) => console.log(line),
- });
- if (tx.ok) {
- res = { status: 0 };
- } else if (tx.phase === "stage" || tx.phase === "verify") {
- // Live tree untouched: report and stop. Nothing to roll back.
- console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`);
- res = { status: 1 };
+ if (manager === "npm") {
+ const packageDir = resolve(here, "..");
+ const tx = transactionalNpmUpdate({
+ packageDir,
+ pkgName: PKG,
+ targetVersion: latest || undefined,
+ tag,
+ runNpm: (args) => {
+ const invocation = npmInvocation(args);
+ if (!invocation) return { status: 1 };
+ return spawnSync(invocation.file, invocation.args, {
+ stdio: "inherit",
+ timeout: 180000,
+ windowsHide: true,
+ ...invocation.options,
+ });
+ },
+ log: (line) => console.log(line),
+ });
+ postUpdateLauncherUsable = tx.ok
+ || tx.rolledBack === true
+ || ["stage", "verify", "swap-backup"].includes(tx.phase);
+ if (tx.ok) {
+ res = { status: 0 };
+ } else if (tx.phase === "stage" || tx.phase === "verify") {
+ // Live tree untouched: report and stop. Nothing to roll back.
+ console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`);
+ res = { status: 1 };
+ } else {
+ console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`);
+ res = { status: 1 };
+ }
} else {
- console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`);
- res = { status: 1 };
+ const update = runPnpmGlobalUpdate({
+ packageName: PKG,
+ currentVersion: current,
+ targetVersion: latest || undefined,
+ tag,
+ owner,
+ runningPackagePath: resolve(here, ".."),
+ runPnpm: (args, capture = false) => {
+ const invocation = pnpmOwnerInvocation(owner, args);
+ if (!invocation) return { status: 1 };
+ return spawnSync(invocation.file, invocation.args, {
+ stdio: capture ? "pipe" : "inherit",
+ encoding: "utf8",
+ timeout: 180000,
+ windowsHide: true,
+ env: invocation.env,
+ ...invocation.options,
+ });
+ },
+ log: line => console.log(line),
+ });
+ if (update.ok) {
+ // pnpm switches the active global group and updates its shim. Continue recovery
+ // through that fresh package tree, not the old group whose launcher is still
+ // executing this update.
+ postUpdateLauncher = join(update.path, "bin", "ocx.mjs");
+ res = { status: 0 };
+ } else {
+ console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`);
+ postUpdateLauncherUsable = Boolean(update.activePath);
+ if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs");
+ res = { status: 1 };
+ }
}
} catch (error) {
// An unexpected throw means we cannot prove the live tree is untouched, so the
// legacy in-place install (which deletes live first) is exactly the wrong rescue —
// it recreates the #1849 destruction path. Report and stop; the boot probe and the
// recovery marker cover the swap-window states.
- console.error(`opencodex: transactional update failed unexpectedly (${error?.message ?? error}). ` +
- `The live install was not knowingly modified; run 'ocx update' again or reinstall with ` +
- `npm install -g --allow-scripts=bun ${PKG}@${tag}.`);
+ const manual = manager === "pnpm"
+ ? `pnpm add -g --allow-build=bun ${PKG}@${tag}`
+ : `npm install -g --allow-scripts=bun ${PKG}@${tag}`;
+ // An unexpected exception leaves the active package path unproven for either manager.
+ // Do not run service/tray/proxy recovery through a possibly half-swapped tree.
+ postUpdateLauncherUsable = false;
+ console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` +
+ `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`);
res = { status: 1 };
}
if (res.status === 0) {
console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`);
- repairCodexShimIfNeeded();
+ repairCodexShimIfNeeded(postUpdateLauncher);
if (trayBeforeUpdate.refreshAfterReplacement) {
- const tray = spawnSync(process.execPath, [launcher, ...trayBeforeUpdate.installArgs], {
+ const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], {
stdio: "inherit",
windowsHide: true,
});
if (tray.status !== 0) {
console.warn("opencodex: Windows tray refresh failed. Run: ocx tray install");
- if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
+ if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start");
}
}
// The stop above unloaded any managed service; refresh via the freshly-installed
@@ -487,19 +631,22 @@ function runNpmSelfUpdate() {
console.log("Refreshing the background service with the updated files...");
refreshBackgroundServiceOrStartDirect();
} else {
- console.log("Restart the proxy: ocx start");
+ console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`);
}
process.exit(0);
}
- if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
+ if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start");
recoverStoppedRuntimeAfterFailure();
- console.error(`\nUpdate failed (npm exit ${res.status ?? "?"}). Try manually: npm install -g --allow-scripts=bun ${PKG}@${tag}`);
+ const manual = manager === "pnpm"
+ ? `pnpm add -g --allow-build=bun ${PKG}@${tag}`
+ : `npm install -g --allow-scripts=bun ${PKG}@${tag}`;
+ console.error(`\nUpdate failed (${manager} exit ${res.status ?? "?"}). Try manually: ${manual}`);
process.exit(1);
}
function bunBinDir() {
// Resolve the `bun` dependency's directory without hardcoding the platform
- // package — npm's os/cpu/libc resolution already picked the right @oven/bun-*.
+ // package — the package manager's os/cpu/libc resolution already picked the right @oven/bun-*.
return dirname(require.resolve("bun/package.json"));
}
@@ -511,7 +658,7 @@ const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";
const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH";
function findBunBinary(bunDir) {
- // The npm `bun` package ships the binary as bin/bun.exe on every platform;
+ // The bundled `bun` package ships the binary as bin/bun.exe on every platform;
// probe bin/bun too for forward compatibility.
for (const name of ["bun.exe", "bun"]) {
const p = join(bunDir, "bin", name);
@@ -521,12 +668,15 @@ function findBunBinary(bunDir) {
}
function fail(msg) {
+ const reinstall = installMethod === "pnpm"
+ ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex"
+ : "npm install -g --allow-scripts=bun @bitkyc08/opencodex";
console.error(
`opencodex: ${msg}\n` +
"The bundled Bun runtime could not be prepared. This usually means the\n" +
- "install skipped lifecycle scripts (e.g. npm blocked bun's postinstall\n" +
- "under allowScripts) or optional dependencies. Reinstall with:\n" +
- " npm install -g --allow-scripts=bun @bitkyc08/opencodex\n" +
+ "install skipped lifecycle scripts (for example npm blocked bun's postinstall\n" +
+ "or pnpm did not approve bun's build) or optional dependencies. Reinstall with:\n" +
+ ` ${reinstall}\n` +
"(use sudo if the original install used sudo; without --ignore-scripts\n" +
"and without --omit=optional / optional=false)"
);
@@ -534,7 +684,7 @@ function fail(msg) {
}
function resolveBun({ allowInstall = true } = {}) {
- // Keep direct npm-launcher starts aligned with durable service/shim installs:
+ // Keep direct package-launcher starts aligned with durable service/shim installs:
// a valid explicit runtime must win even when the bundled dependency exists.
const override = process.env[BUN_OVERRIDE_ENV]?.trim();
if (override) {
@@ -566,7 +716,7 @@ function resolveBun({ allowInstall = true } = {}) {
return { path: bin, source: "bundled" };
}
-// `ocx update --help` prints usage and exits WITHOUT side effects. The npm launcher
+// `ocx update --help` prints usage and exits WITHOUT side effects. The Node launcher
// intercepts `update` before the Bun CLI starts, so the help short-circuit must live
// here too — otherwise --help runs the real self-update, stops the proxy, and drops
// in-flight routed streams (issue #168).
@@ -584,13 +734,14 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") {
}
if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) {
- runNpmSelfUpdate();
+ if (installMethod === "npm") runNpmSelfUpdate();
+ if (installMethod === "pnpm") runPnpmSelfUpdate();
}
// #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a
// backup sibling and a broken live tree. Restore before anything tries to run from the
// broken tree; reap stale backups once the live tree verifies healthy.
-if (!codexCliUpdateInspection && isNodeModulesInstall() && !isBunGlobalInstall()) {
+if (!codexCliUpdateInspection && installMethod === "npm" && isNodeModulesInstall() && !isBunGlobalInstall()) {
try {
const probe = bootRestoreProbe(resolve(here, ".."));
if (probe.action === "restored") {
diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md
index b02c39812a..4a0d584091 100644
--- a/docs-site/src/content/docs/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/getting-started/for-agents.md
@@ -14,6 +14,7 @@ Install the published package and confirm that `ocx` is on `PATH`:
```bash
npm install -g @bitkyc08/opencodex
+# Or with pnpm 10.4+: pnpm add -g --allow-build=bun @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md
index 7c68e1b840..4ef61c5566 100644
--- a/docs-site/src/content/docs/getting-started/installation.md
+++ b/docs-site/src/content/docs/getting-started/installation.md
@@ -11,7 +11,7 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod
| Requirement | Why |
| --- | --- |
-| **[Node](https://nodejs.org) ≥ 18** | `ocx` runs on the Bun runtime, but the runtime is bundled automatically on `npm install` — you do **not** need to install Bun yourself. |
+| **[Node](https://nodejs.org) ≥ 18** | `ocx` runs on the Bun runtime, but the runtime is bundled automatically by the npm or pnpm install — you do **not** need to install Bun yourself. |
| **[OpenAI Codex](https://openai.com/codex)** (CLI, App, or SDK) | The client opencodex sits in front of. opencodex writes to `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`). |
| A provider account or API key | Anthropic, xAI, Kimi, Ollama Cloud, OpenRouter, an OpenAI-compatible endpoint, or your ChatGPT login. |
@@ -21,6 +21,12 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod
npm install -g @bitkyc08/opencodex
```
+With pnpm 10.4 or later:
+
+```bash
+pnpm add -g --allow-build=bun @bitkyc08/opencodex
+```
+
:::note[npm blocked the bun postinstall?]
Recent npm versions may block bun's postinstall script (`npm warn
install-scripts ... blocked because they are not covered by allowScripts`),
diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md
index 24ccd5c094..f9babe7228 100644
--- a/docs-site/src/content/docs/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/reference/cli/lifecycle.md
@@ -535,14 +535,17 @@ package registry or install an update.
### `ocx update [--tag latest|preview]`
-Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview`
+Self-update opencodex through the package manager that installed it. npm and pnpm installs use
+their native global update paths; pnpm verifies the active global group and rolls back through pnpm
+if the new package is incomplete. Stable installs use `@latest`; preview installs stay on `@preview`
unless you pass `--tag latest|preview`. It detects a source checkout and tells you to
`git pull && bun install` instead, and is a no-op if you are already on the newest version for that
tag. Before stopping anything, npm installations run a bounded Unix cache ownership and access
-check. Nested symlinks are checked with `lstat` but not followed; Windows explicitly skips this
-Unix-only check. A failure aborts while the tray and proxy are still running. A running proxy is
-then stopped before files are replaced; an installed service is rebuilt and started automatically,
-while a foreground installation prints `ocx start` as the next step. Dashboard update records
+check; pnpm installations verify the active global package before mutation. Nested symlinks are
+checked with `lstat` but not followed; Windows explicitly skips this Unix-only check. A failure
+aborts while the tray and proxy are still running. A running proxy is then stopped before files
+are replaced; an installed service is rebuilt and started automatically, while a foreground
+installation prints an exact active-launcher command as the next step. Dashboard update records
redact profile/cache paths and UID/GID values before they are persisted.
```bash
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index b5ea45c4a3..eee20ff3c8 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -1232,6 +1232,7 @@
"update-notify.test.ts": "update",
"update-npm-cache-preflight.test.ts": "update",
"update-npm-invocation.test.ts": "update",
+ "update-pnpm.test.ts": "update",
"update-stop-classification.test.ts": "update",
"update-stop-first.test.ts": "update",
"update-transactional.test.ts": "update",
diff --git a/src/cli.ts b/src/cli.ts
index 2f060ab6aa..a852d7c80f 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -3,8 +3,8 @@
//
// Before the src/ restructure the CLI lived at src/cli.ts, and durable launchers
// (codex shim wrappers, installed service definitions) baked that absolute path
-// into their command lines. Users who upgrade in place with a bare
-// `npm install -g @bitkyc08/opencodex` (instead of `ocx update`, which repairs the
+// into their command lines. Users who upgrade in place with a bare package-manager
+// install (`npm install -g` or `pnpm add -g`) (instead of `ocx update`, which repairs the
// shim/service) would otherwise be stranded on a dead path. Keep this stub for at
// least one release cycle after the restructure ships.
import "./cli/index.ts";
diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts
index 091e46d3a9..5541ba7472 100644
--- a/src/cli/launcher-context.ts
+++ b/src/cli/launcher-context.ts
@@ -1,5 +1,5 @@
/**
- * Trusted facts captured by the plain-Node npm launcher before Bun auto-loads
+ * Trusted facts captured by the plain-Node package launcher before Bun auto-loads
* project dotenv files. The random proof travels in argv while the context
* travels in the environment, so a project `.env` cannot forge the pair during
* an ordinary `ocx ...` invocation.
diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts
index 31082b0fbb..bfab1cf757 100644
--- a/src/config/pending-teardown.ts
+++ b/src/config/pending-teardown.ts
@@ -175,7 +175,7 @@ export function listPendingTeardowns(): OutstandingTeardown[] {
}
const out: OutstandingTeardown[] = [];
for (const name of names) {
- // One naming rule, shared with the npm launcher: the two lanes drifting apart is
+ // One naming rule, shared with the package launcher: the two lanes drifting apart is
// exactly how the Node updater stopped seeing receipts at all.
if (!isPendingTeardownFileName(name)) continue;
const nonce = pendingTeardownNonceFromFileName(name)!;
diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts
index b8a09149a8..e5c7ed31d3 100644
--- a/src/lib/bun-runtime.ts
+++ b/src/lib/bun-runtime.ts
@@ -176,7 +176,7 @@ export function durableBunRuntime(): DurableBunRuntime {
/**
* Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and
* the Codex auto-start shim). Prefer the bundled binary — it lives under the
- * npm global prefix and survives across `ocx update` — and fall back to the
+ * manager-owned global package directory and survives across `ocx update` — and fall back to the
* current runtime, which is Bun when launched normally.
*/
export function durableBunPath(): string {
diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts
index 443a164519..9b7d1e8429 100644
--- a/src/lib/package-tree-integrity.ts
+++ b/src/lib/package-tree-integrity.ts
@@ -16,7 +16,7 @@ export interface PackageTreeIntegrityGuard {
}
type ObservePackageTree = () => PackageTreeObservation | null;
-type PackageTreeRuntimeInstall = "bun" | "npm" | "source";
+type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
const packageManifestUrl = new URL("../../package.json", import.meta.url);
diff --git a/src/service.ts b/src/service.ts
index 15a8876ba3..12d20fa0bb 100644
--- a/src/service.ts
+++ b/src/service.ts
@@ -71,7 +71,7 @@ const TASK = "opencodex-proxy";
export type ServiceBackend = "scheduler" | "native";
function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
- // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
+ // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
// standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
//
diff --git a/src/update/badge.ts b/src/update/badge.ts
index fa4433dda9..0c373c1b71 100644
--- a/src/update/badge.ts
+++ b/src/update/badge.ts
@@ -1,13 +1,14 @@
/**
* Cached "is an update available?" answer for the GUI sidebar badge.
*
- * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a
+ * `/api/update/check` spawns the installing manager's `view` command on every call
+ * (~1s, network-bound), so a
* sidebar that polls it would spawn a process per tick on every page of the GUI.
* The badge instead READS the 20h version cache the CLI update prompt already
* maintains (`~/.opencodex/version.json`).
*
* This is deliberately read-only: it must never trigger a registry refresh. The GUI
- * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view`
+ * polls it, so a refresh-on-read would let repeated polls launch repeated manager `view`
* helpers with no coalescing. Cache warming stays with `ocx start`
* (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
* `/api/update/check` the user reaches by clicking the sidebar update button.
diff --git a/src/update/index.ts b/src/update/index.ts
index 1fd5fb099c..dccc63a288 100644
--- a/src/update/index.ts
+++ b/src/update/index.ts
@@ -3,13 +3,23 @@ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
import { proxyIdentityAt } from "../server/proxy-liveness";
import { probeProxyLiveness } from "./proxy-liveness-probe.mjs";
import { decidePostStopUpdate } from "./stop-decision.mjs";
-import { readFileSync, readdirSync } from "node:fs";
+import { existsSync, readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
-import { dirname, join } from "node:path";
+import { dirname, join, resolve } from "node:path";
import { getConfigDir, loadConfig } from "../config";
import { readPid, readRuntimePort } from "../config/process-state";
import { pendingTeardownOutstanding } from "../config/pending-teardown";
import { npmInvocation } from "./npm-invocation.mjs";
+import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs";
+import { detectInstallFromPath } from "./install-detection.mjs";
+import {
+ pnpmOwnerInvocation,
+ readPnpmGlobalPackage,
+ resolvePnpmGlobalOwner,
+ runPnpmGlobalUpdate,
+} from "./pnpm-global-install.mjs";
+import type { PnpmGlobalOwner, PnpmGlobalOwnerResult } from "./pnpm-global-install.mjs";
+import { checkRegistryPackageIntegrity } from "./registry-integrity.mjs";
import {
npmCachePreflightFailureMessage,
runNpmCachePreflight,
@@ -35,13 +45,101 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean {
export const PKG = "@bitkyc08/opencodex";
const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update
-export type Installer = "bun" | "npm" | "source";
+export type Installer = "bun" | "npm" | "pnpm" | "source";
export type Channel = "latest" | "preview";
/** Infer how opencodex is installed from the running module's path. */
export function detectInstall(): Installer {
- if (!HERE.includes("node_modules")) return "source"; // a git checkout, not a global install
- return HERE.includes(".bun") ? "bun" : "npm";
+ return detectInstallFromPath(HERE, { exists: existsSync });
+}
+
+function packageRoot(): string {
+ return resolve(HERE, "..", "..");
+}
+
+function runningPnpmShimPath(): string | undefined {
+ const invoked = process.argv[1];
+ if (!invoked) return undefined;
+ const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
+ if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) {
+ return undefined;
+ }
+ return resolve(invoked);
+}
+
+function runPnpmCandidate(
+ commandPath: string,
+ args: readonly string[],
+ capture = false,
+): { status: number | null; stdout?: string | null; stderr?: string | null } {
+ const invocation = pnpmInvocationForPath(commandPath, args);
+ if (!invocation) return { status: 1 };
+ return spawnSync(invocation.file, invocation.args, {
+ stdio: capture ? "pipe" : "ignore",
+ encoding: "utf8",
+ timeout: 20_000,
+ windowsHide: true,
+ ...invocation.options,
+ });
+}
+
+/** Resolve the exact pnpm executable/group/bin that own this package. */
+export function resolveCurrentPnpmGlobalOwner(): PnpmGlobalOwnerResult {
+ return resolvePnpmGlobalOwner({
+ packageName: PKG,
+ packagePath: packageRoot(),
+ commandPaths: resolvePnpmCommands(),
+ runningShimPath: runningPnpmShimPath(),
+ runPnpm: runPnpmCandidate,
+ });
+}
+
+function ownerPnpmTarget(
+ owner: PnpmGlobalOwner,
+ args: readonly string[],
+): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean }; env: Record } | null {
+ const invocation = pnpmOwnerInvocation(owner, args);
+ if (!invocation) return null;
+ return {
+ bin: invocation.file,
+ args: invocation.args,
+ options: invocation.options,
+ env: invocation.env,
+ };
+}
+
+function runOwnedPnpm(
+ owner: PnpmGlobalOwner,
+ args: readonly string[],
+ capture: boolean,
+ stdio: "inherit" | "pipe" | "ignore" = capture ? "pipe" : "inherit",
+): { status: number | null; stdout?: string | null; stderr?: string | null } {
+ const target = ownerPnpmTarget(owner, args);
+ if (!target) return { status: 1 };
+ return spawnSync(target.bin, target.args, {
+ stdio,
+ encoding: "utf8",
+ timeout: 180_000,
+ windowsHide: true,
+ env: target.env,
+ ...target.options,
+ });
+}
+
+/** Re-read the owning group's active package and return its verified launcher. */
+export function resolvePnpmActiveLauncher(owner: PnpmGlobalOwner): string | null {
+ const active = readPnpmGlobalPackage(
+ PKG,
+ (args, capture = false) => runOwnedPnpm(owner, args, capture),
+ undefined,
+ {
+ owner,
+ expectedGlobalDir: owner.globalDir,
+ expectedGlobalRoot: owner.globalRoot,
+ globalBinDir: owner.globalBinDir,
+ },
+ );
+ return active.ok ? join(active.path, "bin", "ocx.mjs") : null;
}
export function currentVersion(): string {
@@ -63,14 +161,57 @@ export function updateTag(current: string): Channel {
return defaultUpdateTag(current);
}
-function npmSpawnTarget(args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
+type SpawnTarget = {
+ bin: string;
+ args: string[];
+ options: { windowsVerbatimArguments?: boolean };
+ env?: Record;
+};
+
+function npmSpawnTarget(args: readonly string[]): SpawnTarget | null {
const invocation = npmInvocation(args);
if (!invocation) return null;
return { bin: invocation.file, args: invocation.args, options: invocation.options };
}
-function updateSpawnTarget(bin: string, args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
+function pnpmSpawnTarget(args: readonly string[], owner?: PnpmGlobalOwner): SpawnTarget | null {
+ if (owner) {
+ const invocation = pnpmOwnerInvocation(owner, args);
+ if (!invocation) return null;
+ return {
+ bin: invocation.file,
+ args: invocation.args,
+ options: invocation.options,
+ env: invocation.env,
+ };
+ }
+ const invocation = pnpmInvocation(args);
+ if (!invocation) return null;
+ return { bin: invocation.file, args: invocation.args, options: invocation.options };
+}
+
+function registrySpawnTarget(
+ installer: Installer,
+ args: readonly string[],
+ owner?: PnpmGlobalOwner,
+): SpawnTarget | null {
+ // A pnpm command without an owner would silently fall back to the first PATH
+ // candidate. That is unsafe when multiple PNPM_HOME installations expose the
+ // same version, so registry queries use the same hard binding as mutation.
+ return installer === "pnpm"
+ ? owner ? pnpmSpawnTarget(args, owner) : null
+ : npmSpawnTarget(args);
+}
+
+function selectedPnpmOwner(owner?: PnpmGlobalOwner): PnpmGlobalOwner | undefined {
+ if (owner) return owner;
+ const result = resolveCurrentPnpmGlobalOwner();
+ return result.ok ? result.owner : undefined;
+}
+
+function updateSpawnTarget(bin: string, args: readonly string[]): SpawnTarget | null {
if (bin === "npm") return npmSpawnTarget(args);
+ if (bin === "pnpm") return pnpmSpawnTarget(args);
if (process.platform === "win32" && bin === "bun") {
return { bin: process.execPath, args: [...args], options: {} };
}
@@ -95,28 +236,46 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null
if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr);
}
-/** Latest published version from the registry (best-effort; null if npm isn't available). */
-export function latestVersion(tag: string): string | null {
- const npm = npmSpawnTarget(["view", `${PKG}@${tag}`, "version"]);
- if (!npm) return null;
- const r = spawnSync(npm.bin, npm.args, {
+function shellQuote(value: string): string {
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`;
+ return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+function launcherStartHint(launcher: string, port: number): string {
+ return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`;
+}
+
+/** Latest published version from the registry (best-effort; null if the manager isn't available). */
+export function latestVersion(
+ tag: string,
+ installer: Installer = detectInstall(),
+ owner?: PnpmGlobalOwner,
+): string | null {
+ const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
+ if (installer === "pnpm" && !resolvedOwner) return null;
+ const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner);
+ if (!manager) return null;
+ const r = spawnSync(manager.bin, manager.args, {
encoding: "utf8",
timeout: 12000,
windowsHide: true,
- ...npm.options,
+ ...(manager.env ? { env: manager.env } : {}),
+ ...manager.options,
});
- return r.status === 0 ? (r.stdout.trim() || null) : null;
+ return r.status === 0 && typeof r.stdout === "string" ? (r.stdout.trim() || null) : null;
}
/** The global-install command opencodex would run to update on this channel. */
export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } {
- const bin = installer === "bun" ? "bun" : "npm";
// Immutable target: when the registry resolved a concrete version, install exactly
// that version — the dist-tag can move between resolution and install (TOCTOU).
const target = resolvedVersion || tag;
- const args = installer === "bun"
- ? ["add", "-g", `${PKG}@${target}`]
- : ["install", "-g", `${PKG}@${target}`];
+ if (installer === "bun") return { bin: "bun", args: ["add", "-g", `${PKG}@${target}`] };
+ if (installer === "pnpm") {
+ return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] };
+ }
+ const bin = "npm";
+ const args = ["install", "-g", `${PKG}@${target}`];
return { bin, args };
}
@@ -138,26 +297,33 @@ export function updateCommandStr(installer: Installer, tag: Channel, resolvedVer
export function checkUpdatePackageIntegrity(
version: string | null,
spawn: typeof spawnSync = spawnSync,
+ installer: Installer = detectInstall(),
+ owner?: PnpmGlobalOwner,
): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } {
- if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
- const npm = npmSpawnTarget(["view", `${PKG}@${version}`, "dist.integrity"]);
- if (!npm) return { ok: "skipped", reason: "npm executable was not found on a trusted PATH entry" };
- const r = spawn(
- npm.bin,
- npm.args,
- { encoding: "utf8", timeout: 12000, windowsHide: true, ...npm.options },
- );
- // status !== 0 covers nonzero exits AND timeouts (status === null).
- if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` };
- const tokens = (r.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean);
- const match = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token));
- if (!match) return { ok: false, reason: `registry returned no sha512 integrity for ${PKG}@${version}` };
- return { ok: true, integrity: match };
+ const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
+ if (installer === "pnpm" && !resolvedOwner) {
+ return { ok: false, reason: "could not identify pnpm's owning global installation" };
+ }
+ const manager = registrySpawnTarget(installer, ["view", `${PKG}@${version}`, "dist.integrity"], resolvedOwner);
+ if (!manager) return { ok: "skipped", reason: `${installer} executable was not found on a trusted PATH entry` };
+ const result = checkRegistryPackageIntegrity(PKG, version, args => {
+ const target = registrySpawnTarget(installer, args, resolvedOwner);
+ if (!target) return { status: 1 };
+ return spawn(target.bin, target.args, {
+ encoding: "utf8",
+ timeout: 12000,
+ windowsHide: true,
+ ...(target.env ? { env: target.env } : {}),
+ ...target.options,
+ });
+ });
+ return result;
}
/**
- * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
- * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
+ * `ocx update` fallback for source checkouts and Bun global installs. npm and pnpm global installs
+ * are updated in the Node bin launcher before Bun starts, so Windows does not replace the running
+ * Bun binary.
*/
export async function runUpdate(): Promise {
const installer = detectInstall();
@@ -170,7 +336,13 @@ export async function runUpdate(): Promise {
return;
}
- const latest = latestVersion(tag);
+ const ownerResult = installer === "pnpm" ? resolveCurrentPnpmGlobalOwner() : undefined;
+ if (installer === "pnpm" && (!ownerResult || !ownerResult.ok)) {
+ console.error(`⚠️ ${ownerResult?.reason ?? "Could not identify pnpm's owning global installation"}. Aborting before stopping the proxy.`);
+ process.exit(1);
+ }
+ const owner = ownerResult?.ok ? ownerResult.owner : undefined;
+ const latest = latestVersion(tag, installer, owner);
if (latest && latest === current) {
console.log(`Already on the latest ${tag} version (v${latest}).`);
return;
@@ -178,7 +350,7 @@ export async function runUpdate(): Promise {
// Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an
// anomalous registry entry aborts without unloading the running service.
- const integrity = checkUpdatePackageIntegrity(latest);
+ const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);
if (integrity.ok === false) {
console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`);
process.exit(1);
@@ -198,9 +370,11 @@ export async function runUpdate(): Promise {
}
const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
- const target = updateSpawnTarget(bin, cmdArgs);
+ const target = installer === "pnpm" && owner
+ ? pnpmSpawnTarget(cmdArgs, owner)
+ : updateSpawnTarget(bin, cmdArgs);
if (!target) {
- console.error("⚠️ Could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
+ console.error(`⚠️ Could not resolve ${bin} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
process.exit(1);
}
@@ -257,7 +431,9 @@ export async function runUpdate(): Promise {
// shared client config still points at a proxy that is gone; installing over that
// silently skips the recovery the receipt was written to trigger (#3008).
// Full `ocx stop` semantics (drain, service stop, restore).
+ let stopAttempted = false;
if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) {
+ stopAttempted = true;
console.log("⏹ Stopping the running proxy before updating...");
const stopStdio = updateChildStdio();
const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
@@ -266,7 +442,7 @@ export async function runUpdate(): Promise {
windowsHide: true,
});
if (stopStdio === "pipe") logSpawnOutput("", stop);
- // One decision, shared with the npm launcher (#3008). The two lanes disagreeing about
+ // One decision, shared with the package launcher (#3008). The two lanes disagreeing about
// the same situation is how this shipped fixed on one side only. Absent PID and runtime
// files are weak evidence - a crashed-but-listening proxy leaves none - so the captured
// endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike.
@@ -310,35 +486,96 @@ export async function runUpdate(): Promise {
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
const installStdio = updateChildStdio();
- const r = spawnSync(target.bin, target.args, {
- stdio: installStdio,
- encoding: installStdio === "pipe" ? "utf8" : undefined,
- timeout: 180000,
- windowsHide: true,
- ...target.options,
- });
+ // Every post-update action below receives this path. For pnpm it is replaced only
+ // by a path returned after tree+shim verification; on rollback, activePath is
+ // likewise returned only after the old group has been verified again.
+ let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs");
+ // The pnpm owner preflight has verified this package tree and global group. Keep that exact
+ // package path as the recovery starting point; the path returned by the update transaction
+ // replaces it only after post-update tree+shim verification succeeds.
+ if (installer === "pnpm" && owner) {
+ postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs");
+ }
+ let postUpdateLauncherUsable = true;
+ let r: {
+ status: number | null;
+ signal?: NodeJS.Signals | null;
+ stdout?: string | Buffer | null;
+ stderr?: string | Buffer | null;
+ };
+ if (installer === "pnpm") {
+ let update: ReturnType;
+ try {
+ update = runPnpmGlobalUpdate({
+ packageName: PKG,
+ currentVersion: current,
+ targetVersion: latest || undefined,
+ tag,
+ owner: owner!,
+ runningPackagePath: packageRoot(),
+ runPnpm: (args, capture = false) => runOwnedPnpm(
+ owner!,
+ args,
+ capture,
+ capture ? "pipe" : installStdio,
+ ),
+ log: line => console.log(line),
+ });
+ } catch {
+ // A thrown verifier/runner error means the active group is unknown. Mark the
+ // launcher unusable and let the failure lane report a manual recovery path.
+ update = {
+ ok: false,
+ phase: "rollback",
+ rolledBack: false,
+ error: "pnpm update failed unexpectedly; active package could not be verified",
+ };
+ }
+ if (update.ok) {
+ postUpdateLauncher = join(update.path, "bin", "ocx.mjs");
+ postUpdateLauncherUsable = true;
+ r = { status: 0, signal: null, stdout: "", stderr: "" };
+ } else {
+ postUpdateLauncherUsable = Boolean(update.activePath);
+ if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs");
+ console.error(`⚠️ ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`);
+ r = { status: 1, signal: null, stdout: "", stderr: "" };
+ }
+ } else {
+ r = spawnSync(target.bin, target.args, {
+ stdio: installStdio,
+ encoding: installStdio === "pipe" ? "utf8" : undefined,
+ timeout: 180000,
+ windowsHide: true,
+ ...target.options,
+ });
+ }
if (installStdio === "pipe") logSpawnOutput("", r);
if (r.status === 0) {
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
- // Re-bake the bundled Bun path into the Codex autostart shim on every
- // platform when one is installed (refresh-only; never installs fresh).
+ // Re-enter through the verified active package launcher. This keeps the Codex
+ // shim, tray, service and proxy recovery paths on the same package/group that
+ // pnpm selected, including when the update changed the global link target.
try {
- const { isCodexShimInstalled, installCodexShim } = await import("../codex/shim");
+ const { isCodexShimInstalled } = await import("../codex/shim");
if (isCodexShimInstalled()) {
- const result = installCodexShim();
- if (result.installed) console.log(`🔧 ${result.message}`);
+ const shim = spawnSync(process.execPath, [postUpdateLauncher, "codex-shim", "install"], {
+ stdio: "inherit",
+ windowsHide: true,
+ });
+ if (shim.status !== 0) console.warn("⚠️ Shim repair skipped: run 'ocx codex-shim install'.");
}
- } catch (e) {
- console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
+ } catch {
+ console.warn("⚠️ Shim repair skipped; run 'ocx codex-shim install'.");
}
if (trayWasInstalled) {
- const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
- const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true });
+ const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs;
+ const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true });
if (tray.status === 0) {
console.log("🔧 Refreshed Windows tray startup paths.");
} else {
console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'.");
- if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true });
+ if (trayWasRunning) spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true });
}
}
// The stop above unloaded any managed service; repair it with the NEW files
@@ -362,7 +599,7 @@ export async function runUpdate(): Promise {
process.env.OCX_BAKE_PORT = String(capturedListen.port);
try {
const svcStdio = updateChildStdio();
- const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), {
+ const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], {
stdio: svcStdio,
encoding: svcStdio === "pipe" ? "utf8" : undefined,
windowsHide: true,
@@ -407,7 +644,7 @@ export async function runUpdate(): Promise {
: " Run 'ocx service repair' to refresh the background service and see why it failed.");
const env = { ...process.env };
delete env.OCX_SERVICE;
- const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), {
+ const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -422,14 +659,30 @@ export async function runUpdate(): Promise {
else process.env.OCX_BAKE_PORT = prevBake;
}
} else {
- console.log(`Restart the proxy: ocx start --port ${capturedListen.port}`);
+ console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`);
}
} else {
- if (trayWasRunning) {
- try {
- const { startWindowsTray } = await import("../tray/windows");
- startWindowsTray();
- } catch { /* keep the primary update failure */ }
+ if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) {
+ spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true });
+ }
+ if (stopAttempted && serviceWasInstalled && postUpdateLauncherUsable) {
+ const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], {
+ stdio: "inherit",
+ windowsHide: true,
+ });
+ if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'.");
+ } else if (stopAttempted && postUpdateLauncherUsable) {
+ const env = { ...process.env };
+ delete env.OCX_SERVICE;
+ const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: true,
+ env: withProcessRuntimeProvenance(env),
+ });
+ child.unref();
+ } else if (stopAttempted) {
+ console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually.");
}
console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
process.exit(1);
diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts
new file mode 100644
index 0000000000..88f68ba731
--- /dev/null
+++ b/src/update/install-detection.d.mts
@@ -0,0 +1,6 @@
+export type DetectedInstall = "bun" | "npm" | "pnpm" | "source";
+
+export declare function detectInstallFromPath(
+ packagePath: string,
+ deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string },
+): DetectedInstall;
diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs
new file mode 100644
index 0000000000..e21064c9b5
--- /dev/null
+++ b/src/update/install-detection.mjs
@@ -0,0 +1,73 @@
+import { realpathSync } from "node:fs";
+
+/**
+ * Infer the package manager from the path of the running package.
+ *
+ * PATH is deliberately not consulted here. A machine can have npm, pnpm, and Bun
+ * installed at the same time; the package layout is the evidence of which manager owns
+ * the files that the updater must change. The legacy `global/` spelling is only
+ * accepted when the adjacent filesystem metadata also looks like a pnpm global group.
+ *
+ * The real path is considered in addition to the spelling visible to the module loader.
+ * This matters when Node is launched with preserved symlinks: a pnpm package can be
+ * exposed through an npm-looking prefix while its target is still under pnpm's global
+ * virtual store.
+ */
+export function detectInstallFromPath(packagePath, deps = {}) {
+ const exists = deps.exists;
+ const candidates = [String(packagePath)];
+ try {
+ const resolved = (deps.realpath ?? realpathSync)(String(packagePath));
+ if (resolved && !candidates.includes(resolved)) candidates.push(resolved);
+ } catch {
+ // Synthetic paths in source-level checks, and a partially removed install, have no
+ // realpath. The lexical path still carries the evidence when it is available.
+ }
+
+ let sawNodeModules = false;
+ for (const candidate of candidates) {
+ const detected = detectInstallCandidate(candidate, exists);
+ if (detected === "pnpm" || detected === "bun") return detected;
+ if (detected === "npm") sawNodeModules = true;
+ }
+ return sawNodeModules ? "npm" : "source";
+}
+
+function detectInstallCandidate(packagePath, exists) {
+ const normalized = String(packagePath).replaceAll("\\", "/");
+ const segments = normalized.split("/").filter(Boolean);
+ // Windows paths are case-insensitive. Treating the structural marker this way also
+ // keeps a preserved-symlink path from being downgraded merely because its casing came
+ // from a Windows API or a user-created junction.
+ if (!segments.some(segment => segment.toLowerCase() === "node_modules")) return "source";
+
+ // Strong signatures survive normal symlink resolution: the v10 isolated virtual store
+ // and v11 global virtual store are both manager-owned paths. Do not classify an arbitrary
+ // npm prefix such as `/opt/global/v11` from its directory name alone.
+ if (
+ /(?:^|\/)node_modules\/\.pnpm(?:\/|$)/i.test(normalized)
+ || /(?:^|\/)store\/v\d+\/links(?:\/|$)/i.test(normalized)
+ ) return "pnpm";
+
+ // `--preserve-symlinks` can leave a v10/v11 group path visible. Corroborate it with the
+ // group's virtual store or the v11 global store before selecting pnpm.
+ const globalMatch = normalized.match(/^(.*\/global\/(?:v)?\d+)(?:\/[^/]+)*\/node_modules(?:\/|$)/i);
+ if (globalMatch && exists) {
+ const globalRoot = globalMatch[1];
+ const groupRoot = normalized.slice(0, normalized.toLowerCase().indexOf("/node_modules"));
+ if (
+ exists(`${groupRoot}/node_modules/.pnpm`)
+ || exists(`${groupRoot}/node_modules/.modules.yaml`)
+ || exists(`${groupRoot}/node_modules/.pnpm/lock.yaml`)
+ || exists(`${globalRoot}/store`)
+ || exists(`${globalRoot}/pnpm-lock.yaml`)
+ ) return "pnpm";
+ }
+
+ // Bun's global layout is specifically `.bun/install/global/node_modules`. A bare `.bun`
+ // directory is not enough: npm projects can quite legitimately live under a dot-directory
+ // with that name, especially on Windows where package paths are often user-selected.
+ if (/(?:^|\/)\.bun\/install\/global\/node_modules(?:\/|$)/i.test(normalized)) return "bun";
+
+ return "npm";
+}
diff --git a/src/update/job.ts b/src/update/job.ts
index 75a55b40b3..b7e51ff8c5 100644
--- a/src/update/job.ts
+++ b/src/update/job.ts
@@ -37,7 +37,10 @@ import {
latestVersion,
updateCommand,
updateCommandStr,
+ resolveCurrentPnpmGlobalOwner,
+ resolvePnpmActiveLauncher,
} from "./index";
+import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs";
import { isNewer } from "./notify";
import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
@@ -132,6 +135,10 @@ function nodeBin(): string {
return process.platform === "win32" ? "node.exe" : "node";
}
+function usesNodeLauncher(installer: Installer): boolean {
+ return installer === "npm" || installer === "pnpm";
+}
+
/**
* Strict bind script: exit 0 only after listen+close. Any listen error (including
* Windows ghost-TCB failures under Bun) is busy — matches published `ocx start`
@@ -436,11 +443,13 @@ export function updateExecutionCommand(
launcher = packageLauncherPath(),
resolvedVersion?: string | null,
): { bin: string; args: string[]; display: string } {
- if (installer === "npm") {
+ if (usesNodeLauncher(installer)) {
const bin = nodeBin();
const args = [launcher, "update", "--tag", channel];
// The Node launcher self-update re-resolves the tag at its own time — a residual
// divergence window this path cannot close (documented, not claimed immutable).
+ // Both npm and pnpm use it so package files are never replaced by the running Bun
+ // process; the launcher then selects the manager-native update implementation.
return { bin, args, display: formatCommand(bin, args) };
}
if (installer === "bun") {
@@ -467,7 +476,7 @@ export function restartCommand(
// Default to the in-place refresh: `install` always registers, while repair reuses a healthy
// Windows scheduler definition and re-registers only when the live definition is stale.
const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs;
- if (installer === "npm") {
+ if (usesNodeLauncher(installer)) {
const bin = nodeBin();
const args = svcArgs;
return { mode, bin, args, display: formatCommand(bin, args) };
@@ -920,8 +929,9 @@ function spawnDetachedStart(
job: UpdateJobState,
installer: Installer,
port?: number,
+ launcher = packageLauncherPath(),
): ChildProcess {
- const cmd = restartCommand(false, installer, packageLauncherPath(), port);
+ const cmd = restartCommand(false, installer, launcher, port);
const env = { ...process.env };
delete env.OCX_SERVICE;
updateJob(job, {}, `$ ${cmd.display}`);
@@ -961,7 +971,7 @@ function spawnDetachedStart(
return child;
}
-/** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */
+/** Identity snapshot used to prove a package-manager self-update replaced the pre-update process. */
export interface RestartProxyIdentity {
pid: number | null;
version?: string;
@@ -970,7 +980,9 @@ export interface RestartProxyIdentity {
/** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */
export interface RestartIo {
waitForPort?: typeof reclaimListenPort;
- spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
+ spawnStart?: (job: UpdateJobState, installer: Installer, port?: number, launcher?: string) => void;
+ /** The package launcher verified after a pnpm group switch or rollback. */
+ packageLauncherPathFn?: () => string;
serviceInstalledFn?: () => boolean;
/**
* After a service reinstall exits 0, only trust the service path when this is true.
@@ -1090,13 +1102,14 @@ async function restartAfterUpdate(
svcArgs = serviceReinstallArgs();
} catch { /* fallback to default service install */ }
}
- const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
+ const launcher = io.packageLauncherPathFn?.() ?? packageLauncherPath();
+ const cmd = restartCommand(serviceInstalled, job.installer, launcher, port, svcArgs);
const waitFn = io.waitForPort ?? reclaimListenPort;
const listPids = io.listListenPidsFn ?? listListenPids;
const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity;
const aliveFn = io.isAliveFn ?? isProcessAlive;
// Pre-update PID plus any ocx still LISTENing on the captured port. After a
- // stop-first npm self-update Windows often leaves a respawned bun/node child
+ // stop-first package-manager self-update Windows often leaves a respawned bun/node child
// that is not the captured PID; treating it as protected blocks reclaim and
// the direct-start fallback never binds.
const reclaimKillAllowlist = (): number[] => {
@@ -1294,7 +1307,7 @@ async function restartAfterUpdate(
// Injected spawnStart keeps unit tests deterministic (one call). Production path
// retries on missing /healthz after prepare + ghost-LISTEN clear.
if (io.spawnStart) {
- io.spawnStart(job, job.installer, port);
+ io.spawnStart(job, job.installer, port, launcher);
return;
}
const sleep = io.sleepMs ?? ((ms: number) => new Promise(r => setTimeout(r, ms)));
@@ -1352,7 +1365,7 @@ async function restartAfterUpdate(
);
continue;
}
- lastChild = spawnDetachedStart(job, job.installer, port);
+ lastChild = spawnDetachedStart(job, job.installer, port, launcher);
const healthDeadline = Date.now() + perAttemptHealthMs;
while (Date.now() < healthDeadline) {
if (await probe(port, hostname)) return;
@@ -1422,11 +1435,18 @@ export function restartAfterUpdateForTests(
return restartAfterUpdate(job, captured, io);
}
-function restartFailureHint(port: number): string {
+function restartFailureHint(port: number, installer: Installer): string {
+ const reinstall = installer === "pnpm"
+ ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex"
+ : installer === "bun"
+ ? "bun add -g @bitkyc08/opencodex"
+ : installer === "source"
+ ? "git pull && bun install"
+ : "npm install -g --allow-scripts=bun @bitkyc08/opencodex";
return `Update installed, but the restarted proxy did not stay healthy on port ${port}. `
+ `Try 'ocx start --port ${port}'. `
+ "If the update log shows bun postinstall or EPERM warnings, "
- + "reinstall with 'npm install -g --allow-scripts=bun @bitkyc08/opencodex'.";
+ + `reinstall with '${reinstall}'.`;
}
type AwaitHealthyResult =
@@ -1517,7 +1537,7 @@ async function confirmRestartedProxy(
status: "failed",
restarted: false,
error,
- }, restartFailureHint(port));
+ }, restartFailureHint(port, job.installer));
return false;
}
@@ -1559,7 +1579,7 @@ async function defaultProbeProxyIdentity(
* when the pre-update PID was captured, and/or /healthz reporting the job's target
* version when PID evidence is unavailable.
*/
-export function npmSelfUpdateRestartEvidence(
+export function packageManagerSelfUpdateRestartEvidence(
job: Pick,
captured: { oldPid?: number },
identity: RestartProxyIdentity | null,
@@ -1601,21 +1621,25 @@ export function npmSelfUpdateRestartEvidence(
return { ok: false, reason: "no pre-update PID capture and no expected-version match" };
}
+// Kept as a compatibility export for callers and existing integrations that used the
+// original npm-specific name before pnpm became a supported package-manager path.
+export const npmSelfUpdateRestartEvidence = packageManagerSelfUpdateRestartEvidence;
+
/**
* Post-install restart for the GUI worker.
*
- * npm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls /
+ * npm and pnpm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls /
* starts the service (or falls back to a direct start). A second `service install` here
* calls `stopWindows()` on that healthy listener, then often fails elevation from the
* non-interactive worker — leaving the captured port (default 10100) dead until a manual
- * restart. Prefer confirming the npm self-update's own restart first; only re-run restart
+ * restart. Prefer confirming the package-manager self-update's own restart first; only re-run restart
* when that probe fails. Bun/source installs still always take the explicit restart path.
*
- * Probe-first applies only to service-managed npm installs: without a service, `ocx.mjs`
+ * Probe-first applies only to service-managed npm/pnpm installs: without a service, `ocx.mjs`
* only prints `ocx start` and never brings the proxy back, so waiting would always burn
* the full health timeout. Skipping also requires update-correlated evidence (PID change
* and/or target version) so a surviving pre-update process cannot look like success.
- * After an explicit npm restart the same evidence is required again — health alone is
+ * After an explicit package-manager restart the same evidence is required again — health alone is
* not enough when a no-op restart or failed port reclaim leaves the old proxy up.
*
* Browser-dashboard update recovery must not require a viable Background Service: when
@@ -1628,10 +1652,10 @@ export async function finishGuiUpdateRestart(
installer: Installer,
io: RestartIo = {},
): Promise {
- if (installer === "npm") {
+ if (usesNodeLauncher(installer)) {
const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
if (serviceInstalled) {
- // Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the
+ // Stop-first package-manager update leaves a dead PID's LISTEN row. Polling /healthz for the
// full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind.
// If nothing live owns the port, skip straight to explicit restart. A failed
// listener scan must not look like "no listeners" — fall back to /healthz.
@@ -1646,13 +1670,13 @@ export async function finishGuiUpdateRestart(
? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
: null;
if (liveListeners !== null && liveListeners.length === 0) {
- updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart...");
+ updateJob(job, {}, `${installer} self-update did not leave a live listener; performing explicit restart...`);
} else {
if (!scan.ok) {
updateJob(
job,
{},
- "Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...",
+ `Listener scan inconclusive after ${installer} self-update; probing /healthz before deciding on explicit restart...`,
);
}
const already = await awaitRestartedProxyHealthy(job, captured, io);
@@ -1661,42 +1685,42 @@ export async function finishGuiUpdateRestart(
captured.port,
captured.hostname,
);
- const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
+ const evidence = packageManagerSelfUpdateRestartEvidence(job, captured, identity);
if (evidence.ok) {
updateJob(
job,
{},
- `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`,
+ `Proxy already healthy on ${captured.hostname}:${captured.port} after ${installer} self-update (${evidence.detail}); skipping redundant restart.`,
);
return true;
}
updateJob(
job,
{},
- `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
+ `${installer} self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
);
} else {
- updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart...");
+ updateJob(job, {}, `${installer} self-update did not leave a healthy proxy; performing explicit restart...`);
}
}
}
}
const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate;
await restartFn(job, captured, io);
- if (installer !== "npm") {
+ if (!usesNodeLauncher(installer)) {
// Bun/source: health alone remains enough unless a richer identity probe is supplied.
if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io);
}
- return confirmNpmExplicitRestart(job, captured, io);
+ return confirmPackageManagerExplicitRestart(job, captured, io);
}
/**
- * After an explicit npm (or identity-aware) restart, require update-correlated
+ * After an explicit package-manager (or identity-aware) restart, require update-correlated
* evidence — not merely a healthy OpenCodex listener. A no-op restart or a
* failed port reclaim can leave the pre-update process on the captured port;
* `confirmRestartedProxy` alone would treat that as success.
*/
-async function confirmNpmExplicitRestart(
+async function confirmPackageManagerExplicitRestart(
job: UpdateJobState,
captured: { port: number; hostname: string; oldPid?: number },
io: RestartIo = {},
@@ -1712,7 +1736,7 @@ async function confirmNpmExplicitRestart(
status: "failed",
restarted: false,
error,
- }, restartFailureHint(port));
+ }, restartFailureHint(port, job.installer));
return false;
}
@@ -1720,13 +1744,13 @@ async function confirmNpmExplicitRestart(
captured.port,
captured.hostname,
);
- const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
+ const evidence = packageManagerSelfUpdateRestartEvidence(job, captured, identity);
if (!evidence.ok) {
updateJob(job, {
status: "failed",
restarted: false,
error: `proxy restart did not show update-correlated identity (${evidence.reason})`,
- }, restartFailureHint(captured.port));
+ }, restartFailureHint(captured.port, job.installer));
return false;
}
@@ -1748,10 +1772,16 @@ async function confirmNpmExplicitRestart(
*/
export interface GuiUpdateWorkerIo {
cachePreflightFn?: () => { ok: boolean; reason: string };
- /** Force the resolved update target. A source checkout otherwise aborts before the npm branch. */
+ /** Force the resolved update target. A source checkout otherwise aborts before the update branch. */
checkForUpdateFn?: (channel: Channel) => ReturnType;
/** Bypass the registry integrity probe, which runs before the cache gate and needs network. */
integrityFn?: (version: string | null) => ReturnType;
+ /** Override pnpm ownership discovery for an isolated worker test. */
+ resolvePnpmOwnerFn?: () => ReturnType;
+ /** Resolve the launcher only after the pnpm update has verified its active group and shims. */
+ resolvePnpmActiveLauncherFn?: (owner: PnpmGlobalOwner) => string | null;
+ /** Restart seams used by focused worker tests; the verified launcher is always injected. */
+ restartIo?: RestartIo;
runCommandFn?: (
job: UpdateJobState,
bin: string,
@@ -1786,6 +1816,9 @@ export async function runGuiUpdateWorker(
};
let trayWasInstalled = false;
let trayWasRunning = false;
+ let activeLauncher = packageLauncherPath();
+ let activeLauncherVerified = true;
+ let pnpmOwner: PnpmGlobalOwner | undefined;
if (!job) {
job = {
id: jobId,
@@ -1809,10 +1842,18 @@ export async function runGuiUpdateWorker(
throw new Error(check.reason ?? "No update is available");
}
+ if (check.installer === "pnpm") {
+ const ownerResult = (io.resolvePnpmOwnerFn ?? resolveCurrentPnpmGlobalOwner)();
+ if (!ownerResult.ok) throw new Error(`Could not identify pnpm's owning global installation: ${ownerResult.reason}`);
+ pnpmOwner = ownerResult.owner;
+ }
+
// Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry
// metadata for a resolved version fails the job BEFORE anything is spawned or the
// proxy is stopped; transient registry failure degrades to a logged skip.
- const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);
+ const integrity = io.integrityFn
+ ? io.integrityFn(check.latestVersion)
+ : checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner);
if (integrity.ok === false) {
updateJob(job, { status: "failed", error: integrity.reason });
return;
@@ -1865,15 +1906,19 @@ export async function runGuiUpdateWorker(
/* [Decision Log]
- 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다.
- 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능.
- - 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다.
+ - 선택 근거: 현재 CLI의 package-manager self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다.
*/
const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);
if (result.status !== 0) {
- if (trayWasRunning) {
- try {
- const { startWindowsTray } = await import("../tray/windows");
- startWindowsTray();
- } catch { /* retain the primary update failure */ }
+ if (check.installer === "pnpm") {
+ const verifiedLauncher = pnpmOwner
+ ? (io.resolvePnpmActiveLauncherFn ?? resolvePnpmActiveLauncher)(pnpmOwner)
+ : null;
+ if (verifiedLauncher) activeLauncher = verifiedLauncher;
+ else activeLauncherVerified = false;
+ }
+ if (trayWasRunning && activeLauncherVerified) {
+ runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000);
}
updateJob(job, {
status: "failed",
@@ -1884,29 +1929,36 @@ export async function runGuiUpdateWorker(
return;
}
+ if (check.installer === "pnpm") {
+ const verifiedLauncher = (io.resolvePnpmActiveLauncherFn ?? resolvePnpmActiveLauncher)(pnpmOwner!);
+ if (!verifiedLauncher) throw new Error("pnpm update succeeded but no verified active launcher remains");
+ activeLauncher = verifiedLauncher;
+ }
+
if (trayWasInstalled) {
- const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
- const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
+ const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs;
+ const tray = runLoggedCommand(job, process.execPath, [activeLauncher, ...trayArgs], 20_000);
if (tray.status !== 0) {
updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'.");
- if (trayWasRunning) runLoggedCommand(job, process.execPath, selfLaunchArgv(["tray", "start"]), 15_000);
+ if (trayWasRunning) runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000);
}
}
if (restart) {
job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy...");
- if (!(await finishGuiUpdateRestart(job, captured, check.installer))) return;
+ if (!(await finishGuiUpdateRestart(job, captured, check.installer, {
+ ...io.restartIo,
+ packageLauncherPathFn: () => activeLauncher,
+ }))) return;
updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy.");
return;
}
updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version.");
} catch (err) {
- if (trayWasRunning) {
- try {
- const { startWindowsTray } = await import("../tray/windows");
- startWindowsTray();
- } catch { /* retain the primary worker failure */ }
+ if (check.installer === "pnpm") activeLauncherVerified = false;
+ if (trayWasRunning && activeLauncherVerified) {
+ runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000);
}
updateJob(job, {
status: "failed",
diff --git a/src/update/pnpm-global-install.d.mts b/src/update/pnpm-global-install.d.mts
new file mode 100644
index 0000000000..5dc5b6004a
--- /dev/null
+++ b/src/update/pnpm-global-install.d.mts
@@ -0,0 +1,144 @@
+export interface PnpmRunResult {
+ status: number | null;
+ stdout?: string | Uint8Array | null;
+ stderr?: string | Uint8Array | null;
+}
+
+export type RunPnpm = (args: readonly string[], capture?: boolean) => PnpmRunResult;
+export type RunPnpmCandidate = (
+ commandPath: string,
+ args: readonly string[],
+ capture?: boolean,
+) => PnpmRunResult;
+
+export interface PnpmGlobalOwner {
+ commandPath: string;
+ packagePath: string;
+ /** Base passed to pnpm's --global-dir; pnpm creates the versioned group below it. */
+ globalDir: string;
+ /** Actual versioned global group reported by pnpm root/list. */
+ globalRoot: string;
+ globalBinDir: string;
+ version?: string;
+}
+
+export type PnpmGlobalOwnerResult =
+ | { ok: true; owner: PnpmGlobalOwner }
+ | { ok: false; reason: string };
+
+export type PnpmVerificationResult = { ok: boolean; reason?: string };
+
+export declare const PNPM_BUILD_APPROVAL: string;
+
+export declare function pnpmGlobalCommandArgs(
+ args: readonly string[],
+ owner: PnpmGlobalOwner,
+): string[];
+
+export declare function pnpmOwnerEnvironment(
+ owner: PnpmGlobalOwner,
+ env?: Record,
+ platform?: NodeJS.Platform,
+): Record;
+
+export interface PnpmOwnerInvocation {
+ file: string;
+ args: string[];
+ options: { windowsVerbatimArguments?: boolean };
+ env: Record;
+}
+
+export declare function pnpmOwnerInvocation(
+ owner: PnpmGlobalOwner,
+ args: readonly string[],
+ platform?: NodeJS.Platform,
+ env?: Record,
+): PnpmOwnerInvocation | null;
+
+export declare function verifyPnpmGlobalShims(
+ packageDir: string,
+ globalBinDir: string,
+ platform?: NodeJS.Platform,
+ exists?: (path: string) => boolean,
+): PnpmVerificationResult;
+
+export type PnpmGlobalPackage =
+ | {
+ ok: true;
+ version: string;
+ path: string;
+ globalDir?: string;
+ globalRoot?: string;
+ globalBinDir?: string;
+ }
+ | { ok: false; reason: string };
+
+export interface PnpmGlobalReadConstraints {
+ owner?: PnpmGlobalOwner;
+ expectedPackagePath?: string;
+ expectedGlobalDir?: string;
+ expectedGlobalRoot?: string;
+ globalBinDir?: string;
+ /** Set false only for pre-update owner binding; post-update reads verify shims by default. */
+ checkShims?: boolean;
+ platform?: NodeJS.Platform;
+ verifyShims?: (
+ packageDir: string,
+ globalBinDir: string,
+ platform?: NodeJS.Platform,
+ ) => PnpmVerificationResult;
+}
+
+export declare function readPnpmGlobalPackage(
+ packageName: string,
+ runPnpm: RunPnpm,
+ verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult,
+ constraints?: PnpmGlobalReadConstraints,
+): PnpmGlobalPackage;
+
+export declare function resolvePnpmGlobalOwner(options: {
+ packageName: string;
+ packagePath: string;
+ commandPaths: readonly string[];
+ runningShimPath?: string;
+ runPnpm: RunPnpmCandidate;
+ verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult;
+ platform?: NodeJS.Platform;
+}): PnpmGlobalOwnerResult;
+
+export type PnpmGlobalUpdateResult =
+ | {
+ ok: true;
+ phase: "done";
+ version: string;
+ path: string;
+ globalDir: string;
+ globalBinDir: string;
+ }
+ | {
+ ok: false;
+ phase: "preflight" | "install" | "rollback";
+ rolledBack?: boolean;
+ activePath?: string;
+ globalDir?: string;
+ globalBinDir?: string;
+ error: string;
+ };
+
+export declare function runPnpmGlobalUpdate(options: {
+ packageName: string;
+ currentVersion?: string;
+ targetVersion?: string;
+ tag: string;
+ owner: PnpmGlobalOwner;
+ runningPackagePath?: string;
+ runPnpm: RunPnpm;
+ verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult;
+ verifyShims?: (
+ packageDir: string,
+ globalBinDir: string,
+ platform?: NodeJS.Platform,
+ ) => PnpmVerificationResult;
+ platform?: NodeJS.Platform;
+ log?: (line: string) => void;
+}): PnpmGlobalUpdateResult;
diff --git a/src/update/pnpm-global-install.mjs b/src/update/pnpm-global-install.mjs
new file mode 100644
index 0000000000..a038a374f0
--- /dev/null
+++ b/src/update/pnpm-global-install.mjs
@@ -0,0 +1,591 @@
+import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
+import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "node:path";
+import { pnpmInvocationForPath } from "./pnpm-invocation.mjs";
+import { verifyPnpmInstallTree } from "./transactional-install.mjs";
+
+export const PNPM_BUILD_APPROVAL = "--allow-build=bun";
+
+function outputText(value) {
+ if (typeof value === "string") return value;
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value);
+ return "";
+}
+
+function pathKey(value, platform = process.platform) {
+ const raw = String(value).replaceAll("\\", "/");
+ const normalise = path => {
+ const result = path.replaceAll("\\", "/");
+ return platform === "win32" ? result.toLowerCase() : result;
+ };
+ try {
+ return normalise(realpathSync.native(raw));
+ } catch {
+ return normalise(resolve(raw));
+ }
+}
+
+function lexicalPathKey(value, platform = process.platform) {
+ const result = resolve(String(value).replaceAll("\\", "/")).replaceAll("\\", "/");
+ return platform === "win32" ? result.toLowerCase() : result;
+}
+
+function samePath(left, right, platform = process.platform) {
+ return pathKey(left, platform) === pathKey(right, platform)
+ || lexicalPathKey(left, platform) === lexicalPathKey(right, platform);
+}
+
+function singleAbsoluteCommandPath(runPnpm, commandPath, args, platform = process.platform) {
+ let result;
+ try {
+ result = runPnpm(commandPath, args, true);
+ } catch {
+ return null;
+ }
+ if (result?.status !== 0) return null;
+ const lines = outputText(result.stdout).split(/\r?\n/).map(line => line.trim()).filter(Boolean);
+ if (lines.length !== 1 || /^(?:undefined|null)$/i.test(lines[0])) return null;
+ return isAbsolutePath(lines[0], platform) ? lines[0] : null;
+}
+
+function configPathValue(runPnpm, commandPath, key, platform = process.platform) {
+ return singleAbsoluteCommandPath(runPnpm, commandPath, ["config", "get", key], platform);
+}
+
+function absoluteConfigPath(value, platform = process.platform) {
+ return typeof value === "string" && isAbsolutePath(value, platform) ? value : null;
+}
+
+function isAbsolutePath(value, platform = process.platform) {
+ if (typeof value !== "string") return false;
+ return isAbsolute(value) || (
+ platform === "win32"
+ && /^(?:[A-Za-z]:[\\/]|\\\\|\/)/.test(String(value))
+ );
+}
+
+function pathDirname(value, platform = process.platform) {
+ return platform === "win32" ? win32.dirname(String(value)) : dirname(value);
+}
+
+function pathBasename(value, platform = process.platform) {
+ return platform === "win32" ? win32.basename(String(value)) : basename(value);
+}
+
+/** Normalize pnpm list/root output to the versioned global group, not its node_modules root. */
+function normaliseGlobalRoot(value, platform = process.platform) {
+ if (!isAbsolutePath(value, platform)) return null;
+ const trimmed = String(value).replace(/[\\/]+$/, "");
+ return pathBasename(trimmed, platform).toLowerCase() === "node_modules"
+ ? pathDirname(trimmed, platform)
+ : trimmed;
+}
+
+/** pnpm's --global-dir is the base; pnpm appends the major-version group below it. */
+function globalDirFromRoot(globalRoot, platform = process.platform) {
+ const separator = platform === "win32" ? /[\\/]/ : /\//;
+ const match = String(globalRoot).match(new RegExp(`^(.*)${separator.source}(?:v)?\\d+$`, "i"));
+ return match?.[1] || globalRoot;
+}
+
+function globalRootMatchesDir(globalRoot, globalDir, platform = process.platform) {
+ return samePath(globalRoot, globalDir, platform)
+ || samePath(pathDirname(globalRoot, platform), globalDir, platform);
+}
+
+/** Resolve manager-owned global paths, including pnpm defaults that config get leaves undefined. */
+function resolveGlobalPaths(commandPath, runPnpm, listedRoot, platform = process.platform) {
+ const listedGlobalRoot = normaliseGlobalRoot(listedRoot, platform);
+ const commandGlobalRoot = normaliseGlobalRoot(
+ singleAbsoluteCommandPath(runPnpm, commandPath, ["root", "-g"], platform),
+ platform,
+ );
+ if (listedGlobalRoot && commandGlobalRoot && !samePath(listedGlobalRoot, commandGlobalRoot, platform)) {
+ return null;
+ }
+ const globalRoot = commandGlobalRoot ?? listedGlobalRoot;
+ if (!globalRoot) return null;
+
+ const configuredGlobalDir = absoluteConfigPath(configPathValue(runPnpm, commandPath, "global-dir", platform), platform);
+ const globalDir = configuredGlobalDir ?? globalDirFromRoot(globalRoot, platform);
+ const configuredGlobalBinDir = absoluteConfigPath(configPathValue(runPnpm, commandPath, "global-bin-dir", platform), platform);
+ const globalBinDir = configuredGlobalBinDir
+ ?? absoluteConfigPath(singleAbsoluteCommandPath(runPnpm, commandPath, ["bin", "-g"], platform), platform);
+ if (!globalDir || !globalBinDir || !globalRootMatchesDir(globalRoot, globalDir, platform)) return null;
+ return { globalDir, globalRoot, globalBinDir };
+}
+
+function packageEntryFromRoot(root, packageName) {
+ const maps = [root?.dependencies, root?.devDependencies, root?.optionalDependencies];
+ for (const dependencies of maps) {
+ if (!dependencies || typeof dependencies !== "object") continue;
+ const direct = dependencies[packageName];
+ if (direct && typeof direct === "object") return direct;
+ }
+ return null;
+}
+
+function inspectListOutput(stdout, packageName) {
+ let roots;
+ try {
+ roots = JSON.parse(outputText(stdout));
+ } catch {
+ return null;
+ }
+ if (!Array.isArray(roots)) return null;
+ for (const root of roots) {
+ const entry = packageEntryFromRoot(root, packageName);
+ if (entry) return { root, entry };
+ }
+ return null;
+}
+
+function ownerGlobalArgs(args, owner) {
+ const input = [...args];
+ if (!owner) return input;
+ const command = input[0];
+ if (!["add", "install", "update", "list", "remove", "uninstall"].includes(command)) return input;
+ const rest = [];
+ for (let index = 1; index < input.length; index += 1) {
+ const arg = input[index];
+ // The owner is authoritative. Remove an accidentally inherited/supplied value rather
+ // than relying on duplicate pnpm flags having stable precedence across pnpm 10/11.
+ if (arg === "--global-dir" || arg.startsWith("--global-dir=")) {
+ if (arg === "--global-dir") index += 1;
+ continue;
+ }
+ if (arg === "--config.global-bin-dir" || arg.startsWith("--config.global-bin-dir=")) {
+ if (arg === "--config.global-bin-dir") index += 1;
+ continue;
+ }
+ rest.push(arg);
+ }
+ return [
+ command,
+ `--global-dir=${owner.globalDir}`,
+ `--config.global-bin-dir=${owner.globalBinDir}`,
+ ...rest,
+ ];
+}
+
+/** Add the selected pnpm global group and bin directory to a command's config. */
+export function pnpmGlobalCommandArgs(args, owner) {
+ return ownerGlobalArgs(args, owner);
+}
+
+/**
+ * Add the owning global bin directory to PATH for commands such as pnpm's bin
+ * validation. Do not replace PATH: registry auth and the selected pnpm executable
+ * can depend on the rest of the inherited environment.
+ */
+export function pnpmOwnerEnvironment(owner, env = process.env, platform = process.platform) {
+ const key = platform === "win32" && env.Path !== undefined && env.PATH === undefined ? "Path" : "PATH";
+ const delimiter = platform === "win32" ? ";" : ":";
+ const existing = env[key] ?? env.PATH ?? env.Path ?? "";
+ const entries = String(existing).split(delimiter).filter(Boolean);
+ if (!entries.some(entry => samePath(entry, owner.globalBinDir, platform))) entries.unshift(owner.globalBinDir);
+ return { ...env, [key]: entries.join(delimiter) };
+}
+
+/** Build an invocation for the already-selected pnpm executable and global group. */
+export function pnpmOwnerInvocation(owner, args, platform = process.platform, env = process.env) {
+ const ownerEnv = pnpmOwnerEnvironment(owner, env, platform);
+ const invocation = pnpmInvocationForPath(
+ owner.commandPath,
+ ownerGlobalArgs(args, owner),
+ platform,
+ ownerEnv,
+ );
+ return invocation ? { ...invocation, env: ownerEnv } : null;
+}
+
+function shimNames(platform) {
+ return platform === "win32"
+ ? ["ocx.cmd", "ocx.ps1", "opencodex.cmd", "opencodex.ps1"]
+ : ["ocx", "opencodex"];
+}
+
+function targetVariants(packageDir, globalBinDir, platform) {
+ const packageCandidates = [packageDir];
+ try { packageCandidates.push(realpathSync(packageDir)); } catch { /* keep lexical path */ }
+ const binCandidates = [globalBinDir];
+ try { binCandidates.push(realpathSync(globalBinDir)); } catch { /* keep lexical path */ }
+ const launcherCandidates = packageCandidates.map(candidate => join(candidate, "bin", "ocx.mjs"));
+ const relativeCandidates = binCandidates.flatMap(bin => launcherCandidates.map(candidate => relative(bin, candidate)));
+ return [...new Set([...launcherCandidates, ...relativeCandidates].map(value => {
+ const normalised = String(value).replaceAll("\\", "/").replace(/^\.\//, "");
+ return platform === "win32" ? normalised.toLowerCase() : normalised;
+ }))].filter(Boolean);
+}
+
+function shimPointsToPackage(shimPath, packageDir, globalBinDir, platform) {
+ try {
+ if (samePath(shimPath, join(packageDir, "bin", "ocx.mjs"), platform)) return true;
+ } catch { /* fall through to text inspection */ }
+ let text;
+ try {
+ text = readFileSync(shimPath, "utf8").replaceAll("\\", "/");
+ if (platform === "win32") text = text.toLowerCase();
+ } catch {
+ return false;
+ }
+ // A comment containing the new path is not a launcher. Generated cmd/PowerShell
+ // shims are simple enough that the target appears on an executable line; discard
+ // shebang/hash and REM lines before matching so a stale or hand-edited shim cannot
+ // pass verification by mentioning the right package in a comment.
+ const executableText = text.split(/\r?\n/).filter(line => {
+ const trimmed = line.trim();
+ return !trimmed.startsWith("#") && !/^rem(?:\s|$)/i.test(trimmed);
+ }).join("\n");
+ const expectedLauncher = join(packageDir, "bin", "ocx.mjs");
+ if (shimTargetPaths(shimPath, executableText, platform).some(target => samePath(target, expectedLauncher, platform))) {
+ return true;
+ }
+ return targetVariants(packageDir, globalBinDir, platform).some(target => executableText.includes(target));
+}
+
+function shimIsRunnable(shimPath, platform) {
+ if (platform === "win32") return true;
+ try {
+ return (statSync(shimPath).mode & 0o111) !== 0;
+ } catch {
+ return false;
+ }
+}
+
+function shimTargetPaths(shimPath, text, platform) {
+ const targets = [];
+ const launcherPattern = /((?:\$basedir(?:_win)?|\$PSScriptRoot|%~dp0|[A-Za-z]:[\\/]|\/|\.\.?[\\/])[^"'`\r\n]*[\\/]bin[\\/]ocx\.mjs)/gi;
+ const shimDir = pathDirname(shimPath, platform);
+ for (const match of text.matchAll(launcherPattern)) {
+ let target = match[1];
+ target = target.replace(/\$basedir_win|\$basedir|\$PSScriptRoot|%~dp0/gi, shimDir);
+ target = target.replaceAll("\\", "/");
+ targets.push(isAbsolutePath(target, platform)
+ ? target
+ : platform === "win32" ? win32.resolve(shimDir, target) : resolve(shimDir, target));
+ }
+ return targets;
+}
+
+/**
+ * Verify both generated command names and all launcher forms pnpm supports for
+ * the current platform. A package list/tree check is not enough: a stale shim
+ * can still execute the old global group after pnpm changes the active link.
+ */
+export function verifyPnpmGlobalShims(
+ packageDir,
+ globalBinDir,
+ platform = process.platform,
+ exists = existsSync,
+) {
+ if (!isAbsolutePath(globalBinDir, platform)) {
+ return { ok: false, reason: "pnpm global bin directory is not absolute" };
+ }
+ const missing = [];
+ for (const name of shimNames(platform)) {
+ const path = join(globalBinDir, name);
+ if (
+ !exists(path)
+ || !shimIsRunnable(path, platform)
+ || !shimPointsToPackage(path, packageDir, globalBinDir, platform)
+ ) missing.push(name);
+ }
+ return missing.length === 0
+ ? { ok: true }
+ : { ok: false, reason: `pnpm generated shim verification failed (${missing.join(", ")})` };
+}
+
+/**
+ * Read and verify the package manager's active global link. `owner` constraints
+ * make the listing a proof of the group selected during preflight, not merely a
+ * successful listing from whichever pnpm happens to be first on PATH.
+ */
+export function readPnpmGlobalPackage(
+ packageName,
+ runPnpm,
+ verify = verifyPnpmInstallTree,
+ constraints = {},
+) {
+ const platform = constraints.platform ?? process.platform;
+ const expectedGlobalDir = constraints.expectedGlobalDir ?? constraints.owner?.globalDir;
+ const expectedGlobalRoot = constraints.expectedGlobalRoot ?? constraints.owner?.globalRoot;
+ const globalBinDir = constraints.globalBinDir ?? constraints.owner?.globalBinDir;
+ let result;
+ try {
+ result = runPnpm(ownerGlobalArgs(["list", "-g", "--depth=0", "--json", packageName], constraints.owner), true);
+ } catch {
+ return { ok: false, reason: "pnpm global package listing failed" };
+ }
+ if (result?.status !== 0) return { ok: false, reason: "pnpm global package listing failed" };
+
+ const inspected = inspectListOutput(result.stdout, packageName);
+ const entry = inspected?.entry;
+ const version = typeof entry?.version === "string" ? entry.version.trim() : "";
+ const packagePath = typeof entry?.path === "string" ? entry.path : "";
+ if (!version || !packagePath || !isAbsolutePath(packagePath, platform)) {
+ return { ok: false, reason: "pnpm did not report a valid active global package" };
+ }
+ if (constraints.expectedPackagePath && !samePath(packagePath, constraints.expectedPackagePath, platform)) {
+ return { ok: false, reason: "pnpm active package is not the running package" };
+ }
+
+ const rootPath = normaliseGlobalRoot(inspected?.root?.path, platform);
+ if (expectedGlobalRoot || expectedGlobalDir) {
+ // pnpm versions have reported either the global group or its node_modules root;
+ // accept both, but require the root when the caller is proving ownership. Without
+ // it a successful package listing cannot distinguish a command that ignored the
+ // pinned group from the selected group.
+ if (!rootPath) return { ok: false, reason: "pnpm did not report the selected global group" };
+ const rootMatches = expectedGlobalRoot
+ ? samePath(rootPath, expectedGlobalRoot, platform)
+ : globalRootMatchesDir(rootPath, expectedGlobalDir, platform);
+ if (!rootMatches) return { ok: false, reason: "pnpm listed a different global group" };
+ }
+
+ let tree;
+ try {
+ tree = verify(packagePath, version);
+ } catch {
+ return { ok: false, reason: "the active pnpm package could not be verified" };
+ }
+ if (!tree?.ok) return { ok: false, reason: "the active pnpm package failed verification" };
+
+ // A valid package tree/group is enough to bind the owner before an update. The
+ // existing shim may be stale from an older pnpm run; post-update and rollback
+ // reads leave this enabled so a successful transaction must produce fresh shims.
+ if (globalBinDir && constraints.checkShims !== false) {
+ const shims = (constraints.verifyShims ?? verifyPnpmGlobalShims)(
+ packagePath,
+ globalBinDir,
+ platform,
+ );
+ if (!shims?.ok) return { ok: false, reason: shims?.reason ?? "pnpm global shims failed verification" };
+ }
+ return {
+ ok: true,
+ version,
+ path: packagePath,
+ globalDir: expectedGlobalDir,
+ globalRoot: rootPath || expectedGlobalRoot,
+ globalBinDir,
+ };
+}
+
+function statusText(status) {
+ return status === null || status === undefined ? "?" : String(status);
+}
+
+function packageSpec(packageName, versionOrTag) {
+ return `${packageName}@${versionOrTag}`;
+}
+
+function listGlobalPackage(commandPath, packageName, runPnpm) {
+ try {
+ return runPnpm(commandPath, ["list", "-g", "--depth=0", "--json", packageName], true);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Find the pnpm executable and global group that own the running package. Every
+ * candidate is inspected independently; this matters when two pnpm homes expose
+ * the same pnpm version but only one owns the current package/shim.
+ */
+export function resolvePnpmGlobalOwner({
+ packageName,
+ packagePath,
+ commandPaths,
+ runningShimPath,
+ runPnpm,
+ verify = verifyPnpmInstallTree,
+ platform = process.platform,
+}) {
+ if (!isAbsolutePath(packagePath, platform)) return { ok: false, reason: "running pnpm package path is not absolute" };
+ let lastReason = "no pnpm global installation owns the running package";
+
+ for (const commandPath of commandPaths ?? []) {
+ const list = listGlobalPackage(commandPath, packageName, runPnpm);
+ if (list?.status !== 0) continue;
+ const inspected = inspectListOutput(list.stdout, packageName);
+ const listedPath = typeof inspected?.entry?.path === "string" ? inspected.entry.path : "";
+ if (!listedPath || !isAbsolutePath(listedPath, platform) || !samePath(listedPath, packagePath, platform)) continue;
+
+ const paths = resolveGlobalPaths(commandPath, runPnpm, inspected?.root?.path, platform);
+ if (!paths) {
+ lastReason = "pnpm owns the running package but did not report its global group and bin directory";
+ continue;
+ }
+ const { globalDir, globalRoot, globalBinDir } = paths;
+
+ // If the process was entered through a generated command shim, its directory is
+ // another owner fact. This disambiguates two pnpm homes that expose the same pnpm
+ // version and (for example after a copied prefix) report the same package path.
+ // Direct `node bin/ocx.mjs` and Windows shims that invoke the package path do not
+ // provide a usable shim path, so they continue to rely on the package/group proof.
+ if (runningShimPath) {
+ const shimName = String(runningShimPath).replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
+ const isCommandShim = ["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"].includes(shimName ?? "");
+ if (isCommandShim && !samePath(dirname(runningShimPath), globalBinDir, platform)) {
+ lastReason = "pnpm package owner did not match the running global shim";
+ continue;
+ }
+ }
+
+ const owner = { commandPath, packagePath: listedPath, globalDir, globalRoot, globalBinDir };
+ const active = readPnpmGlobalPackage(
+ packageName,
+ (args, capture = false) => runPnpm(commandPath, args, capture),
+ verify,
+ {
+ owner,
+ expectedPackagePath: packagePath,
+ expectedGlobalDir: globalDir,
+ expectedGlobalRoot: globalRoot,
+ globalBinDir,
+ // Owner binding must remain possible when an older pnpm invocation left
+ // the top-level shim stale; the transaction verifies it after mutation.
+ checkShims: false,
+ platform,
+ },
+ );
+ if (active.ok) return { ok: true, owner: { ...owner, packagePath: active.path, version: active.version } };
+ lastReason = active.reason;
+ }
+ return { ok: false, reason: lastReason };
+}
+
+/**
+ * Update a pnpm global package through pnpm itself. Never rename or remove files in
+ * the pnpm store. Every command is pinned to the owner discovered before the proxy
+ * is stopped. The pre-update proof accepts an existing stale shim; success and
+ * rollback require a valid tree plus fresh generated shims.
+ */
+export function runPnpmGlobalUpdate({
+ packageName,
+ currentVersion,
+ targetVersion,
+ tag,
+ owner,
+ runningPackagePath,
+ runPnpm,
+ verify = verifyPnpmInstallTree,
+ verifyShims = verifyPnpmGlobalShims,
+ platform = process.platform,
+ log = () => {},
+}) {
+ if (
+ !owner?.commandPath
+ || !owner?.globalDir
+ || !owner?.globalRoot
+ || !owner?.globalBinDir
+ || !isAbsolutePath(owner.commandPath, platform)
+ || !isAbsolutePath(owner.globalDir, platform)
+ || !isAbsolutePath(owner.globalBinDir, platform)
+ ) {
+ return { ok: false, phase: "preflight", error: "pnpm global owner was not pinned" };
+ }
+ const constraints = {
+ owner,
+ expectedPackagePath: runningPackagePath ?? owner.packagePath,
+ expectedGlobalDir: owner.globalDir,
+ expectedGlobalRoot: owner.globalRoot,
+ globalBinDir: owner.globalBinDir,
+ verifyShims,
+ platform,
+ };
+ const before = readPnpmGlobalPackage(
+ packageName,
+ runPnpm,
+ verify,
+ { ...constraints, checkShims: false },
+ );
+ if (!before.ok) return { ok: false, phase: "preflight", error: before.reason };
+ if (currentVersion && before.version !== currentVersion) {
+ return { ok: false, phase: "preflight", error: "pnpm's active package does not match the running package" };
+ }
+
+ const requested = targetVersion || tag;
+ const spec = packageSpec(packageName, requested);
+ log(`Updating ${spec} with pnpm…`);
+ let install;
+ try {
+ install = runPnpm(ownerGlobalArgs(["add", "-g", PNPM_BUILD_APPROVAL, spec], owner), false);
+ } catch {
+ install = { status: 1 };
+ }
+
+ // The package path may switch to a different group link, so only constrain
+ // the group/bin pair after the command; the active package path is intentionally open.
+ const after = readPnpmGlobalPackage(
+ packageName,
+ runPnpm,
+ verify,
+ { ...constraints, expectedPackagePath: undefined },
+ );
+ const targetMatches = after.ok && (!targetVersion || after.version === targetVersion);
+ if (install?.status === 0 && targetMatches) {
+ return {
+ ok: true,
+ phase: "done",
+ version: after.version,
+ path: after.path,
+ globalDir: owner.globalDir,
+ globalBinDir: owner.globalBinDir,
+ };
+ }
+
+ const installReason = install?.status !== 0
+ ? `pnpm update failed (${statusText(install?.status)})`
+ : after.ok
+ ? "pnpm update produced an unexpected active package version"
+ : "pnpm update completed but the active package failed verification";
+
+ // A failed command may have left the original active group intact. Do not create a
+ // second update transaction in that case; the verified previous package is already safe.
+ if (after.ok && after.version === before.version) {
+ return {
+ ok: false,
+ phase: "install",
+ rolledBack: true,
+ activePath: after.path,
+ globalDir: owner.globalDir,
+ globalBinDir: owner.globalBinDir,
+ error: `${installReason}; previous version remains active`,
+ };
+ }
+
+ log(`Restoring ${packageName}@${before.version} with pnpm…`);
+ let rollback;
+ try {
+ rollback = runPnpm(ownerGlobalArgs(["add", "-g", PNPM_BUILD_APPROVAL, packageSpec(packageName, before.version)], owner), false);
+ } catch {
+ rollback = { status: 1 };
+ }
+ const restored = readPnpmGlobalPackage(
+ packageName,
+ runPnpm,
+ verify,
+ { ...constraints, expectedPackagePath: undefined },
+ );
+ if (rollback?.status === 0 && restored.ok && restored.version === before.version) {
+ return {
+ ok: false,
+ phase: "rollback",
+ rolledBack: true,
+ activePath: restored.path,
+ globalDir: owner.globalDir,
+ globalBinDir: owner.globalBinDir,
+ error: `${installReason}; previous version restored`,
+ };
+ }
+ return {
+ ok: false,
+ phase: "rollback",
+ rolledBack: false,
+ ...(restored.ok ? { activePath: restored.path } : {}),
+ globalDir: owner.globalDir,
+ globalBinDir: owner.globalBinDir,
+ error: `${installReason}; previous version could not be verified after rollback`,
+ };
+}
diff --git a/src/update/pnpm-invocation.d.mts b/src/update/pnpm-invocation.d.mts
new file mode 100644
index 0000000000..4ae7a865d3
--- /dev/null
+++ b/src/update/pnpm-invocation.d.mts
@@ -0,0 +1,43 @@
+export interface PnpmInvocationDeps {
+ cwd?: string;
+ exists?: (path: string) => boolean;
+}
+
+export interface PnpmInvocation {
+ file: string;
+ args: string[];
+ options: { windowsVerbatimArguments?: boolean };
+}
+
+export declare function resolvePnpmCommands(
+ platform?: NodeJS.Platform,
+ env?: Record,
+ deps?: PnpmInvocationDeps,
+): string[];
+
+export declare function pnpmInvocationForPath(
+ pnpm: string,
+ args: readonly string[],
+ platform?: NodeJS.Platform,
+ env?: Record,
+): PnpmInvocation | null;
+
+export declare function resolvePnpmCommand(
+ platform?: NodeJS.Platform,
+ env?: Record,
+ deps?: PnpmInvocationDeps,
+): string | null;
+
+export declare function pnpmInvocation(
+ args: readonly string[],
+ platform?: NodeJS.Platform,
+ env?: Record,
+ deps?: PnpmInvocationDeps,
+): PnpmInvocation | null;
+
+export declare function pnpmInvocations(
+ args: readonly string[],
+ platform?: NodeJS.Platform,
+ env?: Record,
+ deps?: PnpmInvocationDeps,
+): PnpmInvocation[];
diff --git a/src/update/pnpm-invocation.mjs b/src/update/pnpm-invocation.mjs
new file mode 100644
index 0000000000..aa5de3b699
--- /dev/null
+++ b/src/update/pnpm-invocation.mjs
@@ -0,0 +1,141 @@
+import { existsSync } from "node:fs";
+import { win32 } from "node:path";
+
+const CMD_META = /([()%!^"`<>&|;, *?])/g;
+
+function escapeCmdArg(arg) {
+ const out = String(arg).replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\*)$/, "$1$1");
+ return `"${out}"`.replace(CMD_META, "^$1");
+}
+
+function escapeCmdCommand(command) {
+ return command.replace(CMD_META, "^$1");
+}
+
+function cleanPathEntry(entry) {
+ const trimmed = entry.trim();
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1);
+ return trimmed;
+}
+
+function pathEntries(platform, env) {
+ const raw = env.PATH ?? env.Path ?? "";
+ const delimiter = platform === "win32" ? win32.delimiter : ":";
+ return raw.split(delimiter).map(cleanPathEntry).filter(Boolean);
+}
+
+function isCurrentDirectory(cwd, entry) {
+ const left = win32.resolve(entry);
+ const right = win32.resolve(cwd);
+ return left.toLowerCase() === right.toLowerCase();
+}
+
+function systemCommandProcessor(env) {
+ const systemRoot = env.SystemRoot ?? env.windir;
+ if (systemRoot && win32.isAbsolute(systemRoot)) {
+ return win32.join(systemRoot, "System32", "cmd.exe");
+ }
+ const comSpec = env.ComSpec;
+ return comSpec && win32.isAbsolute(comSpec) ? win32.resolve(comSpec) : null;
+}
+
+function commandPaths(platform, env, deps) {
+ const exists = deps.exists ?? existsSync;
+ const cwd = deps.cwd ?? process.cwd();
+ const entries = pathEntries(platform, env);
+ const paths = [];
+
+ if (platform !== "win32") {
+ for (const entry of entries) {
+ if (!entry.startsWith("/")) continue;
+ const candidate = `${entry}/pnpm`;
+ if (exists(candidate) && !paths.includes(candidate)) paths.push(candidate);
+ }
+ return paths;
+ }
+
+ const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
+ .split(";")
+ .filter(Boolean);
+ for (const entry of entries) {
+ if (!win32.isAbsolute(entry) || isCurrentDirectory(cwd, entry)) continue;
+ for (const extension of extensions) {
+ const candidate = win32.join(entry, `pnpm${extension.toLowerCase()}`);
+ if (exists(candidate)) {
+ const resolved = win32.resolve(candidate);
+ if (!paths.some(path => path.toLowerCase() === resolved.toLowerCase())) paths.push(resolved);
+ }
+ }
+ }
+ return paths;
+}
+
+function invocationForPath(pnpm, args, platform, env) {
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(pnpm)) {
+ return { file: pnpm, args: [...args], options: {} };
+ }
+
+ const commandProcessor = systemCommandProcessor(env);
+ if (!commandProcessor) return null;
+ const line = [escapeCmdCommand(pnpm), ...args.map(escapeCmdArg)].join(" ");
+ return {
+ file: commandProcessor,
+ args: ["/d", "/s", "/c", `"${line}"`],
+ options: { windowsVerbatimArguments: true },
+ };
+}
+
+/** Return every absolute pnpm executable candidate in PATH, in shell order. */
+export function resolvePnpmCommands(
+ platform = process.platform,
+ env = process.env,
+ deps = {},
+) {
+ return commandPaths(platform, env, deps);
+}
+
+/** Build an invocation for one already-selected pnpm executable. */
+export function pnpmInvocationForPath(
+ pnpm,
+ args,
+ platform = process.platform,
+ env = process.env,
+) {
+ return invocationForPath(pnpm, args, platform, env);
+}
+
+/**
+ * Resolve pnpm without relying on cmd.exe's implicit current-directory lookup on Windows.
+ * POSIX returns an absolute PATH candidate as well, so a service receives the same
+ * executable that the interactive shell selected.
+ */
+export function resolvePnpmCommand(
+ platform = process.platform,
+ env = process.env,
+ deps = {},
+) {
+ return resolvePnpmCommands(platform, env, deps)[0] ?? null;
+}
+
+export function pnpmInvocation(
+ args,
+ platform = process.platform,
+ env = process.env,
+ deps = {},
+) {
+ const pnpm = resolvePnpmCommand(platform, env, deps);
+ if (!pnpm) return null;
+ return invocationForPath(pnpm, args, platform, env);
+}
+
+/** Return invocations for every absolute pnpm candidate in PATH. */
+export function pnpmInvocations(
+ args,
+ platform = process.platform,
+ env = process.env,
+ deps = {},
+) {
+ return resolvePnpmCommands(platform, env, deps)
+ .map(command => invocationForPath(command, args, platform, env))
+ .filter(Boolean);
+}
diff --git a/src/update/registry-integrity.d.mts b/src/update/registry-integrity.d.mts
new file mode 100644
index 0000000000..e862672162
--- /dev/null
+++ b/src/update/registry-integrity.d.mts
@@ -0,0 +1,16 @@
+export interface RegistryCommandResult {
+ status: number | null;
+ stdout?: string | Uint8Array | null;
+ stderr?: string | Uint8Array | null;
+}
+
+export type RegistryIntegrityResult =
+ | { ok: true; integrity: string }
+ | { ok: false; reason: string }
+ | { ok: "skipped"; reason: string };
+
+export declare function checkRegistryPackageIntegrity(
+ packageName: string,
+ version: string | null | undefined,
+ run: (args: readonly string[], capture?: boolean) => RegistryCommandResult,
+): RegistryIntegrityResult;
diff --git a/src/update/registry-integrity.mjs b/src/update/registry-integrity.mjs
new file mode 100644
index 0000000000..3c0f5f3893
--- /dev/null
+++ b/src/update/registry-integrity.mjs
@@ -0,0 +1,37 @@
+/**
+ * Shared registry metadata pre-flight for every direct package-manager launcher.
+ *
+ * This intentionally does not perform the query itself. The caller supplies the
+ * already-hardened npm/pnpm invocation, so the plain Node launcher and the Bun
+ * update worker apply exactly the same integrity policy without importing TypeScript
+ * into the published launcher.
+ */
+function outputText(value) {
+ if (typeof value === "string") return value;
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value);
+ return "";
+}
+
+/**
+ * Check the registry's dist.integrity value for one immutable package version.
+ * A failed query is a best-effort skip; successful metadata without a sha512 SRI
+ * value is anomalous and fails closed before the caller changes local state.
+ */
+export function checkRegistryPackageIntegrity(packageName, version, run) {
+ if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
+
+ let result;
+ try {
+ result = run(["view", `${packageName}@${version}`, "dist.integrity"], true);
+ } catch {
+ return { ok: "skipped", reason: "registry integrity query failed" };
+ }
+ if (result?.status !== 0) {
+ return { ok: "skipped", reason: `registry integrity query failed (status ${result?.status ?? "timeout"})` };
+ }
+
+ const tokens = outputText(result.stdout).replace(/["']/g, "").trim().split(/\s+/).filter(Boolean);
+ const integrity = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token));
+ if (!integrity) return { ok: false, reason: `registry returned no sha512 integrity for ${packageName}@${version}` };
+ return { ok: true, integrity };
+}
diff --git a/src/update/transactional-install.d.mts b/src/update/transactional-install.d.mts
index 398639e4f9..11d1c1970e 100644
--- a/src/update/transactional-install.d.mts
+++ b/src/update/transactional-install.d.mts
@@ -1,5 +1,6 @@
export type InstallTreeVerification = { ok: boolean; failures: string[] };
export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification;
+export function verifyPnpmInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification;
export function bootRestoreProbe(
packageDir: string,
deps?: { rename?: (from: string, to: string) => void },
@@ -19,4 +20,3 @@ export function transactionalNpmUpdate(args: {
rolledBack?: boolean;
backup?: string;
};
-
diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs
index 9b61c9fe33..1930a27f7f 100644
--- a/src/update/transactional-install.mjs
+++ b/src/update/transactional-install.mjs
@@ -17,11 +17,27 @@
* /.ocx-recovery.json double-fault marker with a one-line restore
*/
import { spawnSync } from "node:child_process";
-import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
/** Verification manifest for a staged (or live) package tree. */
-export function verifyInstallTree(packageDir, expectedVersion) {
+function dependencyPackageDir(packageDir, name) {
+ const candidates = [packageDir];
+ try {
+ const resolved = realpathSync(packageDir);
+ if (!candidates.includes(resolved)) candidates.push(resolved);
+ } catch { /* keep the lexical package path */ }
+ for (const candidate of candidates) {
+ try {
+ const require = createRequire(join(candidate, "package.json"));
+ return dirname(require.resolve(`${name}/package.json`));
+ } catch { /* try the package's realpath before failing closed */ }
+ }
+ return undefined;
+}
+
+function verifyInstallTreeWithDependencyRoot(packageDir, expectedVersion) {
const failures = [];
let pkg;
try {
@@ -41,9 +57,10 @@ export function verifyInstallTree(packageDir, expectedVersion) {
}
// The bundled Bun binary is the load-bearing artifact: without it the launcher exits
// before serving anything, and a boot probe that called this tree healthy would reap
- // the only backup (review High 3). Size-gate the real binary, not just its package.json.
- const bunPkgDir = join(packageDir, "node_modules", "bun");
- if (existsSync(bunPkgDir)) {
+ // the only backup (review High 3). Resolve the dependency exactly as the launcher does,
+ // rather than assuming npm's nested tree or pnpm's current virtual-store directory.
+ const bunPkgDir = dependencyPackageDir(packageDir, "bun");
+ if (bunPkgDir) {
const bunBinary = findLargestFile(bunPkgDir);
if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) {
failures.push("bundled Bun binary missing or truncated (< 10MB)");
@@ -56,12 +73,24 @@ export function verifyInstallTree(packageDir, expectedVersion) {
? deps.filter(name => name === "bun" || name === "zod")
: deps.slice(0, 2);
for (const name of sentinels) {
- const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json");
- if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name);
+ if (!dependencyPackageDir(packageDir, name)) failures.push("sentinel dependency missing: " + name);
}
return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures };
}
+export function verifyInstallTree(packageDir, expectedVersion) {
+ return verifyInstallTreeWithDependencyRoot(packageDir, expectedVersion);
+}
+
+/**
+ * Verify a package exposed through pnpm's global virtual store. Dependency resolution is
+ * deliberately delegated to Node's package resolver: pnpm 10/11 may use an isolated
+ * virtual store, a custom virtualStoreDir, global virtual-store links, or a hoisted linker.
+ */
+export function verifyPnpmInstallTree(packageDir, expectedVersion) {
+ return verifyInstallTreeWithDependencyRoot(packageDir, expectedVersion);
+}
+
function stampedName(prefix) {
return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-");
}
diff --git a/src/update/tray-update-plan.mjs b/src/update/tray-update-plan.mjs
index 695a3467aa..606a5d254c 100644
--- a/src/update/tray-update-plan.mjs
+++ b/src/update/tray-update-plan.mjs
@@ -1,6 +1,6 @@
/**
* Shared, side-effect-free contract for preserving the Windows tray across all
- * updater entry points (npm launcher, CLI updater, and GUI worker).
+ * updater entry points (package launcher, CLI updater, and GUI worker).
*/
export function planWindowsTrayUpdate(status) {
const installed = status?.installed === true;
diff --git a/structure/01_runtime.md b/structure/01_runtime.md
index 7e529ea503..21f420a6ce 100644
--- a/structure/01_runtime.md
+++ b/structure/01_runtime.md
@@ -4,7 +4,7 @@
| Path | Responsibility |
| --- | --- |
-| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. |
+| `bin/ocx.mjs` | Published npm/pnpm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Its package-manager-aware update path lets npm and pnpm global installs update without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. |
| `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. |
| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). |
| `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. |
diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md
index 9475dba43e..eb1f82bbc2 100644
--- a/structure/06_docs-and-release.md
+++ b/structure/06_docs-and-release.md
@@ -69,11 +69,11 @@ Those controls still have no owner, so there is no image-publish workflow or off
[Decision Log]
- 목적과 의도: Prevent a failed npm replacement from making the Task Scheduler wrapper retry missing package files forever.
-- 기존 구현 및 제약 조건: The wrapper deliberately restarts a proxy after runtime crashes, but an absent baked Bun or CLI path cannot recover inside that process. Current updater preflight and stop-first behavior reduce replacement risk but do not provide a transactional restore of npm's package tree and global launchers.
-- 검토한 주요 대안: Keep unconditional five-second retries, add a generic crash ceiling, restore npm directories in-place, or classify only proven missing executable paths as terminal.
+- 기존 구현 및 제약 조건: The wrapper deliberately restarts a proxy after runtime crashes, but an absent baked Bun or CLI path cannot recover inside that process. npm and pnpm own different global layouts, so each updater path must preserve the manager's launcher and package-tree invariants.
+- 검토한 주요 대안: Keep unconditional five-second retries, add a generic crash ceiling, restore manager-owned directories in-place, or classify only proven missing executable paths as terminal.
- 선택한 방식: Check the baked Bun and CLI paths before every spawn; log one actionable incomplete-install message and exit with code 3 when either is absent. Preserve the existing retry loop for a child that actually launched and then failed.
-- 다른 대안 대신 이 방식을 선택한 이유: A generic retry ceiling can stop a service after unrelated intermittent crashes, while copying a package directory without matching npm shims, ownership, and lock guarantees is not a safe rollback.
-- 장점, 단점 및 영향: File-less package skeletons no longer produce unbounded service logs or restart churn. The wrapper still recovers ordinary proxy crashes, but repairing an incomplete npm install remains an explicit reinstall plus `ocx service repair` operation until a verified staged-update design exists.
+- 다른 대안 대신 이 방식을 선택한 이유: A generic retry ceiling can stop a service after unrelated intermittent crashes, while copying a package directory without matching manager shims, ownership, and lock guarantees is not a safe rollback. npm therefore keeps its staged tree swap; pnpm changes its active global group through pnpm itself.
+- 장점, 단점 및 영향: File-less package skeletons no longer produce unbounded service logs or restart churn. The wrapper recovers ordinary proxy crashes, npm updates retain their staged rollback, and pnpm updates verify and manager-natively restore an incomplete global group.
## GitHub workflow map
@@ -179,21 +179,25 @@ npm dependency (esbuild-style: a tiny main package plus platform-specific `@oven
Invariants:
- `bin/ocx.mjs` resolves the bundled binary via `require.resolve("bun/package.json")` and a size gate
- (`>= 1 MB`) that rejects the ~450-byte placeholder stub left by `--ignore-scripts`/pnpm; it then
+ (`>= 1 MB`) that rejects the ~450-byte placeholder stub left by `--ignore-scripts` or an
+ unapproved pnpm build; it then
lazy-runs `install.js` and execs `src/cli/index.ts` under Bun, propagating exit code and signal.
- `package.json` carries `"trustedDependencies": ["bun"]` so `bun install` runs the dependency's
postinstall, and `"engines": { "node": ">=18" }` (Bun is no longer a user prerequisite).
- The plain-Node launcher owns `OPENCODEX_BUN_PATH` selection before Bun can load project dotenv and
stamps the chosen source/path pair. `src/service.ts` and `src/codex/shim.ts` bake that already-
- selected executable (normally the bundled binary, stable under the npm global prefix) into
+ selected executable (normally the bundled binary, stable under the manager's global package
+ directory) into
launchd/systemd/Task Scheduler and the Codex autostart shim. Bun-side code never re-selects a
durable executable from the post-dotenv environment.
-- Public docs (root READMEs + `docs-site` installation pages, all locales) state Node 18+ as the only
- prerequisite. Do not reintroduce "install Bun first" / "bun must be on PATH" guidance for npm users.
+- Public docs (root READMEs and the maintained English `docs-site` installation pages) state Node 18+ as the only
+ prerequisite. Do not reintroduce "install Bun first" / "bun must be on PATH" guidance for
+ published-package users.
## Release workflow
-Package release is npm-focused. `package.json` exposes `opencodex` and `ocx`, `prepublishOnly` runs
+Package publication is npm-focused, while the published package supports both npm and pnpm global
+installs. `package.json` exposes `opencodex` and `ocx`, `prepublishOnly` runs
typecheck and GUI build. `scripts/release.ts` accepts either an explicit version or
`--bump patch|minor|major`; the stable and preview channels use separate resolvers in
`scripts/version-line.ts`. It runs local typecheck, `bun test --isolate tests`, and
diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts
index f6c50559f5..fc41950115 100644
--- a/tests/ci-workflows/install-scripts.test.ts
+++ b/tests/ci-workflows/install-scripts.test.ts
@@ -181,15 +181,17 @@ exit 0
},
);
- test("Node launcher handles npm self-update before starting Bun", async () => {
+ test("Node launcher handles package-manager self-update before starting Bun", async () => {
const launcher = await readText("bin/ocx.mjs");
expect(launcher).toContain('process.argv[2] === "update"');
expect(launcher).toContain('["install", "-g", `${PKG}@${tag}`]');
+ expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${tag}`]');
expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"');
expect(launcher).toContain("!isBunGlobalInstall()");
- expect(launcher).toContain("repairCodexShimIfNeeded()");
+ expect(launcher).toContain("repairCodexShimIfNeeded(postUpdateLauncher)");
expect(launcher).toContain("runNpmSelfUpdate()");
+ expect(launcher).toContain("runPnpmSelfUpdate()");
});
test("release helper watches the workflow run it just dispatched", async () => {
diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts
index b6064d2284..b6edb5b412 100644
--- a/tests/cli/ocx-launcher-runtime.test.ts
+++ b/tests/cli/ocx-launcher-runtime.test.ts
@@ -311,7 +311,7 @@ function isolatedLauncherEnv(root: string, override: string): NodeJS.ProcessEnv
};
}
-describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () => {
+describe.skipIf(!nodeAvailable)("ocx package launcher relative Bun override", () => {
test("resolves a valid bare relative override before spawning", () => {
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-relative-"));
try {
@@ -358,7 +358,7 @@ describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () =>
}, 60_000);
});
-describe.skipIf(!runnable)("ocx npm launcher effective Bun runtime", () => {
+describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => {
test("uses a valid OPENCODEX_BUN_PATH for the actual proxy process", async () => {
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-copy-"));
try {
diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts
index 169646aeba..f15293f48a 100644
--- a/tests/cli/ocx-launcher-source.test.ts
+++ b/tests/cli/ocx-launcher-source.test.ts
@@ -14,7 +14,7 @@ const validatorSource = readFileSync(
"utf8",
);
-describe("ocx.mjs npm launcher (source invariants)", () => {
+describe("ocx.mjs package launcher (source invariants)", () => {
test("the Bun child receives the runtime provenance the launcher actually selected (#848)", () => {
// The launcher is a plain-Node bin script executing at import time, so this is
// asserted at the source level: the marker must reach the spawn env, and it must
@@ -58,9 +58,12 @@ describe("ocx.mjs npm launcher (source invariants)", () => {
expect(spawnCall).toContain("windowsHide: true");
});
- test("Windows npm spawns use the trusted absolute invocation without shell lookup", () => {
- expect(source).toContain("const latestInvocation = npmInvocation(");
- expect(source).toContain("const installInvocation = npmInvocation(");
+ test("Windows package-manager spawns use the trusted absolute invocation without shell lookup", () => {
+ expect(source).toContain("resolvePnpmGlobalOwner");
+ expect(source).toContain("const managerInvocation = args => manager === \"pnpm\"");
+ expect(source).toContain("pnpmOwnerInvocation(owner, args)");
+ expect(source).toContain("const latestInvocation = managerInvocation(");
+ expect(source).toContain("const installInvocation = managerInvocation(installArgs);");
expect(source).toContain("spawnSync(latestInvocation.file, latestInvocation.args");
// #1942: the staged install spawns through the same hardened npmInvocation resolver
// inside the transactional runNpm callback.
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index d772205061..b848189c73 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -1067,6 +1067,7 @@
"update-notify.test.ts": "update",
"update-npm-cache-preflight.test.ts": "update",
"update-npm-invocation.test.ts": "update",
+ "update-pnpm.test.ts": "update",
"update-stop-classification.test.ts": "update",
"update-stop-first.test.ts": "update",
"update-transactional.test.ts": "update",
diff --git a/tests/update/update-badge.test.ts b/tests/update/update-badge.test.ts
index b290b7615c..c961fc466f 100644
--- a/tests/update/update-badge.test.ts
+++ b/tests/update/update-badge.test.ts
@@ -57,7 +57,7 @@ describe("readUpdateBadge", () => {
test("reading the badge never spawns a registry refresh", () => {
// The GUI polls this endpoint. A refresh-on-read would let repeated polls launch
- // repeated `npm view` helpers with no coalescing, so the deps surface has no
+ // repeated manager `view` helpers with no coalescing, so the deps surface has no
// refresh hook at all — this test pins that shape.
const keys = Object.keys(deps({}));
expect(keys).toEqual(["currentVersion", "detectInstall", "readCache"]);
diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts
index 6e02ecd575..9ba42b8150 100644
--- a/tests/update/update-job.test.ts
+++ b/tests/update/update-job.test.ts
@@ -436,6 +436,55 @@ describe("GUI update execution decisions", () => {
expect(cmd.args).toEqual(["/pkg/bin/ocx.mjs", "update", "--tag", "preview"]);
});
+ test("pnpm worker uses the Node launcher update path", () => {
+ const cmd = updateExecutionCommand("pnpm", "latest", "/pkg/bin/ocx.mjs");
+ expect(cmd.bin).toMatch(/^node/);
+ expect(cmd.args).toEqual(["/pkg/bin/ocx.mjs", "update", "--tag", "latest"]);
+ });
+
+ test("pnpm GUI worker passes the verified active launcher into restart recovery", async () => {
+ const activeLauncher = "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs";
+ let restartLauncher = "";
+ let now = 0;
+ await runGuiUpdateWorker("pnpm-active-launcher", "latest", true, {
+ checkForUpdateFn: () => ({
+ currentVersion: "2.7.40",
+ latestVersion: "2.7.41",
+ channel: "latest",
+ installer: "pnpm",
+ updateAvailable: true,
+ canUpdate: true,
+ command: "node /old/bin/ocx.mjs update --tag latest",
+ releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest",
+ }),
+ resolvePnpmOwnerFn: () => ({
+ ok: true as const,
+ owner: {
+ commandPath: "/pnpm/owner/bin/pnpm",
+ packagePath: "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex",
+ globalDir: "/pnpm/owner/global",
+ globalRoot: "/pnpm/owner/global/v11",
+ globalBinDir: "/pnpm/owner/bin",
+ },
+ }),
+ resolvePnpmActiveLauncherFn: () => activeLauncher,
+ integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }),
+ runCommandFn: () => ({ status: 0, signal: null }),
+ restartIo: {
+ serviceInstalledFn: () => false,
+ restartAfterUpdateFn: async (_job, _captured, io) => {
+ restartLauncher = io?.packageLauncherPathFn?.() ?? "";
+ },
+ probeProxy: async () => true,
+ probeProxyIdentity: async () => ({ pid: 4242, version: "2.7.41" }),
+ now: () => now,
+ sleepMs: async ms => { now += ms; },
+ },
+ });
+ expect(restartLauncher).toBe(activeLauncher);
+ expect(readUpdateJob("pnpm-active-launcher")?.status).toBe("succeeded");
+ });
+
test("restart command separates service and direct proxy modes", () => {
expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs")).toMatchObject({
mode: "service",
@@ -445,6 +494,55 @@ describe("GUI update execution decisions", () => {
mode: "proxy",
args: ["/pkg/bin/ocx.mjs", "start"],
});
+ expect(restartCommand(true, "pnpm", "/pkg/bin/ocx.mjs")).toMatchObject({
+ mode: "service",
+ args: ["/pkg/bin/ocx.mjs", "service", "repair"],
+ });
+ });
+
+ test("restart recovery uses the verified active launcher for direct and service paths", async () => {
+ const activeLauncher = "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs";
+ const directJob: UpdateJobState = {
+ id: "restart-active-launcher-direct",
+ status: "restarting",
+ startedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ currentVersion: "2.7.40",
+ latestVersion: "2.7.41",
+ channel: "latest",
+ installer: "pnpm",
+ restart: true,
+ command: "",
+ log: [],
+ };
+ writeFileSync(updateJobPath(directJob.id), JSON.stringify(directJob));
+ let directLauncher = "";
+ await restartAfterUpdateForTests(directJob, { port: 19001, hostname: "127.0.0.1" }, {
+ serviceInstalledFn: () => false,
+ packageLauncherPathFn: () => activeLauncher,
+ listListenPidsFn: () => [],
+ waitForPort: async () => true,
+ spawnStart: (_job, _installer, _port, launcher) => { directLauncher = launcher ?? ""; },
+ });
+ expect(directLauncher).toBe(activeLauncher);
+
+ const serviceJob = { ...directJob, id: "restart-active-launcher-service" };
+ writeFileSync(updateJobPath(serviceJob.id), JSON.stringify(serviceJob));
+ let serviceArgs: string[] = [];
+ await restartAfterUpdateForTests(serviceJob, { port: 19002, hostname: "127.0.0.1" }, {
+ serviceInstalledFn: () => true,
+ packageLauncherPathFn: () => activeLauncher,
+ listListenPidsFn: () => [],
+ waitForPort: async () => true,
+ runService: (_job, _bin, args) => {
+ serviceArgs = args;
+ return { status: 0 };
+ },
+ serviceViableFn: () => true,
+ probeProxy: async () => true,
+ serviceHealthTimeoutMs: 1_000,
+ });
+ expect(serviceArgs).toEqual([activeLauncher, "service", "repair"]);
});
test("service restart is not skipped when the listener scan fails", async () => {
@@ -1605,7 +1703,7 @@ describe("immutable update target (WP160)", () => {
test("GUI worker gates integrity before spawning and fails the job on anomalous metadata", async () => {
const source = await Bun.file(new URL("../../src/update/job.ts", import.meta.url)).text();
- const gateAt = source.indexOf("const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);");
+ const gateAt = source.indexOf("checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner)");
const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();");
const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray");
const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });');
diff --git a/tests/update/update-pnpm.test.ts b/tests/update/update-pnpm.test.ts
new file mode 100644
index 0000000000..0d0e87de04
--- /dev/null
+++ b/tests/update/update-pnpm.test.ts
@@ -0,0 +1,617 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, chmodSync } from "node:fs";
+import { tmpdir } from "node:os";
+import {
+ detectInstallFromPath,
+} from "../../src/update/install-detection.mjs";
+import {
+ pnpmInvocation,
+ pnpmInvocations,
+ resolvePnpmCommand,
+ resolvePnpmCommands,
+} from "../../src/update/pnpm-invocation.mjs";
+import {
+ pnpmGlobalCommandArgs,
+ pnpmOwnerEnvironment,
+ readPnpmGlobalPackage,
+ resolvePnpmGlobalOwner,
+ runPnpmGlobalUpdate,
+ verifyPnpmGlobalShims,
+ type PnpmGlobalOwner,
+ type PnpmRunResult,
+} from "../../src/update/pnpm-global-install.mjs";
+import { checkRegistryPackageIntegrity } from "../../src/update/registry-integrity.mjs";
+import { verifyPnpmInstallTree } from "../../src/update/transactional-install.mjs";
+import { updateCommand, updateCommandStr } from "../../src/update/index";
+
+const PKG = "@bitkyc08/opencodex";
+
+describe("pnpm installation detection", () => {
+ test("requires strong evidence for legacy global/vN paths", () => {
+ expect(detectInstallFromPath("/work/opencodex/src/update")).toBe("source");
+ expect(detectInstallFromPath("/usr/lib/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
+ expect(detectInstallFromPath("/home/oliver/.bun/install/global/node_modules/@bitkyc08/opencodex/bin")).toBe("bun");
+ expect(detectInstallFromPath("/home/oliver/.bun/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
+ expect(detectInstallFromPath("/opt/global/v11/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
+ expect(detectInstallFromPath("/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin", {
+ exists: path => path === "/opt/pnpm/global/v11/node_modules/.pnpm",
+ })).toBe("pnpm");
+ });
+
+ test("recognises isolated, store-link, and preserved-symlink layouts", () => {
+ expect(detectInstallFromPath("/home/oliver/.local/share/pnpm/global/v11/node_modules/.pnpm/@bitkyc08+opencodex@2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm");
+ expect(detectInstallFromPath("/home/oliver/.local/share/pnpm/store/v11/links/@bitkyc08/opencodex/2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm");
+ expect(detectInstallFromPath("/home/oliver/.local/share/pnpm/global/11/group/node_modules/@bitkyc08/opencodex/bin", {
+ exists: path => path === "/home/oliver/.local/share/pnpm/global/11/group/node_modules/.pnpm",
+ })).toBe("pnpm");
+ expect(detectInstallFromPath("C:\\work\\node_modules\\.pnpm\\@bitkyc08+opencodex@2.49.0\\node_modules\\@bitkyc08\\opencodex\\bin")).toBe("pnpm");
+ });
+
+ test("follows a preserved npm-looking symlink to the pnpm package target", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-detect-link-"));
+ try {
+ const target = join(root, "pnpm", "global", "v11", "node_modules", ".pnpm", "pkg", "node_modules", PKG, "bin");
+ const exposed = join(root, "prefix", "node_modules", PKG, "bin");
+ mkdirSync(target, { recursive: true });
+ mkdirSync(dirname(exposed), { recursive: true });
+ symlinkSync(target, exposed, "dir");
+ expect(detectInstallFromPath(exposed)).toBe("pnpm");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("recognises pnpm metadata for a hoisted group without a virtual-store directory", () => {
+ const path = "/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin";
+ expect(detectInstallFromPath(path, {
+ exists: candidate => candidate === "/opt/pnpm/global/v11/node_modules/.modules.yaml",
+ })).toBe("pnpm");
+ });
+});
+
+describe("pnpm executable selection", () => {
+ test("retains every absolute candidate so ownership can be matched", () => {
+ const env = { PATH: "/first:/second:/third" };
+ const existing = new Set(["/first/pnpm", "/second/pnpm"]);
+ expect(resolvePnpmCommands("linux", env, { exists: path => existing.has(path) })).toEqual([
+ "/first/pnpm", "/second/pnpm",
+ ]);
+ expect(pnpmInvocations(["--version"], "linux", env, { exists: path => existing.has(path) })).toHaveLength(2);
+ });
+
+ test("uses the trusted Windows pnpm shim through cmd.exe", () => {
+ const cwd = "C:\\work\\untrusted-project";
+ const trustedPnpm = "C:\\Program Files\\pnpm\\pnpm.cmd";
+ const systemCmd = "C:\\Windows\\System32\\cmd.exe";
+ const env = {
+ PATH: `${cwd};C:\\Program Files\\pnpm`,
+ PATHEXT: ".CMD",
+ SystemRoot: "C:\\Windows",
+ };
+ const existing = new Set([`${cwd}\\pnpm.cmd`, trustedPnpm]);
+
+ expect(resolvePnpmCommand("win32", env, {
+ cwd,
+ exists: path => existing.has(path),
+ })).toBe(trustedPnpm);
+
+ const invocation = pnpmInvocation(["add", "-g", "--allow-build=bun", `${PKG}@2.50.0`], "win32", env, {
+ cwd,
+ exists: path => existing.has(path),
+ });
+ expect(invocation).toMatchObject({
+ file: systemCmd,
+ args: ["/d", "/s", "/c", expect.stringContaining("pnpm\\pnpm.cmd")],
+ options: { windowsVerbatimArguments: true },
+ });
+ expect(String(invocation?.args.at(-1) ?? "").includes(cwd)).toBe(false);
+ });
+});
+
+const ownerFor = (version = "1.0.0"): PnpmGlobalOwner => ({
+ commandPath: "/pnpm/owner/bin/pnpm",
+ packagePath: `/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex-${version}`,
+ globalDir: "/pnpm/owner/global",
+ globalRoot: "/pnpm/owner/global/v11",
+ globalBinDir: "/pnpm/owner/bin",
+});
+
+describe("pnpm global owner binding", () => {
+ test("selects the candidate whose global listing owns the running package", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-owner-"));
+ const packagePath = join(root, "home-b", "global", "v11", "node_modules", "@bitkyc08", "opencodex");
+ const groupB = join(root, "home-b", "global", "v11");
+ const baseB = join(root, "home-b", "global");
+ const binB = join(root, "home-b", "bin");
+ mkdirSync(packagePath, { recursive: true });
+ try {
+ const calls: { command: string; args: string[] }[] = [];
+ const run = (command: string, args: readonly string[], capture = false): PnpmRunResult => {
+ calls.push({ command, args: [...args] });
+ if (args[0] === "list") {
+ const listed = command === "/pnpm/a/bin/pnpm"
+ ? join(root, "home-a", "global", "v11", "node_modules", "@bitkyc08", "opencodex")
+ : packagePath;
+ const group = command === "/pnpm/a/bin/pnpm" ? join(root, "home-a", "global", "v11") : groupB;
+ return {
+ status: 0,
+ stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: listed } } }]),
+ };
+ }
+ if (args[0] === "root") {
+ return { status: 0, stdout: `${groupB}\n` };
+ }
+ if (args[0] === "config" && args[2] === "global-dir") {
+ return { status: 0, stdout: `${baseB}\n` };
+ }
+ if (args[0] === "config" && args[2] === "global-bin-dir") {
+ return { status: 0, stdout: `${binB}\n` };
+ }
+ return { status: 1 };
+ };
+
+ const result = resolvePnpmGlobalOwner({
+ packageName: PKG,
+ packagePath,
+ commandPaths: ["/pnpm/a/bin/pnpm", "/pnpm/b/bin/pnpm"],
+ runPnpm: run,
+ verify: () => ({ ok: true }),
+ });
+
+ expect(result).toEqual({
+ ok: true,
+ owner: {
+ commandPath: "/pnpm/b/bin/pnpm",
+ packagePath,
+ version: "1.0.0",
+ globalDir: baseB,
+ globalRoot: groupB,
+ globalBinDir: binB,
+ },
+ });
+ expect(calls.some(call => call.command === "/pnpm/a/bin/pnpm")).toBe(true);
+ expect(calls.filter(call => call.command === "/pnpm/b/bin/pnpm" && call.args[0] === "list").at(-1)?.args).toContain(`--global-dir=${baseB}`);
+ expect(calls.filter(call => call.command === "/pnpm/b/bin/pnpm" && call.args[0] === "list").at(-1)?.args).toContain(`--config.global-bin-dir=${binB}`);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("uses the running shim to disambiguate same-version pnpm homes", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shim-owner-"));
+ const packagePath = join(root, "shared", "node_modules", PKG);
+ const groupA = join(root, "home-a", "global", "v11");
+ const groupB = join(root, "home-b", "global", "v11");
+ const baseA = join(root, "home-a", "global");
+ const baseB = join(root, "home-b", "global");
+ const binA = join(root, "home-a", "bin");
+ const binB = join(root, "home-b", "bin");
+ const runningShim = join(binB, "ocx");
+ mkdirSync(packagePath, { recursive: true });
+ try {
+ const run = (command: string, args: readonly string[]): PnpmRunResult => {
+ if (args[0] === "list") {
+ const group = command === "/pnpm/a" ? groupA : groupB;
+ return {
+ status: 0,
+ stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: packagePath } } }]),
+ };
+ }
+ if (args[0] === "root") {
+ return { status: 0, stdout: `${command === "/pnpm/a" ? groupA : groupB}\n` };
+ }
+ if (args[0] === "config" && args[2] === "global-dir") {
+ return { status: 0, stdout: `${command === "/pnpm/a" ? baseA : baseB}\n` };
+ }
+ if (args[0] === "config" && args[2] === "global-bin-dir") {
+ return { status: 0, stdout: `${command === "/pnpm/a" ? binA : binB}\n` };
+ }
+ return { status: 1 };
+ };
+ const result = resolvePnpmGlobalOwner({
+ packageName: PKG,
+ packagePath,
+ commandPaths: ["/pnpm/a", "/pnpm/b"],
+ runningShimPath: runningShim,
+ runPnpm: run,
+ verify: () => ({ ok: true }),
+ });
+ expect(result).toMatchObject({ ok: true, owner: { commandPath: "/pnpm/b", globalDir: baseB, globalRoot: groupB, globalBinDir: binB } });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("derives pnpm's default global-dir base when config get is undefined", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-default-owner-"));
+ const base = join(root, "global");
+ const group = join(base, "v11");
+ const bin = join(root, "bin");
+ const packagePath = join(group, "node_modules", PKG);
+ mkdirSync(packagePath, { recursive: true });
+ try {
+ const calls: string[][] = [];
+ const run = (_command: string, args: readonly string[]): PnpmRunResult => {
+ calls.push([...args]);
+ if (args[0] === "list") {
+ return {
+ status: 0,
+ stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: packagePath } } }]),
+ };
+ }
+ if (args[0] === "root") return { status: 0, stdout: `${group}\n` };
+ if (args[0] === "bin") return { status: 0, stdout: `${bin}\n` };
+ if (args[0] === "config" && args[2] !== undefined) return { status: 0, stdout: "undefined\n" };
+ return { status: 1 };
+ };
+ const result = resolvePnpmGlobalOwner({
+ packageName: PKG,
+ packagePath,
+ commandPaths: ["/pnpm/default"],
+ runPnpm: run,
+ verify: () => ({ ok: true }),
+ });
+
+ expect(result).toEqual({
+ ok: true,
+ owner: { commandPath: "/pnpm/default", packagePath, version: "1.0.0", globalDir: base, globalRoot: group, globalBinDir: bin },
+ });
+ const pinnedList = calls.find(args => args[0] === "list" && args.includes(`--global-dir=${base}`));
+ expect(pinnedList).toBeDefined();
+ expect(pinnedList).not.toContain(`--global-dir=${group}`);
+ expect(pinnedList).not.toContain(`${group}/v11`);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("pins both global group and bin directory and preserves the selected PATH", () => {
+ const owner = ownerFor();
+ expect(pnpmGlobalCommandArgs(["add", "-g", "--allow-build=bun", `${PKG}@2.0.0`], owner)).toEqual([
+ "add",
+ `--global-dir=${owner.globalDir}`,
+ `--config.global-bin-dir=${owner.globalBinDir}`,
+ "-g",
+ "--allow-build=bun",
+ `${PKG}@2.0.0`,
+ ]);
+ const env = pnpmOwnerEnvironment(owner, { PATH: "/usr/bin" }, "linux");
+ expect(env.PATH).toBe(`${owner.globalBinDir}:/usr/bin`);
+ expect(pnpmGlobalCommandArgs([
+ "list", "-g", `--global-dir=${owner.globalDir}/wrong`, "--config.global-bin-dir", "/wrong/bin", PKG,
+ ], owner)).toEqual([
+ "list",
+ `--global-dir=${owner.globalDir}`,
+ `--config.global-bin-dir=${owner.globalBinDir}`,
+ "-g", PKG,
+ ]);
+ });
+
+ test("rejects a pinned verification when pnpm omits the group root", () => {
+ const owner = ownerFor();
+ const result = readPnpmGlobalPackage(
+ PKG,
+ () => ({
+ status: 0,
+ stdout: JSON.stringify([{ dependencies: { [PKG]: { version: "1.0.0", path: owner.packagePath } } }]),
+ }),
+ () => ({ ok: true }),
+ { owner, expectedGlobalDir: owner.globalDir, globalBinDir: owner.globalBinDir, verifyShims: () => ({ ok: true }) },
+ );
+ expect(result).toEqual({ ok: false, reason: "pnpm did not report the selected global group" });
+ });
+});
+
+function makePackageFixture(
+ root: string,
+ dependencyRoot: string,
+ options: { packageDir?: string; linkDependenciesInside?: boolean } = {},
+) {
+ const packageDir = options.packageDir ?? join(root, "package");
+ const bunDir = join(dependencyRoot, "bun");
+ const zodDir = join(dependencyRoot, "zod");
+ mkdirSync(join(packageDir, "bin", "nested"), { recursive: true });
+ mkdirSync(join(packageDir, "node_modules"), { recursive: true });
+ mkdirSync(join(bunDir, "bin"), { recursive: true });
+ mkdirSync(zodDir, { recursive: true });
+ writeFileSync(join(packageDir, "package.json"), JSON.stringify({
+ name: PKG,
+ version: "2.0.0",
+ dependencies: { bun: "1", zod: "1" },
+ }));
+ writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048));
+ writeFileSync(join(bunDir, "package.json"), JSON.stringify({ name: "bun" }));
+ writeFileSync(join(bunDir, "bin", "bun.exe"), Buffer.alloc(10 * 1024 * 1024 + 1));
+ writeFileSync(join(zodDir, "package.json"), JSON.stringify({ name: "zod" }));
+ if (options.linkDependenciesInside !== false) {
+ symlinkSync(bunDir, join(packageDir, "node_modules", "bun"), "dir");
+ symlinkSync(zodDir, join(packageDir, "node_modules", "zod"), "dir");
+ }
+ return packageDir;
+}
+
+describe("pnpm package tree verification", () => {
+ test("resolves dependencies through a custom virtual store and hoisted-style links", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-tree-"));
+ try {
+ const packageDir = makePackageFixture(root, join(root, "custom-virtual-store", "node_modules"));
+ expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("resolves a genuinely hoisted package from an ancestor node_modules", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-hoisted-"));
+ try {
+ const hoistedRoot = join(root, "global", "node_modules");
+ const packageDir = makePackageFixture(root, hoistedRoot, {
+ packageDir: join(hoistedRoot, "@bitkyc08", "opencodex"),
+ linkDependenciesInside: false,
+ });
+ expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("resolves dependencies through a pnpm package-root symlink", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-linked-tree-"));
+ try {
+ const target = makePackageFixture(root, join(root, "store", "node_modules"));
+ const exposed = join(root, "global", "v11", "node_modules", PKG);
+ mkdirSync(dirname(exposed), { recursive: true });
+ symlinkSync(target, exposed, "dir");
+ expect(verifyPnpmInstallTree(exposed, "2.0.0")).toEqual({ ok: true, failures: [] });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("does not accept a package tree whose runtime dependency cannot resolve", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-tree-missing-"));
+ try {
+ const packageDir = makePackageFixture(root, join(root, "deps"));
+ rmSync(join(packageDir, "node_modules", "zod"), { force: true });
+ expect(verifyPnpmInstallTree(packageDir, "2.0.0").ok).toBe(false);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("pnpm generated shims", () => {
+ test("verifies POSIX shims point at the active package", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shims-"));
+ try {
+ const packageDir = join(root, "global", "v11", "node_modules", PKG);
+ const globalBinDir = join(root, "bin");
+ mkdirSync(join(packageDir, "bin"), { recursive: true });
+ mkdirSync(globalBinDir, { recursive: true });
+ writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n");
+ const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs"));
+ for (const name of ["ocx", "opencodex"]) {
+ writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`);
+ chmodSync(join(globalBinDir, name), 0o755);
+ }
+ expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true });
+ writeFileSync(join(globalBinDir, "opencodex"), "#!/bin/sh\nexec node ../global/v11/node_modules/old/bin/ocx.mjs\n");
+ expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux").ok).toBe(false);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("accepts a pnpm group alias when it resolves to the active package", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shim-alias-"));
+ try {
+ const activeGroup = join(root, "global", "v11", "active");
+ const aliasGroup = join(root, "global", "v11", "stable-link");
+ const packageDir = join(activeGroup, "node_modules", PKG);
+ const globalBinDir = join(root, "bin");
+ mkdirSync(join(packageDir, "bin"), { recursive: true });
+ mkdirSync(globalBinDir, { recursive: true });
+ writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n");
+ symlinkSync(activeGroup, aliasGroup, "dir");
+ const target = relative(globalBinDir, join(aliasGroup, "node_modules", PKG, "bin", "ocx.mjs"));
+ for (const name of ["ocx", "opencodex"]) {
+ writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`);
+ chmodSync(join(globalBinDir, name), 0o755);
+ }
+ expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("verifies Windows cmd and PowerShell shim forms", () => {
+ const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-win-shims-"));
+ try {
+ const packageDir = join(root, "global", "v11", "node_modules", PKG);
+ const globalBinDir = join(root, "bin");
+ mkdirSync(join(packageDir, "bin"), { recursive: true });
+ mkdirSync(globalBinDir, { recursive: true });
+ writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n");
+ const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs")).replaceAll("/", "\\");
+ for (const name of ["ocx.cmd", "ocx.ps1", "opencodex.cmd", "opencodex.ps1"]) {
+ const body = name.endsWith(".cmd")
+ ? `@echo off\r\nnode "%~dp0\\${target}" %*\r\n`
+ : `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\n& "$basedir\\${target}" @args\n`;
+ writeFileSync(join(globalBinDir, name), body);
+ }
+ expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "win32")).toEqual({ ok: true });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
+
+interface FakePnpmState {
+ activeVersion: string;
+ invalidVersions?: Set;
+ failVersions?: Set;
+ invalidShimVersions?: Set;
+ calls: { args: string[]; capture: boolean }[];
+}
+
+function fakePnpm(state: FakePnpmState, owner = ownerFor()): (args: readonly string[], capture?: boolean) => PnpmRunResult {
+ return (args, capture = false) => {
+ const normalized = [...args];
+ state.calls.push({ args: normalized, capture });
+ if (normalized[0] === "list") {
+ return {
+ status: 0,
+ stdout: JSON.stringify([{
+ path: owner.globalRoot,
+ dependencies: {
+ [PKG]: {
+ version: state.activeVersion,
+ path: state.activeVersion === "1.0.0"
+ ? owner.packagePath
+ : `/virtual/pnpm/${state.activeVersion}`,
+ },
+ },
+ }]),
+ };
+ }
+ if (normalized[0] !== "add") return { status: 1 };
+ const spec = normalized.at(-1) ?? "";
+ const requested = spec.slice(spec.lastIndexOf("@") + 1);
+ if (state.failVersions?.has(requested)) return { status: 1 };
+ state.activeVersion = requested;
+ return { status: 0 };
+ };
+}
+
+function fakeVerify(state: FakePnpmState, owner = ownerFor()) {
+ return (path: string, expectedVersion?: string) => ({
+ ok: (path === owner.packagePath || path === `/virtual/pnpm/${state.activeVersion}`)
+ && expectedVersion === state.activeVersion
+ && !state.invalidVersions?.has(state.activeVersion),
+ });
+}
+
+function fakeShims(state: FakePnpmState) {
+ return (_path: string, _bin: string, _platform?: string) => ({
+ ok: !state.invalidShimVersions?.has(state.activeVersion),
+ });
+}
+
+describe("pnpm global update", () => {
+ test("updates through the selected pnpm owner and verifies the new active group", () => {
+ const owner = ownerFor();
+ const state: FakePnpmState = { activeVersion: "1.0.0", calls: [] };
+ const result = runPnpmGlobalUpdate({
+ packageName: PKG,
+ currentVersion: "1.0.0",
+ targetVersion: "2.0.0",
+ tag: "latest",
+ owner,
+ runPnpm: fakePnpm(state, owner),
+ verify: fakeVerify(state, owner),
+ verifyShims: fakeShims(state),
+ });
+
+ expect(result).toMatchObject({ ok: true, phase: "done", version: "2.0.0", path: "/virtual/pnpm/2.0.0" });
+ expect(state.calls.map(call => call.args)).toContainEqual([
+ "add",
+ `--global-dir=${owner.globalDir}`,
+ `--config.global-bin-dir=${owner.globalBinDir}`,
+ "-g", "--allow-build=bun", `${PKG}@2.0.0`,
+ ]);
+ expect(state.calls.every(call => call.args[0] !== "list" || call.args.includes(`--global-dir=${owner.globalDir}`))).toBe(true);
+ });
+
+ test("does not block on a stale pre-existing shim, but requires fresh shims after update", () => {
+ const owner = ownerFor();
+ const state: FakePnpmState = {
+ activeVersion: "1.0.0",
+ invalidShimVersions: new Set(["1.0.0"]),
+ calls: [],
+ };
+ const result = runPnpmGlobalUpdate({
+ packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner,
+ runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state),
+ });
+ expect(result).toMatchObject({ ok: true, phase: "done", version: "2.0.0" });
+ });
+
+ test("does not run a second transaction when a failed command leaves the verified old group active", () => {
+ const owner = ownerFor();
+ const state: FakePnpmState = { activeVersion: "1.0.0", failVersions: new Set(["2.0.0"]), calls: [] };
+ const result = runPnpmGlobalUpdate({
+ packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner,
+ runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state),
+ });
+ expect(result).toMatchObject({ ok: false, phase: "install", rolledBack: true, activePath: owner.packagePath });
+ expect(state.calls.filter(call => call.args[0] === "add")).toHaveLength(1);
+ });
+
+ test("rolls back when a zero-exit install leaves an invalid tree or stale shim", () => {
+ const owner = ownerFor();
+ const state: FakePnpmState = {
+ activeVersion: "1.0.0",
+ invalidVersions: new Set(["2.0.0"]),
+ invalidShimVersions: new Set(["2.0.0"]),
+ calls: [],
+ };
+ const result = runPnpmGlobalUpdate({
+ packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner,
+ runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state),
+ });
+ expect(result).toMatchObject({
+ ok: false, phase: "rollback", rolledBack: true, activePath: owner.packagePath,
+ });
+ expect(state.calls.filter(call => call.args[0] === "add").map(call => call.args.at(-1))).toEqual([
+ `${PKG}@2.0.0`, `${PKG}@1.0.0`,
+ ]);
+ });
+
+ test("does not claim rollback when the restored group cannot be verified", () => {
+ const owner = ownerFor();
+ const state: FakePnpmState = {
+ activeVersion: "1.0.0",
+ invalidVersions: new Set(["2.0.0"]),
+ failVersions: new Set(["1.0.0"]),
+ calls: [],
+ };
+ const result = runPnpmGlobalUpdate({
+ packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner,
+ runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state),
+ });
+ expect(result).toMatchObject({ ok: false, phase: "rollback", rolledBack: false });
+ expect((result as { activePath?: string }).activePath).toBeUndefined();
+ });
+});
+
+describe("shared registry integrity pre-flight", () => {
+ test("fails closed only for successful metadata without sha512 and skips query failures", () => {
+ expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({
+ status: 0,
+ stdout: '"sha512-abc="',
+ }))).toEqual({ ok: true, integrity: "sha512-abc=" });
+ expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ status: 0, stdout: "sha1-deprecated" })).ok).toBe(false);
+ expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ status: 1 })).ok).toBe("skipped");
+ });
+
+ test("both launcher and Bun worker use the shared helper before stopping", () => {
+ const launcher = readFileSync(join(dirname(import.meta.dir), "..", "bin", "ocx.mjs"), "utf8");
+ const update = readFileSync(join(dirname(import.meta.dir), "..", "src", "update", "index.ts"), "utf8");
+ expect(launcher).toContain("checkRegistryPackageIntegrity");
+ expect(launcher.indexOf("checkRegistryPackageIntegrity")).toBeLessThan(launcher.indexOf("Stopping the running proxy"));
+ expect(update).toContain("checkRegistryPackageIntegrity");
+ });
+});
+
+describe("pnpm update command", () => {
+ test("uses pnpm's native global build approval and pins resolved versions", () => {
+ expect(updateCommand("pnpm", "latest", "2.50.0")).toEqual({
+ bin: "pnpm",
+ args: ["add", "-g", "--allow-build=bun", `${PKG}@2.50.0`],
+ });
+ expect(updateCommandStr("pnpm", "latest", "2.50.0")).toContain("pnpm add -g --allow-build=bun");
+ expect(updateCommand("pnpm", "latest").args.at(-1)).toBe(`${PKG}@latest`);
+ });
+});
diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts
index f52c53a008..7fa117758b 100644
--- a/tests/update/update-stop-first.test.ts
+++ b/tests/update/update-stop-first.test.ts
@@ -524,7 +524,7 @@ describe("update stops the running proxy before replacing files", () => {
test("bun/source update path gates on the pid file and spawns 'stop' before the package manager", () => {
expect(updateSource).toContain('spawnSync(process.execPath, selfLaunchArgv(["stop"])');
const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])');
- const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args");
+ const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args", stopAt);
expect(stopAt).toBeGreaterThan(-1);
expect(updateAt).toBeGreaterThan(-1);
expect(stopAt).toBeLessThan(updateAt);
@@ -532,7 +532,7 @@ describe("update stops the running proxy before replacing files", () => {
});
test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => {
- const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest);");
+ const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);");
const abortAt = updateSource.indexOf("aborting the update before stopping the proxy");
const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])');
expect(gateAt).toBeGreaterThan(-1);
@@ -568,14 +568,16 @@ describe("update stops the running proxy before replacing files", () => {
expect(launcherSource).toContain('existsSync(join(configDir(), "runtime-port.json"))');
});
- test("Windows npm paths resolve safely before stop and never use shell:true", () => {
- const updateResolveAt = updateSource.indexOf("const target = updateSpawnTarget(bin, cmdArgs);");
+ test("Windows package-manager paths resolve safely before stop and never use shell:true", () => {
+ const updateResolveAt = updateSource.indexOf("const target = installer === \"pnpm\" && owner");
const updateStopAt = updateSource.indexOf('selfLaunchArgv(["stop"])');
- const launcherResolveAt = launcherSource.indexOf("const installInvocation = npmInvocation(");
+ const launcherResolveAt = launcherSource.indexOf("const installInvocation = managerInvocation(installArgs);");
const launcherStopAt = launcherSource.indexOf('[launcher, "stop"]');
expect(updateResolveAt).toBeGreaterThan(-1);
expect(launcherResolveAt).toBeGreaterThan(-1);
+ expect(launcherSource).toContain("resolvePnpmGlobalOwner");
+ expect(launcherSource).toContain("pnpmOwnerInvocation(owner, args)");
expect(updateResolveAt).toBeLessThan(updateStopAt);
expect(launcherResolveAt).toBeLessThan(launcherStopAt);
expect(updateSource).not.toContain("shell: true");
@@ -653,7 +655,9 @@ describe("update stops the running proxy before replacing files", () => {
writeFileSync(join(opencodexHome, "runtime-port.json"), JSON.stringify({ port, pid: 999_999_999 }));
writeFileSync(fakeNpm, `#!/bin/sh
case "$1" in
- view) printf '2.0.0\\n' ;;
+ view)
+ if [ "$3" = "dist.integrity" ]; then printf 'sha512-testfixturevalue000000000\\n'; else printf '2.0.0\\n'; fi
+ ;;
config) printf '%s\\n' "$OCX_FAKE_NPM_CACHE" ;;
install) exit 1 ;;
*) exit 1 ;;
From e74c6e54d3fbbe0ed0014769c60cf7232141fc93 Mon Sep 17 00:00:00 2001
From: Oliver Mee <102673257+oliver-mee@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:02:37 +0800
Subject: [PATCH 2/2] test(update): anonymise pnpm fixture paths
---
tests/update/update-pnpm.test.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/tests/update/update-pnpm.test.ts b/tests/update/update-pnpm.test.ts
index 0d0e87de04..9b015a5cc0 100644
--- a/tests/update/update-pnpm.test.ts
+++ b/tests/update/update-pnpm.test.ts
@@ -32,8 +32,8 @@ describe("pnpm installation detection", () => {
test("requires strong evidence for legacy global/vN paths", () => {
expect(detectInstallFromPath("/work/opencodex/src/update")).toBe("source");
expect(detectInstallFromPath("/usr/lib/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
- expect(detectInstallFromPath("/home/oliver/.bun/install/global/node_modules/@bitkyc08/opencodex/bin")).toBe("bun");
- expect(detectInstallFromPath("/home/oliver/.bun/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
+ expect(detectInstallFromPath("/tmp/test-user/.bun/install/global/node_modules/@bitkyc08/opencodex/bin")).toBe("bun");
+ expect(detectInstallFromPath("/tmp/test-user/.bun/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
expect(detectInstallFromPath("/opt/global/v11/node_modules/@bitkyc08/opencodex/bin")).toBe("npm");
expect(detectInstallFromPath("/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin", {
exists: path => path === "/opt/pnpm/global/v11/node_modules/.pnpm",
@@ -41,10 +41,10 @@ describe("pnpm installation detection", () => {
});
test("recognises isolated, store-link, and preserved-symlink layouts", () => {
- expect(detectInstallFromPath("/home/oliver/.local/share/pnpm/global/v11/node_modules/.pnpm/@bitkyc08+opencodex@2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm");
- expect(detectInstallFromPath("/home/oliver/.local/share/pnpm/store/v11/links/@bitkyc08/opencodex/2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm");
- expect(detectInstallFromPath("/home/oliver/.local/share/pnpm/global/11/group/node_modules/@bitkyc08/opencodex/bin", {
- exists: path => path === "/home/oliver/.local/share/pnpm/global/11/group/node_modules/.pnpm",
+ expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/global/v11/node_modules/.pnpm/@bitkyc08+opencodex@2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm");
+ expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/store/v11/links/@bitkyc08/opencodex/2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm");
+ expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/global/11/group/node_modules/@bitkyc08/opencodex/bin", {
+ exists: path => path === "/tmp/test-user/.local/share/pnpm/global/11/group/node_modules/.pnpm",
})).toBe("pnpm");
expect(detectInstallFromPath("C:\\work\\node_modules\\.pnpm\\@bitkyc08+opencodex@2.49.0\\node_modules\\@bitkyc08\\opencodex\\bin")).toBe("pnpm");
});