@edgehero/pi-dispatch 1.0.0 → 1.2.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.
- package/.env.example +26 -1
- package/deploy/docker-compose.yml +59 -0
- package/deploy/egress-proxy.conf +36 -0
- package/deploy/receiver.service +11 -0
- package/deploy/worker-env-wrapper.cmd +41 -4
- package/deploy/worker-env-wrapper.sh +96 -7
- package/package.json +1 -1
- package/src/azure-prompt.mjs +57 -9
- package/src/config.mjs +133 -6
- package/src/docker-run.mjs +16 -1
- package/src/doctor.mjs +398 -13
- package/src/egress.mjs +221 -0
- package/src/env-allowlist.mjs +16 -1
- package/src/forgejo-prompt.mjs +65 -11
- package/src/get-token.mjs +5 -3
- package/src/github-prompt.mjs +11 -2
- package/src/gitlab-prompt.mjs +65 -11
- package/src/init.mjs +30 -0
- package/src/packages.mjs +4 -1
- package/src/prepare-github.mjs +6 -4
- package/src/processor.mjs +67 -5
- package/src/run-container.mjs +42 -6
- package/src/run-history.mjs +41 -3
- package/src/sandbox-cli.mjs +24 -3
- package/src/sandbox.mjs +14 -1
- package/src/service.mjs +289 -25
- package/src/session-store.mjs +294 -15
- package/src/start.mjs +29 -0
- package/src/triggers.mjs +11 -12
- package/src/up.mjs +58 -0
package/src/service.mjs
CHANGED
|
@@ -94,6 +94,13 @@ export const TEMPLATE_PINS = {
|
|
|
94
94
|
"EnvironmentFile=/opt/pi-dispatch/.env",
|
|
95
95
|
"\nUser=pi\n",
|
|
96
96
|
"WantedBy=multi-user.target",
|
|
97
|
+
// Byte-for-byte survivors — semantics the render must not lose, matching worker.service's list
|
|
98
|
+
// above. Added with #187: the receiver had none of the three, so any config it could not parse
|
|
99
|
+
// produced an unbounded five-second restart loop, and `service install` rendering them away would
|
|
100
|
+
// put that back on a deployment that had just been fixed.
|
|
101
|
+
"RestartPreventExitStatus=2", // EXIT_POLICY is never restarted (the next start reads the same bad file)
|
|
102
|
+
"StartLimitIntervalSec=60",
|
|
103
|
+
"StartLimitBurst=5",
|
|
97
104
|
"KillSignal=SIGTERM",
|
|
98
105
|
"TimeoutStopSec=30",
|
|
99
106
|
],
|
|
@@ -118,8 +125,112 @@ export const TEMPLATE_PINS = {
|
|
|
118
125
|
"AppExit Default Restart",
|
|
119
126
|
"AppExit 2 Exit",
|
|
120
127
|
],
|
|
128
|
+
// The wrappers are COPIED, never substituted, so they have no substitution surface of their own.
|
|
129
|
+
// They are pinned anyway for the same reason nssm-install.cmd is: the render EMITS the variable name
|
|
130
|
+
// they read back (`--env-setup`, issue #209 — the plist's EnvironmentVariables dict on macOS, nssm's
|
|
131
|
+
// AppEnvironmentExtra on Windows), and a rename on one side with silence on the other would produce
|
|
132
|
+
// a unit that starts fine and ignores the operator's secrets manager.
|
|
133
|
+
"worker-env-wrapper.sh": ["PI_ENV_SETUP"],
|
|
134
|
+
"worker-env-wrapper.cmd": ["PI_ENV_SETUP"],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The env-setup seam (issue #209): an operator-typed path to their OWN script, sourced before the
|
|
139
|
+
* worker starts, so a secrets manager can put the provider key and the forge token into the process
|
|
140
|
+
* environment without anyone hand-editing a rendered unit. The renderer owns the `exec` that follows
|
|
141
|
+
* it, which is the whole point — the hand-edit an operator naturally reaches for (`<manager> run -- …`)
|
|
142
|
+
* reports the CHILD's exit 2 as 1, and exit 2 is the determinate policy refusal that
|
|
143
|
+
* RestartPreventExitStatus=2, nssm's `AppExit 2 Exit` and the wrapper's exit-2 conversion all key on.
|
|
144
|
+
*
|
|
145
|
+
* A PATH and not a command, deliberately. systemd expands `$…`/`${…}` inside Exec lines whatever the
|
|
146
|
+
* quoting, a plist is XML, and nssm's argv is neither, so an inline command would need three separate
|
|
147
|
+
* escaping stories — and it is the shape docs/secrets.md already tells operators not to write.
|
|
148
|
+
*
|
|
149
|
+
* Absolute, and never resolved against anything: `.` in POSIX sh SEARCHES $PATH for an operand with no
|
|
150
|
+
* slash in it, so a relative path here would be a different file depending on the service manager's
|
|
151
|
+
* environment. Refuse rather than guess.
|
|
152
|
+
*
|
|
153
|
+
* The refused characters are per platform because the constraint is: on Linux the path lands inside a
|
|
154
|
+
* systemd Exec word (which owns `$`, `"` and `\`), on macOS inside a plist <string> (XML), and on
|
|
155
|
+
* Windows inside an nssm NAME=VALUE that cmd expands with `%`. Spaces are fine everywhere: every
|
|
156
|
+
* composed form quotes.
|
|
157
|
+
*/
|
|
158
|
+
const ENV_SETUP_REFUSALS = {
|
|
159
|
+
linux: { re: /['"$\\\x00-\x1f\x7f]/, why: "the path lands inside a single-quoted systemd Exec word, and systemd expands $VAR there whatever the quoting" },
|
|
160
|
+
darwin: { re: /[<>&"\x00-\x1f\x7f]/, why: "the launchd unit is a plist, and the path lands in an XML <string>" },
|
|
161
|
+
win32: { re: /[%"\x00-\x1f\x7f]/, why: "cmd expands %VAR% when the wrapper runs the script" },
|
|
121
162
|
};
|
|
122
163
|
|
|
164
|
+
function resolveEnvSetup(ctx, raw) {
|
|
165
|
+
const path = String(raw);
|
|
166
|
+
const absolute = ctx.platform === "win32" ? /^([A-Za-z]:[\\/]|\\\\)/.test(path) : path.startsWith("/");
|
|
167
|
+
if (!absolute) {
|
|
168
|
+
return { error: `--env-setup needs an ABSOLUTE path, got: ${path}\nPOSIX \`.\` searches $PATH for an operand with no slash in it, and a service manager's environment is not your shell's, so a relative path here is a different file on every host.` };
|
|
169
|
+
}
|
|
170
|
+
const { re, why } = ENV_SETUP_REFUSALS[ctx.platform];
|
|
171
|
+
const hit = re.exec(path);
|
|
172
|
+
if (hit) {
|
|
173
|
+
const shown = hit[0].charCodeAt(0) < 0x20 || hit[0].charCodeAt(0) === 0x7f ? `\\x${hit[0].charCodeAt(0).toString(16).padStart(2, "0")}` : hit[0];
|
|
174
|
+
return { error: `--env-setup path contains ${JSON.stringify(shown)}, which cannot be rendered safely on ${ctx.platform}: ${why}. Move the script somewhere without it: ${path}` };
|
|
175
|
+
}
|
|
176
|
+
if (!ctx.fs.existsSync(path)) {
|
|
177
|
+
return { error: `--env-setup script not found: ${path}\nRefusing rather than rendering: a unit pointing at a script that is not there installs cleanly and then fails at every boot.` };
|
|
178
|
+
}
|
|
179
|
+
return { path };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The read side of the same seam (issue #216). `--env-setup` is a RENDER-TIME flag: once the unit is
|
|
184
|
+
* written, the unit is the only record of the path, so a preflight that wants to check the script has
|
|
185
|
+
* to read it back out of the file that actually boots. These patterns match exactly what
|
|
186
|
+
* composeEnvSetupExec and renderPlist emit, and they live here beside ENV_SETUP_REFUSALS so the writer
|
|
187
|
+
* and the reader cannot drift apart unnoticed — worker/test/service.test.mjs round-trips every render
|
|
188
|
+
* through readUnitSeam, so changing one half without the other turns the suite red.
|
|
189
|
+
*
|
|
190
|
+
* The capture classes are exact rather than lazy because ENV_SETUP_REFUSALS already guarantees what
|
|
191
|
+
* cannot be in the path: no `"` on any platform, so `[^"]*` ends where the renderer's quote does, and
|
|
192
|
+
* no `<`/`>`/`&` on macOS, so a plist <string> can never hold an XML entity to decode.
|
|
193
|
+
*
|
|
194
|
+
* win32 is not a file at all — the value comes back from `nssm get <service> AppEnvironmentExtra`,
|
|
195
|
+
* which some nssm builds write as UTF-16, hence the NUL strip in readUnitSeam.
|
|
196
|
+
*/
|
|
197
|
+
const ENV_SETUP_READERS = {
|
|
198
|
+
linux: {
|
|
199
|
+
setup: /^ExecStart=\/bin\/sh -c 'set -a; \. "([^"]*)" \|\| exit 1;/m,
|
|
200
|
+
deployDir: /^WorkingDirectory=(.*)$/m,
|
|
201
|
+
},
|
|
202
|
+
darwin: {
|
|
203
|
+
setup: /<key>PI_ENV_SETUP<\/key>\s*<string>([^<]*)<\/string>/,
|
|
204
|
+
deployDir: /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/,
|
|
205
|
+
},
|
|
206
|
+
win32: {
|
|
207
|
+
setup: /^PI_ENV_SETUP=(.*)$/m,
|
|
208
|
+
// nssm holds the deployment folder in a SEPARATE property (AppDirectory), and there is only one
|
|
209
|
+
// machine-scoped service per name to confuse it with — so the caller matches on nothing here
|
|
210
|
+
// rather than spending a second `nssm get` to answer a question Windows cannot ask twice.
|
|
211
|
+
deployDir: null,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What an installed unit records: the configured `--env-setup` path and the deployment folder it
|
|
217
|
+
* serves. Both null for a unit rendered without the flag, which is every unit that predates issue #209
|
|
218
|
+
* and every default render since. Never throws: an unreadable, truncated or hand-rewritten unit simply
|
|
219
|
+
* reads as "no seam configured", because a preflight guessing at a shape it does not recognise is
|
|
220
|
+
* worse than one that says nothing.
|
|
221
|
+
*/
|
|
222
|
+
export function readUnitSeam(text, platform) {
|
|
223
|
+
const readers = ENV_SETUP_READERS[platform];
|
|
224
|
+
if (!readers || typeof text !== "string") return { setup: null, deployDir: null };
|
|
225
|
+
const clean = text.replace(/\0/g, "");
|
|
226
|
+
const grab = (re) => (re ? (re.exec(clean)?.[1] ?? null) : null);
|
|
227
|
+
// Only a trailing CR is stripped, never surrounding whitespace: on the quoted and XML forms the
|
|
228
|
+
// capture already ends exactly where the renderer's delimiter does, and a path is allowed to hold a
|
|
229
|
+
// space anywhere in it. An empty value means the same as no value at all.
|
|
230
|
+
const clip = (v) => (v === null ? null : v.replace(/\r$/, "") || null);
|
|
231
|
+
return { setup: clip(grab(readers.setup)), deployDir: clip(grab(readers.deployDir)) };
|
|
232
|
+
}
|
|
233
|
+
|
|
123
234
|
const SUBCOMMANDS = new Set(["render", "install", "uninstall", "status", "start", "stop", "restart"]);
|
|
124
235
|
|
|
125
236
|
const SERVICE_USAGE = `pi-dispatch service — run the worker (or --receiver) as an OS service, rendered for THIS host
|
|
@@ -137,6 +248,9 @@ const SERVICE_USAGE = `pi-dispatch service — run the worker (or --receiver) as
|
|
|
137
248
|
--user | --system linux scope (default --user; --system never executes root commands)
|
|
138
249
|
--force replace an existing unit in the same scope
|
|
139
250
|
--print also print the rendered unit before installing
|
|
251
|
+
--env-setup <path> render|install: source YOUR script before the worker starts, so a secrets
|
|
252
|
+
manager fills the environment. Absolute path, env only (no exec, no paths):
|
|
253
|
+
the render owns the exec, so a policy refusal still exits 2. docs/secrets.md
|
|
140
254
|
`;
|
|
141
255
|
|
|
142
256
|
export async function runService(argv = [], deps = {}) {
|
|
@@ -175,6 +289,7 @@ export async function runService(argv = [], deps = {}) {
|
|
|
175
289
|
print: { type: "boolean", default: false },
|
|
176
290
|
drain: { type: "boolean", default: false },
|
|
177
291
|
"drain-timeout": { type: "string" },
|
|
292
|
+
"env-setup": { type: "string" },
|
|
178
293
|
},
|
|
179
294
|
}));
|
|
180
295
|
} catch (error) {
|
|
@@ -224,8 +339,23 @@ export async function runService(argv = [], deps = {}) {
|
|
|
224
339
|
which: values.receiver ? "receiver" : "worker",
|
|
225
340
|
scope: platform === "linux" && values.system ? "system" : "user",
|
|
226
341
|
force: values.force,
|
|
342
|
+
// The operator's env-setup script, or null for the shipped default. Null is load-bearing: with no
|
|
343
|
+
// seam configured every render below is byte-identical to what it produced before issue #209.
|
|
344
|
+
envSetup: null,
|
|
227
345
|
};
|
|
228
346
|
|
|
347
|
+
if (values["env-setup"] !== undefined) {
|
|
348
|
+
// Only the two subcommands that COMPOSE a command line can honour it. Ignoring the flag on the
|
|
349
|
+
// others would be the silent no-op this project refuses on principle: an operator who typed it on
|
|
350
|
+
// `restart` would believe the seam was in place.
|
|
351
|
+
if (cmd !== "render" && cmd !== "install") {
|
|
352
|
+
return fail(err, `--env-setup applies to \`service render\` and \`service install\`, which compose the command the unit runs; \`service ${cmd}\` renders nothing. Re-render (or re-install --force) to change it.`);
|
|
353
|
+
}
|
|
354
|
+
const resolved = resolveEnvSetup(ctx, values["env-setup"]);
|
|
355
|
+
if (resolved.error) return fail(err, resolved.error);
|
|
356
|
+
ctx.envSetup = resolved.path;
|
|
357
|
+
}
|
|
358
|
+
|
|
229
359
|
switch (cmd) {
|
|
230
360
|
case "render":
|
|
231
361
|
return doRender(ctx);
|
|
@@ -282,10 +412,59 @@ function unitPaths(ctx) {
|
|
|
282
412
|
return { name: `pi-dispatch-${ctx.which}` };
|
|
283
413
|
}
|
|
284
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Every unit FILE this tool could have installed on this host: both daemons, both scopes, plus the
|
|
417
|
+
* `worker.service` name the README's manual `sudo cp` instructions produce (doStatus already counts
|
|
418
|
+
* that one as a real worker unit, so a preflight reading units must too). Derived from unitPaths
|
|
419
|
+
* rather than restated, so the two cannot disagree about where a unit lives.
|
|
420
|
+
*
|
|
421
|
+
* Empty on win32 by construction: there is no file to read there, only `nssm get`.
|
|
422
|
+
*/
|
|
423
|
+
export function installedUnitPaths(platform, home) {
|
|
424
|
+
if (platform !== "linux" && platform !== "darwin") return [];
|
|
425
|
+
const found = [];
|
|
426
|
+
for (const which of ["worker", "receiver"]) {
|
|
427
|
+
const paths = unitPaths({ platform, home, which, scope: "user" });
|
|
428
|
+
found.push({ which, scope: "user", path: platform === "linux" ? paths.userPath : paths.installPath });
|
|
429
|
+
found.push({ which, scope: "system", path: paths.systemPath });
|
|
430
|
+
if (platform === "linux" && which === "worker") found.push({ which, scope: "system", path: "/etc/systemd/system/worker.service" });
|
|
431
|
+
}
|
|
432
|
+
return found;
|
|
433
|
+
}
|
|
434
|
+
|
|
285
435
|
function readTemplate(ctx, name) {
|
|
286
436
|
return ctx.fs.readFileSync(join(ctx.templatesDir, name), "utf8");
|
|
287
437
|
}
|
|
288
438
|
|
|
439
|
+
/**
|
|
440
|
+
* The per-host path substitutions, applied to a TEMPLATE and never to anything a render composed.
|
|
441
|
+
* /opt/pi-dispatch becomes the deployment folder (the cwd this render ran from): WorkingDirectory and
|
|
442
|
+
* EnvironmentFile stay operator territory, never the package dir npm may wipe on update.
|
|
443
|
+
*
|
|
444
|
+
* The ordering matters and used to be wrong. A composed value is already absolute for THIS host, so
|
|
445
|
+
* putting it through these rewrites a path the operator typed rather than a placeholder the template
|
|
446
|
+
* shipped — and an `--env-setup /opt/pi-dispatch/setup-env.sh` on a deployment at /srv/pi-deploy became
|
|
447
|
+
* `. "/srv/pi-deploy/setup-env.sh"`, a file resolveEnvSetup never checked, while the banner above it
|
|
448
|
+
* still named the one that was typed. Substitute first, compose second, and anchor on the substituted
|
|
449
|
+
* form.
|
|
450
|
+
*/
|
|
451
|
+
function subDeployDir(ctx, text) {
|
|
452
|
+
// Function replacement, here and everywhere below a computed path is the REPLACEMENT: String.replace
|
|
453
|
+
// and replaceAll both read `$&` and its relatives out of a replacement STRING, so a deployment folder
|
|
454
|
+
// holding one would splice itself into the unit. A function replacement is taken literally.
|
|
455
|
+
return text.replaceAll("/opt/pi-dispatch", () => ctx.deployDir);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* subDeployDir plus the systemd unit's node: the shipped `/usr/bin/node` literal does not exist on an
|
|
460
|
+
* nvm host. The PLIST deliberately gets only subDeployDir — its header comment SHOWS
|
|
461
|
+
* `<string>/usr/bin/node</string>` as the argv an operator would append by hand, and that prose is
|
|
462
|
+
* meant to keep reading as an example rather than silently becoming this host's node.
|
|
463
|
+
*/
|
|
464
|
+
function subUnitPaths(ctx, text) {
|
|
465
|
+
return subDeployDir(ctx, text).replaceAll("/usr/bin/node", () => ctx.execPath);
|
|
466
|
+
}
|
|
467
|
+
|
|
289
468
|
/**
|
|
290
469
|
* The receiver refusal, shared by render and install: a null receiverStart means the receiver package
|
|
291
470
|
* is not resolvable from this worker install. Refusing beats rendering — a unit pointing at a
|
|
@@ -299,6 +478,32 @@ function refuseMissingReceiver(ctx) {
|
|
|
299
478
|
);
|
|
300
479
|
}
|
|
301
480
|
|
|
481
|
+
/**
|
|
482
|
+
* The systemd form of the env-setup seam. Three pieces, each load-bearing:
|
|
483
|
+
*
|
|
484
|
+
* - `exec` keeps systemd watching the WORKER and not a shell in front of it, so an exit 2 arrives as
|
|
485
|
+
* an exit 2 and RestartPreventExitStatus=2 still sees a real policy refusal. This is the whole
|
|
486
|
+
* reason the renderer owns this line instead of leaving it to a hand-edit.
|
|
487
|
+
* - `|| exit 1` maps a failed setup (an expired token, an unreachable manager) onto the
|
|
488
|
+
* infrastructure code, so systemd retries it under Restart=on-failure inside the StartLimit bound,
|
|
489
|
+
* and it can never be mistaken for the determinate refusal that must stay stopped. A setup script
|
|
490
|
+
* that calls `exit 2` ITSELF still exits 2 — sourcing cannot intercept that — which is why
|
|
491
|
+
* docs/secrets.md tells operators not to.
|
|
492
|
+
* - `set -a` exports a bare KEY=value, exactly as EnvironmentFile= does, so the seam accepts both an
|
|
493
|
+
* `export`-style script and raw dotenv output. EnvironmentFile= is untouched and still read first;
|
|
494
|
+
* this runs after it, so the manager wins over a stale value left in the file.
|
|
495
|
+
*
|
|
496
|
+
* The whole sh script is ONE single-quoted systemd word, which is why resolveEnvSetup refuses a single
|
|
497
|
+
* quote in the path (it would end the word) and a `$` (systemd expands those inside Exec lines whatever
|
|
498
|
+
* the quoting). The inner double quotes are sh's, and they are what makes a path with spaces work.
|
|
499
|
+
* Verified against systemd 252: `systemctl show -p ExecStart` reports argv[] as /bin/sh, -c, and the
|
|
500
|
+
* whole script as one word, and a worker exiting 2 under it reports ExecMainStatus=2 with NRestarts=0.
|
|
501
|
+
*/
|
|
502
|
+
function composeEnvSetupExec(setup, argv) {
|
|
503
|
+
const command = argv.map((a) => `"${a}"`).join(" ");
|
|
504
|
+
return `/bin/sh -c 'set -a; . "${setup}" || exit 1; set +a; exec ${command}'`;
|
|
505
|
+
}
|
|
506
|
+
|
|
302
507
|
/**
|
|
303
508
|
* Render worker.service / receiver.service for this host. Targeted substitution of the templates'
|
|
304
509
|
* known literals (see TEMPLATE_PINS); everything else — RestartPreventExitStatus=2, the StartLimit
|
|
@@ -310,20 +515,24 @@ function renderLinuxUnit(ctx) {
|
|
|
310
515
|
// to a repo-root WorkingDirectory that only a checkout has. The rendered unit points at absolute
|
|
311
516
|
// entries that exist in both layouts — cliPath beside this module; the receiver package's ./start
|
|
312
517
|
// export — so ExecStart works no matter what WorkingDirectory is.
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
518
|
+
const argv = ctx.which === "receiver" ? [ctx.execPath, ctx.receiverStart] : [ctx.execPath, ctx.cliPath, "worker"];
|
|
519
|
+
const templateLine =
|
|
520
|
+
ctx.which === "receiver" ? "ExecStart=/usr/bin/node receiver/src/start.mjs" : "ExecStart=/usr/bin/node worker/src/cli.mjs worker";
|
|
521
|
+
const execStart = `ExecStart=${ctx.envSetup ? composeEnvSetupExec(ctx.envSetup, argv) : argv.join(" ")}`;
|
|
317
522
|
// The banner outranks the template's own "TEMPLATE/UNTESTED EXAMPLE — set the PLACEHOLDERs" header,
|
|
318
523
|
// which renders through below (the no-markers design keeps templates byte-usable, so their prose
|
|
319
524
|
// survives): a reader of the rendered unit should know the placeholders are already substituted.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
525
|
+
const seamNote = ctx.envSetup
|
|
526
|
+
? `# ExecStart also runs \`--env-setup ${ctx.envSetup}\` first: sourced with \`set -a\` (a bare\n# KEY=value exports, exactly as EnvironmentFile= does, and it runs AFTER EnvironmentFile so a secrets\n# manager wins over a stale value in .env). A failed setup exits 1, never the exit 2 below, and \`exec\`\n# keeps systemd watching the worker itself. Re-render to change it; do not hand-edit this line.\n`
|
|
527
|
+
: "";
|
|
528
|
+
// Substituted first, then the composed line goes in — see subUnitPaths for why that order is
|
|
529
|
+
// load-bearing. The anchor is the template's own ExecStart put through the same substitutions, which
|
|
530
|
+
// is what it looks like by the time we search for it. Function replacements throughout: a deployment
|
|
531
|
+
// path holding `$&` would otherwise be spliced by String.replace's own syntax.
|
|
532
|
+
const substituted = subUnitPaths(ctx, readTemplate(ctx, template));
|
|
533
|
+
let unit =
|
|
534
|
+
`# rendered by \`pi-dispatch service\` — paths computed for this host from deploy/${template};\n# the template's PLACEHOLDER prose below is already substituted.\n${seamNote}` +
|
|
535
|
+
substituted.replace(subUnitPaths(ctx, templateLine), () => execStart);
|
|
327
536
|
if (ctx.scope === "user") {
|
|
328
537
|
// A systemd --user unit always runs as the invoking user, and systemd REJECTS a User= line in
|
|
329
538
|
// user scope ("Unknown lvalue"); the line must not survive the render. The replacement is a
|
|
@@ -339,7 +548,7 @@ function renderLinuxUnit(ctx) {
|
|
|
339
548
|
// started. default.target is the user manager's boot target.
|
|
340
549
|
unit = unit.replace("WantedBy=multi-user.target", "WantedBy=default.target");
|
|
341
550
|
} else {
|
|
342
|
-
unit = unit.replace(/^User=pi$/m, `User=${ctx.user}`);
|
|
551
|
+
unit = unit.replace(/^User=pi$/m, () => `User=${ctx.user}`);
|
|
343
552
|
}
|
|
344
553
|
return unit;
|
|
345
554
|
}
|
|
@@ -351,7 +560,10 @@ function renderLinuxUnit(ctx) {
|
|
|
351
560
|
* receiver — it has no EXIT_POLICY — and harmless.
|
|
352
561
|
*/
|
|
353
562
|
function renderPlist(ctx) {
|
|
354
|
-
|
|
563
|
+
// Substituted before anything composed goes in (subDeployDir): the two anchors below are therefore
|
|
564
|
+
// written in their post-substitution form, and PI_ENV_SETUP can no longer be rewritten into a path
|
|
565
|
+
// the operator never typed. Only subDeployDir here — the template's /usr/bin/node is example prose.
|
|
566
|
+
let plist = subDeployDir(ctx, readTemplate(ctx, "com.pi-dispatch.worker.plist"));
|
|
355
567
|
// One wrapper, two daemons: the exec argv IS the difference now. The wrapper sources ./.env in the
|
|
356
568
|
// unit's WorkingDirectory and runs exactly these arguments — no `receiver` selector flag, no paths
|
|
357
569
|
// guessed inside the wrapper (issue #96: the wrapper's self-relative guess broke under npm install).
|
|
@@ -366,26 +578,41 @@ function renderPlist(ctx) {
|
|
|
366
578
|
// the command: absolute node, absolute script. The wrapper path is module-relative (templatesDir),
|
|
367
579
|
// so it exists in a checkout AND under node_modules — unlike the old repo-root guess.
|
|
368
580
|
plist = plist.replace(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
581
|
+
`<string>${ctx.deployDir}/deploy/worker-env-wrapper.sh</string>`,
|
|
582
|
+
() =>
|
|
583
|
+
[
|
|
584
|
+
`<string>${ctx.wrapperSh}</string>`,
|
|
585
|
+
"<!-- the command the wrapper execs after sourcing ./.env in WorkingDirectory - absolute paths, nothing guessed -->",
|
|
586
|
+
...execArgv.map((a) => `<string>${a}</string>`),
|
|
587
|
+
].join("\n\t\t"),
|
|
375
588
|
);
|
|
376
589
|
// launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it. The exec
|
|
377
590
|
// argv above pins THIS node absolutely, but the worker's own children (npx-style hooks, tooling
|
|
378
591
|
// that spawns bare `node`) still resolve via PATH; prepending the render node's directory keeps
|
|
379
592
|
// them on the SAME binary. PATH is configuration, not a secret: the template's deliberate
|
|
380
593
|
// no-EnvironmentVariables stance is about credentials, which still live only in .env.
|
|
594
|
+
// The dict is built as a list so the seam below can extend it without a second anchor. Everything
|
|
595
|
+
// in it is a PATH; nothing in it is a credential, which is what keeps this file committable.
|
|
596
|
+
const envEntries = ["\t\t<key>PATH</key>", `\t\t<string>${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>`];
|
|
597
|
+
if (ctx.envSetup) {
|
|
598
|
+
// launchd has no EnvironmentFile and no shell in front of ProgramArguments, so the seam is a
|
|
599
|
+
// VARIABLE the wrapper reads rather than a composed command line: nothing here has to be quoted
|
|
600
|
+
// for a shell, and ProgramArguments stays byte-identical to the default render. The wrapper
|
|
601
|
+
// sources it AFTER ./.env, so a secrets manager wins over a stale key left in the file.
|
|
602
|
+
envEntries.push(
|
|
603
|
+
"\t\t<!-- `pi-dispatch service --env-setup`: worker-env-wrapper.sh sources this file after ./.env.\n\t\t A path, not a credential. Re-render to change it. -->",
|
|
604
|
+
"\t\t<key>PI_ENV_SETUP</key>",
|
|
605
|
+
`\t\t<string>${ctx.envSetup}</string>`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const workingDir = `<key>WorkingDirectory</key>\n\t<string>${ctx.deployDir}</string>`;
|
|
381
609
|
plist = plist.replace(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
`${
|
|
610
|
+
workingDir,
|
|
611
|
+
() =>
|
|
612
|
+
`${workingDir}\n\n\t<!-- Injected by \`pi-dispatch service\`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and child processes may call bare \`node\`. Not a secrets dict - credentials still live only\n\t in .env (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n` +
|
|
613
|
+
`${envEntries.join("\n")}\n\t</dict>`,
|
|
385
614
|
);
|
|
386
|
-
|
|
387
|
-
// belongs to the deployment folder.
|
|
388
|
-
return plist.replaceAll("/opt/pi-dispatch", ctx.deployDir);
|
|
615
|
+
return plist;
|
|
389
616
|
}
|
|
390
617
|
|
|
391
618
|
/**
|
|
@@ -407,6 +634,10 @@ function nssmSequence(ctx) {
|
|
|
407
634
|
commands: [
|
|
408
635
|
["install", service, ctx.wrapperCmd, ...execArgv],
|
|
409
636
|
["set", service, "AppDirectory", ctx.deployDir],
|
|
637
|
+
// Same seam as the plist's EnvironmentVariables dict, for the same reason: nssm takes a
|
|
638
|
+
// NAME=VALUE with no shell between it and the wrapper, so the path needs no quoting and
|
|
639
|
+
// AppParameters stays byte-identical to the default render.
|
|
640
|
+
...(ctx.envSetup ? [["set", service, "AppEnvironmentExtra", `PI_ENV_SETUP=${ctx.envSetup}`]] : []),
|
|
410
641
|
["set", service, "AppStdout", `${logDir}\\${ctx.which}.out.log`],
|
|
411
642
|
["set", service, "AppStderr", `${logDir}\\${ctx.which}.err.log`],
|
|
412
643
|
["set", service, "AppStopMethodConsole", "15000"],
|
|
@@ -426,6 +657,11 @@ function doRender(ctx) {
|
|
|
426
657
|
ctx.out(
|
|
427
658
|
"\n# note: ProgramArguments runs the package's worker-env-wrapper.sh, which sources ./.env in the\n# WorkingDirectory above and then runs the argv that follows it — launchd has no EnvironmentFile.\n# The wrapper also converts a policy refusal (exit 2, EXIT_POLICY) into a clean exit, so KeepAlive\n# never relaunch-loops a refusal into a provider bill.\n",
|
|
428
659
|
);
|
|
660
|
+
if (ctx.envSetup) {
|
|
661
|
+
ctx.out(
|
|
662
|
+
`# note: --env-setup is delivered as PI_ENV_SETUP above. The wrapper sources ${ctx.envSetup} AFTER\n# ./.env, so your manager wins over a stale value in the file, and a missing or failing script exits 1\n# rather than the exit 2 that means a policy refusal. With it set, ./.env becomes optional.\n`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
429
665
|
return 0;
|
|
430
666
|
}
|
|
431
667
|
if (ctx.platform === "linux") {
|
|
@@ -610,6 +846,11 @@ async function doStatus(ctx) {
|
|
|
610
846
|
if (status.code === null) ctx.out(`${paths.name}: cannot query — nssm.exe not on PATH (https://nssm.cc)\n`);
|
|
611
847
|
else if (status.code !== 0) ctx.out(`${paths.name}: not installed\n`);
|
|
612
848
|
else ctx.out(`${paths.name}: ${status.output.trim()}\n`);
|
|
849
|
+
if (status.code === 0) {
|
|
850
|
+
const extra = await runCapture(ctx, "nssm", ["get", paths.name, "AppEnvironmentExtra"]);
|
|
851
|
+
const setup = extra.code === 0 ? readUnitSeam(extra.output, "win32").setup : null;
|
|
852
|
+
if (setup) ctx.out(`env-setup: ${setup} (named by ${paths.name}'s AppEnvironmentExtra)\n`);
|
|
853
|
+
}
|
|
613
854
|
return 0;
|
|
614
855
|
}
|
|
615
856
|
if (ctx.platform === "darwin") {
|
|
@@ -620,6 +861,7 @@ async function doStatus(ctx) {
|
|
|
620
861
|
ctx.out(`user scope: not installed (${paths.installPath})\n`);
|
|
621
862
|
}
|
|
622
863
|
ctx.out(ctx.fs.existsSync(paths.systemPath) ? `system scope: ${paths.systemPath} EXISTS — not managed by this tool\n` : "system scope: none\n");
|
|
864
|
+
reportEnvSetup(ctx, [paths.installPath, paths.systemPath]);
|
|
623
865
|
return 0;
|
|
624
866
|
}
|
|
625
867
|
if (ctx.fs.existsSync(paths.userPath)) {
|
|
@@ -630,9 +872,31 @@ async function doStatus(ctx) {
|
|
|
630
872
|
}
|
|
631
873
|
const systemHits = [paths.systemPath, ...(ctx.which === "worker" ? ["/etc/systemd/system/worker.service"] : [])].filter((p) => ctx.fs.existsSync(p));
|
|
632
874
|
ctx.out(systemHits.length ? `system scope: ${systemHits.join(", ")} EXISTS — not managed by this tool\n` : "system scope: none\n");
|
|
875
|
+
reportEnvSetup(ctx, [paths.userPath, ...systemHits]);
|
|
633
876
|
return 0;
|
|
634
877
|
}
|
|
635
878
|
|
|
879
|
+
/**
|
|
880
|
+
* One informational line per unit that names an `--env-setup` script (issue #216). status REPORTS: it
|
|
881
|
+
* says where the seam is configured, and nothing about whether the script is still there, still
|
|
882
|
+
* private, or still uncommitted. Those are `doctor`'s — it is the preflight, it owns the warn tiers,
|
|
883
|
+
* and it already has the git check-ignore seam. Prints nothing when no seam is configured, so the
|
|
884
|
+
* output of every deployment that does not use one is byte-identical to before.
|
|
885
|
+
*/
|
|
886
|
+
function reportEnvSetup(ctx, paths) {
|
|
887
|
+
for (const path of paths) {
|
|
888
|
+
if (!ctx.fs.existsSync(path)) continue;
|
|
889
|
+
let setup = null;
|
|
890
|
+
try {
|
|
891
|
+
setup = readUnitSeam(ctx.fs.readFileSync(path, "utf8"), ctx.platform).setup;
|
|
892
|
+
} catch {
|
|
893
|
+
// Unreadable — a system-scope unit this user may not read is the ordinary case. The scope line
|
|
894
|
+
// above already reported that it exists; status never fails on what it could not look at.
|
|
895
|
+
}
|
|
896
|
+
if (setup) ctx.out(`env-setup: ${setup} (named by ${path})\n`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
636
900
|
async function doStart(ctx) {
|
|
637
901
|
return startStop(ctx, "start");
|
|
638
902
|
}
|