om-memory-system 4.4.0-next.65 → 4.4.0-next.69
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/README.md +8 -4
- package/bin/omms-launch.mjs +198 -0
- package/dist/adapters/claude-code/hook-client.d.ts +2 -0
- package/dist/adapters/claude-code/hook-client.js +27 -2
- package/dist/adapters/pi/extension.js +18 -8
- package/dist/cli/index.js +5 -0
- package/dist/cli/web-command.js +1 -59
- package/dist/index.js +15 -2
- package/dist/services/global-version.d.ts +11 -20
- package/dist/services/global-version.js +74 -44
- package/dist/services/runtime-handoff.d.ts +60 -0
- package/dist/services/runtime-handoff.js +134 -0
- package/dist/services/runtime-record.d.ts +47 -0
- package/dist/services/runtime-record.js +143 -0
- package/dist/services/web-autostart.d.ts +9 -7
- package/dist/services/web-autostart.js +33 -54
- package/dist/services/web-ensure.d.ts +11 -0
- package/dist/services/web-ensure.js +37 -5
- package/dist/services/web-handover.d.ts +22 -0
- package/dist/services/web-handover.js +62 -0
- package/dist/services/web-server.js +3 -2
- package/dist/web/assets/{index-D2Gueurd.js → index-D_e39BA6.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ It runs inside [OpenCode](https://opencode.ai) and the
|
|
|
17
17
|
Both share one memory per project, so a note written in one is available in
|
|
18
18
|
the other. Everything is stored locally on your machine.
|
|
19
19
|
|
|
20
|
-
Claude Code is also supported, through a plugin of hooks. It needs
|
|
21
|
-
|
|
20
|
+
Claude Code is also supported, through a plugin of hooks. It needs Node.js and
|
|
21
|
+
your own external API for capture. A global install is optional. See the
|
|
22
22
|
[Claude Code adapter](docs/claude-code-adapter.md) guide.
|
|
23
23
|
|
|
24
24
|
## What it does
|
|
@@ -81,8 +81,12 @@ pi install npm:om-memory-system
|
|
|
81
81
|
|
|
82
82
|
Restart Pi. You can install OMMS in both agents; they share the same memory.
|
|
83
83
|
|
|
84
|
-
**Terminal command (optional
|
|
85
|
-
|
|
84
|
+
**Terminal command (optional).** A global install puts the `om-memory-system`
|
|
85
|
+
command on your `PATH`. You do not need it. OMMS runs the newest copy it finds on
|
|
86
|
+
the machine, so updating one host is enough. A global install from 4.3 or earlier
|
|
87
|
+
needs one update. See
|
|
88
|
+
[Updating and upgrading](docs/upgrading.md#one-update-updates-every-host).
|
|
89
|
+
To install it:
|
|
86
90
|
|
|
87
91
|
```bash
|
|
88
92
|
npm i -g om-memory-system # or: bun add -g om-memory-system
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs the newest valid OMMS copy on the machine. It needs only Node.js and its
|
|
3
|
+
// built-in modules, so the Claude Code plugin (which has no `dist/`) and the
|
|
4
|
+
// login item can both run it.
|
|
5
|
+
//
|
|
6
|
+
// node omms-launch.mjs [--at-least-own-version] <om-memory-system arguments>
|
|
7
|
+
//
|
|
8
|
+
// Candidates: the copy named in ~/.omms/runtime.json, the global install beside
|
|
9
|
+
// the running Node.js, and the copy that holds this file. With
|
|
10
|
+
// `--at-least-own-version`, the version in the `package.json` beside this file is
|
|
11
|
+
// the minimum. When no candidate reaches it, the launcher runs
|
|
12
|
+
// `npx --yes om-memory-system@<that version>`.
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
21
|
+
|
|
22
|
+
function parse(version) {
|
|
23
|
+
const match = SEMVER.exec(String(version).trim());
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
return {
|
|
26
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
27
|
+
pre: match[4] ? match[4].split(".") : [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function comparePart(a, b) {
|
|
32
|
+
const aNumeric = /^\d+$/.test(a);
|
|
33
|
+
const bNumeric = /^\d+$/.test(b);
|
|
34
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
35
|
+
if (aNumeric) return -1;
|
|
36
|
+
if (bNumeric) return 1;
|
|
37
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* SemVer order, the same as `compareVersions` in `src/services/version-compare.ts`.
|
|
42
|
+
* It cannot import that file, because the plugin has no `dist/`;
|
|
43
|
+
* `tests/omms-launch.test.ts` keeps the two the same. Returns null when either
|
|
44
|
+
* version cannot be parsed.
|
|
45
|
+
*/
|
|
46
|
+
export function compareVersions(a, b) {
|
|
47
|
+
const left = parse(a);
|
|
48
|
+
const right = parse(b);
|
|
49
|
+
if (!left || !right) return null;
|
|
50
|
+
for (let i = 0; i < 3; i++) {
|
|
51
|
+
const diff = left.core[i] - right.core[i];
|
|
52
|
+
if (diff) return Math.sign(diff);
|
|
53
|
+
}
|
|
54
|
+
if (!left.pre.length || !right.pre.length) return right.pre.length - left.pre.length;
|
|
55
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
|
|
56
|
+
const l = left.pre[i];
|
|
57
|
+
const r = right.pre[i];
|
|
58
|
+
if (l === undefined) return -1;
|
|
59
|
+
if (r === undefined) return 1;
|
|
60
|
+
const diff = comparePart(l, r);
|
|
61
|
+
if (diff) return diff;
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A valid copy has the package name, a comparable version, and `dist/cli/index.js`. */
|
|
67
|
+
function readCopy(root) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
70
|
+
if (pkg.name !== "om-memory-system" || typeof pkg.version !== "string") return null;
|
|
71
|
+
if (compareVersions(pkg.version, pkg.version) === null) return null;
|
|
72
|
+
return existsSync(join(root, "dist", "cli", "index.js"))
|
|
73
|
+
? { root, version: pkg.version }
|
|
74
|
+
: null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The package folder of a global npm install beside the given Node.js binary. */
|
|
81
|
+
export function globalPackageRoot(execPath, platform = process.platform) {
|
|
82
|
+
if (!execPath) return null;
|
|
83
|
+
if (platform === "win32") return join(dirname(execPath), "node_modules", "om-memory-system");
|
|
84
|
+
return join(dirname(dirname(execPath)), "lib", "node_modules", "om-memory-system");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every place a global install can be for this Node.js binary. Homebrew runs Node
|
|
89
|
+
* from a versioned `Cellar/node/<version>` folder, but npm installs under the
|
|
90
|
+
* prefix above `Cellar`, so that prefix counts too.
|
|
91
|
+
*/
|
|
92
|
+
export function globalPackageRoots(execPath, platform = process.platform) {
|
|
93
|
+
const direct = globalPackageRoot(execPath, platform);
|
|
94
|
+
if (!direct) return [];
|
|
95
|
+
const brew = /^(.*)[\\/]Cellar[\\/][^\\/]+[\\/][^\\/]+[\\/]bin[\\/][^\\/]+$/.exec(execPath);
|
|
96
|
+
return brew ? [direct, join(brew[1], "lib", "node_modules", "om-memory-system")] : [direct];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The record's copy, the global installs, and the own copy, in that order. Invalid ones are null. */
|
|
100
|
+
export function findCandidates({ dir, execPath, ownRoot }) {
|
|
101
|
+
let recorded = null;
|
|
102
|
+
try {
|
|
103
|
+
const record = JSON.parse(readFileSync(join(dir, "runtime.json"), "utf8"));
|
|
104
|
+
if (record && typeof record.root === "string") recorded = readCopy(record.root);
|
|
105
|
+
} catch {
|
|
106
|
+
/* No record, or an unreadable one: the other candidates still work. */
|
|
107
|
+
}
|
|
108
|
+
const globals = globalPackageRoots(execPath).map((root) => readCopy(root));
|
|
109
|
+
return [recorded, ...globals, ownRoot ? readCopy(ownRoot) : null];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The newest candidate. On a tie the first one wins. With `minVersion`, a newest
|
|
114
|
+
* candidate that is older asks for `npx` at that version instead.
|
|
115
|
+
*/
|
|
116
|
+
export function chooseCopy(candidates, minVersion) {
|
|
117
|
+
let best = null;
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
if (!candidate) continue;
|
|
120
|
+
if (!best || (compareVersions(candidate.version, best.version) ?? 0) > 0) best = candidate;
|
|
121
|
+
}
|
|
122
|
+
const wanted = minVersion && parse(minVersion) ? minVersion : null;
|
|
123
|
+
if (wanted && (!best || (compareVersions(best.version, wanted) ?? 0) < 0))
|
|
124
|
+
return { kind: "npx", version: wanted };
|
|
125
|
+
return best ? { kind: "copy", copy: best } : { kind: "none" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseLauncherArgs(argv) {
|
|
129
|
+
return argv[0] === "--at-least-own-version"
|
|
130
|
+
? { atLeastOwnVersion: true, args: argv.slice(1) }
|
|
131
|
+
: { atLeastOwnVersion: false, args: [...argv] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Run a command with inherited input and output, and resolve to its exit code. */
|
|
135
|
+
function spawnInherit(command, args, options = {}) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const child = spawn(command, args, { stdio: "inherit", ...options });
|
|
138
|
+
child.on("error", () => resolve(1));
|
|
139
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The version in the `package.json` beside this file, or null when there is none. */
|
|
144
|
+
function ownVersion(ownRoot) {
|
|
145
|
+
try {
|
|
146
|
+
const pkg = JSON.parse(readFileSync(join(ownRoot, "package.json"), "utf8"));
|
|
147
|
+
return pkg.name === "om-memory-system" && typeof pkg.version === "string" ? pkg.version : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Choose a copy and run it. Resolves to the exit code. */
|
|
154
|
+
export async function runLauncher(argv, options = {}) {
|
|
155
|
+
const dir = options.dir ?? join(homedir(), ".omms");
|
|
156
|
+
const execPath = options.execPath ?? process.execPath;
|
|
157
|
+
const ownRoot = options.ownRoot ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
158
|
+
const run = options.spawn ?? spawnInherit;
|
|
159
|
+
const { atLeastOwnVersion, args } = parseLauncherArgs(argv);
|
|
160
|
+
const choice = chooseCopy(
|
|
161
|
+
findCandidates({ dir, execPath, ownRoot }),
|
|
162
|
+
atLeastOwnVersion ? ownVersion(ownRoot) : null
|
|
163
|
+
);
|
|
164
|
+
if (choice.kind === "copy")
|
|
165
|
+
return run(execPath, [join(choice.copy.root, "dist", "cli", "index.js"), ...args]);
|
|
166
|
+
if (choice.kind === "npx") {
|
|
167
|
+
// npm installs npx as a .cmd wrapper on Windows, which only a shell can run.
|
|
168
|
+
const windows = process.platform === "win32";
|
|
169
|
+
// Run from the temp folder. npx prefers a project in the current folder, so a project
|
|
170
|
+
// named om-memory-system (such as this repository) would win over the npm package.
|
|
171
|
+
// The hook input carries the real folder, so nothing else needs the current folder.
|
|
172
|
+
return run(
|
|
173
|
+
windows ? "npx.cmd" : "npx",
|
|
174
|
+
["--yes", `om-memory-system@${choice.version}`, ...args],
|
|
175
|
+
{
|
|
176
|
+
shell: windows,
|
|
177
|
+
cwd: tmpdir(),
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
process.stderr.write("omms-launch: no OMMS copy found. Install om-memory-system.\n");
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** True when this file is the program Node.js started. A path that cannot be resolved is not. */
|
|
186
|
+
function runDirectly() {
|
|
187
|
+
try {
|
|
188
|
+
const entry = process.argv[1];
|
|
189
|
+
return Boolean(entry) && realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
190
|
+
} catch {
|
|
191
|
+
// A loader or wrapper can leave argv[1] pointing at a file that is not there.
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (runDirectly()) {
|
|
197
|
+
runLauncher(process.argv.slice(2)).then((code) => process.exit(code));
|
|
198
|
+
}
|
|
@@ -33,6 +33,8 @@ export interface ClaudeHookOptions {
|
|
|
33
33
|
sleep?: (ms: number) => Promise<void>;
|
|
34
34
|
log?: (message: string, data: Record<string, unknown>) => void | Promise<void>;
|
|
35
35
|
readToken?: () => Promise<string>;
|
|
36
|
+
/** The version of the newest recorded OMMS copy, or null. Tests inject it. */
|
|
37
|
+
recordedVersion?: () => string | null;
|
|
36
38
|
resolveRuntime?: () => Promise<string | null>;
|
|
37
39
|
loadSettings?: () => Promise<HookServerSettings>;
|
|
38
40
|
/** The `om-memory-system` script that `web` is run from. */
|
|
@@ -182,7 +182,7 @@ async function runSteps(event, deps, result) {
|
|
|
182
182
|
if (!settings.enabled)
|
|
183
183
|
return "server-disabled";
|
|
184
184
|
const budget = BUDGETS[event];
|
|
185
|
-
const ready = await ensureServer(settings, budget.startMs, deps, result);
|
|
185
|
+
const ready = await ensureServer(event, settings, budget.startMs, deps, result);
|
|
186
186
|
if (ready !== "ok")
|
|
187
187
|
return ready;
|
|
188
188
|
const headers = {
|
|
@@ -230,13 +230,38 @@ async function runSteps(event, deps, result) {
|
|
|
230
230
|
result.outputBytes = Buffer.byteLength(output);
|
|
231
231
|
return "ok";
|
|
232
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* The newest recorded copy's version with the local token and, when the web app has
|
|
235
|
+
* a browser password, Basic Auth. Undefined when no copy is recorded.
|
|
236
|
+
*/
|
|
237
|
+
async function replaceOlderFor(settings, deps) {
|
|
238
|
+
try {
|
|
239
|
+
const handoff = await import("../../services/runtime-handoff.js");
|
|
240
|
+
const version = deps.recordedVersion ? deps.recordedVersion() : handoff.newestRecordedVersion();
|
|
241
|
+
if (!version)
|
|
242
|
+
return undefined;
|
|
243
|
+
return {
|
|
244
|
+
version,
|
|
245
|
+
headers: await handoff.replaceHeaders(await deps.readToken(), {
|
|
246
|
+
webServerAuthPassword: settings.basicAuth?.password,
|
|
247
|
+
webServerAuthUsername: settings.basicAuth?.username,
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Without a target, any running web app is used.
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
233
256
|
/** Use a running web app, or start one through the shared rule and wait for it within the budget. */
|
|
234
|
-
async function ensureServer(settings, budgetMs, deps, result) {
|
|
257
|
+
async function ensureServer(event, settings, budgetMs, deps, result) {
|
|
235
258
|
const { ensureWebApp } = await import("../../services/web-ensure.js");
|
|
236
259
|
const answer = await ensureWebApp({
|
|
237
260
|
settings: { enabled: settings.enabled, baseUrl: settings.baseUrl },
|
|
238
261
|
budgetMs,
|
|
239
262
|
wait: true,
|
|
263
|
+
// Only a new session replaces an older web app. A prompt or a stop uses any running one.
|
|
264
|
+
...(event === "session-start" ? { replaceOlder: await replaceOlderFor(settings, deps) } : {}),
|
|
240
265
|
deps: {
|
|
241
266
|
fetch: deps.fetch,
|
|
242
267
|
now: deps.now,
|
|
@@ -105,6 +105,10 @@ export default function ommsPiExtension(pi) {
|
|
|
105
105
|
// Runs even with tracing off, so turning it off does not leave old traces behind.
|
|
106
106
|
pruneTraces(CONFIG);
|
|
107
107
|
captureState = createPiCaptureState();
|
|
108
|
+
// Record this copy first: the login item and the other hosts run the newest recorded copy.
|
|
109
|
+
void import("../../services/runtime-handoff.js")
|
|
110
|
+
.then(({ registerOwnCopy }) => registerOwnCopy())
|
|
111
|
+
.catch((error) => log("Pi runtime record failed", { code: error instanceof Error ? error.name : "unknown" }));
|
|
108
112
|
if (CONFIG.webServerAutoStart !== undefined &&
|
|
109
113
|
process.env.OMMS_DISABLE_WEB_AUTOSTART !== "1") {
|
|
110
114
|
void import("../../services/web-autostart.js")
|
|
@@ -118,15 +122,21 @@ export default function ommsPiExtension(pi) {
|
|
|
118
122
|
void Promise.all([
|
|
119
123
|
import("../../services/web-ensure.js"),
|
|
120
124
|
import("../../services/web-api-auth.js"),
|
|
125
|
+
import("../../services/runtime-handoff.js"),
|
|
121
126
|
])
|
|
122
|
-
.then(([{ ensureWebApp }, { webServerUrl }]) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
.then(async ([{ ensureWebApp }, { webServerUrl }, { hostReplaceOlder }]) => {
|
|
128
|
+
// A web app older than the newest recorded copy steps aside for it.
|
|
129
|
+
const replaceOlder = await hostReplaceOlder(CONFIG).catch(() => undefined);
|
|
130
|
+
return ensureWebApp({
|
|
131
|
+
settings: {
|
|
132
|
+
enabled: CONFIG.webServerEnabled,
|
|
133
|
+
baseUrl: webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort),
|
|
134
|
+
},
|
|
135
|
+
budgetMs: 0,
|
|
136
|
+
wait: false,
|
|
137
|
+
...(replaceOlder ? { replaceOlder } : {}),
|
|
138
|
+
});
|
|
139
|
+
})
|
|
130
140
|
.catch((error) => log("Pi web app start failed", {
|
|
131
141
|
code: error instanceof Error ? error.name : "unknown",
|
|
132
142
|
}));
|
package/dist/cli/index.js
CHANGED
|
@@ -38,6 +38,11 @@ export function parseImportArgs(argv) {
|
|
|
38
38
|
return { host, args };
|
|
39
39
|
}
|
|
40
40
|
export async function runCli(argv) {
|
|
41
|
+
// An older copy runs the newest recorded copy instead; otherwise it records itself.
|
|
42
|
+
const { handOffOrRegister } = await import("../services/runtime-handoff.js");
|
|
43
|
+
const handedOff = await handOffOrRegister(argv);
|
|
44
|
+
if (handedOff !== null)
|
|
45
|
+
return handedOff;
|
|
41
46
|
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
42
47
|
const { packageVersion } = await import("../services/package-version.js");
|
|
43
48
|
console.log(packageVersion());
|
package/dist/cli/web-command.js
CHANGED
|
@@ -1,63 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
|
|
3
|
-
const POLL_LIMIT = 40;
|
|
4
|
-
const REQUEST_TIMEOUT_MS = 2_000;
|
|
5
|
-
/** The version an OMMS web app reports on `url`, or null when none answers with one. */
|
|
6
|
-
async function readWebVersion(fetchFn, url, headers) {
|
|
7
|
-
try {
|
|
8
|
-
const response = await fetchFn(`${url}/api/settings/version`, {
|
|
9
|
-
headers,
|
|
10
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
11
|
-
});
|
|
12
|
-
if (!response.ok)
|
|
13
|
-
return null;
|
|
14
|
-
const body = (await response.json());
|
|
15
|
-
return typeof body.running === "string" ? body.running : null;
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
async function portAnswers(fetchFn, url) {
|
|
22
|
-
try {
|
|
23
|
-
await fetchFn(`${url}/api/health`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/** Ask an older OMMS web app on the configured port to step aside, and wait for the port. */
|
|
31
|
-
async function negotiateOwner(ctx) {
|
|
32
|
-
const { compareVersions } = await import("../services/version-compare.js");
|
|
33
|
-
const owner = await readWebVersion(ctx.fetchFn, ctx.url, ctx.headers);
|
|
34
|
-
const order = owner === null ? null : compareVersions(owner, ctx.version);
|
|
35
|
-
// A version that cannot be placed is never stopped.
|
|
36
|
-
if (owner === null || order === null)
|
|
37
|
-
return { kind: "none" };
|
|
38
|
-
if (order === 0)
|
|
39
|
-
return { kind: "same", owner };
|
|
40
|
-
if (order > 0)
|
|
41
|
-
return { kind: "newer", owner };
|
|
42
|
-
try {
|
|
43
|
-
const reply = await ctx.fetchFn(`${ctx.url}/api/web/step-aside`, {
|
|
44
|
-
method: "POST",
|
|
45
|
-
headers: { ...ctx.headers, "content-type": "application/json" },
|
|
46
|
-
body: JSON.stringify({ version: ctx.version }),
|
|
47
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
48
|
-
});
|
|
49
|
-
if (reply.status !== 202)
|
|
50
|
-
return { kind: "stuck", owner };
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return { kind: "stuck", owner };
|
|
54
|
-
}
|
|
55
|
-
// If the old app is slow to stop, the login item still takes the port later.
|
|
56
|
-
for (let i = 0; i < POLL_LIMIT && (await portAnswers(ctx.fetchFn, ctx.url)); i++) {
|
|
57
|
-
await ctx.sleep(POLL_MS);
|
|
58
|
-
}
|
|
59
|
-
return { kind: "handed" };
|
|
60
|
-
}
|
|
2
|
+
import { negotiateOwner, POLL_LIMIT, POLL_MS, readWebVersion, } from "../services/web-handover.js";
|
|
61
3
|
/** Print which version serves the port after `web install`. */
|
|
62
4
|
async function announceHandover(ctx, handover) {
|
|
63
5
|
const { url, port, version } = ctx;
|
package/dist/index.js
CHANGED
|
@@ -183,6 +183,12 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
183
183
|
initConfigWithLegacyMigration(directory);
|
|
184
184
|
// Runs even with tracing off, so turning it off does not leave old traces behind.
|
|
185
185
|
pruneTraces(CONFIG);
|
|
186
|
+
// Record this copy first: the login item and the other hosts run the newest recorded copy.
|
|
187
|
+
void import("./services/runtime-handoff.js")
|
|
188
|
+
.then(({ registerOwnCopy }) => registerOwnCopy())
|
|
189
|
+
.catch((error) => log("OpenCode runtime record failed", {
|
|
190
|
+
code: error instanceof Error ? error.name : "unknown",
|
|
191
|
+
}));
|
|
186
192
|
if (CONFIG.webServerAutoStart !== undefined && process.env.OMMS_DISABLE_WEB_AUTOSTART !== "1") {
|
|
187
193
|
void import("./services/web-autostart.js")
|
|
188
194
|
.then(({ reconcileWebAutostart }) => reconcileWebAutostart(CONFIG))
|
|
@@ -300,12 +306,19 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
300
306
|
.catch(() => { });
|
|
301
307
|
};
|
|
302
308
|
// The shared web app serves the page. This plugin starts it only when none runs.
|
|
303
|
-
void Promise.all([
|
|
304
|
-
.
|
|
309
|
+
void Promise.all([
|
|
310
|
+
import("./services/web-ensure.js"),
|
|
311
|
+
import("./services/web-api-auth.js"),
|
|
312
|
+
import("./services/runtime-handoff.js"),
|
|
313
|
+
])
|
|
314
|
+
.then(async ([{ ensureWebApp }, { webServerUrl }, { hostReplaceOlder }]) => {
|
|
305
315
|
const baseUrl = webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort);
|
|
316
|
+
// A web app older than the newest recorded copy steps aside for it.
|
|
317
|
+
const replaceOlder = await hostReplaceOlder(CONFIG).catch(() => undefined);
|
|
306
318
|
return ensureWebApp({
|
|
307
319
|
settings: { enabled: CONFIG.webServerEnabled, baseUrl },
|
|
308
320
|
budgetMs: 10_000,
|
|
321
|
+
...(replaceOlder ? { replaceOlder } : {}),
|
|
309
322
|
}).then((result) => {
|
|
310
323
|
if (result === "running" || result === "started") {
|
|
311
324
|
showToast(`Web UI available at ${baseUrl}`, "info", 3000);
|
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
export interface GlobalCommandVersion {
|
|
2
|
-
/** The version the global
|
|
2
|
+
/** The version in the global install's `package.json`, or null when not installed or unreadable. */
|
|
3
3
|
version: string | null;
|
|
4
4
|
path: string | null;
|
|
5
|
-
error?: "
|
|
5
|
+
error?: "failed";
|
|
6
6
|
}
|
|
7
|
-
type Runner = (file: string, args: string[], options: {
|
|
8
|
-
timeout: number;
|
|
9
|
-
windowsHide: boolean;
|
|
10
|
-
shell: false;
|
|
11
|
-
}) => Promise<string>;
|
|
12
7
|
/** The first `om-memory-system` on PATH; on Windows npm installs a `.cmd` wrapper. */
|
|
13
8
|
export declare function findGlobalCommand(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
|
|
14
9
|
/**
|
|
15
|
-
* The command
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* The global command's version, read from its install's `package.json`. The
|
|
11
|
+
* command does not run: after a hand-off it would print the newest copy's version.
|
|
12
|
+
* The login item runs with a minimal PATH, so with no command on PATH the install
|
|
13
|
+
* beside the running Node.js counts too.
|
|
18
14
|
*/
|
|
19
|
-
export declare function versionInvocation(path: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): {
|
|
20
|
-
file: string;
|
|
21
|
-
args: string[];
|
|
22
|
-
};
|
|
23
|
-
/** Look up the global command's version, at most once every 10 minutes. */
|
|
24
15
|
export declare function globalCommandVersion(options?: {
|
|
25
|
-
now?: () => number;
|
|
26
|
-
runner?: Runner;
|
|
27
16
|
find?: () => string | null;
|
|
28
17
|
platform?: NodeJS.Platform;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export
|
|
18
|
+
execPath?: string;
|
|
19
|
+
}): GlobalCommandVersion;
|
|
20
|
+
export type GlobalRelation = "missing" | "same" | "older" | "newer" | "unknown";
|
|
21
|
+
/** How the global install compares with the running OMMS. */
|
|
22
|
+
export declare function globalRelation(running: string, global: string | null): GlobalRelation;
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
const TIMEOUT_MS = 3_000;
|
|
5
|
-
const CACHE_MS = 10 * 60_000;
|
|
6
|
-
const run = (file, args, options) => new Promise((resolve, reject) => {
|
|
7
|
-
execFile(file, args, { ...options, encoding: "utf8" }, (error, stdout) => error ? reject(error) : resolve(String(stdout)));
|
|
8
|
-
});
|
|
1
|
+
import { accessSync, constants, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { compareVersions } from "./version-compare.js";
|
|
9
4
|
function executable(path) {
|
|
10
5
|
try {
|
|
11
6
|
accessSync(path, constants.X_OK);
|
|
@@ -31,48 +26,83 @@ export function findGlobalCommand(env = process.env, platform = process.platform
|
|
|
31
26
|
}
|
|
32
27
|
return null;
|
|
33
28
|
}
|
|
29
|
+
/** The package folder of the install the command belongs to, or null. */
|
|
30
|
+
function packageFolder(command, platform) {
|
|
31
|
+
// npm's Windows layout: the wrapper sits beside node_modules.
|
|
32
|
+
if (platform === "win32")
|
|
33
|
+
return join(dirname(command), "node_modules", "om-memory-system");
|
|
34
|
+
let dir = dirname(realpathSync(command));
|
|
35
|
+
for (;;) {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
38
|
+
if (pkg.name === "om-memory-system")
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* Try the parent. */
|
|
43
|
+
}
|
|
44
|
+
const parent = dirname(dir);
|
|
45
|
+
if (parent === dir)
|
|
46
|
+
return null;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
34
50
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
51
|
+
* Where a global npm install can be for a Node.js binary, the same places the
|
|
52
|
+
* launcher looks. Homebrew runs Node from a versioned `Cellar/node/<version>`
|
|
53
|
+
* folder, but npm installs under the prefix above `Cellar`.
|
|
38
54
|
*/
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
function installsBesideRuntime(execPath, platform) {
|
|
56
|
+
if (!execPath)
|
|
57
|
+
return [];
|
|
58
|
+
if (platform === "win32")
|
|
59
|
+
return [join(dirname(execPath), "node_modules", "om-memory-system")];
|
|
60
|
+
const direct = join(dirname(dirname(execPath)), "lib", "node_modules", "om-memory-system");
|
|
61
|
+
const brew = /^(.*)[\\/]Cellar[\\/][^\\/]+[\\/][^\\/]+[\\/]bin[\\/][^\\/]+$/.exec(execPath);
|
|
62
|
+
return brew ? [direct, join(brew[1], "lib", "node_modules", "om-memory-system")] : [direct];
|
|
45
63
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
/** The version in an install's `package.json`, or null when it is not an OMMS install. */
|
|
65
|
+
function installVersion(folder) {
|
|
66
|
+
const pkg = JSON.parse(readFileSync(join(folder, "package.json"), "utf8"));
|
|
67
|
+
return pkg.name === "om-memory-system" && typeof pkg.version === "string" ? pkg.version : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The global command's version, read from its install's `package.json`. The
|
|
71
|
+
* command does not run: after a hand-off it would print the newest copy's version.
|
|
72
|
+
* The login item runs with a minimal PATH, so with no command on PATH the install
|
|
73
|
+
* beside the running Node.js counts too.
|
|
74
|
+
*/
|
|
75
|
+
export function globalCommandVersion(options = {}) {
|
|
76
|
+
const platform = options.platform ?? process.platform;
|
|
52
77
|
const path = (options.find ?? (() => findGlobalCommand()))();
|
|
53
|
-
let value;
|
|
54
78
|
if (!path) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
const version = /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
|
|
66
|
-
value = version ? { version, path } : { version: null, path, error: "failed" };
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
const killed = error.killed;
|
|
70
|
-
value = { version: null, path, error: killed ? "timeout" : "failed" };
|
|
79
|
+
for (const folder of installsBesideRuntime(options.execPath ?? process.execPath, platform)) {
|
|
80
|
+
try {
|
|
81
|
+
const version = installVersion(folder);
|
|
82
|
+
if (version)
|
|
83
|
+
return { version, path: folder };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* No install there. */
|
|
87
|
+
}
|
|
71
88
|
}
|
|
89
|
+
return { version: null, path: null };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const folder = packageFolder(path, platform);
|
|
93
|
+
const version = folder ? installVersion(folder) : null;
|
|
94
|
+
return version ? { version, path } : { version: null, path, error: "failed" };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { version: null, path, error: "failed" };
|
|
72
98
|
}
|
|
73
|
-
cached = { at: now, value };
|
|
74
|
-
return value;
|
|
75
99
|
}
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
/** How the global install compares with the running OMMS. */
|
|
101
|
+
export function globalRelation(running, global) {
|
|
102
|
+
if (global === null)
|
|
103
|
+
return "missing";
|
|
104
|
+
const order = compareVersions(global, running);
|
|
105
|
+
if (order === null)
|
|
106
|
+
return "unknown";
|
|
107
|
+
return order === 0 ? "same" : order < 0 ? "older" : "newer";
|
|
78
108
|
}
|