om-memory-system 4.10.0 → 4.11.0
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 +11 -4
- package/dist/cli/web-update-command.d.ts +38 -0
- package/dist/cli/web-update-command.js +211 -0
- package/dist/services/web-ensure.d.ts +1 -0
- package/dist/services/web-ensure.js +1 -1
- package/dist/services/web-retire.d.ts +12 -0
- package/dist/services/web-retire.js +26 -0
- package/dist/services/web-server.d.ts +8 -0
- package/dist/services/web-server.js +45 -19
- package/dist/services/web-update-runtime.d.ts +3 -1
- package/dist/services/web-update-runtime.js +15 -9
- package/dist/services/web-update.d.ts +14 -3
- package/dist/services/web-update.js +59 -55
- package/dist/web/assets/index-DJrAbX8u.css +2 -0
- package/dist/web/assets/index-DnSx1elT.js +125 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BkqDHQCc.css +0 -2
- package/dist/web/assets/index-DWPF5lXj.js +0 -125
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());
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
/** Take the start lock. False when another caller holds a fresh one. */
|
|
24
|
+
takeStartLock: () => boolean;
|
|
25
|
+
/** Remove the start lock, only when this process holds it. */
|
|
26
|
+
removeStartLock: () => void;
|
|
27
|
+
writeRetireMarker: (before: number) => void;
|
|
28
|
+
print: (line: string) => void;
|
|
29
|
+
log: (message: string, data: Record<string, unknown>) => void;
|
|
30
|
+
}
|
|
31
|
+
/** Run `web update`. Returns the exit code. */
|
|
32
|
+
export declare function runWebUpdate(deps: WebUpdateCommandDeps): Promise<number>;
|
|
33
|
+
/** The real network, npm, login item, and launcher. */
|
|
34
|
+
export declare function productionWebUpdateDeps(options: {
|
|
35
|
+
url: string;
|
|
36
|
+
home: string;
|
|
37
|
+
autostart: import("../services/web-autostart.js").WebAutostartOptions;
|
|
38
|
+
}): Promise<WebUpdateCommandDeps>;
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
// A fresh lock of another caller is left alone, and so is that caller's start.
|
|
95
|
+
if (!deps.takeStartLock()) {
|
|
96
|
+
deps.print("Another start is running (a host start or another web update). Wait a few seconds, then run om-memory-system web update again.");
|
|
97
|
+
return finish(1, "lock-held", "lock-held");
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
deps.writeRetireMarker(deps.now());
|
|
101
|
+
const owner = await readOwner(deps);
|
|
102
|
+
record.oldOwner = owner?.version ?? null;
|
|
103
|
+
// A web app before `replace` existed ignores the flag and steps aside only for a newer caller.
|
|
104
|
+
if (owner && !(await stepAside(deps))) {
|
|
105
|
+
const name = owner.version ? `OMMS ${owner.version}` : "Another web app";
|
|
106
|
+
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.`);
|
|
107
|
+
return finish(1, "stuck", "stuck");
|
|
108
|
+
}
|
|
109
|
+
// 3. Start one fresh web app on the newest copy.
|
|
110
|
+
const viaLoginItem = deps.loginItemInstalled() && deps.restartLoginItem();
|
|
111
|
+
record.start = viaLoginItem ? "login-item" : "launcher";
|
|
112
|
+
if (!viaLoginItem && !deps.startDetached()) {
|
|
113
|
+
deps.print("No OMMS copy to start. Run om-memory-system web to start the web app.");
|
|
114
|
+
return finish(1, "no-copy", "no-copy");
|
|
115
|
+
}
|
|
116
|
+
// 4. Wait for a web app that is not the old one.
|
|
117
|
+
const askedAside = new Set(owner ? [owner.instance] : []);
|
|
118
|
+
const deadline = deps.now() + FRESH_WAIT_MS;
|
|
119
|
+
while (deps.now() < deadline) {
|
|
120
|
+
const current = await readOwner(deps);
|
|
121
|
+
// A web app that hides its version cannot be told from an old waiter, so keep waiting.
|
|
122
|
+
if (current?.version && !askedAside.has(current.instance)) {
|
|
123
|
+
if ((compareVersions(current.version, deps.version) ?? 0) >= 0) {
|
|
124
|
+
record.served = current.version;
|
|
125
|
+
deps.print(`OMMS web app: ${deps.url} (version ${current.version})`);
|
|
126
|
+
return finish(0, "replaced");
|
|
127
|
+
}
|
|
128
|
+
// An older web app that waited for the port took it before the fresh one.
|
|
129
|
+
askedAside.add(current.instance);
|
|
130
|
+
await stepAside(deps);
|
|
131
|
+
}
|
|
132
|
+
await deps.sleep(POLL_MS);
|
|
133
|
+
}
|
|
134
|
+
deps.print(`No web app answered on ${deps.url} within ${FRESH_WAIT_MS / 1000} seconds. Run om-memory-system web status.`);
|
|
135
|
+
return finish(1, "no-answer", "no-answer");
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
deps.removeStartLock();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The real network, npm, login item, and launcher. */
|
|
142
|
+
export async function productionWebUpdateDeps(options) {
|
|
143
|
+
const [{ spawn }, { existsSync }, { join }, { AUTH_HEADER, getOrCreateAuthToken }, { packageVersion }, { latestNpmVersion }, { globalCommandVersion }, { runGlobalInstall }, { nodeInstallDeps }, { restartWebAutostart, webAutostartStatus }, { launcherPath, recordedCopy }, { ommsDir }, { nodeLockFs, processAlive, removeStartLockFor, startLockPath, takeStartLock }, { writeRetireMarker }, { log },] = await Promise.all([
|
|
144
|
+
import("node:child_process"),
|
|
145
|
+
import("node:fs"),
|
|
146
|
+
import("node:path"),
|
|
147
|
+
import("../services/auth-token.js"),
|
|
148
|
+
import("../services/package-version.js"),
|
|
149
|
+
import("../services/update-check.js"),
|
|
150
|
+
import("../services/global-version.js"),
|
|
151
|
+
import("../services/web-update.js"),
|
|
152
|
+
import("../services/web-update-runtime.js"),
|
|
153
|
+
import("../services/web-autostart.js"),
|
|
154
|
+
import("../services/runtime-record.js"),
|
|
155
|
+
import("../services/runtime-handoff.js"),
|
|
156
|
+
import("../services/web-ensure.js"),
|
|
157
|
+
import("../services/web-retire.js"),
|
|
158
|
+
import("../services/logger.js"),
|
|
159
|
+
]);
|
|
160
|
+
const dir = ommsDir(options.home);
|
|
161
|
+
const lockPath = startLockPath(options.home);
|
|
162
|
+
return {
|
|
163
|
+
url: options.url,
|
|
164
|
+
headers: { [AUTH_HEADER]: getOrCreateAuthToken() },
|
|
165
|
+
version: packageVersion(),
|
|
166
|
+
fetchFn: fetch,
|
|
167
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
168
|
+
now: () => Date.now(),
|
|
169
|
+
latest: () => latestNpmVersion(fetch),
|
|
170
|
+
// npm installs beside this Node.js, so read that install, not the first command on PATH.
|
|
171
|
+
globalVersion: () => globalCommandVersion({ find: () => null }).version,
|
|
172
|
+
install: (target) => runGlobalInstall(nodeInstallDeps(), target),
|
|
173
|
+
loginItemInstalled: () => webAutostartStatus(options.autostart).state === "installed",
|
|
174
|
+
restartLoginItem: () => restartWebAutostart(options.autostart),
|
|
175
|
+
startDetached: () => {
|
|
176
|
+
// The launcher picks the newest copy, the global install beside this Node.js included.
|
|
177
|
+
const launcher = launcherPath(dir);
|
|
178
|
+
const copy = recordedCopy(dir);
|
|
179
|
+
const script = existsSync(launcher)
|
|
180
|
+
? launcher
|
|
181
|
+
: copy
|
|
182
|
+
? join(copy.root, "dist", "cli", "index.js")
|
|
183
|
+
: null;
|
|
184
|
+
if (!script)
|
|
185
|
+
return false;
|
|
186
|
+
try {
|
|
187
|
+
spawn(process.execPath, [script, "web"], {
|
|
188
|
+
detached: true,
|
|
189
|
+
stdio: "ignore",
|
|
190
|
+
cwd: options.home,
|
|
191
|
+
windowsHide: true,
|
|
192
|
+
}).unref();
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
takeStartLock: () => takeStartLock({
|
|
200
|
+
lockPath,
|
|
201
|
+
lockFs: nodeLockFs,
|
|
202
|
+
pid: process.pid,
|
|
203
|
+
now: () => Date.now(),
|
|
204
|
+
pidAlive: processAlive,
|
|
205
|
+
}),
|
|
206
|
+
removeStartLock: () => removeStartLockFor(process.pid, lockPath),
|
|
207
|
+
writeRetireMarker: (before) => writeRetireMarker(before, { home: options.home }),
|
|
208
|
+
print: (line) => console.log(line),
|
|
209
|
+
log,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -74,6 +74,7 @@ export declare const nodeLockFs: LockFs;
|
|
|
74
74
|
export declare function renameRetrying(from: string, to: string, rename?: (from: string, to: string) => void, platform?: NodeJS.Platform, wait?: (ms: number) => void): void;
|
|
75
75
|
/** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
|
|
76
76
|
export declare function removeStartLockFor(pid: number, path?: string, lockFs?: LockFs): void;
|
|
77
|
+
export declare function processAlive(pid: number): boolean;
|
|
77
78
|
/** What taking the start lock needs; a test drives two callers through it step by step. */
|
|
78
79
|
export type LockDeps = Pick<EnsureDeps, "lockPath" | "lockFs" | "pid" | "now" | "pidAlive">;
|
|
79
80
|
/**
|
|
@@ -111,7 +111,7 @@ export function removeStartLockFor(pid, path = startLockPath(), lockFs = nodeLoc
|
|
|
111
111
|
/* An unreadable lock is stale and the next caller replaces it. */
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
-
function processAlive(pid) {
|
|
114
|
+
export function processAlive(pid) {
|
|
115
115
|
try {
|
|
116
116
|
process.kill(pid, 0);
|
|
117
117
|
return true;
|
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,8 @@ interface WebServerConfig {
|
|
|
26
26
|
apiToken?: string;
|
|
27
27
|
/** How long a stepped-aside server waits before it may take the port back. */
|
|
28
28
|
stepAsideHoldOffMs?: number;
|
|
29
|
+
/** Test seam: the retire marker's time, or null. Defaults to `~/.omms/web-retire.json`. */
|
|
30
|
+
readRetireMarker?: () => number | null;
|
|
29
31
|
}
|
|
30
32
|
type PowerAction = "stop" | "restart";
|
|
31
33
|
export declare class WebServer {
|
|
@@ -37,6 +39,8 @@ export declare class WebServer {
|
|
|
37
39
|
private onTakeoverCallback;
|
|
38
40
|
private onPortsExhaustedCallback;
|
|
39
41
|
private onStepAsideCallback;
|
|
42
|
+
/** `web update` retires waiting web apps that started before its marker. */
|
|
43
|
+
private readonly startedAt;
|
|
40
44
|
private onPowerActionCallback;
|
|
41
45
|
private webUpdate;
|
|
42
46
|
private powerActionRunning;
|
|
@@ -83,6 +87,10 @@ export declare class WebServer {
|
|
|
83
87
|
start(): Promise<void>;
|
|
84
88
|
private _start;
|
|
85
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;
|
|
86
94
|
private ownerIsNewer;
|
|
87
95
|
private stopHealthCheckLoop;
|
|
88
96
|
private attemptTakeover;
|
|
@@ -164,6 +164,8 @@ export class WebServer {
|
|
|
164
164
|
onTakeoverCallback = null;
|
|
165
165
|
onPortsExhaustedCallback = null;
|
|
166
166
|
onStepAsideCallback = null;
|
|
167
|
+
/** `web update` retires waiting web apps that started before its marker. */
|
|
168
|
+
startedAt = Date.now();
|
|
167
169
|
onPowerActionCallback = null;
|
|
168
170
|
webUpdate = null;
|
|
169
171
|
powerActionRunning = false;
|
|
@@ -490,16 +492,19 @@ export class WebServer {
|
|
|
490
492
|
const callerVersion = body && typeof body.version === "string"
|
|
491
493
|
? body.version.slice(0, 40)
|
|
492
494
|
: "";
|
|
495
|
+
// `web update` replaces a web app of any version; other callers must be newer.
|
|
496
|
+
const replace = Boolean(body && body.replace === true);
|
|
493
497
|
const record = (outcome) => log("Web server step-aside request", {
|
|
494
498
|
outcome,
|
|
495
499
|
ownVersion: packageVersion(),
|
|
496
500
|
callerVersion,
|
|
501
|
+
replace,
|
|
497
502
|
});
|
|
498
503
|
if (!isLoopbackAddress(remoteAddress)) {
|
|
499
504
|
record("refused_auth");
|
|
500
505
|
return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
|
|
501
506
|
}
|
|
502
|
-
if (!callerVersion || !isOlderVersion(packageVersion(), callerVersion)) {
|
|
507
|
+
if (!callerVersion || (!replace && !isOlderVersion(packageVersion(), callerVersion))) {
|
|
503
508
|
record("refused_not_newer");
|
|
504
509
|
return this.jsonResponse({ success: false, error: "Caller is not newer" }, 409);
|
|
505
510
|
}
|
|
@@ -572,24 +577,45 @@ export class WebServer {
|
|
|
572
577
|
if (this.healthCheckInterval) {
|
|
573
578
|
return;
|
|
574
579
|
}
|
|
575
|
-
this.healthCheckInterval = setInterval(
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
}
|
|
593
619
|
}
|
|
594
620
|
async ownerIsNewer() {
|
|
595
621
|
try {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import { WebUpdate } from "./web-update.js";
|
|
1
|
+
import { WebUpdate, type InstallDeps } from "./web-update.js";
|
|
2
2
|
/** The npm check and installer of a standalone web app, with the real process, npm, and clock. */
|
|
3
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;
|
|
@@ -12,18 +12,27 @@ export function startWebUpdate(restart, canRestart) {
|
|
|
12
12
|
fetch,
|
|
13
13
|
setInterval: (callback, ms) => setInterval(callback, ms),
|
|
14
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 {
|
|
15
29
|
execPath: process.execPath,
|
|
16
30
|
platform: process.platform,
|
|
17
|
-
exists: existsSync,
|
|
18
31
|
spawn: (command, args, options) => spawn(command, args, {
|
|
19
32
|
shell: options.shell,
|
|
20
33
|
stdio: ["ignore", "ignore", "pipe"],
|
|
21
34
|
windowsHide: true,
|
|
22
35
|
}),
|
|
23
|
-
// npm installs beside this Node.js, so read that install, not the first command on PATH.
|
|
24
|
-
globalVersion: () => globalCommandVersion({ find: () => null }).version,
|
|
25
|
-
restart,
|
|
26
|
-
canRestart,
|
|
27
36
|
// Resolves once npm has stopped. `taskkill /T` also stops npm under the cmd.exe
|
|
28
37
|
// wrapper that `shell: true` starts; the child's own exit confirms it.
|
|
29
38
|
killTree: (child) => new Promise((resolve) => {
|
|
@@ -40,8 +49,5 @@ export function startWebUpdate(restart, canRestart) {
|
|
|
40
49
|
}),
|
|
41
50
|
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
42
51
|
clearTimeout: (handle) => clearTimeout(handle),
|
|
43
|
-
|
|
44
|
-
});
|
|
45
|
-
update.start();
|
|
46
|
-
return update;
|
|
52
|
+
};
|
|
47
53
|
}
|
|
@@ -65,12 +65,23 @@ export declare class WebUpdate {
|
|
|
65
65
|
start(): void;
|
|
66
66
|
/** Read npm `latest`. A failed read keeps the last result. */
|
|
67
67
|
check(): Promise<void>;
|
|
68
|
-
/** The npm that installs into the prefix the launcher reads. */
|
|
69
|
-
private npmPath;
|
|
70
68
|
status(): UpdateStatus;
|
|
71
69
|
private canInstall;
|
|
72
70
|
/** Start the install and the restart. A request while one runs joins it. */
|
|
73
71
|
requestInstall(): InstallRequest;
|
|
74
72
|
private install;
|
|
75
|
-
private runNpm;
|
|
76
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>;
|