@bitkyc08/opencodex 2.50.0 → 2.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/cli/capabilities.ts +28 -0
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +94 -30
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +26 -10
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
package/src/types/config.ts
CHANGED
|
@@ -259,6 +259,21 @@ export type OcxRuntimeRole = "standalone" | "hub" | "client";
|
|
|
259
259
|
export interface OcxHubConfig {
|
|
260
260
|
/** Canonical browser-reachable management origin advertised by a hub. */
|
|
261
261
|
managementPublicOrigin?: string;
|
|
262
|
+
/**
|
|
263
|
+
* Canonical client-reachable DATA origin of this hub — what a remote machine passes as the
|
|
264
|
+
* positional URL to `ocx connect`, and what `ocx hub invite` prints.
|
|
265
|
+
*
|
|
266
|
+
* Separate from `managementPublicOrigin` because the two are genuinely different sockets on a
|
|
267
|
+
* real deployment: management is a loopback-only ingress published by an HTTPS frontend, while
|
|
268
|
+
* the data listener is bound to the hub's tailnet/LAN address and fronted on its own port
|
|
269
|
+
* (`https://hub.tailnet.ts.net:8443`). Deriving one from the other produced an origin that
|
|
270
|
+
* answered `/readyz` and nothing else.
|
|
271
|
+
*
|
|
272
|
+
* Advisory only: it is the origin the hub ADVERTISES, never a bind address. When omitted,
|
|
273
|
+
* `ocx hub invite` falls back to `http://<hostname>:<port>`, which is correct for a plain
|
|
274
|
+
* tailnet bind with no TLS frontend.
|
|
275
|
+
*/
|
|
276
|
+
dataPublicOrigin?: string;
|
|
262
277
|
/**
|
|
263
278
|
* Optional management-only listener for a local HTTPS frontend such as Tailscale Serve.
|
|
264
279
|
* The hostname is deliberately not configurable: when enabled the socket is always bound
|
|
@@ -661,13 +676,24 @@ export interface OcxConfig {
|
|
|
661
676
|
* surface: every process on the machine can reach it, spend account quota, and consume paid
|
|
662
677
|
* provider credentials. Off by default; not for multi-tenant hosts.
|
|
663
678
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
679
|
+
* Two enabled forms:
|
|
680
|
+
*
|
|
681
|
+
* - `{ enabled: true, port: N }` — a distinct port (the #1102 form). N must differ from the
|
|
682
|
+
* proxy port.
|
|
683
|
+
* - `{ enabled: true }` — the "companion" form: bind `127.0.0.1:<proxy port>`. Legal only
|
|
684
|
+
* when `hostname` is a specific non-loopback, non-wildcard address (a tailnet or LAN IP),
|
|
685
|
+
* because otherwise the public socket already owns that loopback address. This is the
|
|
686
|
+
* one-port hub shape: remote clients dial `hostname:port`, local processes dial
|
|
687
|
+
* `127.0.0.1:port`, and every integration that hardcodes `http://127.0.0.1:<proxy port>`
|
|
688
|
+
* keeps working on a hub whose public bind they cannot reach (#4236).
|
|
689
|
+
*
|
|
690
|
+
* Neither form is OS-assigned. A changing port would break already-running app-servers
|
|
691
|
+
* holding the previous `base_url` — the exact symptom #1102 reported and we disproved for
|
|
692
|
+
* token rotation.
|
|
667
693
|
*/
|
|
668
694
|
unauthenticatedLoopbackListener?:
|
|
669
695
|
| { enabled: false }
|
|
670
|
-
| { enabled: true; port
|
|
696
|
+
| { enabled: true; port?: number };
|
|
671
697
|
/**
|
|
672
698
|
* Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or
|
|
673
699
|
* "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when
|
|
@@ -746,6 +772,14 @@ export interface OcxConfig {
|
|
|
746
772
|
codexAccounts?: CodexAccount[];
|
|
747
773
|
/** Account ids administratively excluded from future pool selection until resumed. */
|
|
748
774
|
pausedCodexAccountIds?: string[];
|
|
775
|
+
/**
|
|
776
|
+
* Codex pool selection policy. Absent means no policy, so an existing install rotates exactly
|
|
777
|
+
* as before.
|
|
778
|
+
*
|
|
779
|
+
* Not in `getDefaultConfig()` on purpose — that function carries no optional-feature keys, so
|
|
780
|
+
* absence is the only default state this policy has.
|
|
781
|
+
*/
|
|
782
|
+
codexPool?: OcxCodexPoolConfig;
|
|
749
783
|
/** Opt-in per-account activation of newly reset Codex quota windows. */
|
|
750
784
|
codexQuotaAutoRefresh?: Record<string, {
|
|
751
785
|
fiveHour?: boolean;
|
|
@@ -1175,6 +1209,25 @@ export interface OcxWebSearchSidecarConfig {
|
|
|
1175
1209
|
streamRoutedModelOutput?: boolean;
|
|
1176
1210
|
}
|
|
1177
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* Codex account-pool selection policy.
|
|
1214
|
+
*
|
|
1215
|
+
* This is a selection policy, not a block. An excluded account keeps its credential, quota
|
|
1216
|
+
* history, and thread affinity, stays visible on the account surface, and remains reachable by
|
|
1217
|
+
* explicit account selection. Only automatic rotation skips it.
|
|
1218
|
+
*/
|
|
1219
|
+
export interface OcxCodexPoolConfig {
|
|
1220
|
+
/**
|
|
1221
|
+
* Plan keys ordinary rotation skips, matched case-insensitively against the plan stored on each
|
|
1222
|
+
* account. Absent or empty means no policy.
|
|
1223
|
+
*
|
|
1224
|
+
* There is no `minimumPlan` counterpart: ranking ChatGPT plans against each other needs a total
|
|
1225
|
+
* ordering this repository does not have, and inventing one would silently drain a tier the
|
|
1226
|
+
* operator never meant to exclude.
|
|
1227
|
+
*/
|
|
1228
|
+
excludedPlans?: string[];
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1178
1231
|
/**
|
|
1179
1232
|
* Quota-reset notification settings.
|
|
1180
1233
|
*
|
package/src/update/badge.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cached "is an update available?" answer for the GUI sidebar badge.
|
|
3
3
|
*
|
|
4
|
-
* `/api/update/check` spawns `
|
|
4
|
+
* `/api/update/check` spawns the installing manager's `view` command on every call
|
|
5
|
+
* (~1s, network-bound), so a
|
|
5
6
|
* sidebar that polls it would spawn a process per tick on every page of the GUI.
|
|
6
7
|
* The badge instead READS the 20h version cache the CLI update prompt already
|
|
7
8
|
* maintains (`~/.opencodex/version.json`).
|
|
8
9
|
*
|
|
9
10
|
* This is deliberately read-only: it must never trigger a registry refresh. The GUI
|
|
10
|
-
* polls it, so a refresh-on-read would let repeated polls launch repeated `
|
|
11
|
+
* polls it, so a refresh-on-read would let repeated polls launch repeated manager `view`
|
|
11
12
|
* helpers with no coalescing. Cache warming stays with `ocx start`
|
|
12
13
|
* (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
|
|
13
14
|
* `/api/update/check` the user reaches by clicking the sidebar update button.
|
package/src/update/index.ts
CHANGED
|
@@ -3,13 +3,23 @@ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
|
|
|
3
3
|
import { proxyIdentityAt } from "../server/proxy-liveness";
|
|
4
4
|
import { probeProxyLiveness } from "./proxy-liveness-probe.mjs";
|
|
5
5
|
import { decidePostStopUpdate } from "./stop-decision.mjs";
|
|
6
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { dirname, join } from "node:path";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
9
|
import { getConfigDir, loadConfig } from "../config";
|
|
10
10
|
import { readPid, readRuntimePort } from "../config/process-state";
|
|
11
11
|
import { pendingTeardownOutstanding } from "../config/pending-teardown";
|
|
12
12
|
import { npmInvocation } from "./npm-invocation.mjs";
|
|
13
|
+
import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs";
|
|
14
|
+
import { detectInstallFromPath } from "./install-detection.mjs";
|
|
15
|
+
import {
|
|
16
|
+
pnpmOwnerInvocation,
|
|
17
|
+
readPnpmGlobalPackage,
|
|
18
|
+
resolvePnpmGlobalOwner,
|
|
19
|
+
runPnpmGlobalUpdate,
|
|
20
|
+
} from "./pnpm-global-install.mjs";
|
|
21
|
+
import type { PnpmGlobalOwner, PnpmGlobalOwnerResult } from "./pnpm-global-install.mjs";
|
|
22
|
+
import { checkRegistryPackageIntegrity } from "./registry-integrity.mjs";
|
|
13
23
|
import {
|
|
14
24
|
npmCachePreflightFailureMessage,
|
|
15
25
|
runNpmCachePreflight,
|
|
@@ -35,13 +45,101 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean {
|
|
|
35
45
|
export const PKG = "@bitkyc08/opencodex";
|
|
36
46
|
const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update
|
|
37
47
|
|
|
38
|
-
export type Installer = "bun" | "npm" | "source";
|
|
48
|
+
export type Installer = "bun" | "npm" | "pnpm" | "source";
|
|
39
49
|
export type Channel = "latest" | "preview";
|
|
40
50
|
|
|
41
51
|
/** Infer how opencodex is installed from the running module's path. */
|
|
42
52
|
export function detectInstall(): Installer {
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
return detectInstallFromPath(HERE, { exists: existsSync });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function packageRoot(): string {
|
|
57
|
+
return resolve(HERE, "..", "..");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runningPnpmShimPath(): string | undefined {
|
|
61
|
+
const invoked = process.argv[1];
|
|
62
|
+
if (!invoked) return undefined;
|
|
63
|
+
const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
|
|
64
|
+
if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return resolve(invoked);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function runPnpmCandidate(
|
|
71
|
+
commandPath: string,
|
|
72
|
+
args: readonly string[],
|
|
73
|
+
capture = false,
|
|
74
|
+
): { status: number | null; stdout?: string | null; stderr?: string | null } {
|
|
75
|
+
const invocation = pnpmInvocationForPath(commandPath, args);
|
|
76
|
+
if (!invocation) return { status: 1 };
|
|
77
|
+
return spawnSync(invocation.file, invocation.args, {
|
|
78
|
+
stdio: capture ? "pipe" : "ignore",
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
timeout: 20_000,
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
...invocation.options,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve the exact pnpm executable/group/bin that own this package. */
|
|
87
|
+
export function resolveCurrentPnpmGlobalOwner(): PnpmGlobalOwnerResult {
|
|
88
|
+
return resolvePnpmGlobalOwner({
|
|
89
|
+
packageName: PKG,
|
|
90
|
+
packagePath: packageRoot(),
|
|
91
|
+
commandPaths: resolvePnpmCommands(),
|
|
92
|
+
runningShimPath: runningPnpmShimPath(),
|
|
93
|
+
runPnpm: runPnpmCandidate,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ownerPnpmTarget(
|
|
98
|
+
owner: PnpmGlobalOwner,
|
|
99
|
+
args: readonly string[],
|
|
100
|
+
): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean }; env: Record<string, string | undefined> } | null {
|
|
101
|
+
const invocation = pnpmOwnerInvocation(owner, args);
|
|
102
|
+
if (!invocation) return null;
|
|
103
|
+
return {
|
|
104
|
+
bin: invocation.file,
|
|
105
|
+
args: invocation.args,
|
|
106
|
+
options: invocation.options,
|
|
107
|
+
env: invocation.env,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runOwnedPnpm(
|
|
112
|
+
owner: PnpmGlobalOwner,
|
|
113
|
+
args: readonly string[],
|
|
114
|
+
capture: boolean,
|
|
115
|
+
stdio: "inherit" | "pipe" | "ignore" = capture ? "pipe" : "inherit",
|
|
116
|
+
): { status: number | null; stdout?: string | null; stderr?: string | null } {
|
|
117
|
+
const target = ownerPnpmTarget(owner, args);
|
|
118
|
+
if (!target) return { status: 1 };
|
|
119
|
+
return spawnSync(target.bin, target.args, {
|
|
120
|
+
stdio,
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
timeout: 180_000,
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
env: target.env,
|
|
125
|
+
...target.options,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Re-read the owning group's active package and return its verified launcher. */
|
|
130
|
+
export function resolvePnpmActiveLauncher(owner: PnpmGlobalOwner): string | null {
|
|
131
|
+
const active = readPnpmGlobalPackage(
|
|
132
|
+
PKG,
|
|
133
|
+
(args, capture = false) => runOwnedPnpm(owner, args, capture),
|
|
134
|
+
undefined,
|
|
135
|
+
{
|
|
136
|
+
owner,
|
|
137
|
+
expectedGlobalDir: owner.globalDir,
|
|
138
|
+
expectedGlobalRoot: owner.globalRoot,
|
|
139
|
+
globalBinDir: owner.globalBinDir,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
return active.ok ? join(active.path, "bin", "ocx.mjs") : null;
|
|
45
143
|
}
|
|
46
144
|
|
|
47
145
|
export function currentVersion(): string {
|
|
@@ -63,14 +161,57 @@ export function updateTag(current: string): Channel {
|
|
|
63
161
|
return defaultUpdateTag(current);
|
|
64
162
|
}
|
|
65
163
|
|
|
66
|
-
|
|
164
|
+
type SpawnTarget = {
|
|
165
|
+
bin: string;
|
|
166
|
+
args: string[];
|
|
167
|
+
options: { windowsVerbatimArguments?: boolean };
|
|
168
|
+
env?: Record<string, string | undefined>;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function npmSpawnTarget(args: readonly string[]): SpawnTarget | null {
|
|
67
172
|
const invocation = npmInvocation(args);
|
|
68
173
|
if (!invocation) return null;
|
|
69
174
|
return { bin: invocation.file, args: invocation.args, options: invocation.options };
|
|
70
175
|
}
|
|
71
176
|
|
|
72
|
-
function
|
|
177
|
+
function pnpmSpawnTarget(args: readonly string[], owner?: PnpmGlobalOwner): SpawnTarget | null {
|
|
178
|
+
if (owner) {
|
|
179
|
+
const invocation = pnpmOwnerInvocation(owner, args);
|
|
180
|
+
if (!invocation) return null;
|
|
181
|
+
return {
|
|
182
|
+
bin: invocation.file,
|
|
183
|
+
args: invocation.args,
|
|
184
|
+
options: invocation.options,
|
|
185
|
+
env: invocation.env,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const invocation = pnpmInvocation(args);
|
|
189
|
+
if (!invocation) return null;
|
|
190
|
+
return { bin: invocation.file, args: invocation.args, options: invocation.options };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function registrySpawnTarget(
|
|
194
|
+
installer: Installer,
|
|
195
|
+
args: readonly string[],
|
|
196
|
+
owner?: PnpmGlobalOwner,
|
|
197
|
+
): SpawnTarget | null {
|
|
198
|
+
// A pnpm command without an owner would silently fall back to the first PATH
|
|
199
|
+
// candidate. That is unsafe when multiple PNPM_HOME installations expose the
|
|
200
|
+
// same version, so registry queries use the same hard binding as mutation.
|
|
201
|
+
return installer === "pnpm"
|
|
202
|
+
? owner ? pnpmSpawnTarget(args, owner) : null
|
|
203
|
+
: npmSpawnTarget(args);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function selectedPnpmOwner(owner?: PnpmGlobalOwner): PnpmGlobalOwner | undefined {
|
|
207
|
+
if (owner) return owner;
|
|
208
|
+
const result = resolveCurrentPnpmGlobalOwner();
|
|
209
|
+
return result.ok ? result.owner : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function updateSpawnTarget(bin: string, args: readonly string[]): SpawnTarget | null {
|
|
73
213
|
if (bin === "npm") return npmSpawnTarget(args);
|
|
214
|
+
if (bin === "pnpm") return pnpmSpawnTarget(args);
|
|
74
215
|
if (process.platform === "win32" && bin === "bun") {
|
|
75
216
|
return { bin: process.execPath, args: [...args], options: {} };
|
|
76
217
|
}
|
|
@@ -95,28 +236,46 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null
|
|
|
95
236
|
if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr);
|
|
96
237
|
}
|
|
97
238
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
239
|
+
function shellQuote(value: string): string {
|
|
240
|
+
if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`;
|
|
241
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function launcherStartHint(launcher: string, port: number): string {
|
|
245
|
+
return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Latest published version from the registry (best-effort; null if the manager isn't available). */
|
|
249
|
+
export function latestVersion(
|
|
250
|
+
tag: string,
|
|
251
|
+
installer: Installer = detectInstall(),
|
|
252
|
+
owner?: PnpmGlobalOwner,
|
|
253
|
+
): string | null {
|
|
254
|
+
const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
|
|
255
|
+
if (installer === "pnpm" && !resolvedOwner) return null;
|
|
256
|
+
const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner);
|
|
257
|
+
if (!manager) return null;
|
|
258
|
+
const r = spawnSync(manager.bin, manager.args, {
|
|
103
259
|
encoding: "utf8",
|
|
104
260
|
timeout: 12000,
|
|
105
261
|
windowsHide: true,
|
|
106
|
-
...
|
|
262
|
+
...(manager.env ? { env: manager.env } : {}),
|
|
263
|
+
...manager.options,
|
|
107
264
|
});
|
|
108
|
-
return r.status === 0 ? (r.stdout.trim() || null) : null;
|
|
265
|
+
return r.status === 0 && typeof r.stdout === "string" ? (r.stdout.trim() || null) : null;
|
|
109
266
|
}
|
|
110
267
|
|
|
111
268
|
/** The global-install command opencodex would run to update on this channel. */
|
|
112
269
|
export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } {
|
|
113
|
-
const bin = installer === "bun" ? "bun" : "npm";
|
|
114
270
|
// Immutable target: when the registry resolved a concrete version, install exactly
|
|
115
271
|
// that version — the dist-tag can move between resolution and install (TOCTOU).
|
|
116
272
|
const target = resolvedVersion || tag;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
: ["
|
|
273
|
+
if (installer === "bun") return { bin: "bun", args: ["add", "-g", `${PKG}@${target}`] };
|
|
274
|
+
if (installer === "pnpm") {
|
|
275
|
+
return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] };
|
|
276
|
+
}
|
|
277
|
+
const bin = "npm";
|
|
278
|
+
const args = ["install", "-g", `${PKG}@${target}`];
|
|
120
279
|
return { bin, args };
|
|
121
280
|
}
|
|
122
281
|
|
|
@@ -138,26 +297,33 @@ export function updateCommandStr(installer: Installer, tag: Channel, resolvedVer
|
|
|
138
297
|
export function checkUpdatePackageIntegrity(
|
|
139
298
|
version: string | null,
|
|
140
299
|
spawn: typeof spawnSync = spawnSync,
|
|
300
|
+
installer: Installer = detectInstall(),
|
|
301
|
+
owner?: PnpmGlobalOwner,
|
|
141
302
|
): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
303
|
+
const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
|
|
304
|
+
if (installer === "pnpm" && !resolvedOwner) {
|
|
305
|
+
return { ok: false, reason: "could not identify pnpm's owning global installation" };
|
|
306
|
+
}
|
|
307
|
+
const manager = registrySpawnTarget(installer, ["view", `${PKG}@${version}`, "dist.integrity"], resolvedOwner);
|
|
308
|
+
if (!manager) return { ok: "skipped", reason: `${installer} executable was not found on a trusted PATH entry` };
|
|
309
|
+
const result = checkRegistryPackageIntegrity(PKG, version, args => {
|
|
310
|
+
const target = registrySpawnTarget(installer, args, resolvedOwner);
|
|
311
|
+
if (!target) return { status: 1 };
|
|
312
|
+
return spawn(target.bin, target.args, {
|
|
313
|
+
encoding: "utf8",
|
|
314
|
+
timeout: 12000,
|
|
315
|
+
windowsHide: true,
|
|
316
|
+
...(target.env ? { env: target.env } : {}),
|
|
317
|
+
...target.options,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
return result;
|
|
156
321
|
}
|
|
157
322
|
|
|
158
323
|
/**
|
|
159
|
-
* `ocx update` fallback for source checkouts and Bun global installs. npm global installs
|
|
160
|
-
* in the Node bin launcher before Bun starts, so Windows does not replace the running
|
|
324
|
+
* `ocx update` fallback for source checkouts and Bun global installs. npm and pnpm global installs
|
|
325
|
+
* are updated in the Node bin launcher before Bun starts, so Windows does not replace the running
|
|
326
|
+
* Bun binary.
|
|
161
327
|
*/
|
|
162
328
|
export async function runUpdate(): Promise<void> {
|
|
163
329
|
const installer = detectInstall();
|
|
@@ -170,7 +336,13 @@ export async function runUpdate(): Promise<void> {
|
|
|
170
336
|
return;
|
|
171
337
|
}
|
|
172
338
|
|
|
173
|
-
const
|
|
339
|
+
const ownerResult = installer === "pnpm" ? resolveCurrentPnpmGlobalOwner() : undefined;
|
|
340
|
+
if (installer === "pnpm" && (!ownerResult || !ownerResult.ok)) {
|
|
341
|
+
console.error(`⚠️ ${ownerResult?.reason ?? "Could not identify pnpm's owning global installation"}. Aborting before stopping the proxy.`);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
const owner = ownerResult?.ok ? ownerResult.owner : undefined;
|
|
345
|
+
const latest = latestVersion(tag, installer, owner);
|
|
174
346
|
if (latest && latest === current) {
|
|
175
347
|
console.log(`Already on the latest ${tag} version (v${latest}).`);
|
|
176
348
|
return;
|
|
@@ -178,7 +350,7 @@ export async function runUpdate(): Promise<void> {
|
|
|
178
350
|
|
|
179
351
|
// Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an
|
|
180
352
|
// anomalous registry entry aborts without unloading the running service.
|
|
181
|
-
const integrity = checkUpdatePackageIntegrity(latest);
|
|
353
|
+
const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);
|
|
182
354
|
if (integrity.ok === false) {
|
|
183
355
|
console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`);
|
|
184
356
|
process.exit(1);
|
|
@@ -198,9 +370,11 @@ export async function runUpdate(): Promise<void> {
|
|
|
198
370
|
}
|
|
199
371
|
|
|
200
372
|
const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
|
|
201
|
-
const target =
|
|
373
|
+
const target = installer === "pnpm" && owner
|
|
374
|
+
? pnpmSpawnTarget(cmdArgs, owner)
|
|
375
|
+
: updateSpawnTarget(bin, cmdArgs);
|
|
202
376
|
if (!target) {
|
|
203
|
-
console.error(
|
|
377
|
+
console.error(`⚠️ Could not resolve ${bin} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
|
|
204
378
|
process.exit(1);
|
|
205
379
|
}
|
|
206
380
|
|
|
@@ -257,7 +431,9 @@ export async function runUpdate(): Promise<void> {
|
|
|
257
431
|
// shared client config still points at a proxy that is gone; installing over that
|
|
258
432
|
// silently skips the recovery the receipt was written to trigger (#3008).
|
|
259
433
|
// Full `ocx stop` semantics (drain, service stop, restore).
|
|
434
|
+
let stopAttempted = false;
|
|
260
435
|
if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) {
|
|
436
|
+
stopAttempted = true;
|
|
261
437
|
console.log("⏹ Stopping the running proxy before updating...");
|
|
262
438
|
const stopStdio = updateChildStdio();
|
|
263
439
|
const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
|
|
@@ -266,7 +442,7 @@ export async function runUpdate(): Promise<void> {
|
|
|
266
442
|
windowsHide: true,
|
|
267
443
|
});
|
|
268
444
|
if (stopStdio === "pipe") logSpawnOutput("", stop);
|
|
269
|
-
// One decision, shared with the
|
|
445
|
+
// One decision, shared with the package launcher (#3008). The two lanes disagreeing about
|
|
270
446
|
// the same situation is how this shipped fixed on one side only. Absent PID and runtime
|
|
271
447
|
// files are weak evidence - a crashed-but-listening proxy leaves none - so the captured
|
|
272
448
|
// endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike.
|
|
@@ -310,35 +486,96 @@ export async function runUpdate(): Promise<void> {
|
|
|
310
486
|
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
|
|
311
487
|
|
|
312
488
|
const installStdio = updateChildStdio();
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
489
|
+
// Every post-update action below receives this path. For pnpm it is replaced only
|
|
490
|
+
// by a path returned after tree+shim verification; on rollback, activePath is
|
|
491
|
+
// likewise returned only after the old group has been verified again.
|
|
492
|
+
let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs");
|
|
493
|
+
// The pnpm owner preflight has verified this package tree and global group. Keep that exact
|
|
494
|
+
// package path as the recovery starting point; the path returned by the update transaction
|
|
495
|
+
// replaces it only after post-update tree+shim verification succeeds.
|
|
496
|
+
if (installer === "pnpm" && owner) {
|
|
497
|
+
postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs");
|
|
498
|
+
}
|
|
499
|
+
let postUpdateLauncherUsable = true;
|
|
500
|
+
let r: {
|
|
501
|
+
status: number | null;
|
|
502
|
+
signal?: NodeJS.Signals | null;
|
|
503
|
+
stdout?: string | Buffer | null;
|
|
504
|
+
stderr?: string | Buffer | null;
|
|
505
|
+
};
|
|
506
|
+
if (installer === "pnpm") {
|
|
507
|
+
let update: ReturnType<typeof runPnpmGlobalUpdate>;
|
|
508
|
+
try {
|
|
509
|
+
update = runPnpmGlobalUpdate({
|
|
510
|
+
packageName: PKG,
|
|
511
|
+
currentVersion: current,
|
|
512
|
+
targetVersion: latest || undefined,
|
|
513
|
+
tag,
|
|
514
|
+
owner: owner!,
|
|
515
|
+
runningPackagePath: packageRoot(),
|
|
516
|
+
runPnpm: (args, capture = false) => runOwnedPnpm(
|
|
517
|
+
owner!,
|
|
518
|
+
args,
|
|
519
|
+
capture,
|
|
520
|
+
capture ? "pipe" : installStdio,
|
|
521
|
+
),
|
|
522
|
+
log: line => console.log(line),
|
|
523
|
+
});
|
|
524
|
+
} catch {
|
|
525
|
+
// A thrown verifier/runner error means the active group is unknown. Mark the
|
|
526
|
+
// launcher unusable and let the failure lane report a manual recovery path.
|
|
527
|
+
update = {
|
|
528
|
+
ok: false,
|
|
529
|
+
phase: "rollback",
|
|
530
|
+
rolledBack: false,
|
|
531
|
+
error: "pnpm update failed unexpectedly; active package could not be verified",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (update.ok) {
|
|
535
|
+
postUpdateLauncher = join(update.path, "bin", "ocx.mjs");
|
|
536
|
+
postUpdateLauncherUsable = true;
|
|
537
|
+
r = { status: 0, signal: null, stdout: "", stderr: "" };
|
|
538
|
+
} else {
|
|
539
|
+
postUpdateLauncherUsable = Boolean(update.activePath);
|
|
540
|
+
if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs");
|
|
541
|
+
console.error(`⚠️ ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`);
|
|
542
|
+
r = { status: 1, signal: null, stdout: "", stderr: "" };
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
r = spawnSync(target.bin, target.args, {
|
|
546
|
+
stdio: installStdio,
|
|
547
|
+
encoding: installStdio === "pipe" ? "utf8" : undefined,
|
|
548
|
+
timeout: 180000,
|
|
549
|
+
windowsHide: true,
|
|
550
|
+
...target.options,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
320
553
|
if (installStdio === "pipe") logSpawnOutput("", r);
|
|
321
554
|
if (r.status === 0) {
|
|
322
555
|
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
|
|
323
|
-
// Re-
|
|
324
|
-
//
|
|
556
|
+
// Re-enter through the verified active package launcher. This keeps the Codex
|
|
557
|
+
// shim, tray, service and proxy recovery paths on the same package/group that
|
|
558
|
+
// pnpm selected, including when the update changed the global link target.
|
|
325
559
|
try {
|
|
326
|
-
const { isCodexShimInstalled
|
|
560
|
+
const { isCodexShimInstalled } = await import("../codex/shim");
|
|
327
561
|
if (isCodexShimInstalled()) {
|
|
328
|
-
const
|
|
329
|
-
|
|
562
|
+
const shim = spawnSync(process.execPath, [postUpdateLauncher, "codex-shim", "install"], {
|
|
563
|
+
stdio: "inherit",
|
|
564
|
+
windowsHide: true,
|
|
565
|
+
});
|
|
566
|
+
if (shim.status !== 0) console.warn("⚠️ Shim repair skipped: run 'ocx codex-shim install'.");
|
|
330
567
|
}
|
|
331
|
-
} catch
|
|
332
|
-
console.warn(
|
|
568
|
+
} catch {
|
|
569
|
+
console.warn("⚠️ Shim repair skipped; run 'ocx codex-shim install'.");
|
|
333
570
|
}
|
|
334
571
|
if (trayWasInstalled) {
|
|
335
|
-
const trayArgs =
|
|
336
|
-
const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true });
|
|
572
|
+
const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs;
|
|
573
|
+
const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true });
|
|
337
574
|
if (tray.status === 0) {
|
|
338
575
|
console.log("🔧 Refreshed Windows tray startup paths.");
|
|
339
576
|
} else {
|
|
340
577
|
console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'.");
|
|
341
|
-
if (trayWasRunning) spawnSync(process.execPath,
|
|
578
|
+
if (trayWasRunning) spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true });
|
|
342
579
|
}
|
|
343
580
|
}
|
|
344
581
|
// The stop above unloaded any managed service; repair it with the NEW files
|
|
@@ -362,7 +599,7 @@ export async function runUpdate(): Promise<void> {
|
|
|
362
599
|
process.env.OCX_BAKE_PORT = String(capturedListen.port);
|
|
363
600
|
try {
|
|
364
601
|
const svcStdio = updateChildStdio();
|
|
365
|
-
const svc = spawnSync(process.execPath,
|
|
602
|
+
const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], {
|
|
366
603
|
stdio: svcStdio,
|
|
367
604
|
encoding: svcStdio === "pipe" ? "utf8" : undefined,
|
|
368
605
|
windowsHide: true,
|
|
@@ -407,7 +644,7 @@ export async function runUpdate(): Promise<void> {
|
|
|
407
644
|
: " Run 'ocx service repair' to refresh the background service and see why it failed.");
|
|
408
645
|
const env = { ...process.env };
|
|
409
646
|
delete env.OCX_SERVICE;
|
|
410
|
-
const child = spawn(process.execPath,
|
|
647
|
+
const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], {
|
|
411
648
|
detached: true,
|
|
412
649
|
stdio: "ignore",
|
|
413
650
|
windowsHide: true,
|
|
@@ -422,14 +659,30 @@ export async function runUpdate(): Promise<void> {
|
|
|
422
659
|
else process.env.OCX_BAKE_PORT = prevBake;
|
|
423
660
|
}
|
|
424
661
|
} else {
|
|
425
|
-
console.log(`Restart the proxy:
|
|
662
|
+
console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`);
|
|
426
663
|
}
|
|
427
664
|
} else {
|
|
428
|
-
if (trayWasRunning) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
665
|
+
if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) {
|
|
666
|
+
spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true });
|
|
667
|
+
}
|
|
668
|
+
if (stopAttempted && serviceWasInstalled && postUpdateLauncherUsable) {
|
|
669
|
+
const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], {
|
|
670
|
+
stdio: "inherit",
|
|
671
|
+
windowsHide: true,
|
|
672
|
+
});
|
|
673
|
+
if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'.");
|
|
674
|
+
} else if (stopAttempted && postUpdateLauncherUsable) {
|
|
675
|
+
const env = { ...process.env };
|
|
676
|
+
delete env.OCX_SERVICE;
|
|
677
|
+
const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], {
|
|
678
|
+
detached: true,
|
|
679
|
+
stdio: "ignore",
|
|
680
|
+
windowsHide: true,
|
|
681
|
+
env: withProcessRuntimeProvenance(env),
|
|
682
|
+
});
|
|
683
|
+
child.unref();
|
|
684
|
+
} else if (stopAttempted) {
|
|
685
|
+
console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually.");
|
|
433
686
|
}
|
|
434
687
|
console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
|
|
435
688
|
process.exit(1);
|