@bitkyc08/opencodex 2.6.6 → 2.6.7

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DiIi6aZT.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Ct8koBOe.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CSI5RHdZ.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.6",
3
+ "version": "2.6.7",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
package/src/cli.ts CHANGED
@@ -24,6 +24,7 @@ import { killProxy } from "./process-control";
24
24
  import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
25
25
  import { drainAndShutdown, startServer } from "./server";
26
26
  import { maybeShowStarPrompt } from "./star-prompt";
27
+ import { maybeShowUpdatePrompt } from "./update-notify";
27
28
 
28
29
  const args = process.argv.slice(2);
29
30
  const command = args[0];
@@ -134,6 +135,11 @@ async function handleStart(options: { block?: boolean } = {}) {
134
135
  removePid(existingPid);
135
136
  }
136
137
 
138
+ // Interactive-only update prompt. Must run BEFORE we bind a port / write a
139
+ // PID: choosing "Update now" installs globally and exits, so we never want a
140
+ // live daemon holding resources while it overwrites its own binary.
141
+ await maybeShowUpdatePrompt();
142
+
137
143
  const port = await chooseListenPort(requestedPort);
138
144
 
139
145
  const server = startServer(port);
@@ -462,6 +468,14 @@ switch (command) {
462
468
  await runUpdate();
463
469
  break;
464
470
  }
471
+ case "__refresh-version": {
472
+ // Hidden, detached helper spawned by the update prompt to refresh the
473
+ // cached latest version without blocking the foreground start. Not in help.
474
+ const { refreshVersionCache } = await import("./update-notify");
475
+ const channel = args[1] === "preview" ? "preview" : "latest";
476
+ await refreshVersionCache(channel);
477
+ break;
478
+ }
465
479
  case "help":
466
480
  case "--help":
467
481
  case "-h":
@@ -8,6 +8,19 @@ const REPO = "lidge-jun/opencodex";
8
8
  /** Fires exactly once from the first interactive `ocx start`. */
9
9
  const MARKER = ".star-prompted";
10
10
 
11
+ /**
12
+ * True once the one-time star prompt has already fired (marker written). The
13
+ * update prompt uses this to yield on a user's very first run so two prompts
14
+ * never stack on a fresh install.
15
+ */
16
+ export function hasStarPromptRun(): boolean {
17
+ try {
18
+ return existsSync(join(getConfigDir(), MARKER));
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
11
24
  function ghAvailable(): boolean {
12
25
  const r = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
13
26
  return !r.error && r.status === 0;
@@ -0,0 +1,257 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { atomicWriteFile, getConfigDir } from "./config";
6
+ import { hasStarPromptRun } from "./star-prompt";
7
+ import {
8
+ type Channel,
9
+ currentVersion,
10
+ detectInstall,
11
+ latestVersion,
12
+ runUpdate,
13
+ updateCommandStr,
14
+ updateTag,
15
+ } from "./update";
16
+
17
+ const VERSION_FILENAME = "version.json";
18
+ const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs
19
+ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
20
+
21
+ export interface VersionCache {
22
+ latest_version: string;
23
+ /** ISO-8601 (RFC3339) timestamp of the last successful registry check. */
24
+ last_checked_at: string;
25
+ dismissed_version?: string;
26
+ tag: Channel;
27
+ }
28
+
29
+ function versionFilePath(): string {
30
+ return join(getConfigDir(), VERSION_FILENAME);
31
+ }
32
+
33
+ /**
34
+ * Read the cached version info. Returns null on any error or when the cached
35
+ * channel differs from the current one (so a stable<->preview switch re-fetches
36
+ * instead of comparing across channels).
37
+ */
38
+ export function readVersionCache(channel: Channel): VersionCache | null {
39
+ try {
40
+ const raw = readFileSync(versionFilePath(), "utf8");
41
+ const parsed = JSON.parse(raw) as Partial<VersionCache>;
42
+ if (typeof parsed.latest_version !== "string" || typeof parsed.last_checked_at !== "string") return null;
43
+ if (parsed.tag !== channel) return null;
44
+ return {
45
+ latest_version: parsed.latest_version,
46
+ last_checked_at: parsed.last_checked_at,
47
+ dismissed_version: typeof parsed.dismissed_version === "string" ? parsed.dismissed_version : undefined,
48
+ tag: parsed.tag,
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function writeVersionCache(cache: VersionCache): void {
56
+ try {
57
+ atomicWriteFile(versionFilePath(), `${JSON.stringify(cache)}\n`);
58
+ } catch {
59
+ /* best-effort; never block startup */
60
+ }
61
+ }
62
+
63
+ function parseStable(v: string): [number, number, number] | null {
64
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
65
+ if (!m) return null;
66
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
67
+ }
68
+
69
+ function parsePreview(v: string): [number, number, number, number] | null {
70
+ const m = /^(\d+)\.(\d+)\.(\d+)-preview\.(\d+)$/.exec(v.trim());
71
+ if (!m) return null;
72
+ return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
73
+ }
74
+
75
+ function gt(a: number[], b: number[]): boolean {
76
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
77
+ const av = a[i] ?? 0;
78
+ const bv = b[i] ?? 0;
79
+ if (av !== bv) return av > bv;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Channel-aware "is latest newer than current?".
86
+ * - latest channel: compare maj.min.pat only; prereleases are never "newer"
87
+ * (parity with codex-rs), so stable users are not pushed onto previews.
88
+ * - preview channel: preview-vs-preview compares the trailing -preview.N; a
89
+ * stable release with a strictly higher base counts as newer (O3), while a
90
+ * stable release with the same base as the current preview does not.
91
+ */
92
+ export function isNewer(latest: string, current: string, channel: Channel): boolean {
93
+ if (channel === "latest") {
94
+ const l = parseStable(latest);
95
+ const c = parseStable(current);
96
+ if (!l || !c) return false;
97
+ return gt(l, c);
98
+ }
99
+ // preview channel
100
+ const lPre = parsePreview(latest);
101
+ const cPre = parsePreview(current);
102
+ if (lPre && cPre) return gt(lPre, cPre);
103
+
104
+ const lStable = parseStable(latest);
105
+ if (lStable && cPre) {
106
+ // Stable release vs current preview: newer only if the base is strictly
107
+ // higher than the preview's base (equal base would be a downgrade nag).
108
+ return gt(lStable, [cPre[0], cPre[1], cPre[2]]);
109
+ }
110
+ const cStable = parseStable(current);
111
+ if (lStable && cStable) return gt(lStable, cStable);
112
+ return false;
113
+ }
114
+
115
+ export function isSourceBuildVersion(v: string): boolean {
116
+ return v.trim() === "0.0.0";
117
+ }
118
+
119
+ /** The interactive/TTY + install-method gate shared with the star prompt. */
120
+ function interactiveGuardOk(): boolean {
121
+ return !(process.env.OCX_SERVICE || !process.stdin.isTTY || !process.stdout.isTTY);
122
+ }
123
+
124
+ /**
125
+ * Decide whether this run should even consider showing the prompt. Returns the
126
+ * channel + current version when eligible, else null. Eligibility requires a
127
+ * real global install, a non-source version, the interactive guard, and that
128
+ * the one-time star prompt has already run (first-run yield, O1).
129
+ */
130
+ export function shouldConsider(): { channel: Channel; current: string } | null {
131
+ if (detectInstall() === "source") return null;
132
+ const current = currentVersion();
133
+ if (current === "?" || isSourceBuildVersion(current)) return null;
134
+ if (!interactiveGuardOk()) return null;
135
+ if (!hasStarPromptRun()) return null; // yield on the very first run
136
+ return { channel: updateTag(current), current };
137
+ }
138
+
139
+ /** The cached upgrade version to surface, honoring the user's dismissal. */
140
+ export function getUpgradeVersionForPopup(
141
+ cache: VersionCache | null,
142
+ current: string,
143
+ channel: Channel,
144
+ ): string | null {
145
+ if (!cache) return null;
146
+ if (!isNewer(cache.latest_version, current, channel)) return null;
147
+ if (cache.dismissed_version === cache.latest_version) return null;
148
+ return cache.latest_version;
149
+ }
150
+
151
+ function cacheIsStale(cache: VersionCache | null): boolean {
152
+ if (!cache) return true;
153
+ const checked = Date.parse(cache.last_checked_at);
154
+ if (!Number.isFinite(checked)) return true;
155
+ return Date.now() - checked > REFRESH_INTERVAL_MS;
156
+ }
157
+
158
+ /**
159
+ * If the cache is missing or older than 20h, kick off a detached helper to
160
+ * refresh it without blocking this (soon-to-be daemon) process. Fire-and-forget.
161
+ */
162
+ export function triggerBackgroundRefreshIfStale(channel: Channel, cache: VersionCache | null): void {
163
+ if (!cacheIsStale(cache)) return;
164
+ try {
165
+ const entry = process.argv[1];
166
+ if (!entry || !existsSync(entry)) return;
167
+ const child = spawn(process.execPath, [entry, "__refresh-version", channel], {
168
+ detached: true,
169
+ stdio: "ignore",
170
+ windowsHide: true,
171
+ env: { ...process.env, OCX_SERVICE: "1" }, // never let the helper prompt
172
+ });
173
+ child.unref();
174
+ } catch {
175
+ /* best-effort */
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Body of the hidden `__refresh-version` subcommand: fetch the latest version
181
+ * for the channel and persist it. Only advances `last_checked_at` on success so
182
+ * a failed fetch retries on the next start.
183
+ */
184
+ export async function refreshVersionCache(channel: Channel): Promise<void> {
185
+ const latest = latestVersion(channel);
186
+ if (!latest) return; // do not dirty the cache or advance the timestamp
187
+ const prev = readVersionCache(channel);
188
+ writeVersionCache({
189
+ latest_version: latest,
190
+ last_checked_at: new Date().toISOString(),
191
+ dismissed_version: prev?.dismissed_version,
192
+ tag: channel,
193
+ });
194
+ }
195
+
196
+ /** Persist a dismissal so this exact version stops prompting. */
197
+ function dismissVersion(channel: Channel, version: string): void {
198
+ const cache = readVersionCache(channel);
199
+ if (!cache) return;
200
+ writeVersionCache({ ...cache, dismissed_version: version });
201
+ }
202
+
203
+ function renderPrompt(current: string, latest: string, channel: Channel): string {
204
+ const command = updateCommandStr(detectInstall(), channel);
205
+ return [
206
+ "",
207
+ ` \x1b[38;5;141m✨ Update available!\x1b[0m \x1b[2m${current} -> ${latest}\x1b[0m`,
208
+ "",
209
+ ` \x1b[2mRelease notes:\x1b[0m ${RELEASE_NOTES_URL}`,
210
+ "",
211
+ ` 1) Update now (runs \`${command}\`)`,
212
+ " 2) Skip",
213
+ " 3) Skip until next version",
214
+ "",
215
+ " [1/2/3] (default 1): ",
216
+ ].join("\n");
217
+ }
218
+
219
+ /**
220
+ * Interactive-only update prompt for `ocx start`. Must be called BEFORE the
221
+ * server binds a port / writes a PID, because "Update now" installs globally
222
+ * and exits. No-op for service/daemon/non-TTY runs and source checkouts.
223
+ * Never throws.
224
+ */
225
+ export async function maybeShowUpdatePrompt(): Promise<void> {
226
+ try {
227
+ const eligible = shouldConsider();
228
+ if (!eligible) return;
229
+ const { channel, current } = eligible;
230
+
231
+ const cache = readVersionCache(channel);
232
+ triggerBackgroundRefreshIfStale(channel, cache);
233
+
234
+ const latest = getUpgradeVersionForPopup(cache, current, channel);
235
+ if (!latest) return;
236
+
237
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
238
+ let answer = "";
239
+ try {
240
+ answer = (await rl.question(renderPrompt(current, latest, channel))).trim();
241
+ } finally {
242
+ rl.close();
243
+ }
244
+
245
+ const choice = answer === "" ? "1" : answer;
246
+ if (choice === "1") {
247
+ await runUpdate();
248
+ console.log("\nRestart the proxy: ocx start");
249
+ process.exit(0);
250
+ } else if (choice === "3") {
251
+ dismissVersion(channel, latest);
252
+ }
253
+ // "2" (or anything else) -> Skip: continue this run unchanged.
254
+ } catch {
255
+ /* never let the update prompt disrupt startup */
256
+ }
257
+ }
package/src/update.ts CHANGED
@@ -3,18 +3,19 @@ import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
- const PKG = "@bitkyc08/opencodex";
6
+ export const PKG = "@bitkyc08/opencodex";
7
7
  const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src
8
8
 
9
- type Installer = "bun" | "npm" | "source";
9
+ export type Installer = "bun" | "npm" | "source";
10
+ export type Channel = "latest" | "preview";
10
11
 
11
12
  /** Infer how opencodex is installed from the running module's path. */
12
- function detectInstall(): Installer {
13
+ export function detectInstall(): Installer {
13
14
  if (!HERE.includes("node_modules")) return "source"; // a git checkout, not a global install
14
15
  return HERE.includes(".bun") ? "bun" : "npm";
15
16
  }
16
17
 
17
- function currentVersion(): string {
18
+ export function currentVersion(): string {
18
19
  try {
19
20
  return (JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version as string) ?? "?";
20
21
  } catch {
@@ -22,18 +23,34 @@ function currentVersion(): string {
22
23
  }
23
24
  }
24
25
 
25
- function updateTag(current: string): string {
26
+ export function updateTag(current: string): Channel {
26
27
  const tagIndex = process.argv.indexOf("--tag");
27
- if (tagIndex !== -1 && process.argv[tagIndex + 1]) return process.argv[tagIndex + 1];
28
+ const explicit = tagIndex !== -1 ? process.argv[tagIndex + 1] : undefined;
29
+ if (explicit === "preview" || explicit === "latest") return explicit;
28
30
  return current.includes("-preview.") ? "preview" : "latest";
29
31
  }
30
32
 
31
33
  /** Latest published version from the registry (best-effort; null if npm isn't available). */
32
- function latestVersion(tag: string): string | null {
34
+ export function latestVersion(tag: string): string | null {
33
35
  const r = spawnSync("npm", ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true });
34
36
  return r.status === 0 ? r.stdout.trim() : null;
35
37
  }
36
38
 
39
+ /** The global-install command opencodex would run to update on this channel. */
40
+ export function updateCommand(installer: Installer, tag: Channel): { bin: string; args: string[] } {
41
+ const bin = installer === "bun" ? "bun" : "npm";
42
+ const args = installer === "bun"
43
+ ? ["add", "-g", `${PKG}@${tag}`]
44
+ : ["install", "-g", `${PKG}@${tag}`];
45
+ return { bin, args };
46
+ }
47
+
48
+ /** Human-readable form of {@link updateCommand}, used in the update prompt label. */
49
+ export function updateCommandStr(installer: Installer, tag: Channel): string {
50
+ const { bin, args } = updateCommand(installer, tag);
51
+ return `${bin} ${args.join(" ")}`;
52
+ }
53
+
37
54
  /**
38
55
  * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
39
56
  * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
@@ -55,10 +72,7 @@ export async function runUpdate(): Promise<void> {
55
72
  return;
56
73
  }
57
74
 
58
- const bin = installer === "bun" ? "bun" : "npm";
59
- const cmdArgs = installer === "bun"
60
- ? ["add", "-g", `${PKG}@${tag}`]
61
- : ["install", "-g", `${PKG}@${tag}`];
75
+ const { bin, args: cmdArgs } = updateCommand(installer, tag);
62
76
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
63
77
 
64
78
  const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });