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 CHANGED
@@ -15,6 +15,7 @@ Commands:
15
15
  import-claude-history Import Claude Code history into omms
16
16
  web Start the web app in the foreground
17
17
  web install|uninstall|status Manage the web app login item
18
+ web update Update the global install and restart the web app
18
19
  memory <mode> [options] Search, add, list, or forget memories (run with --help)
19
20
  profile-catch-up Analyse every prompt waiting for profile learning (run with --help)
20
21
  claude-hook <event> Run a Claude Code hook (used by the Claude Code plugin)
@@ -7,4 +7,4 @@ export interface WebHandoverDeps {
7
7
  version?: string;
8
8
  }
9
9
  /** Run the standalone web app or manage its per-user login item. */
10
- export declare function runWebCommand(args: string[], options?: WebAutostartOptions, online?: () => Promise<boolean>, handover?: WebHandoverDeps): Promise<number>;
10
+ export declare function runWebCommand(args: string[], options?: WebAutostartOptions, online?: () => Promise<boolean>, handover?: WebHandoverDeps, update?: Partial<import("./web-update-command.js").WebUpdateCommandDeps>): Promise<number>;
@@ -31,23 +31,30 @@ async function announceHandover(ctx, handover) {
31
31
  }
32
32
  }
33
33
  /** Run the standalone web app or manage its per-user login item. */
34
- export async function runWebCommand(args, options = {}, online, handover = {}) {
34
+ export async function runWebCommand(args, options = {}, online, handover = {}, update = {}) {
35
35
  const home = options.home ?? homedir();
36
36
  const config = await import("../config.js");
37
37
  config.initConfig(home);
38
38
  const { installWebAutostart, removeWebAutostart, webAutostartStatus } = await import("../services/web-autostart.js");
39
39
  const [action, ...rest] = args;
40
40
  if (rest.length ||
41
- (action && !["install", "uninstall", "status", "--login-item"].includes(action))) {
42
- console.error("Usage: om-memory-system web [install|uninstall|status]");
41
+ (action && !["install", "uninstall", "status", "update", "--login-item"].includes(action))) {
42
+ console.error("Usage: om-memory-system web [install|uninstall|status|update]");
43
43
  return 1;
44
44
  }
45
- if (action === "install" && !config.CONFIG.webServerEnabled) {
45
+ if ((action === "install" || action === "update") && !config.CONFIG.webServerEnabled) {
46
46
  console.error("OMMS web server is disabled (webServerEnabled is false)");
47
47
  return 1;
48
48
  }
49
49
  const { webServerUrl } = await import("../services/web-api-auth.js");
50
50
  const url = webServerUrl(config.CONFIG.webServerHost, config.CONFIG.webServerPort);
51
+ if (action === "update") {
52
+ const { productionWebUpdateDeps, runWebUpdate } = await import("./web-update-command.js");
53
+ return runWebUpdate({
54
+ ...(await productionWebUpdateDeps({ url, home, autostart: options })),
55
+ ...update,
56
+ });
57
+ }
51
58
  if (action === "install" || action === "uninstall") {
52
59
  const { readGlobalConfigRevision, writeGlobalConfigKeys } = await import("../services/global-config-writer.js");
53
60
  await writeGlobalConfigKeys({ webServerAutoStart: action === "install" }, readGlobalConfigRevision());
@@ -123,7 +130,7 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
123
130
  process.once("SIGTERM", stop);
124
131
  // The page's Stop and Restart buttons reach the same process through this callback.
125
132
  const { createPowerAction } = await import("./web-power.js");
126
- server.setOnPowerAction(createPowerAction({
133
+ const powerAction = createPowerAction({
127
134
  stopServer: async () => {
128
135
  clearInterval(keeper);
129
136
  process.off("SIGINT", stop);
@@ -140,7 +147,16 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
140
147
  baseUrl: server.getUrl(),
141
148
  loginItem: action === "--login-item",
142
149
  autostart: options,
143
- }));
150
+ });
151
+ server.setOnPowerAction(powerAction);
152
+ // The update button installs the npm release and restarts onto it through the launcher.
153
+ const { startWebUpdate } = await import("../services/web-update-runtime.js");
154
+ const { launcherPath } = await import("../services/runtime-record.js");
155
+ const { ommsDir } = await import("../services/runtime-handoff.js");
156
+ const { existsSync } = await import("node:fs");
157
+ // Every restart onto the new copy runs the launcher, the login item's included.
158
+ const canRestart = () => existsSync(launcherPath(ommsDir()));
159
+ server.setWebUpdate(startWebUpdate(() => powerAction("update"), canRestart));
144
160
  // A newer `web install` asked this web app to give up the port.
145
161
  server.setOnStepAside(async () => {
146
162
  clearInterval(keeper);
@@ -1,6 +1,8 @@
1
1
  import type { WebAutostartOptions } from "../services/web-autostart.js";
2
2
  /** The two choices the page offers; the web server passes them through unchanged. */
3
3
  export type PowerAction = "stop" | "restart";
4
+ /** A restart through the launcher, so the newest copy on the machine serves after an update. */
5
+ export type ProcessAction = PowerAction | "update";
4
6
  interface SpawnedChild {
5
7
  pid?: number;
6
8
  unref(): void;
@@ -16,6 +18,7 @@ export interface PowerDeps {
16
18
  stdio: "ignore";
17
19
  cwd: string;
18
20
  windowsHide: true;
21
+ env: NodeJS.ProcessEnv;
19
22
  }) => SpawnedChild;
20
23
  /** Restart the login item through the service manager. False when it cannot. */
21
24
  restartLoginItem: () => boolean | Promise<boolean>;
@@ -23,14 +26,29 @@ export interface PowerDeps {
23
26
  writeStartLock: (pid: number) => void | Promise<void>;
24
27
  /** Remove the start lock when it names `pid`. */
25
28
  removeStartLock: (pid: number) => void | Promise<void>;
26
- /** True when an OMMS web app answers health at `baseUrl`. */
27
- probe: (baseUrl: string) => Promise<boolean>;
29
+ /**
30
+ * The OMMS web app that answers at `baseUrl`, or null when none does. The
31
+ * version is null when this process may not read it, for example behind basic auth.
32
+ */
33
+ readOwner: (baseUrl: string) => Promise<{
34
+ instance: string;
35
+ version: string | null;
36
+ } | null>;
37
+ /** Ask the web app at `baseUrl` to step aside for `version`. True when it agrees. */
38
+ stepAside: (baseUrl: string, version: string) => Promise<boolean>;
39
+ /** The instance id the copy reports, so the handoff knows its own copy. */
40
+ newInstance: () => string;
41
+ /** The version on disk now; an in-place upgrade changes it after this process started. */
42
+ version: () => string;
43
+ env: NodeJS.ProcessEnv;
28
44
  sleep: (ms: number) => Promise<void>;
29
45
  now: () => number;
30
46
  exit: (code: number) => void;
31
47
  /** The runtime and arguments of this process, for a detached copy. */
32
48
  execPath: string;
33
49
  args: string[];
50
+ /** The launcher at `~/.omms/bin`, or null when it is missing. */
51
+ launcher: string | null;
34
52
  cwd: string;
35
53
  pid: number;
36
54
  log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
@@ -51,5 +69,5 @@ export declare function createPowerAction(options: {
51
69
  loginItem: boolean;
52
70
  autostart?: WebAutostartOptions;
53
71
  deps?: Partial<PowerDeps>;
54
- }): (action: PowerAction) => Promise<void>;
72
+ }): (action: ProcessAction) => Promise<string | undefined>;
55
73
  export {};
@@ -1,10 +1,24 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
2
4
  import { homedir } from "node:os";
5
+ import { packageVersion } from "../services/package-version.js";
6
+ import { ommsDir } from "../services/runtime-handoff.js";
7
+ import { launcherPath } from "../services/runtime-record.js";
8
+ import { compareVersions } from "../services/version-compare.js";
3
9
  /** How long a fresh copy must stay up before the old process stops serving. */
4
10
  const SPAWN_CHECK_MS = 1_000;
5
11
  /** How long the old process waits for the copy to answer before it serves again. */
6
12
  export const HANDOFF_WAIT_MS = 15_000;
7
13
  const HANDOFF_POLL_MS = 250;
14
+ let tokenHeader = null;
15
+ /** The web app runs on this machine, so the local token file is enough. Read once. */
16
+ function localTokenHeader() {
17
+ tokenHeader ??= import("../services/auth-token.js").then(({ AUTH_HEADER, getOrCreateAuthToken }) => ({
18
+ [AUTH_HEADER]: getOrCreateAuthToken(),
19
+ }));
20
+ return tokenHeader;
21
+ }
8
22
  function productionDeps(options) {
9
23
  return {
10
24
  spawn: nodeSpawn,
@@ -14,23 +28,55 @@ function productionDeps(options) {
14
28
  nodeLockFs.replace(startLockPath(), JSON.stringify({ pid, at: Date.now() }));
15
29
  },
16
30
  removeStartLock: async (pid) => (await import("../services/web-ensure.js")).removeStartLockFor(pid),
17
- probe: async (baseUrl) => {
31
+ readOwner: async (baseUrl) => {
32
+ // Health skips basic auth, so a restart can find its copy with basic auth on.
33
+ let instance;
34
+ try {
35
+ const health = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(1_000) });
36
+ const body = (await health.json());
37
+ if (!health.ok || typeof body.instance !== "string")
38
+ return null;
39
+ instance = body.instance;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
18
44
  try {
19
- const response = await fetch(`${baseUrl}/api/health`, {
45
+ const response = await fetch(`${baseUrl}/api/web/status`, {
46
+ headers: await localTokenHeader(),
20
47
  signal: AbortSignal.timeout(1_000),
21
48
  });
22
49
  const body = (await response.json());
23
- return response.ok && body.success === true && body.status === "ok";
50
+ const version = response.ok && typeof body.version === "string" ? body.version : null;
51
+ return { instance, version };
52
+ }
53
+ catch {
54
+ return { instance, version: null };
55
+ }
56
+ },
57
+ stepAside: async (baseUrl, version) => {
58
+ try {
59
+ const response = await fetch(`${baseUrl}/api/web/step-aside`, {
60
+ method: "POST",
61
+ headers: { ...(await localTokenHeader()), "content-type": "application/json" },
62
+ body: JSON.stringify({ version }),
63
+ signal: AbortSignal.timeout(1_000),
64
+ });
65
+ return response.status === 202;
24
66
  }
25
67
  catch {
26
68
  return false;
27
69
  }
28
70
  },
71
+ newInstance: () => randomUUID(),
72
+ version: () => packageVersion(),
73
+ env: process.env,
29
74
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
30
75
  now: () => Date.now(),
31
76
  exit: (code) => process.exit(code),
32
77
  execPath: process.execPath,
33
78
  args: process.argv.slice(1),
79
+ launcher: existsSync(launcherPath(ommsDir())) ? launcherPath(ommsDir()) : null,
34
80
  cwd: homedir(),
35
81
  pid: process.pid,
36
82
  log: async (message, data) => (await import("../services/logger.js")).log(message, data),
@@ -49,14 +95,16 @@ export function createPowerAction(options) {
49
95
  const logFailure = (code) => Promise.resolve(deps.log("Web app restart failed", { code })).catch(() => undefined);
50
96
  let stopped = false;
51
97
  /** Start the copy, check it, stop serving, and wait until the copy answers. */
52
- async function handOff() {
98
+ async function handOff(args) {
53
99
  let child;
100
+ const instance = deps.newInstance();
54
101
  try {
55
- child = deps.spawn(deps.execPath, deps.args, {
102
+ child = deps.spawn(deps.execPath, args, {
56
103
  detached: true,
57
104
  stdio: "ignore",
58
105
  cwd: deps.cwd,
59
106
  windowsHide: true,
107
+ env: { ...deps.env, OMMS_WEB_INSTANCE: instance },
60
108
  });
61
109
  }
62
110
  catch {
@@ -82,12 +130,30 @@ export function createPowerAction(options) {
82
130
  stopped = true;
83
131
  }
84
132
  // The copy waits as a non-owner and takes the port within about 7 seconds.
85
- const began = deps.now();
133
+ let began = deps.now();
134
+ // Each older owner is asked once; another older waiter can take the port after it.
135
+ const askedAside = new Set();
86
136
  while (deps.now() - began < HANDOFF_WAIT_MS) {
87
137
  if (exited)
88
138
  return giveUp("copy-exit", copyPid);
89
- if (await deps.probe(options.baseUrl))
90
- return deps.exit(0);
139
+ const owner = await deps.readOwner(options.baseUrl);
140
+ if (owner?.instance === instance) {
141
+ deps.exit(0);
142
+ return undefined;
143
+ }
144
+ // Another web app that waited for the port took it first.
145
+ if (owner) {
146
+ const ownVersion = deps.version();
147
+ const order = owner.version === null ? null : compareVersions(owner.version, ownVersion);
148
+ if (order === null || order >= 0)
149
+ return yieldTo(child, copyPid);
150
+ if (!askedAside.has(owner.instance)) {
151
+ askedAside.add(owner.instance);
152
+ // The copy needs a fresh wait once the older web app leaves.
153
+ if (await deps.stepAside(options.baseUrl, ownVersion))
154
+ began = deps.now();
155
+ }
156
+ }
91
157
  await deps.sleep(HANDOFF_POLL_MS);
92
158
  }
93
159
  if (exited)
@@ -96,7 +162,15 @@ export function createPowerAction(options) {
96
162
  child.kill("SIGTERM");
97
163
  return giveUp("handoff", copyPid);
98
164
  }
99
- /** Keep or resume serving on the old process, and release the lock it wrote. */
165
+ /** A web app of the same or a newer version serves, so the copy would only wait. */
166
+ async function yieldTo(child, copyPid) {
167
+ child.kill("SIGTERM");
168
+ await deps.removeStartLock(copyPid);
169
+ await logFailure("other-owner");
170
+ deps.exit(0);
171
+ return undefined;
172
+ }
173
+ /** Keep or resume serving on the old process, and release the lock it wrote. Returns the code. */
100
174
  async function giveUp(code, copyPid) {
101
175
  if (copyPid !== undefined)
102
176
  await deps.removeStartLock(copyPid);
@@ -104,7 +178,7 @@ export function createPowerAction(options) {
104
178
  await deps.removeStartLock(deps.pid);
105
179
  await logFailure(code);
106
180
  if (!stopped)
107
- return;
181
+ return code;
108
182
  try {
109
183
  await options.resumeServer();
110
184
  stopped = false;
@@ -113,29 +187,39 @@ export function createPowerAction(options) {
113
187
  await logFailure(error instanceof Error ? error.name : "resume-error");
114
188
  deps.exit(1);
115
189
  }
190
+ return code;
116
191
  }
117
192
  return async (action) => {
118
193
  if (action === "stop") {
119
194
  await options.stopServer();
120
195
  deps.exit(0);
121
- return;
196
+ return undefined;
197
+ }
198
+ // After an update the copy of this process is the old version; the launcher picks the newest.
199
+ if (action === "update" && !deps.launcher) {
200
+ await logFailure("no-launcher");
201
+ return "no-launcher";
122
202
  }
203
+ const args = action === "update" ? [deps.launcher, "web"] : deps.args;
123
204
  stopped = false;
124
205
  try {
125
206
  if (options.loginItem) {
207
+ // The login item runs the launcher, so the service manager restart covers an update too.
126
208
  await options.stopServer();
127
209
  stopped = true;
128
- if (await deps.restartLoginItem())
129
- return deps.exit(0);
210
+ if (await deps.restartLoginItem()) {
211
+ deps.exit(0);
212
+ return undefined;
213
+ }
130
214
  }
131
215
  else {
132
216
  // Hold other hosts off until the copy owns the port.
133
217
  await deps.writeStartLock(deps.pid);
134
218
  }
135
- await handOff();
219
+ return await handOff(args);
136
220
  }
137
221
  catch (error) {
138
- await giveUp(error instanceof Error ? error.name : "unknown");
222
+ return giveUp(error instanceof Error ? error.name : "unknown");
139
223
  }
140
224
  };
141
225
  }
@@ -0,0 +1,36 @@
1
+ import type { InstallResult } from "../services/web-update.js";
2
+ /** How long the command waits for the fresh web app to answer. */
3
+ export declare const FRESH_WAIT_MS = 15000;
4
+ export interface WebUpdateCommandDeps {
5
+ url: string;
6
+ /** The local token header; the web app runs on this machine. */
7
+ headers: Record<string, string>;
8
+ /** The version this command runs as, the newest copy after the hand-off. */
9
+ version: string;
10
+ fetchFn: typeof fetch;
11
+ sleep: (ms: number) => Promise<void>;
12
+ now: () => number;
13
+ /** npm `latest`, or null when the registry does not answer. */
14
+ latest: () => Promise<string | null>;
15
+ /** The global install's version from its `package.json`, or null. */
16
+ globalVersion: () => string | null;
17
+ install: (target: string) => Promise<InstallResult>;
18
+ loginItemInstalled: () => boolean;
19
+ /** Restart the login item through the service manager. False when it cannot. */
20
+ restartLoginItem: () => boolean;
21
+ /** Start a detached web app through the launcher. False when there is nothing to start. */
22
+ startDetached: () => boolean;
23
+ writeStartLock: () => void;
24
+ removeStartLock: () => void;
25
+ writeRetireMarker: (before: number) => void;
26
+ print: (line: string) => void;
27
+ log: (message: string, data: Record<string, unknown>) => void;
28
+ }
29
+ /** Run `web update`. Returns the exit code. */
30
+ export declare function runWebUpdate(deps: WebUpdateCommandDeps): Promise<number>;
31
+ /** The real network, npm, login item, and launcher. */
32
+ export declare function productionWebUpdateDeps(options: {
33
+ url: string;
34
+ home: string;
35
+ autostart: import("../services/web-autostart.js").WebAutostartOptions;
36
+ }): Promise<WebUpdateCommandDeps>;
@@ -0,0 +1,203 @@
1
+ import { compareVersions } from "../services/version-compare.js";
2
+ // `om-memory-system web update`: bring the global install to npm `latest`, then
3
+ // replace every standalone web app with one fresh web app on the newest copy.
4
+ // No process is killed: each web app stops itself after a request.
5
+ /** How long the command waits for the fresh web app to answer. */
6
+ export const FRESH_WAIT_MS = 15_000;
7
+ const POLL_MS = 250;
8
+ const REQUEST_TIMEOUT_MS = 2_000;
9
+ /** The web app on the port, or null when none answers. */
10
+ async function readOwner(deps) {
11
+ let instance;
12
+ try {
13
+ // Health skips basic auth, so the command finds a web app with basic auth on.
14
+ const health = await deps.fetchFn(`${deps.url}/api/health`, {
15
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
16
+ });
17
+ const body = (await health.json());
18
+ if (!health.ok || typeof body.instance !== "string")
19
+ return null;
20
+ instance = body.instance;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ try {
26
+ const response = await deps.fetchFn(`${deps.url}/api/web/status`, {
27
+ headers: deps.headers,
28
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
29
+ });
30
+ const body = (await response.json());
31
+ return {
32
+ instance,
33
+ version: response.ok && typeof body.version === "string" ? body.version : null,
34
+ };
35
+ }
36
+ catch {
37
+ return { instance, version: null };
38
+ }
39
+ }
40
+ /** Ask the web app on the port to step aside for a replace. True when it agrees. */
41
+ async function stepAside(deps) {
42
+ try {
43
+ const reply = await deps.fetchFn(`${deps.url}/api/web/step-aside`, {
44
+ method: "POST",
45
+ headers: { ...deps.headers, "content-type": "application/json" },
46
+ body: JSON.stringify({ version: deps.version, replace: true }),
47
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
48
+ });
49
+ return reply.status === 202;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** Run `web update`. Returns the exit code. */
56
+ export async function runWebUpdate(deps) {
57
+ const began = deps.now();
58
+ const record = { ownVersion: deps.version };
59
+ const finish = (exitCode, outcome, code = null) => {
60
+ deps.log("Web app update command", {
61
+ ...record,
62
+ outcome,
63
+ code,
64
+ durationMs: deps.now() - began,
65
+ });
66
+ return exitCode;
67
+ };
68
+ // 1. The global install.
69
+ const latest = await deps.latest();
70
+ const before = deps.globalVersion();
71
+ Object.assign(record, { latest, globalBefore: before });
72
+ if (latest === null) {
73
+ record.npm = "unreachable";
74
+ deps.print("Could not check npm for a release. Replacing the web app anyway.");
75
+ }
76
+ else if (before === null || (compareVersions(before, latest) ?? 0) < 0) {
77
+ record.npm = "ran";
78
+ deps.print(`Installing om-memory-system@${latest} globally (was ${before ?? "not installed"})`);
79
+ const { code } = await deps.install(latest);
80
+ const after = deps.globalVersion();
81
+ record.globalAfter = after;
82
+ const failure = code ?? (after === latest ? null : "version-mismatch");
83
+ if (failure) {
84
+ deps.print(`Global install failed (${failure}). The web app keeps running. Run: npm i -g om-memory-system@${latest}`);
85
+ return finish(1, "install-failed", failure);
86
+ }
87
+ deps.print(`Global install: ${latest}`);
88
+ }
89
+ else {
90
+ record.npm = "skipped";
91
+ deps.print(`Global install is up to date (${before}).`);
92
+ }
93
+ // 2. Replace every standalone web app. Hosts wait while no web app serves.
94
+ deps.writeStartLock();
95
+ try {
96
+ deps.writeRetireMarker(deps.now());
97
+ const owner = await readOwner(deps);
98
+ record.oldOwner = owner?.version ?? null;
99
+ // A web app before `replace` existed ignores the flag and steps aside only for a newer caller.
100
+ if (owner && !(await stepAside(deps))) {
101
+ const name = owner.version ? `OMMS ${owner.version}` : "Another web app";
102
+ deps.print(`${name} holds ${deps.url} and cannot hand it over. Stop that web app (Stop in its power menu, Ctrl+C in its terminal, or quit the session that runs it), then run om-memory-system web update again.`);
103
+ return finish(1, "stuck", "stuck");
104
+ }
105
+ // 3. Start one fresh web app on the newest copy.
106
+ const viaLoginItem = deps.loginItemInstalled() && deps.restartLoginItem();
107
+ record.start = viaLoginItem ? "login-item" : "launcher";
108
+ if (!viaLoginItem && !deps.startDetached()) {
109
+ deps.print("No OMMS copy to start. Run om-memory-system web to start the web app.");
110
+ return finish(1, "no-copy", "no-copy");
111
+ }
112
+ // 4. Wait for a web app that is not the old one.
113
+ const askedAside = new Set(owner ? [owner.instance] : []);
114
+ const deadline = deps.now() + FRESH_WAIT_MS;
115
+ while (deps.now() < deadline) {
116
+ const current = await readOwner(deps);
117
+ if (current && !askedAside.has(current.instance)) {
118
+ const older = current.version !== null && (compareVersions(current.version, deps.version) ?? 0) < 0;
119
+ if (!older) {
120
+ record.served = current.version;
121
+ deps.print(current.version
122
+ ? `OMMS web app: ${deps.url} (version ${current.version})`
123
+ : `OMMS web app: ${deps.url}`);
124
+ return finish(0, "replaced");
125
+ }
126
+ // An older web app that waited for the port took it before the fresh one.
127
+ askedAside.add(current.instance);
128
+ await stepAside(deps);
129
+ }
130
+ await deps.sleep(POLL_MS);
131
+ }
132
+ deps.print(`No web app answered on ${deps.url} within ${FRESH_WAIT_MS / 1000} seconds. Run om-memory-system web status.`);
133
+ return finish(1, "no-answer", "no-answer");
134
+ }
135
+ finally {
136
+ deps.removeStartLock();
137
+ }
138
+ }
139
+ /** The real network, npm, login item, and launcher. */
140
+ export async function productionWebUpdateDeps(options) {
141
+ const [{ spawn }, { existsSync }, { join }, { AUTH_HEADER, getOrCreateAuthToken }, { packageVersion }, { latestNpmVersion }, { globalCommandVersion }, { runGlobalInstall }, { nodeInstallDeps }, { restartWebAutostart, webAutostartStatus }, { launcherPath, recordedCopy }, { ommsDir }, { nodeLockFs, removeStartLockFor, startLockPath }, { writeRetireMarker }, { log },] = await Promise.all([
142
+ import("node:child_process"),
143
+ import("node:fs"),
144
+ import("node:path"),
145
+ import("../services/auth-token.js"),
146
+ import("../services/package-version.js"),
147
+ import("../services/update-check.js"),
148
+ import("../services/global-version.js"),
149
+ import("../services/web-update.js"),
150
+ import("../services/web-update-runtime.js"),
151
+ import("../services/web-autostart.js"),
152
+ import("../services/runtime-record.js"),
153
+ import("../services/runtime-handoff.js"),
154
+ import("../services/web-ensure.js"),
155
+ import("../services/web-retire.js"),
156
+ import("../services/logger.js"),
157
+ ]);
158
+ const dir = ommsDir(options.home);
159
+ const lockPath = startLockPath(options.home);
160
+ return {
161
+ url: options.url,
162
+ headers: { [AUTH_HEADER]: getOrCreateAuthToken() },
163
+ version: packageVersion(),
164
+ fetchFn: fetch,
165
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
166
+ now: () => Date.now(),
167
+ latest: () => latestNpmVersion(fetch),
168
+ // npm installs beside this Node.js, so read that install, not the first command on PATH.
169
+ globalVersion: () => globalCommandVersion({ find: () => null }).version,
170
+ install: (target) => runGlobalInstall(nodeInstallDeps(), target),
171
+ loginItemInstalled: () => webAutostartStatus(options.autostart).state === "installed",
172
+ restartLoginItem: () => restartWebAutostart(options.autostart),
173
+ startDetached: () => {
174
+ // The launcher picks the newest copy, the global install beside this Node.js included.
175
+ const launcher = launcherPath(dir);
176
+ const copy = recordedCopy(dir);
177
+ const script = existsSync(launcher)
178
+ ? launcher
179
+ : copy
180
+ ? join(copy.root, "dist", "cli", "index.js")
181
+ : null;
182
+ if (!script)
183
+ return false;
184
+ try {
185
+ spawn(process.execPath, [script, "web"], {
186
+ detached: true,
187
+ stdio: "ignore",
188
+ cwd: options.home,
189
+ windowsHide: true,
190
+ }).unref();
191
+ return true;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ },
197
+ writeStartLock: () => nodeLockFs.replace(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() })),
198
+ removeStartLock: () => removeStartLockFor(process.pid, lockPath),
199
+ writeRetireMarker: (before) => writeRetireMarker(before, { home: options.home }),
200
+ print: (line) => console.log(line),
201
+ log,
202
+ };
203
+ }
@@ -0,0 +1,12 @@
1
+ import { type LockFs } from "./web-ensure.js";
2
+ export declare function retireMarkerPath(home?: string): string;
3
+ /** Ask every waiting web app started before `before` (epoch ms) to exit. */
4
+ export declare function writeRetireMarker(before: number, options?: {
5
+ home?: string;
6
+ fs?: Pick<LockFs, "replace">;
7
+ }): void;
8
+ /** The marker's `before` time, or null when the marker is missing or damaged. */
9
+ export declare function readRetireMarker(options?: {
10
+ home?: string;
11
+ fs?: Pick<LockFs, "read">;
12
+ }): number | null;
@@ -0,0 +1,26 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { nodeLockFs } from "./web-ensure.js";
4
+ // The retire marker that `web update` writes. A standalone web app that waits for
5
+ // the port and started before `before` exits. A waiting web app holds no port, so
6
+ // a file is the only way to reach it. The marker stays; the next update overwrites it.
7
+ export function retireMarkerPath(home = homedir()) {
8
+ return join(home, ".omms", "web-retire.json");
9
+ }
10
+ /** Ask every waiting web app started before `before` (epoch ms) to exit. */
11
+ export function writeRetireMarker(before, options = {}) {
12
+ (options.fs ?? nodeLockFs).replace(retireMarkerPath(options.home), `${JSON.stringify({ before })}\n`);
13
+ }
14
+ /** The marker's `before` time, or null when the marker is missing or damaged. */
15
+ export function readRetireMarker(options = {}) {
16
+ const text = (options.fs ?? nodeLockFs).read(retireMarkerPath(options.home));
17
+ if (text === null)
18
+ return null;
19
+ try {
20
+ const { before } = JSON.parse(text);
21
+ return typeof before === "number" && Number.isFinite(before) ? before : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }