agent-dag 3.22.1 → 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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
78
|
-
.replace(/"/g, """).replace(/'/g, "'");
|
|
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] ?? "";
|