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
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>;
|
package/dist/cli/web-command.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/cli/web-power.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
27
|
-
|
|
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:
|
|
72
|
+
}): (action: ProcessAction) => Promise<string | undefined>;
|
|
55
73
|
export {};
|
package/dist/cli/web-power.js
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|