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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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, "&")
|
|
240
|
+
.replace(/</g, "<")
|
|
241
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|