@edgehero/pi-dispatch 0.1.0

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 (69) hide show
  1. package/.env.example +160 -0
  2. package/deploy/com.pi-dispatch.worker.plist +66 -0
  3. package/deploy/nssm-install.cmd +59 -0
  4. package/deploy/receiver.service +36 -0
  5. package/deploy/worker-env-wrapper.cmd +50 -0
  6. package/deploy/worker-env-wrapper.sh +63 -0
  7. package/deploy/worker.service +55 -0
  8. package/package.json +83 -0
  9. package/src/azure-auth.mjs +61 -0
  10. package/src/azure-host.mjs +236 -0
  11. package/src/azure-identity.mjs +63 -0
  12. package/src/azure-prompt.mjs +118 -0
  13. package/src/branch.mjs +80 -0
  14. package/src/budget.mjs +179 -0
  15. package/src/cli.mjs +208 -0
  16. package/src/config.mjs +329 -0
  17. package/src/connection.mjs +40 -0
  18. package/src/cron.mjs +94 -0
  19. package/src/docker-run.mjs +119 -0
  20. package/src/doctor.mjs +1127 -0
  21. package/src/env-allowlist.mjs +198 -0
  22. package/src/env-file.mjs +153 -0
  23. package/src/exit-code.mjs +32 -0
  24. package/src/flow-gate.mjs +82 -0
  25. package/src/forgejo-auth.mjs +77 -0
  26. package/src/forgejo-host.mjs +172 -0
  27. package/src/forgejo-identity.mjs +74 -0
  28. package/src/forgejo-prompt.mjs +123 -0
  29. package/src/forges.mjs +148 -0
  30. package/src/get-token.mjs +226 -0
  31. package/src/git-dirty.mjs +16 -0
  32. package/src/github-app-setup.mjs +517 -0
  33. package/src/github-host.mjs +159 -0
  34. package/src/github-prompt.mjs +286 -0
  35. package/src/gitlab-auth.mjs +72 -0
  36. package/src/gitlab-host.mjs +200 -0
  37. package/src/gitlab-identity.mjs +61 -0
  38. package/src/gitlab-prompt.mjs +123 -0
  39. package/src/identity.mjs +57 -0
  40. package/src/image-preflight.mjs +180 -0
  41. package/src/import-pi.mjs +451 -0
  42. package/src/index.mjs +177 -0
  43. package/src/init.mjs +77 -0
  44. package/src/job-id.mjs +100 -0
  45. package/src/materialize.mjs +138 -0
  46. package/src/outbox.mjs +179 -0
  47. package/src/packages.mjs +188 -0
  48. package/src/pause-windows.mjs +218 -0
  49. package/src/prepare-github.mjs +260 -0
  50. package/src/prepare-local.mjs +76 -0
  51. package/src/prepare.mjs +199 -0
  52. package/src/pricing.mjs +168 -0
  53. package/src/processor.mjs +360 -0
  54. package/src/queue.mjs +152 -0
  55. package/src/run-container.mjs +133 -0
  56. package/src/run-history.mjs +534 -0
  57. package/src/runtime-settings.mjs +188 -0
  58. package/src/sandbox-cli.mjs +156 -0
  59. package/src/sandbox-store.mjs +269 -0
  60. package/src/sandbox.mjs +171 -0
  61. package/src/scheduler-stall-guard.mjs +67 -0
  62. package/src/schedules.mjs +62 -0
  63. package/src/service.mjs +677 -0
  64. package/src/session-key.mjs +108 -0
  65. package/src/session-store.mjs +249 -0
  66. package/src/start.mjs +502 -0
  67. package/src/subscriptions.mjs +208 -0
  68. package/src/triggers.mjs +491 -0
  69. package/src/up.mjs +315 -0
@@ -0,0 +1,677 @@
1
+ /**
2
+ * `pi-dispatch service` — render and install the deploy/ daemon templates for THIS host (issue #80).
3
+ *
4
+ * Durable running used to mean hand-editing the per-OS examples in deploy/. This module reads those
5
+ * SAME files from the package and substitutes a documented table of their known literals —
6
+ * `/usr/bin/node` → `process.execPath`, `/opt/pi-dispatch` → the real repo root — rather than
7
+ * introducing a `{{placeholder}}` dialect. That keeps the deploy/ files byte-usable examples (and
8
+ * deploy-lint keeps parsing exactly what ships); TEMPLATE_PINS below is the table's enforcement — the
9
+ * test suite asserts every literal is still present in every template, so template drift breaks the
10
+ * build loudly instead of breaking the render silently.
11
+ *
12
+ * Scope doctrine:
13
+ * - User-level by default, everywhere. macOS REFUSES root outright (a LaunchAgent is per-user, and a
14
+ * root agent could not see the login session's Docker Desktop anyway — the svc.sh precedent).
15
+ * Linux `--system` never executes a privileged write: it stages the render and PRINTS the exact
16
+ * sudo commands (the pm2-startup pattern), so root actions only ever happen in the operator's own
17
+ * shell.
18
+ * - ONE worker per docker daemon (DES-CONCURRENCY-3): install refuses a worker unit when one exists
19
+ * in the OTHER scope, because the worker's boot reaper kills every pi-job container it did not
20
+ * start — a second worker would reap the first's live jobs on every restart. Receivers are exempt
21
+ * from the cross-scope check (a second receiver is pointless, not destructive) but still refuse
22
+ * same-scope duplicates.
23
+ *
24
+ * `restart --drain` composes the README's manual ritual (pause → poll active → restart → resume) with
25
+ * the same VALKEY_URL-only queue connection as cli.mjs's pause/resume: the drain must work even when
26
+ * the rest of the config is broken.
27
+ */
28
+ import { spawn as nodeSpawn } from "node:child_process";
29
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
30
+ import { homedir, tmpdir, userInfo } from "node:os";
31
+ import { dirname, join, resolve } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { parseArgs } from "node:util";
34
+
35
+ // Deploy templates resolved relative to this module (the init.mjs pattern): worker/deploy is SHIPPED
36
+ // in the npm tarball and kept byte-identical to the repo-root deploy/ (the documented source) by
37
+ // worker/test/publish.test.mjs — so `service` renders the same templates from a checkout and from an
38
+ // npm install, no matter where the CLI is invoked from.
39
+ const DEPLOY_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "deploy");
40
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
41
+
42
+ /**
43
+ * The whole substitution surface, template by template. The render replaces ONLY these literals (plus
44
+ * the scope-dependent `User=` / `WantedBy=` rewrites called out below); the pin test in
45
+ * worker/test/service.test.mjs asserts each one is still present in the real deploy/ file, so an edit
46
+ * to a template that would break the render fails the build instead of shipping a broken `service`.
47
+ */
48
+ export const TEMPLATE_PINS = {
49
+ "worker.service": [
50
+ "ExecStart=/usr/bin/node worker/src/cli.mjs worker", // /usr/bin/node → process.execPath
51
+ "WorkingDirectory=/opt/pi-dispatch", // /opt/pi-dispatch → the real repo root
52
+ "EnvironmentFile=/opt/pi-dispatch/.env",
53
+ "\nUser=pi\n", // the DIRECTIVE line (the header comment also says User=pi mid-line, hence the \n anchors): stripped for --user scope; rewritten to the invoking user for --system
54
+ "WantedBy=multi-user.target", // → default.target in user scope (multi-user.target never runs there)
55
+ // Byte-for-byte survivors — semantics the render must not lose:
56
+ "RestartPreventExitStatus=2", // EXIT_POLICY is never restarted (a retry loop is a bill)
57
+ "StartLimitIntervalSec=60",
58
+ "StartLimitBurst=5",
59
+ "KillSignal=SIGTERM",
60
+ "TimeoutStopSec=30",
61
+ ],
62
+ "receiver.service": [
63
+ "ExecStart=/usr/bin/node receiver/src/start.mjs",
64
+ "WorkingDirectory=/opt/pi-dispatch",
65
+ "EnvironmentFile=/opt/pi-dispatch/.env",
66
+ "\nUser=pi\n",
67
+ "WantedBy=multi-user.target",
68
+ "KillSignal=SIGTERM",
69
+ "TimeoutStopSec=30",
70
+ ],
71
+ "com.pi-dispatch.worker.plist": [
72
+ "<string>com.pi-dispatch.worker</string>", // → com.pi-dispatch.receiver for --receiver
73
+ "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>", // gains a `receiver` argument for --receiver
74
+ "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>", // anchor for the PATH injection below
75
+ "<string>/opt/pi-dispatch/logs/worker.out.log</string>",
76
+ "<string>/opt/pi-dispatch/logs/worker.err.log</string>",
77
+ "<key>SuccessfulExit</key>", // the KeepAlive shape the wrapper's exit-2 conversion pairs with
78
+ "<integer>30</integer>", // ExitTimeOut — room for the SIGTERM drain
79
+ ],
80
+ // Windows is command-driven, not file-rendered: install SPAWNS nssmSequence() below instead of
81
+ // copying the .cmd. These pins hold the worked example to the same values the sequence uses, so
82
+ // the two cannot drift apart — especially the AppExit pair, which is the EXIT_POLICY never-retry.
83
+ "nssm-install.cmd": [
84
+ "pi-dispatch-worker",
85
+ "C:\\pi-dispatch", // the REPO placeholder → the real repo root
86
+ "deploy\\worker-env-wrapper.cmd",
87
+ "AppStopMethodConsole 15000",
88
+ "AppThrottle 5000",
89
+ "AppExit Default Restart",
90
+ "AppExit 2 Exit",
91
+ ],
92
+ };
93
+
94
+ const SUBCOMMANDS = new Set(["render", "install", "uninstall", "status", "start", "stop", "restart"]);
95
+
96
+ const SERVICE_USAGE = `pi-dispatch service — run the worker (or --receiver) as an OS service, rendered for THIS host
97
+
98
+ pi-dispatch service render the unit(s) with this host's real node + repo paths
99
+ pi-dispatch service install [--force] write + enable the user-level unit
100
+ (linux --system: prints the exact sudo commands, runs nothing)
101
+ pi-dispatch service uninstall stop, disable and remove the installed unit
102
+ pi-dispatch service status which unit exists in which scope, and whether it is active
103
+ pi-dispatch service start|stop thin launchctl / systemctl --user / nssm wrappers
104
+ pi-dispatch service restart [--drain] restart; --drain pauses the queue, waits for active jobs
105
+ to finish, restarts, resumes [--drain-timeout <s>, default 600]
106
+
107
+ flags: --receiver the webhook receiver instead of the worker
108
+ --user | --system linux scope (default --user; --system never executes root commands)
109
+ --force replace an existing unit in the same scope
110
+ --print also print the rendered unit before installing
111
+ `;
112
+
113
+ export async function runService(argv = [], deps = {}) {
114
+ const {
115
+ env = process.env,
116
+ platform = process.platform,
117
+ euid = typeof process.geteuid === "function" ? process.geteuid() : null,
118
+ execPath = process.execPath,
119
+ repoRoot = REPO_ROOT,
120
+ home = homedir(),
121
+ user = env.USER || userInfo().username,
122
+ tmp = tmpdir(),
123
+ fs = { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync },
124
+ spawn = nodeSpawn,
125
+ out = (s) => process.stdout.write(s),
126
+ err = (s) => process.stderr.write(s),
127
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
128
+ now = () => Date.now(),
129
+ queue = null, // test seam; production builds one lazily in doRestart from VALKEY_URL
130
+ } = deps;
131
+
132
+ let values, positionals;
133
+ try {
134
+ ({ values, positionals } = parseArgs({
135
+ args: argv,
136
+ allowPositionals: true,
137
+ options: {
138
+ receiver: { type: "boolean", default: false },
139
+ user: { type: "boolean", default: false },
140
+ system: { type: "boolean", default: false },
141
+ force: { type: "boolean", default: false },
142
+ print: { type: "boolean", default: false },
143
+ drain: { type: "boolean", default: false },
144
+ "drain-timeout": { type: "string" },
145
+ },
146
+ }));
147
+ } catch (error) {
148
+ return fail(err, error.message);
149
+ }
150
+
151
+ const cmd = positionals[0];
152
+ if (!cmd || !SUBCOMMANDS.has(cmd)) {
153
+ out(SERVICE_USAGE);
154
+ return cmd ? 1 : 0; // bare `service` is a usage view, an unknown subcommand is an error
155
+ }
156
+ if (values.user && values.system) return fail(err, "--user and --system are mutually exclusive");
157
+ if (platform === "darwin" && values.system) {
158
+ return fail(err, "macOS is user-scope only (a LaunchAgent): Docker Desktop lives in the login session, so a system daemon would wait on a docker socket that only exists once you log in. Drop --system.");
159
+ }
160
+ if (platform === "win32" && (values.user || values.system)) {
161
+ return fail(err, "Windows services via nssm are machine-scoped; --user/--system do not apply here");
162
+ }
163
+ if (!["darwin", "linux", "win32"].includes(platform)) return fail(err, `unsupported platform: ${platform}`);
164
+
165
+ const ctx = {
166
+ env,
167
+ platform,
168
+ euid,
169
+ execPath,
170
+ repoRoot,
171
+ home,
172
+ user,
173
+ tmp,
174
+ fs,
175
+ spawn,
176
+ out,
177
+ err,
178
+ sleep,
179
+ now,
180
+ queue,
181
+ which: values.receiver ? "receiver" : "worker",
182
+ scope: platform === "linux" && values.system ? "system" : "user",
183
+ force: values.force,
184
+ };
185
+
186
+ switch (cmd) {
187
+ case "render":
188
+ return doRender(ctx);
189
+ case "install":
190
+ // --print is implied for render and opt-in here: see what will be written, then write it.
191
+ if (values.print) doRender(ctx);
192
+ return doInstall(ctx);
193
+ case "uninstall":
194
+ return doUninstall(ctx);
195
+ case "status":
196
+ return doStatus(ctx);
197
+ case "start":
198
+ return doStart(ctx);
199
+ case "stop":
200
+ return doStop(ctx);
201
+ case "restart":
202
+ return doRestart(ctx, values);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Where each unit lives (or is looked for) per platform. `otherScopeWorkerPaths` exists only to
208
+ * enforce DES-CONCURRENCY-3 at install time — those locations are READ, never written. On Linux the
209
+ * system-scope check covers both our canonical name and `worker.service`, the name the README's
210
+ * manual `sudo cp` instructions produce: a hand-installed worker is still a second worker.
211
+ */
212
+ function unitPaths(ctx) {
213
+ if (ctx.platform === "darwin") {
214
+ const label = `com.pi-dispatch.${ctx.which}`;
215
+ return {
216
+ name: label,
217
+ installPath: join(ctx.home, "Library", "LaunchAgents", `${label}.plist`),
218
+ systemPath: join("/Library/LaunchDaemons", `${label}.plist`),
219
+ otherScopeWorkerPaths: ["/Library/LaunchDaemons/com.pi-dispatch.worker.plist"],
220
+ };
221
+ }
222
+ if (ctx.platform === "linux") {
223
+ const unit = `pi-dispatch-${ctx.which}.service`;
224
+ const userPath = join(ctx.home, ".config", "systemd", "user", unit);
225
+ const systemPath = join("/etc/systemd/system", unit);
226
+ return {
227
+ name: unit,
228
+ userPath,
229
+ systemPath,
230
+ installPath: ctx.scope === "system" ? systemPath : userPath,
231
+ otherScopeWorkerPaths:
232
+ ctx.scope === "system"
233
+ ? [join(ctx.home, ".config", "systemd", "user", "pi-dispatch-worker.service")]
234
+ : ["/etc/systemd/system/pi-dispatch-worker.service", "/etc/systemd/system/worker.service"],
235
+ };
236
+ }
237
+ // win32: the unit is an nssm-registered service, not a file this tool addresses. Same-scope
238
+ // detection happens via `nssm status`, and there is no second scope to cross-check.
239
+ return { name: `pi-dispatch-${ctx.which}` };
240
+ }
241
+
242
+ function readTemplate(ctx, name) {
243
+ return ctx.fs.readFileSync(join(DEPLOY_DIR, name), "utf8");
244
+ }
245
+
246
+ /**
247
+ * Render worker.service / receiver.service for this host. Targeted substitution of the templates'
248
+ * known literals (see TEMPLATE_PINS); everything else — RestartPreventExitStatus=2, the StartLimit
249
+ * crash-loop bound, KillSignal, TimeoutStopSec — passes through byte-for-byte.
250
+ */
251
+ function renderLinuxUnit(ctx) {
252
+ const template = ctx.which === "receiver" ? "receiver.service" : "worker.service";
253
+ // The banner outranks the template's own "TEMPLATE/UNTESTED EXAMPLE — set the PLACEHOLDERs" header,
254
+ // which renders through below (the no-markers design keeps templates byte-usable, so their prose
255
+ // survives): a reader of the rendered unit should know the placeholders are already substituted.
256
+ let unit = `# rendered by \`pi-dispatch service\` — paths computed for this host from deploy/${template};\n# the template's PLACEHOLDER prose below is already substituted.\n` +
257
+ readTemplate(ctx, template)
258
+ .replaceAll("/opt/pi-dispatch", ctx.repoRoot)
259
+ .replaceAll("/usr/bin/node", ctx.execPath);
260
+ if (ctx.scope === "user") {
261
+ // A systemd --user unit always runs as the invoking user, and systemd REJECTS a User= line in
262
+ // user scope ("Unknown lvalue"); the line must not survive the render. The replacement is a
263
+ // comment so the rendered unit explains its own difference from the shipped template. Anchored
264
+ // to the whole line (^…$) because the template's header comment ALSO says "User=pi" mid-line —
265
+ // a bare replace would rewrite the prose and leave the directive standing.
266
+ unit = unit.replace(
267
+ /^User=pi$/m,
268
+ "# User= stripped by `pi-dispatch service`: a --user unit always runs as the invoking user,\n# and systemd rejects User= in user scope.",
269
+ );
270
+ // multi-user.target exists only in the SYSTEM instance. A user unit enabled into it would
271
+ // symlink into a .wants/ directory no user-instance boot ever walks — enabled but never
272
+ // started. default.target is the user manager's boot target.
273
+ unit = unit.replace("WantedBy=multi-user.target", "WantedBy=default.target");
274
+ } else {
275
+ unit = unit.replace(/^User=pi$/m, `User=${ctx.user}`);
276
+ }
277
+ return unit;
278
+ }
279
+
280
+ /**
281
+ * Render the launchd plist for this host. For --receiver the worker plist is DERIVED, not a second
282
+ * template: same KeepAlive/ExitTimeOut shape, label and log names swapped, and the shared wrapper told
283
+ * (via its one argument) to run the receiver. The wrapper's exit-2 conversion is a no-op for the
284
+ * receiver — it has no EXIT_POLICY — and harmless.
285
+ */
286
+ function renderPlist(ctx) {
287
+ let plist = readTemplate(ctx, "com.pi-dispatch.worker.plist");
288
+ if (ctx.which === "receiver") {
289
+ plist = plist
290
+ .replace("<string>com.pi-dispatch.worker</string>", "<string>com.pi-dispatch.receiver</string>")
291
+ .replace(
292
+ "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>",
293
+ "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>\n\t\t<!-- the argument that makes the shared .env wrapper run the receiver instead of the worker -->\n\t\t<string>receiver</string>",
294
+ )
295
+ .replaceAll("worker.out.log", "receiver.out.log")
296
+ .replaceAll("worker.err.log", "receiver.err.log");
297
+ }
298
+ // launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it, and the
299
+ // wrapper invokes bare `node`. Prepend the directory of the node that ran this render so the
300
+ // service runs the SAME binary, no guessing. PATH is configuration, not a secret: the template's
301
+ // deliberate no-EnvironmentVariables stance is about credentials, which still live only in .env.
302
+ plist = plist.replace(
303
+ "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>",
304
+ "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>\n\n\t<!-- Injected by `pi-dispatch service`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and the wrapper calls bare `node`. Not a secrets dict - credentials still live only in .env\n\t (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>PATH</key>\n\t\t<string>" +
305
+ `${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>\n\t</dict>`,
306
+ );
307
+ return plist.replaceAll("/opt/pi-dispatch", ctx.repoRoot);
308
+ }
309
+
310
+ /**
311
+ * The nssm command sequence — deploy/nssm-install.cmd's exact steps with computed paths. Values that
312
+ * carry semantics (AppStopMethodConsole 15000, AppThrottle 5000, AppExit Default Restart, AppExit 2
313
+ * Exit) mirror the template byte-for-byte and are pinned. Backslashes on purpose: this argv reaches
314
+ * nssm on a real Windows host, where repoRoot is already a Windows path.
315
+ */
316
+ function nssmSequence(ctx) {
317
+ const service = `pi-dispatch-${ctx.which}`;
318
+ const logDir = `${ctx.repoRoot}\\logs`;
319
+ return {
320
+ service,
321
+ commands: [
322
+ ["install", service, `${ctx.repoRoot}\\deploy\\worker-env-wrapper.cmd`, ...(ctx.which === "receiver" ? ["receiver"] : [])],
323
+ ["set", service, "AppDirectory", ctx.repoRoot],
324
+ ["set", service, "AppStdout", `${logDir}\\${ctx.which}.out.log`],
325
+ ["set", service, "AppStderr", `${logDir}\\${ctx.which}.err.log`],
326
+ ["set", service, "AppStopMethodConsole", "15000"],
327
+ ["set", service, "AppThrottle", "5000"],
328
+ ["set", service, "AppExit", "Default", "Restart"],
329
+ ["set", service, "AppExit", "2", "Exit"],
330
+ ],
331
+ };
332
+ }
333
+
334
+ function doRender(ctx) {
335
+ const paths = unitPaths(ctx);
336
+ if (ctx.platform === "darwin") {
337
+ ctx.out(`# → ${paths.installPath}\n`);
338
+ ctx.out(renderPlist(ctx));
339
+ ctx.out(
340
+ "\n# note: ProgramArguments runs deploy/worker-env-wrapper.sh — launchd has no EnvironmentFile,\n# so the wrapper loads .env at runtime. The wrapper also converts a policy refusal (exit 2,\n# EXIT_POLICY) into a clean exit, so KeepAlive never relaunch-loops a refusal into a provider bill.\n",
341
+ );
342
+ return 0;
343
+ }
344
+ if (ctx.platform === "linux") {
345
+ ctx.out(`# → ${paths.installPath}\n`);
346
+ ctx.out(renderLinuxUnit(ctx));
347
+ return 0;
348
+ }
349
+ const { service, commands } = nssmSequence(ctx);
350
+ ctx.out("The nssm sequence for this host (nssm.exe: https://nssm.cc, or `winget install nssm`):\n\n");
351
+ for (const args of commands) ctx.out(` nssm ${quoteArgs(args)}\n`);
352
+ ctx.out(` nssm start ${service}\n`);
353
+ return 0;
354
+ }
355
+
356
+ async function doInstall(ctx) {
357
+ const paths = unitPaths(ctx);
358
+
359
+ // THE refusal, worker only: one worker per docker daemon (DES-CONCURRENCY-3). The worker's boot
360
+ // reaper treats every running pi-job container as an orphan of its OWN previous life and kills it,
361
+ // so a second worker — even in the other scope — would reap the first worker's live jobs on every
362
+ // restart. Receivers are exempt: a second receiver is pointless, not destructive.
363
+ if (ctx.which === "worker") {
364
+ const other = paths.otherScopeWorkerPaths?.find((p) => ctx.fs.existsSync(p));
365
+ if (other) {
366
+ return fail(
367
+ ctx.err,
368
+ `a worker unit already exists in the other scope: ${other}\n` +
369
+ "one worker per docker daemon (DES-CONCURRENCY-3): the worker's boot reaper kills every pi-job container it did not start, so a second worker would kill the first's live jobs. Remove that unit first.",
370
+ );
371
+ }
372
+ }
373
+
374
+ if (ctx.platform === "darwin") return installDarwin(ctx, paths);
375
+ if (ctx.platform === "linux") return ctx.scope === "system" ? installLinuxSystem(ctx, paths) : installLinuxUser(ctx, paths);
376
+ return installWindows(ctx);
377
+ }
378
+
379
+ async function installDarwin(ctx, paths) {
380
+ // Root refusal (the svc.sh darwin precedent): sudo would bootstrap into ROOT's gui domain and
381
+ // write root's LaunchAgents — a unit the operator's own session neither sees nor controls, running
382
+ // outside the login session that owns Docker Desktop.
383
+ if (ctx.euid === 0) {
384
+ return fail(
385
+ ctx.err,
386
+ "refusing to run as root on macOS: the unit belongs in YOUR ~/Library/LaunchAgents, bootstrapped into your gui domain — sudo would install it for root, outside the login session that owns Docker Desktop. Re-run without sudo.",
387
+ );
388
+ }
389
+ const existed = ctx.fs.existsSync(paths.installPath);
390
+ if (existed && !ctx.force) {
391
+ return fail(ctx.err, `${paths.installPath} already exists — pass --force to replace and re-bootstrap it (same non-clobber contract as init)`);
392
+ }
393
+ if (existed) {
394
+ // --force replaces a possibly-loaded unit: bootstrap refuses an already-loaded label, so boot
395
+ // the old copy out first. "Not loaded" is a fine answer — the nonzero exit is ignored.
396
+ await run(ctx, "launchctl", ["bootout", `gui/${ctx.euid}/${paths.name}`]);
397
+ }
398
+ ctx.fs.mkdirSync(dirname(paths.installPath), { recursive: true });
399
+ ctx.fs.writeFileSync(paths.installPath, renderPlist(ctx));
400
+ const bootstrap = await run(ctx, "launchctl", ["bootstrap", `gui/${ctx.euid}`, paths.installPath]);
401
+ if (bootstrap !== 0) {
402
+ return fail(ctx.err, `launchctl bootstrap failed (exit ${bootstrap}) — the plist is written; retry by hand: launchctl bootstrap gui/${ctx.euid} ${paths.installPath}`);
403
+ }
404
+ const enable = await run(ctx, "launchctl", ["enable", `gui/${ctx.euid}/${paths.name}`]);
405
+ if (enable !== 0) return fail(ctx.err, `launchctl enable gui/${ctx.euid}/${paths.name} failed (exit ${enable})`);
406
+ ctx.out(`installed ${paths.name} → ${paths.installPath} (bootstrapped into gui/${ctx.euid}; RunAtLoad starts it now and on login)\n`);
407
+ // The honest note: no pretending a LaunchAgent is a boot daemon. It is the right fit anyway.
408
+ ctx.out(
409
+ "note: a LaunchAgent is LOGIN-scoped — it runs while you are logged in, not from boot. That is the honest fit here: Docker Desktop is itself login-scoped, so a boot-time daemon would only wait on a docker socket that appears at login anyway.\n",
410
+ );
411
+ return 0;
412
+ }
413
+
414
+ async function installLinuxUser(ctx, paths) {
415
+ if (ctx.fs.existsSync(paths.installPath) && !ctx.force) {
416
+ return fail(ctx.err, `${paths.installPath} already exists — pass --force to replace it (same non-clobber contract as init)`);
417
+ }
418
+ ctx.fs.mkdirSync(dirname(paths.installPath), { recursive: true });
419
+ ctx.fs.writeFileSync(paths.installPath, renderLinuxUnit(ctx));
420
+ const reload = await run(ctx, "systemctl", ["--user", "daemon-reload"]);
421
+ if (reload === null) return fail(ctx.err, `systemctl not found — is this a systemd host? The unit is written at ${paths.installPath}`);
422
+ const enable = await run(ctx, "systemctl", ["--user", "enable", "--now", paths.name]);
423
+ if (enable !== 0) {
424
+ return fail(ctx.err, `systemctl --user enable --now ${paths.name} failed (exit ${enable}) — the unit is written at ${paths.installPath}; \`systemctl --user status ${paths.name}\` has the details`);
425
+ }
426
+ ctx.out(`installed ${paths.name} → ${paths.installPath} (enabled and started in your user manager)\n`);
427
+ // Without linger a user manager only runs while a session exists — fine on a desktop, a silent
428
+ // no-worker-after-reboot on a headless box. Say so instead of letting the operator find out.
429
+ ctx.out(`note: user units run while you have a session. For a headless host that must start at boot: sudo loginctl enable-linger ${ctx.user}\n`);
430
+ return 0;
431
+ }
432
+
433
+ async function installLinuxSystem(ctx, paths) {
434
+ if (ctx.fs.existsSync(paths.installPath) && !ctx.force) {
435
+ return fail(ctx.err, `${paths.installPath} already exists — pass --force to re-stage the render (the printed sudo commands would overwrite it)`);
436
+ }
437
+ // The pm2-startup pattern: this tool NEVER writes or spawns as root. The render is staged where
438
+ // the operator can read it, and the exact commands are printed — their shell is the consent gate.
439
+ const staged = join(ctx.tmp, paths.name);
440
+ ctx.fs.writeFileSync(staged, renderLinuxUnit(ctx));
441
+ ctx.out(`--system never runs root commands from this tool. The rendered unit is staged at:\n ${staged}\n\nInspect it, then run:\n sudo install -m 644 ${staged} ${paths.installPath}\n sudo systemctl daemon-reload\n sudo systemctl enable --now ${paths.name}\n`);
442
+ return 0;
443
+ }
444
+
445
+ async function installWindows(ctx) {
446
+ const { service, commands } = nssmSequence(ctx);
447
+ // One probe answers two questions: is nssm on PATH (ENOENT → no), and does the service already
448
+ // exist (exit 0 → yes). Task Scheduler is deliberately never offered — it stops tasks with a hard
449
+ // kill, so the worker could never drain (see deploy/nssm-install.cmd's rationale).
450
+ const status = await runCapture(ctx, "nssm", ["status", service]);
451
+ if (status.code === null) {
452
+ return fail(ctx.err, "nssm.exe not found on PATH — download it from https://nssm.cc (or `winget install nssm`) and re-run. Task Scheduler is not a substitute: it kills instead of stopping, so the worker could never drain.");
453
+ }
454
+ if (status.code === 0 && !ctx.force) {
455
+ return fail(ctx.err, `service ${service} already exists (nssm status reports it) — pass --force to remove and re-install it`);
456
+ }
457
+ if (status.code === 0) {
458
+ await run(ctx, "nssm", ["stop", service]); // may already be stopped; nonzero is fine
459
+ const removed = await run(ctx, "nssm", ["remove", service, "confirm"]);
460
+ if (removed !== 0) return fail(ctx.err, `nssm remove ${service} confirm failed (exit ${removed})`);
461
+ }
462
+ for (const args of commands) {
463
+ const code = await run(ctx, "nssm", args);
464
+ if (code !== 0) return fail(ctx.err, `nssm ${args.join(" ")} failed (exit ${code})`);
465
+ }
466
+ // Mirrors the template's own last line: install registers, `nssm start` is the one visible step
467
+ // left to the operator (the service auto-starts on the next boot either way).
468
+ ctx.out(`installed service ${service}. Start it with: nssm start ${service}\n`);
469
+ return 0;
470
+ }
471
+
472
+ async function doUninstall(ctx) {
473
+ const paths = unitPaths(ctx);
474
+ if (ctx.platform === "win32") {
475
+ const status = await runCapture(ctx, "nssm", ["status", paths.name]);
476
+ if (status.code === null) return fail(ctx.err, "nssm.exe not found on PATH — it is also how uninstall talks to the service manager (https://nssm.cc)");
477
+ if (status.code !== 0) return fail(ctx.err, `service ${paths.name} is not installed (\`nssm status ${paths.name}\` reports none)`);
478
+ await run(ctx, "nssm", ["stop", paths.name]); // already-stopped is fine
479
+ const removed = await run(ctx, "nssm", ["remove", paths.name, "confirm"]);
480
+ if (removed !== 0) return fail(ctx.err, `nssm remove ${paths.name} confirm failed (exit ${removed})`);
481
+ ctx.out(`uninstalled service ${paths.name}\n`);
482
+ return 0;
483
+ }
484
+ const userPath = ctx.platform === "darwin" ? paths.installPath : paths.userPath;
485
+ if (!ctx.fs.existsSync(userPath)) {
486
+ // Say where it looked — both scopes — and if the unit turns out to live in ROOT scope, print
487
+ // the removal commands instead of touching them (the same never-root doctrine as install).
488
+ if (ctx.fs.existsSync(paths.systemPath)) {
489
+ const rootCmds =
490
+ ctx.platform === "darwin"
491
+ ? ` sudo launchctl bootout system/${paths.name}\n sudo rm ${paths.systemPath}`
492
+ : ` sudo systemctl disable --now ${paths.name}\n sudo rm ${paths.systemPath}\n sudo systemctl daemon-reload`;
493
+ return fail(ctx.err, `not installed in user scope (looked at ${userPath}); a SYSTEM-scope unit exists at ${paths.systemPath} — this tool never touches root scope. Remove it with:\n${rootCmds}`);
494
+ }
495
+ return fail(ctx.err, `${paths.name} is not installed — looked at ${userPath} (user scope) and ${paths.systemPath} (system scope)`);
496
+ }
497
+ if (ctx.platform === "darwin") {
498
+ await run(ctx, "launchctl", ["bootout", `gui/${ctx.euid}/${paths.name}`]); // not-loaded is fine
499
+ ctx.fs.unlinkSync(userPath);
500
+ ctx.out(`uninstalled ${paths.name} (booted out of gui/${ctx.euid}, plist removed)\n`);
501
+ return 0;
502
+ }
503
+ await run(ctx, "systemctl", ["--user", "disable", "--now", paths.name]); // not-enabled is fine
504
+ ctx.fs.unlinkSync(userPath);
505
+ await run(ctx, "systemctl", ["--user", "daemon-reload"]);
506
+ ctx.out(`uninstalled ${paths.name} (disabled, stopped, unit removed)\n`);
507
+ return 0;
508
+ }
509
+
510
+ /** Informational only — reports every scope it knows about and always exits 0. */
511
+ async function doStatus(ctx) {
512
+ const paths = unitPaths(ctx);
513
+ if (ctx.platform === "win32") {
514
+ const status = await runCapture(ctx, "nssm", ["status", paths.name]);
515
+ if (status.code === null) ctx.out(`${paths.name}: cannot query — nssm.exe not on PATH (https://nssm.cc)\n`);
516
+ else if (status.code !== 0) ctx.out(`${paths.name}: not installed\n`);
517
+ else ctx.out(`${paths.name}: ${status.output.trim()}\n`);
518
+ return 0;
519
+ }
520
+ if (ctx.platform === "darwin") {
521
+ if (ctx.fs.existsSync(paths.installPath)) {
522
+ const print = await runCapture(ctx, "launchctl", ["print", `gui/${ctx.euid}/${paths.name}`]);
523
+ ctx.out(`user scope: ${paths.installPath} — ${print.code === 0 ? "loaded" : "installed but NOT loaded (launchctl bootstrap it, or `pi-dispatch service start`)"}\n`);
524
+ } else {
525
+ ctx.out(`user scope: not installed (${paths.installPath})\n`);
526
+ }
527
+ ctx.out(ctx.fs.existsSync(paths.systemPath) ? `system scope: ${paths.systemPath} EXISTS — not managed by this tool\n` : "system scope: none\n");
528
+ return 0;
529
+ }
530
+ if (ctx.fs.existsSync(paths.userPath)) {
531
+ const active = await runCapture(ctx, "systemctl", ["--user", "is-active", paths.name]);
532
+ ctx.out(`user scope: ${paths.userPath} — ${active.code === null ? "systemctl not found" : active.output.trim() || "unknown"}\n`);
533
+ } else {
534
+ ctx.out(`user scope: not installed (${paths.userPath})\n`);
535
+ }
536
+ const systemHits = [paths.systemPath, ...(ctx.which === "worker" ? ["/etc/systemd/system/worker.service"] : [])].filter((p) => ctx.fs.existsSync(p));
537
+ ctx.out(systemHits.length ? `system scope: ${systemHits.join(", ")} EXISTS — not managed by this tool\n` : "system scope: none\n");
538
+ return 0;
539
+ }
540
+
541
+ async function doStart(ctx) {
542
+ return startStop(ctx, "start");
543
+ }
544
+
545
+ async function doStop(ctx) {
546
+ return startStop(ctx, "stop");
547
+ }
548
+
549
+ /**
550
+ * Thin per-OS start/stop. macOS stop is `launchctl kill SIGTERM`, NOT bootout: bootout unloads the
551
+ * unit entirely, while kill delivers the same graceful SIGTERM systemd's stop does — the worker
552
+ * drains, exits 0, and KeepAlive's SuccessfulExit=false leaves a clean exit stopped.
553
+ */
554
+ async function startStop(ctx, verb) {
555
+ const paths = unitPaths(ctx);
556
+ if (ctx.platform === "linux" && ctx.scope === "system") {
557
+ return fail(ctx.err, `--system is print-only (this tool never runs root commands). Run:\n sudo systemctl ${verb} ${paths.name}`);
558
+ }
559
+ let code;
560
+ if (ctx.platform === "darwin") {
561
+ code =
562
+ verb === "start"
563
+ ? await run(ctx, "launchctl", ["kickstart", `gui/${ctx.euid}/${paths.name}`])
564
+ : await run(ctx, "launchctl", ["kill", "SIGTERM", `gui/${ctx.euid}/${paths.name}`]);
565
+ } else if (ctx.platform === "linux") {
566
+ code = await run(ctx, "systemctl", ["--user", verb, paths.name]);
567
+ } else {
568
+ code = await run(ctx, "nssm", [verb, paths.name]);
569
+ }
570
+ if (code !== 0) return fail(ctx.err, `${verb} ${paths.name} failed (${code === null ? "service tool not found" : `exit ${code}`}) — is it installed? (pi-dispatch service status)`);
571
+ ctx.out(`${verb === "start" ? "started" : "stopped"} ${paths.name}\n`);
572
+ return 0;
573
+ }
574
+
575
+ async function doRestart(ctx, values) {
576
+ if (!values.drain) {
577
+ const stopped = await doStop(ctx);
578
+ if (stopped !== 0) return stopped;
579
+ return doStart(ctx);
580
+ }
581
+
582
+ const timeoutS = Number(values["drain-timeout"] ?? "600");
583
+ if (!Number.isFinite(timeoutS) || timeoutS <= 0) {
584
+ return fail(ctx.err, `--drain-timeout must be a positive number of seconds, got: ${values["drain-timeout"]}`);
585
+ }
586
+ let queue = ctx.queue;
587
+ if (!queue) {
588
+ // VALKEY_URL only, exactly like cli.mjs's pause/resume: the drain must work even when the rest
589
+ // of the config (forge auth …) is broken, and failFast keeps a down Valkey an error in seconds
590
+ // instead of a hung restart. Lazy imports for the same reason cli.mjs uses them: `service`
591
+ // subcommands that never touch the queue must not load bullmq/ioredis.
592
+ const url = ctx.env.VALKEY_URL ?? "redis://127.0.0.1:6379";
593
+ const { parseConnection } = await import("./connection.mjs");
594
+ const { makeQueue } = await import("./queue.mjs");
595
+ queue = makeQueue(parseConnection(url, { failFast: true }));
596
+ }
597
+ try {
598
+ await queue.pause();
599
+ ctx.out("paused — no new jobs will start; waiting for active jobs to finish\n");
600
+ const deadline = ctx.now() + timeoutS * 1000;
601
+ let { active = 0 } = await queue.getJobCounts("active");
602
+ while (active > 0) {
603
+ if (ctx.now() >= deadline) {
604
+ // Deliberately NO resume and NO restart: a job is still running. Restarting would abort
605
+ // it; resuming would feed new jobs toward a restart that is still owed. Paused is the
606
+ // safe durable state (it survives the restart the operator will now do by hand).
607
+ ctx.out(
608
+ `drain timed out after ${timeoutS}s with ${active} job(s) still active — NOT restarting.\nThe queue STAYS PAUSED so the running job can finish undisturbed. Investigate (pi-dispatch status), then restart and \`pi-dispatch resume\` yourself.\n`,
609
+ );
610
+ return 1;
611
+ }
612
+ ctx.out(` ${active} active — waiting\n`);
613
+ await ctx.sleep(2000);
614
+ ({ active = 0 } = await queue.getJobCounts("active"));
615
+ }
616
+ const stopped = await doStop(ctx);
617
+ if (stopped !== 0) {
618
+ ctx.out("restart did not happen — the queue STAYS PAUSED; fix the service, then `pi-dispatch resume`.\n");
619
+ return 1;
620
+ }
621
+ const started = await doStart(ctx);
622
+ if (started !== 0) {
623
+ ctx.out("the service did not come back — the queue STAYS PAUSED; fix the service, then `pi-dispatch resume`.\n");
624
+ return 1;
625
+ }
626
+ await queue.resume();
627
+ ctx.out("resumed — drained restart complete\n");
628
+ return 0;
629
+ } catch (error) {
630
+ return fail(ctx.err, `could not reach Valkey — is it running? (docker compose up)\n ${error.message}`);
631
+ } finally {
632
+ await queue.close().catch(() => {});
633
+ }
634
+ }
635
+
636
+ function fail(err, message) {
637
+ err(`error: ${message}\n`);
638
+ return 1;
639
+ }
640
+
641
+ /** Re-join an argv for display; quote what cmd.exe would split (spaces) or what is a path (backslashes). */
642
+ function quoteArgs(args) {
643
+ return args.map((a) => (a.includes(" ") || a.includes("\\") ? `"${a}"` : a)).join(" ");
644
+ }
645
+
646
+ /** Exit code of a spawned command; null when it could not launch (not on PATH) — the up.mjs pattern. */
647
+ function run(ctx, cmd, args) {
648
+ return new Promise((resolvePromise) => {
649
+ let child;
650
+ try {
651
+ child = ctx.spawn(cmd, args, { stdio: "ignore" });
652
+ } catch {
653
+ resolvePromise(null);
654
+ return;
655
+ }
656
+ child.on("error", () => resolvePromise(null));
657
+ child.on("close", (code) => resolvePromise(code));
658
+ });
659
+ }
660
+
661
+ /** Like run() but with stdout+stderr captured, for read-only lookups (nssm status, is-active …). */
662
+ function runCapture(ctx, cmd, args) {
663
+ return new Promise((resolvePromise) => {
664
+ let child;
665
+ try {
666
+ child = ctx.spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
667
+ } catch {
668
+ resolvePromise({ code: null, output: "" });
669
+ return;
670
+ }
671
+ let output = "";
672
+ child.stdout?.on("data", (d) => (output += d));
673
+ child.stderr?.on("data", (d) => (output += d));
674
+ child.on("error", () => resolvePromise({ code: null, output }));
675
+ child.on("close", (code) => resolvePromise({ code, output }));
676
+ });
677
+ }