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.
@@ -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) => void | Promise<void>): void;
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
- /** Differs between processes, so the page can tell a restarted web app from the old one. */
148
- const PROCESS_INSTANCE = randomUUID();
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(async () => {
538
- const isAvailable = await this.checkServerAvailable();
539
- if (!isAvailable) {
540
- this.stopHealthCheckLoop();
541
- await this.attemptTakeover();
542
- }
543
- }, 5000);
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
+ }