viewable-one 0.0.0-stage → 0.12.1

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,976 @@
1
+ // THE LOGIN DAEMON: one interface, three implementations, chosen by platform.
2
+ //
3
+ // Brief 19 installed ONE thing on the machine - a LaunchAgent - and that was
4
+ // the whole of "install once, never keep a terminal open" (CONTEXT.md, "a
5
+ // person runs the runner", retired 2026-09-06 by ruling 1). Brief 21 asks the
6
+ // same of Windows, and the rulings of 2026-09-07 say what that means:
7
+ //
8
+ // 1A Windows gets a Task Scheduler per-user `onlogon` task (`schtasks`),
9
+ // and it is chosen BECAUSE IT IS QUERYABLE: `doctor` keeps its two
10
+ // halves (is the thing installed, and is it actually running) only if
11
+ // the OS can be asked the first half. A Startup-folder shortcut cannot
12
+ // answer that question, and a Windows service cannot see the logged-in
13
+ // user's agent credentials.
14
+ // 6C WSL counts as well as native Windows, and `doctor` names which one it
15
+ // is running in ("macOS", "Windows", "WSL (Linux)"). WSL is Linux, so
16
+ // saying yes to it is saying yes to a THIRD implementation: a systemd
17
+ // user unit. Where `systemctl --user` is not there at all - an older
18
+ // WSL with no systemd - the row says so and names the fallback rather
19
+ // than pretending, because a lie about the daemon is a session that
20
+ // never starts.
21
+ //
22
+ // The launchd implementation and its macOS tests use this shared daemon
23
+ // interface directly. The former launchagent.mjs compatibility wrapper is
24
+ // retired. All three implementations follow the same rules:
25
+ //
26
+ // - THE EXEC IS INJECTED. Every test asserts the argv sequence and the file
27
+ // content, and NOTHING in the suite runs launchctl, schtasks or
28
+ // systemctl on the machine running it.
29
+ // - GLYTCH_HOME MOVES THE FILE. A test cannot write into
30
+ // ~/Library/LaunchAgents or ~/.config/systemd/user even by accident.
31
+ // - ONE ESCAPE ENV, GLYTCH_NO_DAEMON_INSTALL, honoured by all three: the
32
+ // file is written and NOTHING is registered or loaded. GLYTCH_NO_LAUNCHCTL
33
+ // keeps working as an alias (2026-09-07: it is what brief 19 ruling 7
34
+ // named, it is set in the CLI suite and it may be set in his shell, so it
35
+ // is retired as a NAME and not as a behaviour).
36
+ //
37
+ // THE PATH TRAP TRAVELS. On macOS a launchd job's PATH is a short system
38
+ // default with no Homebrew and no npm global bin, so the plist carries the
39
+ // shell's PATH captured at `glytch init` (see renderPlist). Brief 21's audit
40
+ // said a scheduled task inherits the logon environment and so needs no
41
+ // capture - true of the environment a task STARTS with, and no help at all
42
+ // when the user installs Homebrew-for-Windows, nvm or a new npm prefix after
43
+ // logon and the daemon keeps the old one until the next reboot. So all three
44
+ // capture, all three re-check, and all three refresh as a ONE-WAY UNION
45
+ // (lib/sync.mjs): the same trap is covered in the same words on every
46
+ // platform, and there is one story to tell rather than three.
47
+
48
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
49
+ import { readFileSync } from "node:fs";
50
+ import { execFile } from "node:child_process";
51
+ import { homedir } from "node:os";
52
+ import { fileURLToPath } from "node:url";
53
+ import { dirname, join, win32 } from "node:path";
54
+ import { glytchHome } from "./registry.mjs";
55
+ import { logDir, logPath } from "./logs.mjs";
56
+
57
+ /** One name on all three platforms: the launchd label, the scheduled task
58
+ * name and the systemd unit are the same string, so a person reading any of
59
+ * the three OS tools sees what the docs call it. */
60
+ export const DAEMON_LABEL = "ai.glytch.runner";
61
+ export const PLIST_NAME = `${DAEMON_LABEL}.plist`;
62
+ export const LAUNCHER_NAME = `${DAEMON_LABEL}.cmd`;
63
+ export const TASK_XML_NAME = `${DAEMON_LABEL}.xml`;
64
+ export const UNIT_NAME = `${DAEMON_LABEL}.service`;
65
+
66
+ /** Which escape is set, by name, or null. GLYTCH_NO_DAEMON_INSTALL is the
67
+ * one brief 21 names; GLYTCH_NO_LAUNCHCTL is brief 19's and still works. */
68
+ export const noDaemonInstall = (env = process.env) =>
69
+ (env.VIEWABLE_NO_DAEMON_INSTALL ?? env.GLYTCH_NO_DAEMON_INSTALL) ? "GLYTCH_NO_DAEMON_INSTALL" : (env.VIEWABLE_NO_LAUNCHCTL ?? env.GLYTCH_NO_LAUNCHCTL) ? "GLYTCH_NO_LAUNCHCTL" : null;
70
+
71
+ /** /proc/version, or "". The WSL probe: a WSL kernel says "microsoft" in it,
72
+ * and a bare Linux kernel does not. Read synchronously and injectable, so
73
+ * platformKind stays a pure function of two inputs in every test. */
74
+ export const readProcVersion = () => {
75
+ try {
76
+ return readFileSync("/proc/version", "utf8");
77
+ } catch {
78
+ return "";
79
+ }
80
+ };
81
+
82
+ /** "macos" | "windows" | "wsl" | "linux". Ruling 6C: BOTH native Windows and
83
+ * WSL count, and doctor names which one it is in - so the two Linuxes are
84
+ * told apart here rather than merged, even though they install the same
85
+ * systemd unit. */
86
+ export function platformKind({ platform = process.platform, procVersion } = {}) {
87
+ if (platform === "darwin") return "macos";
88
+ if (platform === "win32") return "windows";
89
+ const text = procVersion ?? (platform === "linux" ? readProcVersion() : "");
90
+ return /microsoft/i.test(String(text)) ? "wsl" : "linux";
91
+ }
92
+
93
+ /** What doctor's first row prints. Ruling 6C's three words, plus the plain
94
+ * Linux that the same systemd implementation serves. */
95
+ export const PLATFORM_LABEL = {
96
+ macos: "macOS",
97
+ windows: "Windows",
98
+ wsl: "WSL (Linux)",
99
+ linux: "Linux",
100
+ };
101
+
102
+ /** The glytch dispatcher, as an absolute path.
103
+ *
104
+ * The daemon runs `node <this file> runner watch` rather than bare `glytch`,
105
+ * and that is not a shortcut around ruling 2's one name - it is the same
106
+ * command with one less thing to resolve. launchd would have to find `glytch`
107
+ * on a PATH it does not have yet (the PATH is set by the very plist being
108
+ * loaded), and a global npm bin is a symlink to exactly this file anyway. On
109
+ * Windows the same argument is stronger: the global bin is `glytch.cmd`, and
110
+ * a `.cmd` is exactly what Node refuses to spawn without a shell. */
111
+ export const glytchScriptPath = () => fileURLToPath(new URL("../glytch.mjs", import.meta.url));
112
+
113
+ /** The script path the INSTALLED daemon file on this machine actually runs,
114
+ * or null - never glytchScriptPath() above, which is what THIS running
115
+ * package would install, not what a plist, launcher or unit written by some
116
+ * earlier `viewable init` still points at. Null covers both "there is no file"
117
+ * (impl.read already returns null, and the plist/scheduled-task/systemd row
118
+ * says so) and "the file is there but does not carry the shape we render" -
119
+ * doctor's daemon-version row treats both the same way: unknown, not a
120
+ * second FAIL for a fact the row above it already reported.
121
+ *
122
+ * GLYTCH_HOME moves the file exactly the way impl.read does, and nothing
123
+ * here runs launchctl, schtasks or systemctl - the same two rules every
124
+ * other function in this module follows. */
125
+ export async function daemonScriptPath({
126
+ env = process.env,
127
+ platform = process.platform,
128
+ procVersion,
129
+ impl = loginDaemon({ env, platform, procVersion }),
130
+ } = {}) {
131
+ const text = await impl.read(env);
132
+ return text === null ? null : impl.programScriptPath(text);
133
+ }
134
+
135
+ /** The version of the package a daemon script path belongs to, read from the
136
+ * package.json beside it - the script is `<pkg>/glytch.mjs`, so
137
+ * `<pkg>/package.json`, the same layout packageVersion() (lib/update.mjs)
138
+ * reads for the package running doctor. Never throws: a stale or since-
139
+ * evicted npx cache, or a script path with nothing next to it, is data for
140
+ * the row to call "unknown", not a crash. */
141
+ export async function daemonPackageVersion(scriptPath) {
142
+ if (!scriptPath) return null;
143
+ try {
144
+ const text = await readFile(join(dirname(scriptPath), "package.json"), "utf8");
145
+ const version = JSON.parse(text).version;
146
+ return typeof version === "string" ? version : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ const exists = async (path) => {
153
+ try {
154
+ await stat(path);
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ };
160
+
161
+ /** The default runner for one of the three OS tools. Never throws: a non-zero
162
+ * exit is data.
163
+ *
164
+ * THE ESCAPE WRITES THE FILE AND REGISTERS NOTHING. It exists for two callers
165
+ * and both are real: the test suite, which must never touch launchd, Task
166
+ * Scheduler or systemd on the machine running it, and ruling 7 of brief 19 -
167
+ * "the LaunchAgent goes on his Mac only after the build is finished, by his
168
+ * word at that moment". So `viewable init` can be run end to end today, leave
169
+ * the file on disk, and load nothing until he says so. The skip is REPORTED,
170
+ * never silent: the row says the daemon was not loaded. */
171
+ export const makeDaemonExec = (tool) => (args, env = process.env) => {
172
+ const escape = noDaemonInstall(env);
173
+ if (escape) {
174
+ return Promise.resolve({
175
+ ok: false,
176
+ code: 0,
177
+ stdout: "",
178
+ stderr: `${tool} ${args.join(" ")} was NOT run: ${escape} is set.`,
179
+ });
180
+ }
181
+ return new Promise((resolve) => {
182
+ execFile(tool, args, { timeout: 10_000, windowsHide: true }, (error, stdout, stderr) =>
183
+ resolve({
184
+ ok: !error,
185
+ code: error?.code ?? 0,
186
+ stdout: String(stdout ?? ""),
187
+ stderr: String(stderr ?? ""),
188
+ }),
189
+ );
190
+ });
191
+ };
192
+
193
+ export const execLaunchctl = makeDaemonExec("launchctl");
194
+ export const execSchtasks = makeDaemonExec("schtasks");
195
+ export const execSystemctl = makeDaemonExec("systemctl");
196
+
197
+ /** Write the file if it is absent, or when `force` says so.
198
+ *
199
+ * IF ABSENT IS THE WHOLE RULE (ruling 5's sync: put back what is missing
200
+ * without touching what is there). A plist, a launcher or a unit someone has
201
+ * edited by hand - a different node, an extra variable, a nice+20 - is
202
+ * theirs, and `init` run again must not quietly undo it. `force` exists for
203
+ * the one caller that means it: a `doctor` that found the captured PATH
204
+ * gone. */
205
+ async function writeIfAbsent(file, text, force) {
206
+ if ((await exists(file)) && !force) return false;
207
+ await mkdir(join(file, ".."), { recursive: true });
208
+ await writeFile(file, text, "utf8");
209
+ return true;
210
+ }
211
+
212
+ /** The log directory, made BEFORE the daemon is registered.
213
+ *
214
+ * WHY (2026-09-11, his Windows machine, the first `viewable init` that
215
+ * registered without elevation): every launcher below redirects the runner's
216
+ * output into `~/.glytch/logs/runner.log`, and the only code that ever
217
+ * created that directory was the runner's own logger (logs.mjs) - which is
218
+ * Node, and Node is what the redirect has to succeed BEFORE. cmd.exe opens
219
+ * the `>>` target before it starts the command, cannot create a parent
220
+ * directory to do it, fails the whole line with "The system cannot find the
221
+ * path specified" and exits 1: Task Scheduler reports the task ran and
222
+ * exited at once, doctor says "daemon loaded - OK" (the registration is
223
+ * there) and "heartbeat - never" (the process never was), and nothing on
224
+ * disk says why. systemd's `append:` has the same rule. launchd starts the
225
+ * job with or without its StandardOutPath, which is why the Mac never saw
226
+ * it - but it loses the log the same way. So install makes the directory
227
+ * itself, once, for all three; the .cmd launcher also carries an `if not
228
+ * exist` line, because a per-user onlogon task outlives a `rm -r
229
+ * ~/.glytch/logs` and a launcher that dies at the next logon is this bug
230
+ * back with a delay. */
231
+ async function ensureLogDir(env) {
232
+ await mkdir(logDir(env), { recursive: true });
233
+ }
234
+
235
+ // ---------------------------------------------------------------- launchd --
236
+
237
+ const xml = (text) =>
238
+ String(text)
239
+ .replace(/&/g, "&amp;")
240
+ .replace(/</g, "&lt;")
241
+ .replace(/>/g, "&gt;");
242
+
243
+ /** Where the plist goes. ~/Library/LaunchAgents on a real machine; under
244
+ * GLYTCH_HOME when it is set, which is what keeps the suite off this Mac. */
245
+ export const plistPath = (env = process.env) =>
246
+ (env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim()
247
+ ? join(glytchHome(env), "LaunchAgents", PLIST_NAME)
248
+ : join(homedir(), "Library", "LaunchAgents", PLIST_NAME);
249
+
250
+ /** The plist, rendered. Kept a pure function of its inputs so the test reads
251
+ * the same string launchd would. */
252
+ export function renderPlist({
253
+ nodePath = process.execPath,
254
+ scriptPath = glytchScriptPath(),
255
+ path = process.env.PATH ?? "",
256
+ out = logPath(),
257
+ home,
258
+ } = {}) {
259
+ const env = [
260
+ // The switch. A daemon is still not consent by itself: this is the same
261
+ // variable a person would have typed, written down once at install
262
+ // instead of on every command line.
263
+ ["PM_SPAWN_RUNNER", "on"],
264
+ // The captured shell PATH. See the launchd PATH trap above.
265
+ ["PATH", path],
266
+ ...(home ? [["GLYTCH_HOME", home]] : []),
267
+ ];
268
+ return `<?xml version="1.0" encoding="UTF-8"?>
269
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
270
+ <plist version="1.0">
271
+ <dict>
272
+ <key>Label</key>
273
+ <string>${xml(DAEMON_LABEL)}</string>
274
+ <key>ProgramArguments</key>
275
+ <array>
276
+ <string>${xml(nodePath)}</string>
277
+ <string>${xml(scriptPath)}</string>
278
+ <string>runner</string>
279
+ <string>watch</string>
280
+ </array>
281
+ <key>EnvironmentVariables</key>
282
+ <dict>
283
+ ${env.map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`).join("\n")}
284
+ </dict>
285
+ <key>RunAtLoad</key>
286
+ <true/>
287
+ <key>KeepAlive</key>
288
+ <true/>
289
+ <key>StandardOutPath</key>
290
+ <string>${xml(out)}</string>
291
+ <key>StandardErrorPath</key>
292
+ <string>${xml(out)}</string>
293
+ </dict>
294
+ </plist>
295
+ `;
296
+ }
297
+
298
+ /** The PATH a plist on disk carries, or null. `doctor` and the sync compare
299
+ * it against the shell's, because a PATH captured before Homebrew was
300
+ * installed is a daemon that cannot find the engine the app is offering. */
301
+ export function capturedPlistPath(text) {
302
+ const match = /<key>PATH<\/key>\s*<string>([^<]*)<\/string>/.exec(String(text ?? ""));
303
+ return match ? match[1] : null;
304
+ }
305
+
306
+ /** The script path a plist's ProgramArguments carries - the SECOND
307
+ * `<string>`, after the node binary - or null. The counterpart of
308
+ * capturedPlistPath: same file, the OTHER value doctor's daemon-version row
309
+ * needs (brief 31, Move 5). A plist that is there and loaded is not proof it
310
+ * runs THIS package: his Mac's pointed at an `~/.npm/_npx` cache running
311
+ * 0.5.0 while `doctor` ran from a 0.7.0 checkout and called the login agent
312
+ * fine, because that row only checks the plist exists. */
313
+ export function scriptPathFromPlist(text) {
314
+ const args = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(String(text ?? ""));
315
+ if (!args) return null;
316
+ const strings = [...args[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
317
+ return strings[1] ?? null;
318
+ }
319
+
320
+ const launchd = {
321
+ kind: "launchd",
322
+ platforms: ["macos"],
323
+ /** The OS tool this implementation shells out to, by name, for the rows
324
+ * that have to say what was or was not run. */
325
+ tool: "launchctl",
326
+ /** What doctor and init call this row. Kept "login agent" on macOS because
327
+ * that is what brief 19 shipped and what the CLI suite reads. */
328
+ rowLabel: "LaunchAgent",
329
+ /** Who holds the registration, in a sentence. */
330
+ holder: "launchd",
331
+ mechanism: "a launchd LaunchAgent",
332
+ pathDelimiter: ":",
333
+ defaultExec: execLaunchctl,
334
+ filePath: plistPath,
335
+ render: renderPlist,
336
+ capturedPath: capturedPlistPath,
337
+ programScriptPath: scriptPathFromPlist,
338
+ read: (env = process.env) => readFile(plistPath(env), "utf8").catch(() => null),
339
+
340
+ /** Write the plist if it is absent and load it. Returns {file, wrote,
341
+ * loaded, why, calls} - `calls` being every launchctl argv, in order, so a
342
+ * test can assert the sequence. */
343
+ async install({
344
+ env = process.env,
345
+ exec = execLaunchctl,
346
+ nodePath = process.execPath,
347
+ scriptPath = glytchScriptPath(),
348
+ path = env.PATH ?? "",
349
+ force = false,
350
+ load = true,
351
+ } = {}) {
352
+ const file = plistPath(env);
353
+ const calls = [];
354
+ const run = async (args) => {
355
+ calls.push(args);
356
+ return exec(args, env);
357
+ };
358
+ await ensureLogDir(env);
359
+ const wrote = await writeIfAbsent(
360
+ file,
361
+ renderPlist({
362
+ nodePath,
363
+ scriptPath,
364
+ path,
365
+ out: logPath(env),
366
+ home: (env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim() || undefined,
367
+ }),
368
+ force,
369
+ );
370
+ if (!load) return { file, wrote, loaded: false, why: "not asked to load", calls };
371
+ // UNLOAD FIRST, ALWAYS, and ignore what it says. `launchctl load` on a
372
+ // label that is already loaded is an error, and a rewritten plist that is
373
+ // never unloaded keeps running the OLD one until the next login - which is
374
+ // the failure mode where `init` says it fixed the PATH and nothing changed.
375
+ await run(["unload", file]);
376
+ const loaded = await run(["load", "-w", file]);
377
+ return {
378
+ file,
379
+ wrote,
380
+ loaded: loaded.ok === true,
381
+ why: loaded.ok === true ? "" : loaded.stderr.trim() || `launchctl load exited ${loaded.code}`,
382
+ calls,
383
+ };
384
+ },
385
+
386
+ /** Unload the agent. The plist is LEFT ON DISK: `viewable stop` is "stop the
387
+ * daemon", not "forget this machine", and a person who stops it for an
388
+ * afternoon should get it back with `viewable init` rather than a reinstall.
389
+ * `removeFile` is there for an uninstall that means it. */
390
+ async stop({ env = process.env, exec = execLaunchctl, removeFile = false } = {}) {
391
+ const file = plistPath(env);
392
+ const calls = [];
393
+ if (!(await exists(file))) {
394
+ return { file, unloaded: false, why: "there is no plist to unload", calls };
395
+ }
396
+ calls.push(["unload", "-w", file]);
397
+ const result = await exec(["unload", "-w", file], env);
398
+ if (removeFile) await rm(file, { force: true }).catch(() => {});
399
+ return {
400
+ file,
401
+ unloaded: result.ok === true,
402
+ why: result.ok === true ? "" : result.stderr.trim() || `launchctl unload exited ${result.code}`,
403
+ calls,
404
+ };
405
+ },
406
+
407
+ /** Is launchd holding the label? `launchctl list <label>` exits non-zero
408
+ * when it is not. Doctor checks row-backed presence separately because
409
+ * a loaded daemon is not necessarily sending heartbeats. */
410
+ async loadedState({ env = process.env, exec = execLaunchctl } = {}) {
411
+ const result = await exec(["list", DAEMON_LABEL], env);
412
+ return { loaded: result.ok === true, unavailable: false, why: result.stderr?.trim() ?? "" };
413
+ },
414
+ };
415
+
416
+ // --------------------------------------------------------------- schtasks --
417
+
418
+ /** Where the launcher batch file goes: ~/.glytch/ai.glytch.runner.cmd, or
419
+ * under GLYTCH_HOME.
420
+ *
421
+ * WHY THERE IS A FILE AT ALL. Ruling 1A's task command is `<node> <script>
422
+ * runner watch`, and that is exactly the last line of this file. The wrapper
423
+ * exists for the two things `schtasks` has no slot for and the plist gets for
424
+ * free: the environment (PM_SPAWN_RUNNER, and the captured PATH - see the
425
+ * PATH trap at the top) and a place to send stdout, since a scheduled task
426
+ * has no StandardOutPath. It also keeps `/tr` a single unquoted argument,
427
+ * which is the one part of schtasks that is genuinely hard to get right. */
428
+ export const launcherPath = (env = process.env) => join(glytchHome(env), LAUNCHER_NAME);
429
+
430
+ /** The launcher, rendered. Pure, so the test reads the same text Task
431
+ * Scheduler would run.
432
+ *
433
+ * A `"` in a PATH entry would end the `set` quoting early, and Windows does
434
+ * not allow one in a path anyway, so it is dropped rather than escaped. */
435
+ export function renderLauncher({
436
+ nodePath = process.execPath,
437
+ scriptPath = glytchScriptPath(),
438
+ path = process.env.PATH ?? "",
439
+ out = logPath(),
440
+ home,
441
+ } = {}) {
442
+ const q = (value) => String(value).replace(/"/g, "");
443
+ return `@echo off
444
+ rem ${DAEMON_LABEL} - the viewable login daemon, run by a Task Scheduler
445
+ rem onlogon task (brief 21, ruling 1A of 2026-09-07). Written by
446
+ rem \`viewable init\`; edit it and \`viewable init\` will leave your copy alone.
447
+ set "PM_SPAWN_RUNNER=on"
448
+ set "PATH=${q(path)}"
449
+ ${home ? `set "GLYTCH_HOME=${q(home)}"\n` : ""}if not exist "${q(win32.dirname(out))}" mkdir "${q(win32.dirname(out))}"
450
+ "${q(nodePath)}" "${q(scriptPath)}" runner watch >> "${q(out)}" 2>&1
451
+ `;
452
+ }
453
+
454
+ /** The PATH the launcher carries, or null. The counterpart of
455
+ * capturedPlistPath, and it exists for the same reason: a PATH captured
456
+ * before nvm was installed is a daemon that cannot find the engine. */
457
+ export function capturedLauncherPath(text) {
458
+ const match = /^set "PATH=(.*)"$/m.exec(String(text ?? ""));
459
+ return match ? match[1] : null;
460
+ }
461
+
462
+ /** The script path the launcher's own invocation line runs - the second
463
+ * quoted argument, after the node binary, on the `"<node>" "<script>" runner
464
+ * watch >> ...` line - or null. The Windows half of the same daemon-version
465
+ * check (brief 31, Move 5): the task XML's `<Command>` only names this
466
+ * launcher, so the launcher itself is where the actual glytch.mjs path
467
+ * lives. */
468
+ export function scriptPathFromLauncher(text) {
469
+ const match = /^"[^"]*"\s+"([^"]*)"\s+runner watch\b/m.exec(String(text ?? ""));
470
+ return match ? match[1] : null;
471
+ }
472
+
473
+ /** Where the task definition goes: ~/.glytch/ai.glytch.runner.xml, or under
474
+ * GLYTCH_HOME.
475
+ *
476
+ * WHY THERE IS AN XML AT ALL (2026-09-11, his Windows machine, `glytch
477
+ * doctor`): `schtasks /create /sc onlogon /tn <name> /tr <file>` names no
478
+ * principal, so Task Scheduler registers the logon trigger for the Users
479
+ * GROUP - an all-users task in the root of the Task Scheduler Library, which
480
+ * is an administrator's registration to make. From a normal PowerShell it is
481
+ * `ERROR: Access is denied.`, and the row that reported it sent him to
482
+ * `viewable init`, which ran the same command and failed the same way. Brief
483
+ * 21's audit ("no dependency, no admin, runs as the user") was right about
484
+ * the MECHANISM and wrong about this spelling of it.
485
+ *
486
+ * The XML carries the two things the command line has no slot for: the
487
+ * trigger's UserId is THIS user, and the principal runs with an
488
+ * InteractiveToken at LeastPrivilege. That registration is the user's own
489
+ * and needs no elevation. It is still a per-user onlogon task and still
490
+ * queryable with `/query /tn`, so ruling 1A is untouched - only the spelling
491
+ * of the registration changed, and the command-line form stays as the
492
+ * fallback for a schtasks that will not take the file. */
493
+ export const taskXmlPath = (env = process.env) => join(glytchHome(env), TASK_XML_NAME);
494
+
495
+ /** `DOMAIN\user`, or null when the environment does not say who we are (a
496
+ * stripped env, or a test). Null is what sends install back to the
497
+ * command-line form: an XML with no UserId would be the all-users task this
498
+ * exists to avoid. */
499
+ export function taskUser(env = process.env) {
500
+ const user = String(env.USERNAME ?? "").trim();
501
+ if (!user) return null;
502
+ const domain = String(env.USERDOMAIN ?? "").trim();
503
+ return domain && domain.toLowerCase() !== user.toLowerCase() ? `${domain}\\${user}` : user;
504
+ }
505
+
506
+ /** The task definition, rendered. Pure, so the test reads the same text Task
507
+ * Scheduler would import.
508
+ *
509
+ * The element ORDER is Task Scheduler's own export order and not a taste:
510
+ * the schema is a sequence, and a Settings block in another order is
511
+ * rejected with a parse error rather than a helpful one. Everything here is
512
+ * the launchd plist's intent said in Windows: run at logon (LogonTrigger),
513
+ * keep running (ExecutionTimeLimit PT0S is "no limit", RestartOnFailure is
514
+ * KeepAlive), start it even if the trigger was missed (StartWhenAvailable),
515
+ * and never stop it for a battery or an idle machine - a laptop on its own
516
+ * power is not a reason for the board to go quiet.
517
+ *
518
+ * `<Command>` IS QUOTED WHEN IT HAS A SPACE. Task Scheduler resolves this
519
+ * element as a path the same way CreateProcess would, not as text a pair of
520
+ * XML tags already delimits unambiguously - Microsoft's own schema docs say
521
+ * a path containing a space must be quoted here, exactly the way the .cmd
522
+ * launcher already quotes its own node/script invocation below. `glytch
523
+ * init` run from `C:\Users\John Doe\...` or a OneDrive-synced folder
524
+ * (`...\OneDrive - Acme Corp\...`) both put a space in this path, launcher
525
+ * name included (`~\.glytch\ai.glytch.runner.cmd`), and an unquoted one is
526
+ * read as a shorter path plus a stray trailing argument - Task Scheduler
527
+ * reports the task ran and immediately exited, with nothing on the board to
528
+ * say why. */
529
+ export function renderTaskXml({ command, user, description = "Viewable on your computer" } = {}) {
530
+ const commandXml = /\s/.test(String(command ?? "")) ? `"${command}"` : command;
531
+ return `<?xml version="1.0" encoding="UTF-16"?>
532
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
533
+ <RegistrationInfo>
534
+ <Description>${xml(description)}</Description>
535
+ <URI>\\${xml(DAEMON_LABEL)}</URI>
536
+ </RegistrationInfo>
537
+ <Triggers>
538
+ <LogonTrigger>
539
+ <Enabled>true</Enabled>
540
+ <UserId>${xml(user)}</UserId>
541
+ </LogonTrigger>
542
+ </Triggers>
543
+ <Principals>
544
+ <Principal id="Author">
545
+ <UserId>${xml(user)}</UserId>
546
+ <LogonType>InteractiveToken</LogonType>
547
+ <RunLevel>LeastPrivilege</RunLevel>
548
+ </Principal>
549
+ </Principals>
550
+ <Settings>
551
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
552
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
553
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
554
+ <AllowHardTerminate>true</AllowHardTerminate>
555
+ <StartWhenAvailable>true</StartWhenAvailable>
556
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
557
+ <IdleSettings>
558
+ <StopOnIdleEnd>false</StopOnIdleEnd>
559
+ <RestartOnIdle>false</RestartOnIdle>
560
+ </IdleSettings>
561
+ <AllowStartOnDemand>true</AllowStartOnDemand>
562
+ <Enabled>true</Enabled>
563
+ <Hidden>false</Hidden>
564
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
565
+ <WakeToRun>false</WakeToRun>
566
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
567
+ <Priority>7</Priority>
568
+ <RestartOnFailure>
569
+ <Interval>PT1M</Interval>
570
+ <Count>3</Count>
571
+ </RestartOnFailure>
572
+ </Settings>
573
+ <Actions Context="Author">
574
+ <Exec>
575
+ <Command>${xml(commandXml)}</Command>
576
+ </Exec>
577
+ </Actions>
578
+ </Task>
579
+ `;
580
+ }
581
+
582
+ /** Task Scheduler XML is read as UTF-16: schtasks refuses a plain UTF-8 file
583
+ * with a parse error that says nothing about the encoding. The BOM is the
584
+ * half that matters, and `utf16le` + `\ufeff` is how Node writes one. */
585
+ async function writeTaskXml(file, text) {
586
+ await mkdir(join(file, ".."), { recursive: true });
587
+ await writeFile(file, Buffer.from(`\ufeff${text}`, "utf16le"));
588
+ return file;
589
+ }
590
+
591
+ /** Is this Task Scheduler refusing US, rather than a task that is not there?
592
+ * Told apart because the two need opposite answers: a missing task is
593
+ * installed, and a refused one is a person opening an elevated terminal. */
594
+ export const schtasksDenied = (result) =>
595
+ /access is denied|do(es)? not have permission|requires elevation/i.test(
596
+ `${result?.stderr ?? ""} ${result?.stdout ?? ""}`,
597
+ );
598
+
599
+ /** What to type on Windows when the daemon cannot be registered.
600
+ * POWERSHELL'S SPELLING, because that is the shell VS Code and Windows
601
+ * Terminal open - `PM_SPAWN_RUNNER=on viewable runner watch` is POSIX syntax
602
+ * that neither PowerShell nor cmd.exe has, so RUNNER_BY_HAND pasted there is
603
+ * an error rather than a runner. cmd.exe's own is `set PM_SPAWN_RUNNER=on &&
604
+ * viewable runner watch`; the README carries both. */
605
+ export const RUNNER_BY_HAND_WINDOWS = '$env:PM_SPAWN_RUNNER="on"; viewable runner watch';
606
+
607
+ /** The one next step a denied registration leaves. It travels on the row as
608
+ * `action`, so doctor prints THIS under "Next steps" instead of its default
609
+ * "run viewable init", which is the advice that looped. */
610
+ export const ELEVATED_INIT =
611
+ 'Task Scheduler refused the registration. Open a terminal with "Run as administrator" and run viewable init once from this project folder' +
612
+ ` - or skip the daemon and run the runner by hand: ${RUNNER_BY_HAND_WINDOWS}`;
613
+
614
+ const schtasks = {
615
+ kind: "schtasks",
616
+ platforms: ["windows"],
617
+ tool: "schtasks",
618
+ rowLabel: "scheduled task",
619
+ holder: "Task Scheduler",
620
+ mechanism: "a Task Scheduler per-user onlogon task",
621
+ // Windows PATH is `;` separated and its entries contain `C:`. Splitting on
622
+ // `:` here would make every comparison nonsense (brief 21, sync.mjs:90).
623
+ pathDelimiter: ";",
624
+ defaultExec: execSchtasks,
625
+ filePath: launcherPath,
626
+ render: renderLauncher,
627
+ capturedPath: capturedLauncherPath,
628
+ programScriptPath: scriptPathFromLauncher,
629
+ read: (env = process.env) => readFile(launcherPath(env), "utf8").catch(() => null),
630
+
631
+ async install({
632
+ env = process.env,
633
+ exec = execSchtasks,
634
+ nodePath = process.execPath,
635
+ scriptPath = glytchScriptPath(),
636
+ path = env.PATH ?? env.Path ?? "",
637
+ force = false,
638
+ load = true,
639
+ } = {}) {
640
+ const file = launcherPath(env);
641
+ const calls = [];
642
+ const run = async (args) => {
643
+ calls.push(args);
644
+ return exec(args, env);
645
+ };
646
+ await ensureLogDir(env);
647
+ const wrote = await writeIfAbsent(
648
+ file,
649
+ renderLauncher({
650
+ nodePath,
651
+ scriptPath,
652
+ path,
653
+ out: logPath(env),
654
+ home: (env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim() || undefined,
655
+ }),
656
+ force,
657
+ );
658
+ if (!load) return { file, wrote, loaded: false, why: "not asked to load", calls };
659
+ // THE REGISTRATION, IN TWO SPELLINGS. The XML is the one that works
660
+ // unelevated (see taskXmlPath): it names this user on both the trigger
661
+ // and the principal. /f OVERWRITES an existing registration, which is the
662
+ // schtasks spelling of launchd's unload-then-load: the task must end up
663
+ // naming the launcher we just wrote, not a stale one.
664
+ const user = taskUser(env);
665
+ let created = null;
666
+ if (user) {
667
+ const xmlFile = await writeTaskXml(taskXmlPath(env), renderTaskXml({ command: file, user }));
668
+ created = await run(["/create", "/tn", DAEMON_LABEL, "/xml", xmlFile, "/f"]);
669
+ }
670
+ // THE FALLBACK IS THE OLD COMMAND LINE, and it is not tried after a
671
+ // refusal: `/sc onlogon` with no principal asks for MORE than the XML did
672
+ // (an all-users task), so a machine that denied the XML denies this too
673
+ // and the second error only buries the first. It is for the other
674
+ // failures - a schtasks too old for `/xml`, a definition it will not
675
+ // parse - where the form that used to work still can.
676
+ // The escape makes every call a reported no-op (makeDaemonExec), and a
677
+ // second no-op says nothing the first did not - so it is the same one
678
+ // call it always was.
679
+ if (!created || (created.ok !== true && !noDaemonInstall(env) && !schtasksDenied(created))) {
680
+ created = await run(["/create", "/sc", "onlogon", "/tn", DAEMON_LABEL, "/tr", file, "/f"]);
681
+ }
682
+ if (created.ok !== true) {
683
+ const denied = schtasksDenied(created);
684
+ return {
685
+ file,
686
+ wrote,
687
+ loaded: false,
688
+ denied,
689
+ action: denied ? ELEVATED_INIT : undefined,
690
+ // The advice rides on `action`, never in `why`: the detail line says
691
+ // what the OS said, and the next step is printed once, under its own
692
+ // heading, rather than twice in two different wordings.
693
+ why: created.stderr.trim() || (denied ? "access is denied" : `schtasks /create exited ${created.code}`),
694
+ calls,
695
+ };
696
+ }
697
+ // END THEN RUN, and ignore what /end says. An onlogon task does not start
698
+ // itself at install time (the next logon is a reboot away), and one that
699
+ // IS running is running the environment it was given before the PATH was
700
+ // refreshed - the same failure the plist's unload-then-load exists to
701
+ // prevent. /end on a task that is not running exits non-zero and that is
702
+ // fine, exactly as `launchctl unload` on an unloaded label is.
703
+ await run(["/end", "/tn", DAEMON_LABEL]);
704
+ const started = await run(["/run", "/tn", DAEMON_LABEL]);
705
+ const denied = started.ok !== true && schtasksDenied(started);
706
+ return {
707
+ file,
708
+ wrote,
709
+ loaded: started.ok === true,
710
+ denied,
711
+ action: denied ? ELEVATED_INIT : undefined,
712
+ why: started.ok === true
713
+ ? ""
714
+ : started.stderr.trim() || (denied ? "access is denied" : `schtasks /run exited ${started.code}`),
715
+ calls,
716
+ };
717
+ },
718
+
719
+ /** Delete the registration, leave the launcher on disk.
720
+ *
721
+ * /delete rather than /end, because `viewable stop` means "this machine stops
722
+ * running it until I say otherwise" and an ended task is still registered -
723
+ * doctor would keep saying the daemon is installed and the next logon would
724
+ * start it again. The launcher file stays, so `viewable init` brings it back
725
+ * without a reinstall, which is exactly what the plist does. */
726
+ async stop({ env = process.env, exec = execSchtasks, removeFile = false } = {}) {
727
+ const file = launcherPath(env);
728
+ const calls = [];
729
+ if (!(await exists(file))) {
730
+ return { file, unloaded: false, why: "there is no scheduled task to unload", calls };
731
+ }
732
+ calls.push(["/end", "/tn", DAEMON_LABEL]);
733
+ await exec(["/end", "/tn", DAEMON_LABEL], env);
734
+ calls.push(["/delete", "/tn", DAEMON_LABEL, "/f"]);
735
+ const result = await exec(["/delete", "/tn", DAEMON_LABEL, "/f"], env);
736
+ // The XML is the registration's argument and nothing else reads it, so it
737
+ // goes with the launcher when the caller means "leave nothing behind".
738
+ if (removeFile) {
739
+ await rm(file, { force: true }).catch(() => {});
740
+ await rm(taskXmlPath(env), { force: true }).catch(() => {});
741
+ }
742
+ return {
743
+ file,
744
+ unloaded: result.ok === true,
745
+ why: result.ok === true ? "" : result.stderr.trim() || `schtasks /delete exited ${result.code}`,
746
+ calls,
747
+ };
748
+ },
749
+
750
+ /** Is the task registered? `schtasks /query /tn <name>` exits non-zero when
751
+ * it is not. This is the half ruling 1A chose Task Scheduler FOR: a Startup
752
+ * shortcut could not be asked. */
753
+ async loadedState({ env = process.env, exec = execSchtasks } = {}) {
754
+ const result = await exec(["/query", "/tn", DAEMON_LABEL], env);
755
+ // A REFUSED QUERY IS NOT A MISSING TASK. Both exit non-zero, and reading
756
+ // the refusal as "not registered" is what sent init off to re-register
757
+ // something it was never allowed to touch. The caller gets the
758
+ // difference and stops rather than loops.
759
+ const denied = result.ok !== true && schtasksDenied(result);
760
+ return {
761
+ loaded: result.ok === true,
762
+ unavailable: false,
763
+ denied,
764
+ action: denied ? ELEVATED_INIT : undefined,
765
+ why: result.stderr?.trim() || (denied ? "access is denied" : ""),
766
+ };
767
+ },
768
+ };
769
+
770
+ // ---------------------------------------------------------------- systemd --
771
+
772
+ /** ~/.config/systemd/user/ai.glytch.runner.service, XDG honoured, and under
773
+ * GLYTCH_HOME when it is set so the suite writes nowhere systemd reads. */
774
+ export const unitPath = (env = process.env) =>
775
+ (env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim()
776
+ ? join(glytchHome(env), "systemd", "user", UNIT_NAME)
777
+ : join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "systemd", "user", UNIT_NAME);
778
+
779
+ /** The unit, rendered. Every value is quoted: systemd splits ExecStart and
780
+ * Environment on whitespace otherwise, and a home directory with a space in
781
+ * it is ordinary. */
782
+ export function renderUnit({
783
+ nodePath = process.execPath,
784
+ scriptPath = glytchScriptPath(),
785
+ path = process.env.PATH ?? "",
786
+ out = logPath(),
787
+ home,
788
+ } = {}) {
789
+ const q = (value) => String(value).replace(/"/g, '\\"');
790
+ return `[Unit]
791
+ Description=Viewable on your computer
792
+ Documentation=https://github.com/glytch-ai/viewable
793
+ After=default.target
794
+
795
+ [Service]
796
+ Type=simple
797
+ ExecStart="${q(nodePath)}" "${q(scriptPath)}" runner watch
798
+ Environment="PM_SPAWN_RUNNER=on"
799
+ Environment="PATH=${q(path)}"
800
+ ${home ? `Environment="GLYTCH_HOME=${q(home)}"\n` : ""}# KeepAlive, in systemd's words. The mac's plist asks for the same thing.
801
+ Restart=always
802
+ RestartSec=5
803
+ StandardOutput=append:${out}
804
+ StandardError=append:${out}
805
+
806
+ [Install]
807
+ WantedBy=default.target
808
+ `;
809
+ }
810
+
811
+ /** The PATH the unit carries, or null. */
812
+ export function capturedUnitPath(text) {
813
+ const match = /^Environment="PATH=(.*)"$/m.exec(String(text ?? ""));
814
+ return match ? match[1] : null;
815
+ }
816
+
817
+ /** The script path a systemd unit's ExecStart runs - the second quoted
818
+ * argument, after the node binary - or null. The Linux/WSL half of the same
819
+ * daemon-version check (brief 31, Move 5). */
820
+ export function scriptPathFromUnit(text) {
821
+ const match = /^ExecStart="[^"]*"\s+"([^"]*)"\s+runner watch\b/m.exec(String(text ?? ""));
822
+ return match ? match[1] : null;
823
+ }
824
+
825
+ /** Is this "systemctl --user is not here" rather than "the unit is not
826
+ * active"? An older WSL with no systemd has no `systemctl` on PATH at all,
827
+ * and a systemd that is there without a user bus refuses to connect. Ruling
828
+ * 6C: say so honestly and name the fallback - never report it as a unit that
829
+ * simply is not running, which would send a person to `viewable init` for ever. */
830
+ export const systemctlUnavailable = (result) =>
831
+ /ENOENT|not found|no such file|failed to connect to bus|failed to get d-?bus|is not running/i.test(
832
+ `${result?.stderr ?? ""}`,
833
+ ) || result?.code === "ENOENT";
834
+
835
+ /** What to type when there is no systemd to install into. */
836
+ export const RUNNER_BY_HAND = "PM_SPAWN_RUNNER=on viewable runner watch";
837
+
838
+ const systemd = {
839
+ kind: "systemd",
840
+ platforms: ["wsl", "linux"],
841
+ tool: "systemctl",
842
+ rowLabel: "systemd user unit",
843
+ holder: "systemd --user",
844
+ mechanism: "a systemd user unit",
845
+ pathDelimiter: ":",
846
+ defaultExec: execSystemctl,
847
+ filePath: unitPath,
848
+ render: renderUnit,
849
+ capturedPath: capturedUnitPath,
850
+ programScriptPath: scriptPathFromUnit,
851
+ read: (env = process.env) => readFile(unitPath(env), "utf8").catch(() => null),
852
+
853
+ async install({
854
+ env = process.env,
855
+ exec = execSystemctl,
856
+ nodePath = process.execPath,
857
+ scriptPath = glytchScriptPath(),
858
+ path = env.PATH ?? "",
859
+ force = false,
860
+ load = true,
861
+ } = {}) {
862
+ const file = unitPath(env);
863
+ const calls = [];
864
+ const run = async (args) => {
865
+ calls.push(args);
866
+ return exec(args, env);
867
+ };
868
+ await ensureLogDir(env);
869
+ const wrote = await writeIfAbsent(
870
+ file,
871
+ renderUnit({
872
+ nodePath,
873
+ scriptPath,
874
+ path,
875
+ out: logPath(env),
876
+ home: (env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim() || undefined,
877
+ }),
878
+ force,
879
+ );
880
+ if (!load) return { file, wrote, loaded: false, why: "not asked to load", calls };
881
+ // DAEMON-RELOAD FIRST, ALWAYS. systemd caches unit files, so a rewritten
882
+ // unit that is never re-read keeps running the OLD one - the same failure
883
+ // the plist's unload-then-load prevents. `enable --now` then both
884
+ // registers it for the next login and starts it in this one, which is
885
+ // RunAtLoad and the plist's `-w` in one call.
886
+ const reloaded = await run(["--user", "daemon-reload"]);
887
+ if (!reloaded.ok) {
888
+ const unavailable = systemctlUnavailable(reloaded);
889
+ return { file, wrote, loaded: false, unavailable,
890
+ why: unavailable ? `systemctl --user is not available on your computer: run ${RUNNER_BY_HAND}` : reloaded.stderr?.trim() || "systemctl daemon-reload failed", calls };
891
+ }
892
+ let enabled = await run(["--user", "enable", "--now", UNIT_NAME]);
893
+ // enable --now leaves an already-running process alone, even after the
894
+ // file at ExecStart was replaced. Explicit repair/update must reload it.
895
+ if (enabled.ok && force) enabled = await run(["--user", "restart", UNIT_NAME]);
896
+ const unavailable = enabled.ok !== true && systemctlUnavailable(enabled);
897
+ return {
898
+ file,
899
+ wrote,
900
+ loaded: enabled.ok === true,
901
+ unavailable,
902
+ why: enabled.ok === true
903
+ ? ""
904
+ : unavailable
905
+ ? `systemctl --user is not available on your computer: run ${RUNNER_BY_HAND}`
906
+ : enabled.stderr.trim() || `systemctl --user enable exited ${enabled.code}`,
907
+ calls,
908
+ };
909
+ },
910
+
911
+ /** Disable and stop, leaving the unit file on disk - the plist's rule. */
912
+ async stop({ env = process.env, exec = execSystemctl, removeFile = false } = {}) {
913
+ const file = unitPath(env);
914
+ const calls = [];
915
+ if (!(await exists(file))) {
916
+ return { file, unloaded: false, why: "there is no systemd user unit to unload", calls };
917
+ }
918
+ calls.push(["--user", "disable", "--now", UNIT_NAME]);
919
+ const result = await exec(["--user", "disable", "--now", UNIT_NAME], env);
920
+ if (removeFile) await rm(file, { force: true }).catch(() => {});
921
+ return {
922
+ file,
923
+ unloaded: result.ok === true,
924
+ why: result.ok === true ? "" : result.stderr.trim() || `systemctl --user disable exited ${result.code}`,
925
+ calls,
926
+ };
927
+ },
928
+
929
+ /** `systemctl --user is-active <unit>` exits non-zero for every kind of
930
+ * not-running, so the unavailable case is told apart by what it SAID. */
931
+ async loadedState({ env = process.env, exec = execSystemctl } = {}) {
932
+ const result = await exec(["--user", "is-active", UNIT_NAME], env);
933
+ if (result.ok === true) return { loaded: true, unavailable: false, why: "" };
934
+ const unavailable = systemctlUnavailable(result);
935
+ return {
936
+ loaded: false,
937
+ unavailable,
938
+ why: unavailable
939
+ ? `systemctl --user is not available on your computer: run ${RUNNER_BY_HAND}`
940
+ : result.stderr?.trim() ?? "",
941
+ };
942
+ },
943
+ };
944
+
945
+ export const IMPLEMENTATIONS = { launchd, schtasks, systemd };
946
+
947
+ const BY_KIND = { macos: launchd, windows: schtasks, wsl: systemd, linux: systemd };
948
+
949
+ /** THE INTERFACE, for one machine.
950
+ *
951
+ * Everything above is chosen here and nowhere else: sync, doctor and the CLI
952
+ * ask for the implementation and then call install / stop / isLoaded /
953
+ * describe on it, so adding a fourth platform is a fourth object and no
954
+ * fourth `if`. */
955
+ export function loginDaemon({ platform = process.platform, procVersion, env = process.env } = {}) {
956
+ const kind = platformKind({ platform, procVersion });
957
+ const impl = BY_KIND[kind] ?? launchd;
958
+ return {
959
+ ...impl,
960
+ platform: kind,
961
+ platformLabel: PLATFORM_LABEL[kind] ?? kind,
962
+ /** Everything a row needs to print itself, without asking the OS. */
963
+ describe: () => ({
964
+ platform: kind,
965
+ platformLabel: PLATFORM_LABEL[kind] ?? kind,
966
+ kind: impl.kind,
967
+ label: impl.rowLabel,
968
+ holder: impl.holder,
969
+ mechanism: impl.mechanism,
970
+ file: impl.filePath(env),
971
+ }),
972
+ /** The boolean half. `loadedState` is there for the caller that needs to
973
+ * tell "not running" from "there is no systemd here". */
974
+ isLoaded: async (options = {}) => (await impl.loadedState({ env, ...options })).loaded,
975
+ };
976
+ }