agent-dag 3.22.0 → 3.22.3

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 (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,473 +0,0 @@
1
- // Starting the deck when you log in.
2
- //
3
- // Detaching answers "the terminal closed". It does not answer "the machine
4
- // rebooted", and between those two the deck is exactly as absent: the hooks post
5
- // into a refused connection, the LAN beacon goes quiet, and paired colleagues
6
- // watch this machine time out. The first `ccdeck` typed after a reboot brings it
7
- // back, and everything before that is lost.
8
- //
9
- // THREE PLATFORMS, THREE NATIVE SHAPES, and they differ for one reason: where
10
- // the deck's console output can go.
11
- //
12
- // macOS, launchd. The job runs the deck IN THE FOREGROUND — the plist carries
13
- // AGENTS_DECK_DETACHED so the launcher does not run away from launchd, which
14
- // is the whole point of handing a process to a supervisor. StandardOutPath and
15
- // StandardErrorPath put its voice in deck.log, where `--logs` reads it.
16
- //
17
- // Linux, systemd --user. The same shape for the same reason, with
18
- // `StandardOutput=append:` instead of the plist keys. Type=simple, so systemd
19
- // holds the real process rather than guessing at a forked child.
20
- //
21
- // Windows, Task Scheduler. There IS no output redirection, and wrapping the
22
- // command in `cmd /c "… >> log"` puts a quoting problem between the user and
23
- // their deck starting at all. So this is the one platform where the task runs
24
- // the LAUNCHER and lets it detach: the launcher already writes deck.log, the
25
- // task goes to "Ready" the moment it exits, and the deck it left behind keeps
26
- // running. Task Scheduler does not reap a task's orphans.
27
- //
28
- // NO RESTART POLICY, on any of the three. `KeepAlive`, `Restart=always` and
29
- // "restart on failure" all exist and all are deliberately absent: the ceiling
30
- // that decides when a crashed deck stops coming back lives in ONE place
31
- // (crashPolicy, in supervisor.mjs), it is identical on every platform, and it is
32
- // tested. Two policies over one process is how `ccdeck --stop` becomes a
33
- // suggestion the machine overrules a second later.
34
- import { spawnSync } from "node:child_process";
35
- import { homedir } from "node:os";
36
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
37
- // BOTH SPELLINGS, NOT `node:path`'s. `join` is bound to the HOST: a bare one
38
- // answers `\Users\x\Library\LaunchAgents\...` when a Windows runner is asked
39
- // where a macOS login item lives, which is how this landed red in CI on the
40
- // first push. The only way a Windows answer stays right is if it can be checked
41
- // from a Mac — the same reason deck-home.mjs and claudeCliCandidates do it, and
42
- // the third time this class of bug has been caught here.
43
- import { posix as posixPath, win32 as winPath } from "node:path";
44
-
45
- /** The joiner for the platform being ASKED ABOUT, which is not always this one. */
46
- const joinFor = (platform) => (platform === "win32" ? winPath.join : posixPath.join);
47
-
48
- /**
49
- * What the service is called.
50
- *
51
- * A bare label rather than reverse-DNS, and that is a choice rather than an
52
- * oversight: reverse-DNS names a domain, this project does not own one, and
53
- * inventing `md.ccdeck` or `com.ccdeck` would put a claim in a file on a user's
54
- * machine that nothing backs. launchd, systemd and schtasks all take a plain
55
- * name.
56
- */
57
- export const SERVICE_LABEL = "ccdeck";
58
-
59
- /** Where the record of what we did lives, so an install happens at most once and
60
- * an uninstall is not undone by the next boot. */
61
- export const SERVICE_RECORD = "service.json";
62
-
63
- /** Where this platform keeps a per-user login item. */
64
- export function servicePath(platform = process.platform, home = homedir(), env = process.env) {
65
- const join = joinFor(platform);
66
- if (platform === "darwin") return join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
67
- if (platform === "win32") return `\\${SERVICE_LABEL}`; // a Task Scheduler path, not a file
68
- const config = env.XDG_CONFIG_HOME?.trim() || join(home, ".config");
69
- return join(config, "systemd", "user", `${SERVICE_LABEL}.service`);
70
- }
71
-
72
- /** XML text escaping, for the two formats that are XML. A path can hold an
73
- * ampersand, and a plist with a bare `&` in it does not parse — which launchd
74
- * reports as a job that simply never runs. */
75
- export function xmlEscape(s) {
76
- return String(s)
77
- .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
78
- .replace(/"/g, "&quot;").replace(/'/g, "&apos;");
79
- }
80
-
81
- /**
82
- * The launchd job.
83
- *
84
- * RunAtLoad and nothing else. No KeepAlive — see the header — and no
85
- * StartInterval, because a deck that is already running is found by the next
86
- * launcher rather than started twice.
87
- *
88
- * AND NO `ProcessType`. `Background` was the obvious-looking choice and it is
89
- * measurably wrong: it puts the job in PRIO_DARWIN_BG, where disk I/O is
90
- * throttled hard enough that a boot which takes 0.8s from a shell had still
91
- * printed nothing after thirty seconds under launchd. This job is not
92
- * background work — it is the server behind a browser tab the user is looking
93
- * at. The default band is the honest one.
94
- */
95
- export function plistFor({ execPath, script, logPath, label = SERVICE_LABEL, args = [], env = {} } = {}) {
96
- const argv = [execPath, script, ...args].map(a => ` <string>${xmlEscape(a)}</string>`).join("\n");
97
- const vars = Object.entries({ AGENTS_DECK_DETACHED: "1", ...env })
98
- .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
99
- .join("\n");
100
- return `<?xml version="1.0" encoding="UTF-8"?>
101
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
102
- <plist version="1.0">
103
- <dict>
104
- <key>Label</key>
105
- <string>${xmlEscape(label)}</string>
106
- <key>ProgramArguments</key>
107
- <array>
108
- ${argv}
109
- </array>
110
- <key>RunAtLoad</key>
111
- <true/>
112
- <key>EnvironmentVariables</key>
113
- <dict>
114
- ${vars}
115
- </dict>
116
- <key>StandardOutPath</key>
117
- <string>${xmlEscape(logPath)}</string>
118
- <key>StandardErrorPath</key>
119
- <string>${xmlEscape(logPath)}</string>
120
- </dict>
121
- </plist>
122
- `;
123
- }
124
-
125
- /**
126
- * The systemd --user unit.
127
- *
128
- * `append:` needs systemd 240, which is 2018 and older than every distribution
129
- * still receiving updates. No Restart=, for the reason in the header.
130
- */
131
- export function unitFor({ execPath, script, logPath, args = [], env = {}, product = "ccdeck" } = {}) {
132
- const cmd = [execPath, script, ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
133
- const vars = Object.entries({ AGENTS_DECK_DETACHED: "1", ...env })
134
- .map(([k, v]) => `Environment=${k}=${v}`).join("\n");
135
- return `[Unit]
136
- Description=${product} — live deck of Claude Code + Codex agents
137
- After=default.target
138
-
139
- [Service]
140
- Type=simple
141
- ${vars}
142
- ExecStart=${cmd}
143
- StandardOutput=append:${logPath}
144
- StandardError=append:${logPath}
145
-
146
- [Install]
147
- WantedBy=default.target
148
- `;
149
- }
150
-
151
- /**
152
- * The Task Scheduler job, as XML.
153
- *
154
- * XML rather than `schtasks /TR "<command>"`, and this is not fussiness: /TR
155
- * takes the whole command line as one string, and the two things it has to hold
156
- * — `C:\\Program Files\\nodejs\\node.exe` and a path under the user's profile —
157
- * both contain spaces. Every combination of quotes and doubled quotes that
158
- * survives cmd.exe, schtasks' own parser and the scheduler's stored form is a
159
- * coin flip; the XML form has a field for the program and a field for its
160
- * arguments and no parsing between them.
161
- *
162
- * UTF-16LE with a BOM on disk, which is what schtasks requires and what a file
163
- * written as UTF-8 is silently rejected for.
164
- *
165
- * No <UserId>: schtasks fills in the account running it, which is the one whose
166
- * login this task is about. Naming it would be a guess that breaks on a renamed
167
- * account, a domain user or a machine in another locale.
168
- */
169
- export function taskXmlFor({ execPath, script, args = [], product = "ccdeck" } = {}) {
170
- // NO ENVIRONMENT BLOCK, and that is the schema's doing rather than an
171
- // omission here: there is nowhere in a Task Scheduler task to put a variable.
172
- // Which is the second reason Windows runs the LAUNCHER and lets it detach
173
- // rather than carrying AGENTS_DECK_DETACHED like the other two — there is no
174
- // way to carry it. The launcher writes deck.log either way, so the output
175
- // this platform also cannot redirect lands in the same file as everywhere
176
- // else.
177
- const argLine = [script, ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
178
- return `<?xml version="1.0" encoding="UTF-16"?>
179
- <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
180
- <RegistrationInfo>
181
- <Description>${xmlEscape(product)} — live deck of Claude Code + Codex agents</Description>
182
- </RegistrationInfo>
183
- <Triggers>
184
- <LogonTrigger>
185
- <Enabled>true</Enabled>
186
- </LogonTrigger>
187
- </Triggers>
188
- <Principals>
189
- <Principal id="Author">
190
- <LogonType>InteractiveToken</LogonType>
191
- <RunLevel>LeastPrivilege</RunLevel>
192
- </Principal>
193
- </Principals>
194
- <Settings>
195
- <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
196
- <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
197
- <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
198
- <AllowHardTerminate>true</AllowHardTerminate>
199
- <StartWhenAvailable>true</StartWhenAvailable>
200
- <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
201
- <IdleSettings>
202
- <StopOnIdleEnd>false</StopOnIdleEnd>
203
- <RestartOnIdle>false</RestartOnIdle>
204
- </IdleSettings>
205
- <AllowStartOnDemand>true</AllowStartOnDemand>
206
- <Enabled>true</Enabled>
207
- <Hidden>false</Hidden>
208
- <RunOnlyIfIdle>false</RunOnlyIfIdle>
209
- <WakeToRun>false</WakeToRun>
210
- <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
211
- <Priority>7</Priority>
212
- </Settings>
213
- <Actions Context="Author">
214
- <Exec>
215
- <Command>${xmlEscape(execPath)}</Command>
216
- <Arguments>${xmlEscape(argLine)}</Arguments>
217
- </Exec>
218
- </Actions>
219
- </Task>
220
- `;
221
- }
222
-
223
- /**
224
- * Should this machine be offered a login item without being asked?
225
- *
226
- * ONCE PER MACHINE, EVER, and the record is what makes that true. Without it an
227
- * uninstall would be undone by the next start, which is not an uninstall — it is
228
- * a tool arguing with its user.
229
- *
230
- * TWO KINDS OF INSTALL ARE EXCLUDED, and neither as a preference — in both the
231
- * login item would name a path nobody promised to keep:
232
- *
233
- * `npx` runs out of ~/.npm/_npx/<hash>/, which npm deletes whenever it feels
234
- * like it. A login item pointing at nothing, forever, on a machine where the
235
- * user never installed anything.
236
- *
237
- * A CHECKOUT is somebody's working tree. Found by running this: a test boot
238
- * from the repo on the author's own machine wrote a LaunchAgent naming
239
- * ~/Desktop/agents-deck/bin/agent-dag.js. That directory gets renamed, moved,
240
- * branch-switched and deleted, and none of those are events a login item
241
- * survives — and nobody working in a checkout asked to be given one.
242
- *
243
- *
244
- * AGENTS_DECK_NO_INSTALL is honoured the same way claude-swap and ccusage honour
245
- * it. A CI runner that quietly acquires login items is a CI runner nobody can
246
- * explain.
247
- */
248
- export function shouldOfferService({ record = null, npx = false, checkout = false, env = process.env } = {}) {
249
- if (env.AGENTS_DECK_NO_INSTALL === "1") return false;
250
- if (npx || checkout) return false;
251
- return record === null;
252
- }
253
-
254
- /** Read what we did last time, or null when we have never touched this machine. */
255
- export function readServiceRecord(dataDir, { fs = { readFileSync } } = {}) {
256
- try {
257
- // The HOST's join, deliberately: this reads a file on the machine running
258
- // it, so there is no other platform to be right for.
259
- const d = JSON.parse(fs.readFileSync(joinFor(process.platform)(dataDir, SERVICE_RECORD), "utf8"));
260
- return d && typeof d === "object" ? d : null;
261
- } catch { return null; }
262
- }
263
-
264
- /** Write it. Never throws: a record we cannot keep means we may offer once more
265
- * on this machine, which is a far smaller harm than refusing to start. */
266
- export function writeServiceRecord(dataDir, record, { fs = { mkdirSync, writeFileSync } } = {}) {
267
- try {
268
- fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
269
- fs.writeFileSync(joinFor(process.platform)(dataDir, SERVICE_RECORD), JSON.stringify(record, null, 2) + "\n");
270
- return true;
271
- } catch { return false; }
272
- }
273
-
274
- /** The command that registers what was just written, per platform. Separate
275
- * from the writing so the arguments can be asserted from any machine. */
276
- export function registerCommand(platform, path) {
277
- if (platform === "darwin") {
278
- // `bootstrap` is the modern spelling and `load` the one that works on
279
- // everything back to 10.10. Failure is not fatal either way: the plist is on
280
- // disk and launchd reads it at the next login regardless.
281
- return { file: "launchctl", args: ["load", "-w", path] };
282
- }
283
- if (platform === "win32") {
284
- return { file: "schtasks", args: ["/Create", "/TN", SERVICE_LABEL, "/XML", path, "/F"] };
285
- }
286
- return { file: "systemctl", args: ["--user", "enable", "--now", `${SERVICE_LABEL}.service`] };
287
- }
288
-
289
- /** …and the one that takes it away again. */
290
- export function unregisterCommand(platform, path) {
291
- if (platform === "darwin") return { file: "launchctl", args: ["unload", "-w", path] };
292
- if (platform === "win32") return { file: "schtasks", args: ["/Delete", "/TN", SERVICE_LABEL, "/F"] };
293
- return { file: "systemctl", args: ["--user", "disable", "--now", `${SERVICE_LABEL}.service`] };
294
- }
295
-
296
- /**
297
- * Does a systemd --user session survive logging out?
298
- *
299
- * Only with lingering enabled, and this is the one place where the three
300
- * platforms genuinely differ in what "starts at login" buys you. Without it the
301
- * user's systemd instance is torn down at logout and the deck goes with it —
302
- * which is fine on a laptop somebody stays logged into and wrong on a box they
303
- * ssh out of. Asked rather than assumed, and SAID rather than silently fixed:
304
- * `loginctl enable-linger` changes how the machine treats an account, and that
305
- * is not ours to decide on somebody's behalf.
306
- */
307
- export function lingerState({ platform = process.platform, run = spawnSync, user = currentUser() } = {}) {
308
- if (platform !== "linux") return "n/a";
309
- if (!user) return "unknown";
310
- try {
311
- const out = run("loginctl", ["show-user", String(user), "--property=Linger"], { encoding: "utf8" });
312
- if (out?.status !== 0) return "unknown";
313
- return /Linger=yes/i.test(String(out.stdout ?? "")) ? "on" : "off";
314
- } catch { return "unknown"; }
315
- }
316
-
317
- /**
318
- * Write the login item and register it.
319
- *
320
- * Returns a verdict rather than throwing. Nothing here is worth refusing to run
321
- * a deck over: the worst outcome of a failed install is the behaviour every
322
- * version before this one had.
323
- */
324
- export function installService({
325
- platform = process.platform,
326
- home = homedir(),
327
- env = process.env,
328
- execPath = process.execPath,
329
- script,
330
- logPath,
331
- args = ["--no-open"],
332
- // Extra variables for the JOB's environment, which is not this process's.
333
- //
334
- // A login item runs in the session the service manager builds, not in the
335
- // shell that installed it: launchd hands the job a minimal environment,
336
- // systemd --user the same. That is right in production — the deck should see
337
- // the user's real home — and it is exactly what makes a login item impossible
338
- // to test in a sandbox without this parameter. The end-to-end test passes
339
- // HOME and the deck's own directories through here; nothing else does.
340
- serviceEnv = {},
341
- product = "ccdeck",
342
- fs = { mkdirSync, writeFileSync },
343
- run = spawnSync,
344
- } = {}) {
345
- const path = servicePath(platform, home, env);
346
- try {
347
- // BEFORE THE SERVICE MANAGER IS TOLD ANYTHING. launchd opens
348
- // StandardOutPath when it starts the job and systemd opens
349
- // `append:` when it starts the unit; neither creates a missing directory,
350
- // and both answer a missing one by failing the job silently. On a machine
351
- // where the deck has never run — which is every machine `--install-service`
352
- // is typed on first — that directory does not exist yet.
353
- fs.mkdirSync(logPathDir(logPath), { recursive: true });
354
- if (platform === "win32") {
355
- // The XML goes to a temp file beside the deck's own data rather than to
356
- // %TEMP%: schtasks reads it once and this way a machine that refuses the
357
- // import still has the file that was refused, next to everything else
358
- // this deck owns.
359
- const xmlPath = joinFor(platform)(logPathDir(logPath), `${SERVICE_LABEL}-task.xml`);
360
- // UTF-16LE with a BOM: schtasks rejects UTF-8 with an unhelpful error.
361
- fs.writeFileSync(xmlPath, "\uFEFF" + taskXmlFor({ execPath, script, args, product }), "utf16le");
362
- const cmd = registerCommand(platform, xmlPath);
363
- const out = run(cmd.file, cmd.args, { encoding: "utf8", windowsHide: true });
364
- if (out?.status !== 0) {
365
- return { ok: false, path: SERVICE_LABEL, reason: oneLine(out?.stderr || out?.stdout) || "schtasks refused" };
366
- }
367
- return { ok: true, path: `Task Scheduler ${GLYPH_ARROW} ${SERVICE_LABEL}`, how: "schtasks" };
368
- }
369
-
370
- const body = platform === "darwin"
371
- ? plistFor({ execPath, script, logPath, args, env: serviceEnv })
372
- : unitFor({ execPath, script, logPath, args, env: serviceEnv, product });
373
- fs.mkdirSync(dirOf(path), { recursive: true });
374
- fs.writeFileSync(path, body, { mode: 0o644 });
375
- const cmd = registerCommand(platform, path);
376
- const out = run(cmd.file, cmd.args, { encoding: "utf8" });
377
- // A registration that failed still leaves the file, and both launchd and
378
- // systemd read their directories at the next login — so the service works
379
- // from then on either way. Said, not hidden, and not treated as fatal.
380
- if (out?.status !== 0) {
381
- return { ok: true, path, how: "file-only", reason: oneLine(out?.stderr || out?.stdout) || `${cmd.file} refused` };
382
- }
383
- return { ok: true, path, how: cmd.file };
384
- } catch (err) {
385
- return { ok: false, path, reason: err?.code ?? String(err?.message ?? err) };
386
- }
387
- }
388
-
389
- /**
390
- * Take it away. Same contract: a verdict, never a throw.
391
- *
392
- * `existed` is the half that took three platforms to get right. "Removed it" and
393
- * "there was nothing to remove" are different answers, and the three platforms
394
- * disagreed about which one they were giving: `rmSync` with `force` succeeds on
395
- * a missing file, so macOS and Linux said "removed" either way, while
396
- * `schtasks /Delete` on a task that is not there exits non-zero and Windows
397
- * reported it as a FAILURE — `could not remove it — ERROR: The system cannot
398
- * find the file specified`, and an exit code of 1 from a command that had
399
- * nothing to do.
400
- *
401
- * ASKED, NOT INFERRED FROM THE REFUSAL. schtasks exits 1 for a missing task and
402
- * for a permission it does not have, and the two are told apart only by an
403
- * English sentence that is not English on a localised Windows — the same trap
404
- * `looksMissing` in exec.mjs documents. So the task is queried first, which is
405
- * one extra call and an answer that does not depend on the machine's language.
406
- */
407
- export function uninstallService({
408
- platform = process.platform,
409
- home = homedir(),
410
- env = process.env,
411
- fs = { rmSync, existsSync },
412
- run = spawnSync,
413
- } = {}) {
414
- const path = servicePath(platform, home, env);
415
-
416
- if (platform === "win32") {
417
- let existed = false;
418
- try {
419
- existed = run("schtasks", ["/Query", "/TN", SERVICE_LABEL], { encoding: "utf8", windowsHide: true })?.status === 0;
420
- } catch { /* no schtasks at all: nothing of ours can be registered */ }
421
- if (!existed) return { ok: true, existed: false, path: SERVICE_LABEL };
422
- const cmd = unregisterCommand(platform, path);
423
- let out = null;
424
- try { out = run(cmd.file, cmd.args, { encoding: "utf8", windowsHide: true }); } catch { /* reported below */ }
425
- if (out?.status === 0) return { ok: true, existed: true, path: SERVICE_LABEL };
426
- return { ok: false, existed: true, path: SERVICE_LABEL, reason: oneLine(out?.stderr || out?.stdout) || "schtasks refused" };
427
- }
428
-
429
- let existed = true;
430
- try { existed = fs.existsSync(path); } catch { /* assume it was there */ }
431
- const cmd = unregisterCommand(platform, path);
432
- // Only when there is something to unregister: `launchctl unload` and
433
- // `systemctl --user disable` on a unit that was never there are noise, and on
434
- // some systemd versions an error.
435
- if (existed) { try { run(cmd.file, cmd.args, { encoding: "utf8" }); } catch { /* the file is the part that matters */ } }
436
- // The FILE is what makes it start at login, so removing it is the part that
437
- // actually uninstalls. The unregister above only stops the copy running now.
438
- try { fs.rmSync(path, { force: true }); } catch (err) {
439
- return { ok: false, existed, path, reason: err?.code ?? "could not remove" };
440
- }
441
- return { ok: true, existed, path };
442
- }
443
-
444
- const GLYPH_ARROW = "\u2192";
445
- const dirOf = (p) => p.slice(0, Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")));
446
- const logPathDir = (p) => dirOf(String(p ?? ""));
447
- /**
448
- * Who to ask loginctl about.
449
- *
450
- * THE UID FIRST, and `$USER` only as a fallback — which is the way round it took
451
- * a container to find out. `loginctl show-user "" --property=Linger` answers
452
- * "Failed to look up user : No such process", which lingerState read as
453
- * "unknown" and said nothing about; and `$USER` is unset in every environment
454
- * that is not an interactive login shell, which includes the cron job, the
455
- * systemd unit and the `docker exec` this was caught in. A uid is always there
456
- * on the platform this function runs on, and loginctl takes one.
457
- *
458
- * Pure, and both inputs are parameters, because the rule is about a preference
459
- * between two values and asserting it through `process` makes the assertion
460
- * true only on the machine that has them. The first version of that test read
461
- * `process.getuid`, which does not exist on Windows, and went red on the runner
462
- * rather than on the platform it was about.
463
- */
464
- export function preferredUser({ uid, user } = {}) {
465
- if (Number.isInteger(uid)) return String(uid);
466
- return String(user ?? "").trim();
467
- }
468
-
469
- const currentUser = () => preferredUser({ uid: process.getuid?.(), user: process.env.USER });
470
-
471
- /** First line, trimmed — a refusal from schtasks is a paragraph and a row is a
472
- * row. */
473
- const oneLine = (s) => String(s ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean)[0] ?? "";