@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.
- package/gui/dist/assets/{index-DiIi6aZT.js → index-Ct8koBOe.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -0
- package/src/star-prompt.ts +13 -0
- package/src/update-notify.ts +257 -0
- package/src/update.ts +25 -11
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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
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":
|
package/src/star-prompt.ts
CHANGED
|
@@ -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):
|
|
26
|
+
export function updateTag(current: string): Channel {
|
|
26
27
|
const tagIndex = process.argv.indexOf("--tag");
|
|
27
|
-
|
|
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
|
|
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 });
|