om-memory-system 4.4.0-next.67 → 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.
@@ -0,0 +1,60 @@
1
+ import { type RegisterResult, type RuntimeCopy, type RuntimeLog } from "./runtime-record.js";
2
+ export declare const ommsDir: (home?: string) => string;
3
+ /** The package folder of the running copy, found from source or `dist/`. */
4
+ export declare function ownPackageRoot(from?: string): string | null;
5
+ /**
6
+ * Record the running copy when it is the newest. A failure is logged with a
7
+ * code and never thrown, so it cannot stop a session or a command.
8
+ */
9
+ export declare function registerOwnCopy(options?: {
10
+ dir?: string;
11
+ root?: string | null;
12
+ log?: RuntimeLog;
13
+ }): RegisterResult;
14
+ export interface HandoffOptions {
15
+ dir?: string;
16
+ ownRoot?: string | null;
17
+ ownVersion?: string;
18
+ env?: NodeJS.ProcessEnv;
19
+ log?: RuntimeLog;
20
+ /** Injected so tests run no process. Resolves to the exit code, or null when the copy cannot start. */
21
+ spawn?: (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise<number | null>;
22
+ }
23
+ /** The recorded copy this command must hand off to, or null. */
24
+ export declare function handoffTarget(options: HandoffOptions & {
25
+ ownVersion: string;
26
+ }): RuntimeCopy | null;
27
+ /**
28
+ * The first step of the `om-memory-system` command. When the record names a
29
+ * newer valid copy, run that copy's command with the same arguments and return
30
+ * its exit code. Otherwise record this copy and return null, and the command
31
+ * continues with its own code. `OMMS_HANDED_OFF` stops a loop when two records disagree.
32
+ */
33
+ export declare function handOffOrRegister(argv: string[], options?: HandoffOptions): Promise<number | null>;
34
+ /** The version of the newest valid recorded copy, or null. This is the version a launcher start runs. */
35
+ export declare function newestRecordedVersion(options?: {
36
+ dir?: string;
37
+ log?: RuntimeLog;
38
+ }): string | null;
39
+ /** What `ensureWebApp` needs to replace an older running web app. */
40
+ export interface ReplaceOlder {
41
+ version: string;
42
+ headers: Record<string, string>;
43
+ }
44
+ /** The browser password settings a web app request needs, as `CONFIG` holds them. */
45
+ export interface BasicAuthSettings {
46
+ webServerAuthPassword?: string;
47
+ webServerAuthUsername?: string;
48
+ }
49
+ /**
50
+ * Headers for the version lookup and the step-aside request. They carry the local
51
+ * token, and Basic Auth when a browser password is set: the web server checks Basic
52
+ * Auth first and refuses every other API request without it.
53
+ */
54
+ export declare function replaceHeaders(token: string, settings?: BasicAuthSettings): Promise<Record<string, string>>;
55
+ /**
56
+ * For a host start: record this copy, then name the version a running web app
57
+ * must reach. A web app older than it is asked to step aside. Without a
58
+ * recorded copy there is no target, so any running web app is used.
59
+ */
60
+ export declare function hostReplaceOlder(settings?: BasicAuthSettings): Promise<ReplaceOlder | undefined>;
@@ -0,0 +1,134 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { recordedCopy, registerCopy, } from "./runtime-record.js";
7
+ import { compareVersions } from "./version-compare.js";
8
+ // The newest-copy rule for the terminal command and the hosts: an old command
9
+ // hands off to a newer recorded copy, and every copy that runs records itself.
10
+ // Like `runtime-record.ts`, it imports no config.
11
+ export const ommsDir = (home = homedir()) => join(home, ".omms");
12
+ /** The package folder of the running copy, found from source or `dist/`. */
13
+ export function ownPackageRoot(from = import.meta.url) {
14
+ let dir = dirname(fileURLToPath(from));
15
+ for (;;) {
16
+ try {
17
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
18
+ if (pkg.name === "om-memory-system")
19
+ return dir;
20
+ }
21
+ catch {
22
+ /* Try the parent. */
23
+ }
24
+ const parent = dirname(dir);
25
+ if (parent === dir)
26
+ return null;
27
+ dir = parent;
28
+ }
29
+ }
30
+ /** Log a code through the shared logger. A host calls this after its config is loaded. */
31
+ const logCode = (code, data) => {
32
+ void import("./logger.js")
33
+ .then(({ log }) => log("Runtime record", { code, ...data }))
34
+ .catch(() => { });
35
+ };
36
+ /**
37
+ * Record the running copy when it is the newest. A failure is logged with a
38
+ * code and never thrown, so it cannot stop a session or a command.
39
+ */
40
+ export function registerOwnCopy(options = {}) {
41
+ const root = options.root === undefined ? ownPackageRoot() : options.root;
42
+ if (!root)
43
+ return "skipped";
44
+ // Tests set this so that starting the real CLI never writes the developer's real record.
45
+ if (options.dir === undefined && process.env.OMMS_DISABLE_RUNTIME_RECORD === "1")
46
+ return "skipped";
47
+ try {
48
+ return registerCopy({ dir: options.dir ?? ommsDir(), root, log: options.log ?? logCode });
49
+ }
50
+ catch {
51
+ return "failed";
52
+ }
53
+ }
54
+ /**
55
+ * Run the child and resolve to its exit code, or null when it could not start.
56
+ * Standard input, output, and error flow through.
57
+ */
58
+ function spawnCopy(command, args, env) {
59
+ return new Promise((resolve) => {
60
+ const child = nodeSpawn(command, args, { stdio: "inherit", env });
61
+ child.on("error", () => resolve(null));
62
+ child.on("close", (code) => resolve(code ?? 1));
63
+ });
64
+ }
65
+ /** The recorded copy this command must hand off to, or null. */
66
+ export function handoffTarget(options) {
67
+ const env = options.env ?? process.env;
68
+ if (env.OMMS_NO_HANDOFF === "1" || env.OMMS_HANDED_OFF)
69
+ return null;
70
+ const copy = recordedCopy(options.dir ?? ommsDir(), options.log ?? logCode);
71
+ if (!copy || copy.root === options.ownRoot)
72
+ return null;
73
+ return (compareVersions(copy.version, options.ownVersion) ?? 0) > 0 ? copy : null;
74
+ }
75
+ /**
76
+ * The first step of the `om-memory-system` command. When the record names a
77
+ * newer valid copy, run that copy's command with the same arguments and return
78
+ * its exit code. Otherwise record this copy and return null, and the command
79
+ * continues with its own code. `OMMS_HANDED_OFF` stops a loop when two records disagree.
80
+ */
81
+ export async function handOffOrRegister(argv, options = {}) {
82
+ const ownRoot = options.ownRoot === undefined ? ownPackageRoot() : options.ownRoot;
83
+ let ownVersion = options.ownVersion;
84
+ if (ownVersion === undefined) {
85
+ const { packageVersion } = await import("./package-version.js");
86
+ ownVersion = packageVersion();
87
+ }
88
+ const target = handoffTarget({ ...options, ownRoot, ownVersion });
89
+ if (target) {
90
+ const run = options.spawn ?? spawnCopy;
91
+ const code = await run(process.execPath, [join(target.root, "dist", "cli", "index.js"), ...argv], { ...(options.env ?? process.env), OMMS_HANDED_OFF: "1" });
92
+ if (code !== null)
93
+ return code;
94
+ }
95
+ registerOwnCopy({ dir: options.dir, root: ownRoot, log: options.log });
96
+ return null;
97
+ }
98
+ /** The version of the newest valid recorded copy, or null. This is the version a launcher start runs. */
99
+ export function newestRecordedVersion(options = {}) {
100
+ return recordedCopy(options.dir ?? ommsDir(), options.log ?? logCode)?.version ?? null;
101
+ }
102
+ /**
103
+ * Headers for the version lookup and the step-aside request. They carry the local
104
+ * token, and Basic Auth when a browser password is set: the web server checks Basic
105
+ * Auth first and refuses every other API request without it.
106
+ */
107
+ export async function replaceHeaders(token, settings = {}) {
108
+ const { AUTH_HEADER } = await import("./auth-token.js");
109
+ const headers = { [AUTH_HEADER]: token };
110
+ const password = (settings.webServerAuthPassword ?? "").trim();
111
+ if (!password)
112
+ return headers;
113
+ const { WebAuth } = await import("./web-auth.js");
114
+ const { username } = new WebAuth({
115
+ password,
116
+ username: settings.webServerAuthUsername,
117
+ }).getConfig();
118
+ headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
119
+ return headers;
120
+ }
121
+ /**
122
+ * For a host start: record this copy, then name the version a running web app
123
+ * must reach. A web app older than it is asked to step aside. Without a
124
+ * recorded copy there is no target, so any running web app is used.
125
+ */
126
+ export async function hostReplaceOlder(settings = {}) {
127
+ registerOwnCopy();
128
+ const version = newestRecordedVersion();
129
+ if (!version)
130
+ return undefined;
131
+ const { getOrCreateAuthToken } = await import("./auth-token.js");
132
+ // The web app runs on this machine, so the local token file is enough.
133
+ return { version, headers: await replaceHeaders(getOrCreateAuthToken(), settings) };
134
+ }
@@ -0,0 +1,47 @@
1
+ export interface RuntimeRecord {
2
+ root: string;
3
+ version: string;
4
+ updatedAt: string;
5
+ }
6
+ /** A valid copy: its package folder and the version in its `package.json`. */
7
+ export interface RuntimeCopy {
8
+ root: string;
9
+ version: string;
10
+ }
11
+ /** A log call carries a code and identifiers only, never a path or file text. */
12
+ export type RuntimeLog = (code: string, data: Record<string, unknown>) => void;
13
+ export type RegisterResult = "written" | "kept" | "skipped" | "failed";
14
+ export declare const recordPath: (dir: string) => string;
15
+ export declare const launcherPath: (dir: string) => string;
16
+ /**
17
+ * The version of a valid OMMS copy, or null. A copy is valid when its
18
+ * `package.json` names `om-memory-system`, its version can be compared, and it
19
+ * has `dist/cli/index.js`.
20
+ */
21
+ export declare function copyVersion(root: string): string | null;
22
+ /** The record as written, or null. A missing record is normal; any other failure logs a code. */
23
+ export declare function readRuntimeRecord(dir: string, log?: RuntimeLog): RuntimeRecord | null;
24
+ /** The copy the record names, when it is still valid. The version is read from its `package.json`. */
25
+ export declare function recordedCopy(dir: string, log?: RuntimeLog): RuntimeCopy | null;
26
+ /**
27
+ * Make sure a launcher exists at `~/.omms/bin`. An existing launcher stays, so an
28
+ * older copy never replaces it. When it is missing, the first source with a
29
+ * `bin/omms-launch.mjs` provides it. Returns whether a launcher exists afterwards.
30
+ */
31
+ export declare function ensureLauncher(options: {
32
+ dir: string;
33
+ sources: readonly (string | null | undefined)[];
34
+ log?: RuntimeLog;
35
+ }): boolean;
36
+ /**
37
+ * Record this copy when the record is missing, names a copy that is no longer
38
+ * valid, or names an older version. An equal version keeps the record. The copy
39
+ * that holds the record also keeps the launcher at `~/.omms/bin`. A failure is
40
+ * logged with a code and never thrown.
41
+ */
42
+ export declare function registerCopy(options: {
43
+ dir: string;
44
+ root: string;
45
+ log?: RuntimeLog;
46
+ now?: () => Date;
47
+ }): RegisterResult;
@@ -0,0 +1,143 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { compareVersions } from "./version-compare.js";
4
+ export const recordPath = (dir) => join(dir, "runtime.json");
5
+ export const launcherPath = (dir) => join(dir, "bin", "omms-launch.mjs");
6
+ /**
7
+ * The version of a valid OMMS copy, or null. A copy is valid when its
8
+ * `package.json` names `om-memory-system`, its version can be compared, and it
9
+ * has `dist/cli/index.js`.
10
+ */
11
+ export function copyVersion(root) {
12
+ try {
13
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
14
+ if (pkg.name !== "om-memory-system" || typeof pkg.version !== "string")
15
+ return null;
16
+ if (compareVersions(pkg.version, pkg.version) === null)
17
+ return null;
18
+ return existsSync(join(root, "dist", "cli", "index.js")) ? pkg.version : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /** The record as written, or null. A missing record is normal; any other failure logs a code. */
25
+ export function readRuntimeRecord(dir, log) {
26
+ let text;
27
+ try {
28
+ text = readFileSync(recordPath(dir), "utf8");
29
+ }
30
+ catch (error) {
31
+ if (error.code !== "ENOENT")
32
+ log?.("runtime-record-read-failed", { reason: "read" });
33
+ return null;
34
+ }
35
+ try {
36
+ const value = JSON.parse(text);
37
+ if (!value ||
38
+ typeof value.root !== "string" ||
39
+ typeof value.version !== "string" ||
40
+ typeof value.updatedAt !== "string")
41
+ throw new Error("shape");
42
+ return { root: value.root, version: value.version, updatedAt: value.updatedAt };
43
+ }
44
+ catch {
45
+ log?.("runtime-record-read-failed", { reason: "parse" });
46
+ return null;
47
+ }
48
+ }
49
+ /** The copy the record names, when it is still valid. The version is read from its `package.json`. */
50
+ export function recordedCopy(dir, log) {
51
+ const record = readRuntimeRecord(dir, log);
52
+ if (!record)
53
+ return null;
54
+ const version = copyVersion(record.root);
55
+ return version ? { root: record.root, version } : null;
56
+ }
57
+ /** Write a file in one step, so a reader never finds a partial file. */
58
+ function writeAtomic(path, write) {
59
+ const temp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
60
+ try {
61
+ write(temp);
62
+ renameSync(temp, path);
63
+ }
64
+ finally {
65
+ rmSync(temp, { force: true });
66
+ }
67
+ }
68
+ function sameFile(a, b) {
69
+ try {
70
+ return readFileSync(a).equals(readFileSync(b));
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** Put the copy's launcher at the fixed path when it is missing or different. */
77
+ function placeLauncher(dir, root, log) {
78
+ const source = join(root, "bin", "omms-launch.mjs");
79
+ if (!existsSync(source))
80
+ return;
81
+ const target = launcherPath(dir);
82
+ if (sameFile(source, target))
83
+ return;
84
+ try {
85
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
86
+ writeAtomic(target, (temp) => copyFileSync(source, temp));
87
+ }
88
+ catch {
89
+ log?.("runtime-launcher-write-failed", {});
90
+ }
91
+ }
92
+ /**
93
+ * Make sure a launcher exists at `~/.omms/bin`. An existing launcher stays, so an
94
+ * older copy never replaces it. When it is missing, the first source with a
95
+ * `bin/omms-launch.mjs` provides it. Returns whether a launcher exists afterwards.
96
+ */
97
+ export function ensureLauncher(options) {
98
+ const target = launcherPath(options.dir);
99
+ if (existsSync(target))
100
+ return true;
101
+ for (const root of options.sources) {
102
+ if (root)
103
+ placeLauncher(options.dir, root, options.log);
104
+ if (existsSync(target))
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ /**
110
+ * Record this copy when the record is missing, names a copy that is no longer
111
+ * valid, or names an older version. An equal version keeps the record. The copy
112
+ * that holds the record also keeps the launcher at `~/.omms/bin`. A failure is
113
+ * logged with a code and never thrown.
114
+ */
115
+ export function registerCopy(options) {
116
+ const { dir, root, log } = options;
117
+ const own = copyVersion(root);
118
+ if (!own)
119
+ return "skipped";
120
+ try {
121
+ const current = recordedCopy(dir, log);
122
+ const newer = !current || (compareVersions(own, current.version) ?? 0) > 0;
123
+ if (!newer) {
124
+ // The recorded copy owns the launcher, so it repairs a missing or damaged one.
125
+ if (current?.root === root)
126
+ placeLauncher(dir, root, log);
127
+ return "kept";
128
+ }
129
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
130
+ const record = {
131
+ root,
132
+ version: own,
133
+ updatedAt: (options.now?.() ?? new Date()).toISOString(),
134
+ };
135
+ writeAtomic(recordPath(dir), (temp) => writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }));
136
+ placeLauncher(dir, root, log);
137
+ return "written";
138
+ }
139
+ catch {
140
+ log?.("runtime-record-write-failed", {});
141
+ return "failed";
142
+ }
143
+ }
@@ -1,10 +1,13 @@
1
1
  import { CONFIG } from "../config.js";
2
- type State = "installed" | "not-installed" | "unsupported" | "no-runtime" | "no-package";
2
+ type State = "installed" | "not-installed" | "unsupported" | "no-runtime" | "no-package" | "no-launcher";
3
3
  export type WebAutostartStatus = {
4
4
  state: State;
5
5
  path?: string;
6
6
  runtime?: string;
7
+ /** The copy the launcher starts now: the newest of the record, the global install, and this copy. */
7
8
  packagePath?: string;
9
+ /** The fixed launcher the item runs, `~/.omms/bin/omms-launch.mjs`. */
10
+ launcher?: string;
8
11
  command?: string;
9
12
  };
10
13
  export interface WebAutostartOptions {
@@ -12,19 +15,18 @@ export interface WebAutostartOptions {
12
15
  platform?: string;
13
16
  runtime?: string | null;
14
17
  packageRoot?: string | null;
18
+ /** The running copy, a launcher source when the chosen copy has none. Defaults to `packageRoot`, then this copy. */
19
+ ownRoot?: string | null;
15
20
  systemctlAvailable?: boolean;
16
21
  run?: (command: string, args: string[]) => void;
17
22
  start?: boolean;
18
23
  }
19
24
  /** OpenCode's executable is not a JavaScript runtime. */
20
25
  export declare function resolveWebRuntime(execPath?: string, platform?: NodeJS.Platform): string | null;
21
- /** The package folder a written login item runs, read back from its command line. */
22
- export declare function itemPackageRoot(file: string): string | null;
23
26
  /**
24
- * The newest OMMS copy among this one, the global install beside the runtime,
25
- * and the copy the item already runs. A host starts OMMS from its own cache,
26
- * which can be older than the global install; that copy must not downgrade
27
- * the login item.
27
+ * The newest valid OMMS copy among the candidates. On equal versions the first
28
+ * wins, so a host's cached copy goes last. This is the copy the launcher starts,
29
+ * and it matches the launcher's own choice.
28
30
  */
29
31
  export declare function preferredPackageRoot(candidates: readonly (string | null | undefined)[]): string | null;
30
32
  /** Read the login item's state without changing any system files. */
@@ -6,6 +6,7 @@ import { basename, dirname, join, delimiter, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { CONFIG } from "../config.js";
8
8
  import { log } from "./logger.js";
9
+ import { copyVersion, ensureLauncher, launcherPath, recordedCopy, } from "./runtime-record.js";
9
10
  import { compareVersions } from "./version-compare.js";
10
11
  const MARKER = "OMMS login item";
11
12
  const NAME = "io.github.cmdaltctr.omms.web";
@@ -70,53 +71,18 @@ function packageRoot() {
70
71
  dir = parent;
71
72
  }
72
73
  }
73
- function packageVersionAt(root) {
74
- try {
75
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
76
- if (pkg.name !== "om-memory-system" || !existsSync(join(root, "dist", "cli", "index.js"))) {
77
- return null;
78
- }
79
- return typeof pkg.version === "string" ? pkg.version : null;
80
- }
81
- catch {
82
- return null;
83
- }
84
- }
85
- /** The package folder a written login item runs, read back from its command line. */
86
- export function itemPackageRoot(file) {
87
- try {
88
- const content = readFileSync(file, "utf8");
89
- const match = /([^"<>\r\n]*?)[\\/]dist[\\/]cli[\\/]index\.js/.exec(content);
90
- const path = match?.[1]?.replace(/^.*?<string>/, "").trim();
91
- if (!path)
92
- return null;
93
- // Only plists escape XML. Decode ampersands last to preserve literal entity text.
94
- return content.includes("<plist")
95
- ? path
96
- .replaceAll("&quot;", '"')
97
- .replaceAll("&apos;", "'")
98
- .replaceAll("&lt;", "<")
99
- .replaceAll("&gt;", ">")
100
- .replaceAll("&amp;", "&")
101
- : path;
102
- }
103
- catch {
104
- return null;
105
- }
106
- }
107
74
  /**
108
- * The newest OMMS copy among this one, the global install beside the runtime,
109
- * and the copy the item already runs. A host starts OMMS from its own cache,
110
- * which can be older than the global install; that copy must not downgrade
111
- * the login item.
75
+ * The newest valid OMMS copy among the candidates. On equal versions the first
76
+ * wins, so a host's cached copy goes last. This is the copy the launcher starts,
77
+ * and it matches the launcher's own choice.
112
78
  */
113
79
  export function preferredPackageRoot(candidates) {
114
80
  let best = null;
115
81
  for (const root of candidates) {
116
82
  if (!root)
117
83
  continue;
118
- const version = packageVersionAt(root);
119
- if (!version || compareVersions(version, version) === null)
84
+ const version = copyVersion(root);
85
+ if (!version)
120
86
  continue;
121
87
  if (!best || (compareVersions(version, best.version) ?? 0) > 0)
122
88
  best = { root, version };
@@ -145,15 +111,20 @@ function details(options) {
145
111
  const platform = options.platform ?? process.platform;
146
112
  const path = itemPath(home, platform);
147
113
  const runtime = options.runtime === undefined ? resolveWebRuntime() : options.runtime;
148
- const own = options.packageRoot === undefined ? packageRoot() : options.packageRoot;
149
- const current = path && existsSync(path) ? itemPackageRoot(path) : null;
114
+ const dir = join(home, ".omms");
115
+ const own = options.ownRoot !== undefined
116
+ ? options.ownRoot
117
+ : options.packageRoot !== undefined
118
+ ? options.packageRoot
119
+ : packageRoot();
120
+ const recorded = recordedCopy(dir, logCode)?.root ?? null;
150
121
  const root = options.packageRoot === undefined
151
- ? // On equal versions the first wins, so a host's cached copy goes last.
152
- preferredPackageRoot([globalPackageRoot(runtime ?? null, platform), current, own])
153
- : own;
122
+ ? preferredPackageRoot([recorded, globalPackageRoot(runtime ?? null, platform), own])
123
+ : options.packageRoot;
154
124
  const supported = platform !== "linux" || (options.systemctlAvailable ?? Boolean(executable("systemctl")));
155
- return { home, platform, path, runtime, root, supported };
125
+ return { home, platform, path, runtime, root, own, recorded, dir, supported };
156
126
  }
127
+ const logCode = (code, data) => log("Runtime record", { code, ...data });
157
128
  const xml = (value) => value
158
129
  .replaceAll("&", "&amp;")
159
130
  .replaceAll("<", "&lt;")
@@ -161,21 +132,21 @@ const xml = (value) => value
161
132
  .replaceAll('"', "&quot;");
162
133
  const systemd = (value) => `"${value.replaceAll("%", "%%").replaceAll('"', '\\"')}"`;
163
134
  const windows = (value) => `"${value.replaceAll("%", "%%")}"`;
164
- function itemContent(platform, runtime, cli) {
135
+ function itemContent(platform, runtime, launcher) {
165
136
  if (platform === "darwin")
166
137
  return `<?xml version="1.0" encoding="UTF-8"?>
167
138
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
168
139
  <plist version="1.0"><dict>
169
140
  <!-- ${MARKER} -->
170
141
  <key>Label</key><string>${NAME}</string>
171
- <key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(cli)}</string><string>web</string><string>--login-item</string></array>
142
+ <key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(launcher)}</string><string>web</string><string>--login-item</string></array>
172
143
  <key>RunAtLoad</key><true/><key>KeepAlive</key><false/>
173
144
  <key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string>
174
145
  </dict></plist>\n`;
175
146
  if (platform === "linux")
176
147
  return `# ${MARKER}
177
- [Unit]\nDescription=OMMS web app\n[Service]\nType=simple\nExecStart=${systemd(runtime)} ${systemd(cli)} web --login-item\n[Install]\nWantedBy=default.target\n`;
178
- return `@echo off\r\nrem ${MARKER}\r\nstart "" /min ${windows(runtime)} ${windows(cli)} web --login-item\r\n`;
148
+ [Unit]\nDescription=OMMS web app\n[Service]\nType=simple\nExecStart=${systemd(runtime)} ${systemd(launcher)} web --login-item\n[Install]\nWantedBy=default.target\n`;
149
+ return `@echo off\r\nrem ${MARKER}\r\nstart "" /min ${windows(runtime)} ${windows(launcher)} web --login-item\r\n`;
179
150
  }
180
151
  function owned(path) {
181
152
  return readFileSync(path, "utf8").includes(MARKER);
@@ -188,15 +159,16 @@ function runCommand(options, home, command, args) {
188
159
  }
189
160
  /** Read the login item's state without changing any system files. */
190
161
  export function webAutostartStatus(options = {}) {
191
- const { path, runtime, root, supported } = details(options);
162
+ const { path, runtime, root, dir, supported } = details(options);
192
163
  if (!path || !supported)
193
164
  return { state: "unsupported" };
194
165
  if (!runtime)
195
166
  return { state: "no-runtime", path };
196
167
  if (!root)
197
168
  return { state: "no-package", path, runtime };
169
+ const launcher = launcherPath(dir);
198
170
  if (!existsSync(path))
199
- return { state: "not-installed", path, runtime, packagePath: root };
171
+ return { state: "not-installed", path, runtime, packagePath: root, launcher };
200
172
  if (!owned(path))
201
173
  throw new Error("Login item at the OMMS path is not owned by OMMS");
202
174
  return {
@@ -204,6 +176,7 @@ export function webAutostartStatus(options = {}) {
204
176
  path,
205
177
  runtime,
206
178
  packagePath: root,
179
+ launcher,
207
180
  command: readFileSync(path, "utf8"),
208
181
  };
209
182
  }
@@ -214,9 +187,15 @@ export function installWebAutostart(options = {}) {
214
187
  status.state === "no-runtime" ||
215
188
  status.state === "no-package")
216
189
  return status;
217
- const { home, platform, path, runtime, root } = details(options);
190
+ const { home, platform, path, runtime, root, own, recorded, dir } = details(options);
191
+ // The item runs a fixed launcher, so the launcher must exist before the item does.
192
+ if (!ensureLauncher({ dir, sources: [root, own, recorded], log: logCode })) {
193
+ // A valid package exists. Only the launcher is missing, so say that.
194
+ logCode("launcher-missing", {});
195
+ return { state: "no-launcher", path: path, runtime: runtime };
196
+ }
218
197
  const file = path;
219
- const content = itemContent(platform, runtime, join(root, "dist", "cli", "index.js"));
198
+ const content = itemContent(platform, runtime, launcherPath(dir));
220
199
  mkdirSync(dirname(file), { recursive: true });
221
200
  const changed = !existsSync(file) || readFileSync(file, "utf8") !== content;
222
201
  if (changed) {
@@ -37,6 +37,8 @@ export interface EnsureDeps {
37
37
  resolveRuntime: () => Promise<string | null> | string | null;
38
38
  /** The `om-memory-system` script that `web` is run from. */
39
39
  cliScript: string;
40
+ /** `~/.omms/bin/omms-launch.mjs` when it exists. `web` then starts through it, on the newest recorded copy. */
41
+ launcherScript: string | null;
40
42
  lockPath: string;
41
43
  lockFs: LockFs;
42
44
  pidAlive: (pid: number) => boolean;
@@ -51,6 +53,15 @@ export interface EnsureOptions {
51
53
  budgetMs: number;
52
54
  /** `false` starts the web app and returns at once (session starts). Default `true`. */
53
55
  wait?: boolean;
56
+ /**
57
+ * Replace a running web app older than `version`: ask it to step aside, then
58
+ * start as for an empty port. `headers` are the local token headers the
59
+ * step-aside route needs. Without it, any running OMMS web app is used.
60
+ */
61
+ replaceOlder?: {
62
+ version: string;
63
+ headers: Record<string, string>;
64
+ };
54
65
  deps?: Partial<EnsureDeps>;
55
66
  }
56
67
  /** A lock older than this is stale. Also the longest a background start holds it. */