om-memory-system 4.9.0 → 4.11.0-next.96
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/dist/cli/index.js +1 -0
- package/dist/cli/web-command.d.ts +1 -1
- package/dist/cli/web-command.js +22 -6
- package/dist/cli/web-power.d.ts +21 -3
- package/dist/cli/web-power.js +99 -15
- package/dist/cli/web-update-command.d.ts +36 -0
- package/dist/cli/web-update-command.js +203 -0
- package/dist/services/web-retire.d.ts +12 -0
- package/dist/services/web-retire.js +26 -0
- package/dist/services/web-server.d.ts +16 -1
- package/dist/services/web-server.js +106 -10
- package/dist/services/web-update-runtime.d.ts +5 -0
- package/dist/services/web-update-runtime.js +53 -0
- package/dist/services/web-update.d.ts +87 -0
- package/dist/services/web-update.js +161 -0
- package/dist/web/assets/index-BkqDHQCc.css +2 -0
- package/dist/web/assets/index-DWPF5lXj.js +125 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DyI5iI5F.js +0 -125
- package/dist/web/assets/index-kKl1G5gD.css +0 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WebUpdate } from "./web-update.js";
|
|
1
2
|
import { WebAuth } from "./web-auth.js";
|
|
2
3
|
type NodeEventSource = {
|
|
3
4
|
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
@@ -25,6 +26,8 @@ interface WebServerConfig {
|
|
|
25
26
|
apiToken?: string;
|
|
26
27
|
/** How long a stepped-aside server waits before it may take the port back. */
|
|
27
28
|
stepAsideHoldOffMs?: number;
|
|
29
|
+
/** Test seam: the retire marker's time, or null. Defaults to `~/.omms/web-retire.json`. */
|
|
30
|
+
readRetireMarker?: () => number | null;
|
|
28
31
|
}
|
|
29
32
|
type PowerAction = "stop" | "restart";
|
|
30
33
|
export declare class WebServer {
|
|
@@ -36,7 +39,10 @@ export declare class WebServer {
|
|
|
36
39
|
private onTakeoverCallback;
|
|
37
40
|
private onPortsExhaustedCallback;
|
|
38
41
|
private onStepAsideCallback;
|
|
42
|
+
/** `web update` retires waiting web apps that started before its marker. */
|
|
43
|
+
private readonly startedAt;
|
|
39
44
|
private onPowerActionCallback;
|
|
45
|
+
private webUpdate;
|
|
40
46
|
private powerActionRunning;
|
|
41
47
|
private stepAsideTimer;
|
|
42
48
|
private holdOffTimer;
|
|
@@ -59,9 +65,13 @@ export declare class WebServer {
|
|
|
59
65
|
/** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
|
|
60
66
|
setOnStepAside(callback: () => void | Promise<void>): void;
|
|
61
67
|
/** Standalone web apps register this to stop or restart from the page. */
|
|
62
|
-
setOnPowerAction(callback: (action: PowerAction) =>
|
|
68
|
+
setOnPowerAction(callback: (action: PowerAction) => unknown): void;
|
|
69
|
+
/** Standalone web apps register this to report and install npm releases. */
|
|
70
|
+
setWebUpdate(update: Pick<WebUpdate, "status" | "requestInstall">): void;
|
|
63
71
|
/** Only a local caller may stop the web app; the token alone is not enough. */
|
|
64
72
|
private handlePowerAction;
|
|
73
|
+
/** Install the npm release and restart onto it. Same guards as Restart. */
|
|
74
|
+
private handleUpdate;
|
|
65
75
|
/** API token routes. Only a caller on this machine with the local token manages tokens. */
|
|
66
76
|
private handleTokenRoute;
|
|
67
77
|
/** Profile catch-up: a preview for anyone with API access; runs only from this machine. */
|
|
@@ -77,6 +87,11 @@ export declare class WebServer {
|
|
|
77
87
|
start(): Promise<void>;
|
|
78
88
|
private _start;
|
|
79
89
|
private startHealthCheckLoop;
|
|
90
|
+
/** One check of a web app that waits for the port. */
|
|
91
|
+
private waiterTick;
|
|
92
|
+
/** True when `web update` asked web apps started before this one to retire. */
|
|
93
|
+
private retiredByUpdate;
|
|
94
|
+
private ownerIsNewer;
|
|
80
95
|
private stopHealthCheckLoop;
|
|
81
96
|
private attemptTakeover;
|
|
82
97
|
private notifyPortsExhausted;
|
|
@@ -142,10 +142,19 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
|
|
|
142
142
|
return currentPort + 1;
|
|
143
143
|
}
|
|
144
144
|
const STEP_ASIDE_HOLD_OFF_MS = 60_000;
|
|
145
|
+
/** What a web app without an npm check reports, such as one inside a host session. */
|
|
146
|
+
const NO_UPDATE = { available: null, state: "idle", code: null, canInstall: false };
|
|
145
147
|
/** Lets the 202 reply reach the caller before the server stops. */
|
|
146
148
|
const STEP_ASIDE_REPLY_GRACE_MS = 100;
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Differs between processes, so the page can tell a restarted web app from the old one.
|
|
151
|
+
* A restart names its copy's instance, so it can tell its copy from another web app.
|
|
152
|
+
*/
|
|
153
|
+
const PROCESS_INSTANCE = /^[0-9a-f-]{36}$/.test(process.env.OMMS_WEB_INSTANCE ?? "")
|
|
154
|
+
? process.env.OMMS_WEB_INSTANCE
|
|
155
|
+
: randomUUID();
|
|
156
|
+
// Child processes, such as an OpenCode model list, must not report the same instance.
|
|
157
|
+
delete process.env.OMMS_WEB_INSTANCE;
|
|
149
158
|
export class WebServer {
|
|
150
159
|
server = null;
|
|
151
160
|
config;
|
|
@@ -155,7 +164,10 @@ export class WebServer {
|
|
|
155
164
|
onTakeoverCallback = null;
|
|
156
165
|
onPortsExhaustedCallback = null;
|
|
157
166
|
onStepAsideCallback = null;
|
|
167
|
+
/** `web update` retires waiting web apps that started before its marker. */
|
|
168
|
+
startedAt = Date.now();
|
|
158
169
|
onPowerActionCallback = null;
|
|
170
|
+
webUpdate = null;
|
|
159
171
|
powerActionRunning = false;
|
|
160
172
|
stepAsideTimer = null;
|
|
161
173
|
holdOffTimer = null;
|
|
@@ -222,6 +234,10 @@ export class WebServer {
|
|
|
222
234
|
setOnPowerAction(callback) {
|
|
223
235
|
this.onPowerActionCallback = callback;
|
|
224
236
|
}
|
|
237
|
+
/** Standalone web apps register this to report and install npm releases. */
|
|
238
|
+
setWebUpdate(update) {
|
|
239
|
+
this.webUpdate = update;
|
|
240
|
+
}
|
|
225
241
|
/** Only a local caller may stop the web app; the token alone is not enough. */
|
|
226
242
|
handlePowerAction(action, remoteAddress) {
|
|
227
243
|
const record = (outcome) => log("Web server power request", { action, outcome, ownVersion: packageVersion() });
|
|
@@ -234,6 +250,12 @@ export class WebServer {
|
|
|
234
250
|
record("unsupported");
|
|
235
251
|
return this.jsonResponse({ success: false, error: "Not supported by this web app" }, 409);
|
|
236
252
|
}
|
|
253
|
+
// An update restarts through the same callback; a Stop or Restart now would cut npm off.
|
|
254
|
+
const updateState = this.webUpdate?.status().state;
|
|
255
|
+
if (updateState === "installing" || updateState === "restarting") {
|
|
256
|
+
record("refused_update_running");
|
|
257
|
+
return this.jsonResponse({ success: false, error: "An update is running" }, 409);
|
|
258
|
+
}
|
|
237
259
|
// A repeated request must not start a second stop or restart while one runs.
|
|
238
260
|
if (this.powerActionRunning) {
|
|
239
261
|
record("already_running");
|
|
@@ -255,6 +277,24 @@ export class WebServer {
|
|
|
255
277
|
}, STEP_ASIDE_REPLY_GRACE_MS).unref();
|
|
256
278
|
return this.jsonResponse({ success: true }, 202);
|
|
257
279
|
}
|
|
280
|
+
/** Install the npm release and restart onto it. Same guards as Restart. */
|
|
281
|
+
handleUpdate(remoteAddress) {
|
|
282
|
+
const record = (outcome) => log("Web server update request", { outcome, ownVersion: packageVersion() });
|
|
283
|
+
if (!isLoopbackAddress(remoteAddress)) {
|
|
284
|
+
record("refused_not_loopback");
|
|
285
|
+
return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
|
|
286
|
+
}
|
|
287
|
+
if (this.powerActionRunning) {
|
|
288
|
+
record("refused_power_running");
|
|
289
|
+
return this.jsonResponse({ success: false, error: "A stop or restart is running" }, 409);
|
|
290
|
+
}
|
|
291
|
+
const reply = this.webUpdate?.requestInstall() ?? "cannot-install";
|
|
292
|
+
record(reply);
|
|
293
|
+
if (reply === "accepted")
|
|
294
|
+
return this.jsonResponse({ success: true }, 202);
|
|
295
|
+
const error = reply === "no-update" ? "No newer release" : "This web app cannot install";
|
|
296
|
+
return this.jsonResponse({ success: false, error }, 409);
|
|
297
|
+
}
|
|
258
298
|
/** API token routes. Only a caller on this machine with the local token manages tokens. */
|
|
259
299
|
async handleTokenRoute(req, path, method, remoteAddress) {
|
|
260
300
|
if (!isLoopbackAddress(remoteAddress)) {
|
|
@@ -452,16 +492,19 @@ export class WebServer {
|
|
|
452
492
|
const callerVersion = body && typeof body.version === "string"
|
|
453
493
|
? body.version.slice(0, 40)
|
|
454
494
|
: "";
|
|
495
|
+
// `web update` replaces a web app of any version; other callers must be newer.
|
|
496
|
+
const replace = Boolean(body && body.replace === true);
|
|
455
497
|
const record = (outcome) => log("Web server step-aside request", {
|
|
456
498
|
outcome,
|
|
457
499
|
ownVersion: packageVersion(),
|
|
458
500
|
callerVersion,
|
|
501
|
+
replace,
|
|
459
502
|
});
|
|
460
503
|
if (!isLoopbackAddress(remoteAddress)) {
|
|
461
504
|
record("refused_auth");
|
|
462
505
|
return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
|
|
463
506
|
}
|
|
464
|
-
if (!callerVersion || !isOlderVersion(packageVersion(), callerVersion)) {
|
|
507
|
+
if (!callerVersion || (!replace && !isOlderVersion(packageVersion(), callerVersion))) {
|
|
465
508
|
record("refused_not_newer");
|
|
466
509
|
return this.jsonResponse({ success: false, error: "Caller is not newer" }, 409);
|
|
467
510
|
}
|
|
@@ -534,13 +577,60 @@ export class WebServer {
|
|
|
534
577
|
if (this.healthCheckInterval) {
|
|
535
578
|
return;
|
|
536
579
|
}
|
|
537
|
-
this.healthCheckInterval = setInterval(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
580
|
+
this.healthCheckInterval = setInterval(() => void this.waiterTick(), 5000);
|
|
581
|
+
}
|
|
582
|
+
/** One check of a web app that waits for the port. */
|
|
583
|
+
async waiterTick() {
|
|
584
|
+
const isAvailable = await this.checkServerAvailable();
|
|
585
|
+
if (!isAvailable) {
|
|
586
|
+
this.stopHealthCheckLoop();
|
|
587
|
+
await this.attemptTakeover();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (!this.onStepAsideCallback)
|
|
591
|
+
return;
|
|
592
|
+
// A standalone web app behind a newer one would serve old code after a restart.
|
|
593
|
+
const reason = (await this.retiredByUpdate())
|
|
594
|
+
? "update"
|
|
595
|
+
: (await this.ownerIsNewer())
|
|
596
|
+
? "newer-owner"
|
|
597
|
+
: null;
|
|
598
|
+
if (!reason)
|
|
599
|
+
return;
|
|
600
|
+
this.stopHealthCheckLoop();
|
|
601
|
+
log("Web server waiter retired", { ownVersion: packageVersion(), reason });
|
|
602
|
+
try {
|
|
603
|
+
await this.onStepAsideCallback();
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
log("Step-aside callback error", { error: String(error) });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** True when `web update` asked web apps started before this one to retire. */
|
|
610
|
+
async retiredByUpdate() {
|
|
611
|
+
try {
|
|
612
|
+
const read = this.config.readRetireMarker ?? (await import("./web-retire.js")).readRetireMarker;
|
|
613
|
+
const before = read();
|
|
614
|
+
return before !== null && this.startedAt < before;
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async ownerIsNewer() {
|
|
621
|
+
try {
|
|
622
|
+
const response = await fetch(`${this.getUrl()}/api/web/status`, {
|
|
623
|
+
headers: { [AUTH_HEADER]: getOrCreateAuthToken() },
|
|
624
|
+
signal: AbortSignal.timeout(2_000),
|
|
625
|
+
});
|
|
626
|
+
if (!response.ok)
|
|
627
|
+
return false;
|
|
628
|
+
const { version } = (await response.json());
|
|
629
|
+
return typeof version === "string" && isOlderVersion(packageVersion(), version);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
544
634
|
}
|
|
545
635
|
stopHealthCheckLoop() {
|
|
546
636
|
if (this.healthCheckInterval) {
|
|
@@ -729,6 +819,8 @@ export class WebServer {
|
|
|
729
819
|
success: true,
|
|
730
820
|
status: "ok",
|
|
731
821
|
authEnabled: auth?.isEnabled() ?? false,
|
|
822
|
+
// Opaque per process; a restart finds its copy by it even behind basic auth.
|
|
823
|
+
instance: PROCESS_INSTANCE,
|
|
732
824
|
});
|
|
733
825
|
}
|
|
734
826
|
if (path === "/" || path === "/index.html" || path === "/settings") {
|
|
@@ -813,11 +905,15 @@ export class WebServer {
|
|
|
813
905
|
// Keys, tokens, and the embedder can be changed only from this machine.
|
|
814
906
|
isLocal: isLoopbackAddress(remoteAddress),
|
|
815
907
|
instance: PROCESS_INSTANCE,
|
|
908
|
+
update: this.webUpdate?.status() ?? NO_UPDATE,
|
|
816
909
|
});
|
|
817
910
|
}
|
|
818
911
|
if ((path === "/api/web/stop" || path === "/api/web/restart") && method === "POST") {
|
|
819
912
|
return this.handlePowerAction(path === "/api/web/stop" ? "stop" : "restart", remoteAddress);
|
|
820
913
|
}
|
|
914
|
+
if (path === "/api/web/update" && method === "POST") {
|
|
915
|
+
return this.handleUpdate(remoteAddress);
|
|
916
|
+
}
|
|
821
917
|
if (path === "/api/web/step-aside" && method === "POST") {
|
|
822
918
|
const body = await req.json().catch(() => null);
|
|
823
919
|
return this.handleStepAside(remoteAddress, body);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { WebUpdate, type InstallDeps } from "./web-update.js";
|
|
2
|
+
/** The npm check and installer of a standalone web app, with the real process, npm, and clock. */
|
|
3
|
+
export declare function startWebUpdate(restart: () => Promise<string | undefined>, canRestart: () => boolean): WebUpdate;
|
|
4
|
+
/** The real npm process, shared by the update button and `web update`. */
|
|
5
|
+
export declare function nodeInstallDeps(): InstallDeps;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { globalCommandVersion } from "./global-version.js";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
import { packageVersion } from "./package-version.js";
|
|
6
|
+
import { WebUpdate } from "./web-update.js";
|
|
7
|
+
/** The npm check and installer of a standalone web app, with the real process, npm, and clock. */
|
|
8
|
+
export function startWebUpdate(restart, canRestart) {
|
|
9
|
+
const update = new WebUpdate({
|
|
10
|
+
version: packageVersion(),
|
|
11
|
+
enabled: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1",
|
|
12
|
+
fetch,
|
|
13
|
+
setInterval: (callback, ms) => setInterval(callback, ms),
|
|
14
|
+
log,
|
|
15
|
+
...nodeInstallDeps(),
|
|
16
|
+
exists: existsSync,
|
|
17
|
+
// npm installs beside this Node.js, so read that install, not the first command on PATH.
|
|
18
|
+
globalVersion: () => globalCommandVersion({ find: () => null }).version,
|
|
19
|
+
restart,
|
|
20
|
+
canRestart,
|
|
21
|
+
now: () => Date.now(),
|
|
22
|
+
});
|
|
23
|
+
update.start();
|
|
24
|
+
return update;
|
|
25
|
+
}
|
|
26
|
+
/** The real npm process, shared by the update button and `web update`. */
|
|
27
|
+
export function nodeInstallDeps() {
|
|
28
|
+
return {
|
|
29
|
+
execPath: process.execPath,
|
|
30
|
+
platform: process.platform,
|
|
31
|
+
spawn: (command, args, options) => spawn(command, args, {
|
|
32
|
+
shell: options.shell,
|
|
33
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
34
|
+
windowsHide: true,
|
|
35
|
+
}),
|
|
36
|
+
// Resolves once npm has stopped. `taskkill /T` also stops npm under the cmd.exe
|
|
37
|
+
// wrapper that `shell: true` starts; the child's own exit confirms it.
|
|
38
|
+
killTree: (child) => new Promise((resolve) => {
|
|
39
|
+
child.on("exit", () => resolve());
|
|
40
|
+
if (process.platform === "win32" && child.pid) {
|
|
41
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
42
|
+
stdio: "ignore",
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
}).on("error", () => child.kill("SIGKILL"));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
child.kill("SIGTERM");
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
51
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export declare const UPDATE_CHECK_INTERVAL_MS: number;
|
|
2
|
+
/** An install that takes longer is stopped, and the web app keeps serving. */
|
|
3
|
+
export declare const INSTALL_TIMEOUT_MS: number;
|
|
4
|
+
export type UpdateState = "idle" | "installing" | "restarting" | "failed";
|
|
5
|
+
/** The `update` field of `GET /api/web/status`. */
|
|
6
|
+
export interface UpdateStatus {
|
|
7
|
+
/** The newer release on npm, or null. */
|
|
8
|
+
available: string | null;
|
|
9
|
+
state: UpdateState;
|
|
10
|
+
/** The failure code while `state` is `failed`. */
|
|
11
|
+
code: string | null;
|
|
12
|
+
/** True when this web app can install the release itself. */
|
|
13
|
+
canInstall: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface WebUpdateDeps {
|
|
16
|
+
/** The running version. */
|
|
17
|
+
version: string;
|
|
18
|
+
/** False when `OMMS_DISABLE_UPDATE_CHECK` is `1`. */
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
fetch: typeof fetch;
|
|
21
|
+
setInterval: (callback: () => void, ms: number) => {
|
|
22
|
+
unref?: () => void;
|
|
23
|
+
};
|
|
24
|
+
log: (message: string, data: Record<string, unknown>) => void;
|
|
25
|
+
/** The runtime of this web app; npm must sit beside it. */
|
|
26
|
+
execPath: string;
|
|
27
|
+
platform: NodeJS.Platform;
|
|
28
|
+
exists: (path: string) => boolean;
|
|
29
|
+
spawn: (command: string, args: string[], options: {
|
|
30
|
+
shell: boolean;
|
|
31
|
+
}) => InstallChild;
|
|
32
|
+
/** The version of the global install after npm ran, or null. */
|
|
33
|
+
globalVersion: () => string | null;
|
|
34
|
+
/** False when no restart could serve the new copy, such as a missing launcher. Checked before npm runs. */
|
|
35
|
+
canRestart: () => boolean;
|
|
36
|
+
/** Stop npm and its children. On Windows the shell wrapper alone would leave npm running. */
|
|
37
|
+
killTree: (child: InstallChild) => Promise<void>;
|
|
38
|
+
/** Restart onto the newest copy. Returns a failure code when this web app keeps serving. */
|
|
39
|
+
restart: () => Promise<string | undefined>;
|
|
40
|
+
setTimeout: (callback: () => void, ms: number) => unknown;
|
|
41
|
+
clearTimeout: (handle: unknown) => void;
|
|
42
|
+
now: () => number;
|
|
43
|
+
}
|
|
44
|
+
/** The parts of a child process the install runner uses. */
|
|
45
|
+
export interface InstallChild {
|
|
46
|
+
pid?: number;
|
|
47
|
+
stderr: {
|
|
48
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
49
|
+
} | null;
|
|
50
|
+
on(event: "error", listener: () => void): unknown;
|
|
51
|
+
on(event: "exit", listener: (code: number | null) => void): unknown;
|
|
52
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
53
|
+
}
|
|
54
|
+
export type InstallRequest = "accepted" | "no-update" | "cannot-install";
|
|
55
|
+
/** A failure code from npm's error stream. The text itself never leaves this function. */
|
|
56
|
+
export declare function npmFailureCode(errorText: string): string;
|
|
57
|
+
export declare class WebUpdate {
|
|
58
|
+
private readonly deps;
|
|
59
|
+
private available;
|
|
60
|
+
private state;
|
|
61
|
+
private code;
|
|
62
|
+
private started;
|
|
63
|
+
constructor(deps: WebUpdateDeps);
|
|
64
|
+
/** Check now, then every 6 hours. A second call does nothing. */
|
|
65
|
+
start(): void;
|
|
66
|
+
/** Read npm `latest`. A failed read keeps the last result. */
|
|
67
|
+
check(): Promise<void>;
|
|
68
|
+
status(): UpdateStatus;
|
|
69
|
+
private canInstall;
|
|
70
|
+
/** Start the install and the restart. A request while one runs joins it. */
|
|
71
|
+
requestInstall(): InstallRequest;
|
|
72
|
+
private install;
|
|
73
|
+
}
|
|
74
|
+
/** The parts of the update deps that run npm. */
|
|
75
|
+
export type InstallDeps = Pick<WebUpdateDeps, "execPath" | "platform" | "spawn" | "killTree" | "setTimeout" | "clearTimeout">;
|
|
76
|
+
/** A failure code, or null when npm exited with code 0. */
|
|
77
|
+
export interface InstallResult {
|
|
78
|
+
code: string | null;
|
|
79
|
+
exitCode?: number | null;
|
|
80
|
+
}
|
|
81
|
+
/** The npm that installs into the prefix the launcher reads. */
|
|
82
|
+
export declare function npmBesideRuntime(deps: Pick<WebUpdateDeps, "execPath" | "platform">): string;
|
|
83
|
+
/**
|
|
84
|
+
* Run `npm install -g om-memory-system@<target>` and stop it after 5 minutes.
|
|
85
|
+
* The web app's update button and `web update` share it.
|
|
86
|
+
*/
|
|
87
|
+
export declare function runGlobalInstall(deps: InstallDeps, target: string): Promise<InstallResult>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
import { availableUpdate, latestNpmVersion } from "./update-check.js";
|
|
3
|
+
// The web app's npm release check. It reads npm `latest` at start and then every
|
|
4
|
+
// 6 hours, and keeps the result in memory for `GET /api/web/status`. It imports
|
|
5
|
+
// no host adapter and no config, so tests pass every input.
|
|
6
|
+
export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
7
|
+
/** An install that takes longer is stopped, and the web app keeps serving. */
|
|
8
|
+
export const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
/** Only a plain release reaches the npm arguments; the value comes from the registry. */
|
|
10
|
+
const PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
|
|
11
|
+
/** Enough of npm's error stream to find a code; the rest is dropped. */
|
|
12
|
+
const ERROR_TAIL_BYTES = 64 * 1024;
|
|
13
|
+
/** A failure code from npm's error stream. The text itself never leaves this function. */
|
|
14
|
+
export function npmFailureCode(errorText) {
|
|
15
|
+
if (/\b(EACCES|EPERM)\b/.test(errorText))
|
|
16
|
+
return "permission";
|
|
17
|
+
if (/\b(ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN)\b/.test(errorText))
|
|
18
|
+
return "network";
|
|
19
|
+
return "npm-exit";
|
|
20
|
+
}
|
|
21
|
+
export class WebUpdate {
|
|
22
|
+
deps;
|
|
23
|
+
available = null;
|
|
24
|
+
state = "idle";
|
|
25
|
+
code = null;
|
|
26
|
+
started = false;
|
|
27
|
+
constructor(deps) {
|
|
28
|
+
this.deps = deps;
|
|
29
|
+
}
|
|
30
|
+
/** Check now, then every 6 hours. A second call does nothing. */
|
|
31
|
+
start() {
|
|
32
|
+
if (this.started || !this.deps.enabled)
|
|
33
|
+
return;
|
|
34
|
+
this.started = true;
|
|
35
|
+
void this.check();
|
|
36
|
+
this.deps.setInterval(() => void this.check(), UPDATE_CHECK_INTERVAL_MS).unref?.();
|
|
37
|
+
}
|
|
38
|
+
/** Read npm `latest`. A failed read keeps the last result. */
|
|
39
|
+
async check() {
|
|
40
|
+
if (!this.deps.enabled)
|
|
41
|
+
return;
|
|
42
|
+
const latest = await latestNpmVersion(this.deps.fetch);
|
|
43
|
+
if (latest === null) {
|
|
44
|
+
this.deps.log("Web app update check failed", { code: "unreachable" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.available = availableUpdate(this.deps.version, latest);
|
|
48
|
+
}
|
|
49
|
+
status() {
|
|
50
|
+
return {
|
|
51
|
+
available: this.available,
|
|
52
|
+
state: this.state,
|
|
53
|
+
code: this.code,
|
|
54
|
+
canInstall: this.canInstall(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
canInstall() {
|
|
58
|
+
return this.deps.exists(npmBesideRuntime(this.deps)) && this.deps.canRestart();
|
|
59
|
+
}
|
|
60
|
+
/** Start the install and the restart. A request while one runs joins it. */
|
|
61
|
+
requestInstall() {
|
|
62
|
+
if (this.state === "installing" || this.state === "restarting")
|
|
63
|
+
return "accepted";
|
|
64
|
+
if (!this.available)
|
|
65
|
+
return "no-update";
|
|
66
|
+
if (!this.canInstall())
|
|
67
|
+
return "cannot-install";
|
|
68
|
+
this.state = "installing";
|
|
69
|
+
this.code = null;
|
|
70
|
+
void this.install(this.available);
|
|
71
|
+
return "accepted";
|
|
72
|
+
}
|
|
73
|
+
async install(target) {
|
|
74
|
+
const began = this.deps.now();
|
|
75
|
+
const record = (outcome, code, exitCode) => this.deps.log("Web app update", {
|
|
76
|
+
outcome,
|
|
77
|
+
code,
|
|
78
|
+
from: this.deps.version,
|
|
79
|
+
to: target,
|
|
80
|
+
exitCode,
|
|
81
|
+
durationMs: this.deps.now() - began,
|
|
82
|
+
});
|
|
83
|
+
const fail = (code, exitCode) => {
|
|
84
|
+
this.state = "failed";
|
|
85
|
+
this.code = code;
|
|
86
|
+
record("failed", code, exitCode);
|
|
87
|
+
};
|
|
88
|
+
const { code, exitCode } = await runGlobalInstall(this.deps, target);
|
|
89
|
+
if (code)
|
|
90
|
+
return fail(code, exitCode);
|
|
91
|
+
if (this.deps.globalVersion() !== target)
|
|
92
|
+
return fail("version-mismatch", exitCode);
|
|
93
|
+
this.state = "restarting";
|
|
94
|
+
record("installed", null, exitCode);
|
|
95
|
+
const restartCode = await this.deps.restart();
|
|
96
|
+
// The restart exits this process on success, so a return means it kept serving.
|
|
97
|
+
if (restartCode)
|
|
98
|
+
fail(restartCode);
|
|
99
|
+
else
|
|
100
|
+
this.state = "idle";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The npm that installs into the prefix the launcher reads. */
|
|
104
|
+
export function npmBesideRuntime(deps) {
|
|
105
|
+
// Path rules follow the target platform, not the machine that runs this code.
|
|
106
|
+
const path = deps.platform === "win32" ? win32 : posix;
|
|
107
|
+
const name = deps.platform === "win32" ? "npm.cmd" : "npm";
|
|
108
|
+
return path.join(path.dirname(deps.execPath), name);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run `npm install -g om-memory-system@<target>` and stop it after 5 minutes.
|
|
112
|
+
* The web app's update button and `web update` share it.
|
|
113
|
+
*/
|
|
114
|
+
export function runGlobalInstall(deps, target) {
|
|
115
|
+
if (!PLAIN_VERSION.test(target))
|
|
116
|
+
return Promise.resolve({ code: "bad-version" });
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
let child;
|
|
119
|
+
try {
|
|
120
|
+
// Windows runs `npm.cmd` only through a shell, which splits an unquoted path at a
|
|
121
|
+
// space (`C:\\Program Files`). The arguments are fixed and the version is checked.
|
|
122
|
+
const windows = deps.platform === "win32";
|
|
123
|
+
const command = windows ? `"${npmBesideRuntime(deps)}"` : npmBesideRuntime(deps);
|
|
124
|
+
child = deps.spawn(command, ["install", "-g", `om-memory-system@${target}`], {
|
|
125
|
+
shell: windows,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
resolve({ code: "spawn-error" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let errorText = "";
|
|
133
|
+
let done = false;
|
|
134
|
+
const finish = (result) => {
|
|
135
|
+
if (done)
|
|
136
|
+
return;
|
|
137
|
+
done = true;
|
|
138
|
+
deps.clearTimeout(timer);
|
|
139
|
+
resolve(result);
|
|
140
|
+
};
|
|
141
|
+
let timedOut = false;
|
|
142
|
+
const timer = deps.setTimeout(() => {
|
|
143
|
+
timedOut = true;
|
|
144
|
+
// Keep the update running until npm has stopped, so nothing else touches the install.
|
|
145
|
+
void deps
|
|
146
|
+
.killTree(child)
|
|
147
|
+
.catch(() => undefined)
|
|
148
|
+
.then(() => finish({ code: "timeout" }));
|
|
149
|
+
}, INSTALL_TIMEOUT_MS);
|
|
150
|
+
child.stderr?.on("data", (chunk) => {
|
|
151
|
+
errorText = (errorText + String(chunk)).slice(-ERROR_TAIL_BYTES);
|
|
152
|
+
});
|
|
153
|
+
child.on("error", () => finish({ code: "spawn-error" }));
|
|
154
|
+
child.on("exit", (exitCode) => {
|
|
155
|
+
// After a timeout the tree is still stopping; the timeout path reports it.
|
|
156
|
+
if (timedOut)
|
|
157
|
+
return;
|
|
158
|
+
finish({ code: exitCode === 0 ? null : npmFailureCode(errorText), exitCode });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|