@stravica-ai/digital-operator-pa 0.1.61 → 0.1.63
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/dist/engineering-pa-cli/src/launch/README.md +2 -0
- package/dist/engineering-pa-cli/src/launch/runner.mjs +4 -3
- package/dist/engineering-pa-cli/src/launch/task-loader.mjs +37 -3
- package/dist/engineering-pa-cli/src/scheduler/cli.mjs +12 -2
- package/dist/engineering-pa-core/docs/tools/scheduler.md +5 -3
- package/dist/engineering-pa-core/src/scheduler/install.mjs +108 -21
- package/dist/engineering-pa-core/src/scheduler/list.mjs +2 -4
- package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -2
- package/dist/engineering-pa-core/src/scheduler/verify.mjs +76 -15
- package/package.json +1 -1
|
@@ -82,6 +82,8 @@ The runner spawns the command **directly** — no shell, no expansion, no operat
|
|
|
82
82
|
|
|
83
83
|
If the command contains `'` or `"`, `task-loader` rejects it at load time with exit code 2 (a `--dry-run` also fails). This is a deliberate guard: silently splitting `bash -c "a && b"` on whitespace shreds it to `spawn('bash', ['-c', '"a', '&&', 'b"'])`, bash exits 2 with `unexpected EOF`, and cron mails the operator every interval until someone notices.
|
|
84
84
|
|
|
85
|
+
Shell operators are refused the same way, at load time and in the runner: a command with `;`, `&&`, `||`, `|`, `>`, `<`, `$(`, a backtick or `&` exits 2 with a message naming the schedule id and the offending token. Without a shell the operator would reach the program as a literal argument (`pnpm exec estate-sync run; node ...` handed estate-sync the subcommand `run;`).
|
|
86
|
+
|
|
85
87
|
If you need shell features, you have two options:
|
|
86
88
|
|
|
87
89
|
1. **Wrap the work in a script file** and put the script path in `command`: `command: "/path/to/wrapper.sh"`. The runner spawns the script; the script does whatever it likes.
|
|
@@ -43,6 +43,7 @@ function resolveWorkdir(workdir, repoRoot) {
|
|
|
43
43
|
* expressions. The string is split on whitespace and passed to spawn()
|
|
44
44
|
* WITHOUT a shell — no quoting, globbing, expansion, or operators are
|
|
45
45
|
* honoured. Any single (') or double (") quote character in the command
|
|
46
|
+
* or shell operator (; && || | > < $( ` &)
|
|
46
47
|
* is a configuration error: rejected via assertNoShellQuoting with
|
|
47
48
|
* exitCode 2 (task-loader also asserts this at load time so --dry-run
|
|
48
49
|
* catches it; the check here is the chokepoint guarantee for any caller
|
|
@@ -58,8 +59,8 @@ function resolveWorkdir(workdir, repoRoot) {
|
|
|
58
59
|
* @returns {{ bin: string, args: string[] }}
|
|
59
60
|
* @throws {Error & { exitCode: 2 }} on quoted or empty commands
|
|
60
61
|
*/
|
|
61
|
-
function parseCommand(command) {
|
|
62
|
-
assertNoShellQuoting(command);
|
|
62
|
+
function parseCommand(command, scheduleId) {
|
|
63
|
+
assertNoShellQuoting(command, scheduleId);
|
|
63
64
|
const parts = command.trim().split(/\s+/);
|
|
64
65
|
if (!parts[0]) {
|
|
65
66
|
const e = new Error('command is empty');
|
|
@@ -70,7 +71,7 @@ function parseCommand(command) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function defaultSpawner(plan) {
|
|
73
|
-
const { bin, args } = parseCommand(plan.task.command);
|
|
74
|
+
const { bin, args } = parseCommand(plan.task.command, plan.task.id);
|
|
74
75
|
const cwd = resolveWorkdir(plan.task.workingDirectory, plan.repoRoot);
|
|
75
76
|
return new Promise((resolve, reject) => {
|
|
76
77
|
const child = spawn(bin, args, { cwd, stdio: 'inherit' });
|
|
@@ -16,11 +16,17 @@ const REQUIRED_FIELDS = ['id', 'command'];
|
|
|
16
16
|
* re-asserted by runner.parseCommand as a chokepoint guarantee for
|
|
17
17
|
* programmatic callers.
|
|
18
18
|
*
|
|
19
|
+
* Shell operators (`;`, `&&`, `||`, `|`, `>`, `<`, `$(`, backticks, `&`)
|
|
20
|
+
* are refused for the same reason: without a shell they reach the program
|
|
21
|
+
* as literal args (`pnpm exec estate-sync run; node ...` handed
|
|
22
|
+
* estate-sync the subcommand `run;` and failed 34 runs in a row).
|
|
23
|
+
*
|
|
19
24
|
* @param {string} command
|
|
25
|
+
* @param {string} [scheduleId] named in the operator refusal message
|
|
20
26
|
* @throws {Error & { exitCode: 2 }} if command is not a non-empty string,
|
|
21
|
-
* or contains a single (') or double (") quote character
|
|
27
|
+
* or contains a single (') or double (") quote character, or a shell operator
|
|
22
28
|
*/
|
|
23
|
-
export function assertNoShellQuoting(command) {
|
|
29
|
+
export function assertNoShellQuoting(command, scheduleId) {
|
|
24
30
|
if (typeof command !== 'string') {
|
|
25
31
|
const e = new Error('command must be a string');
|
|
26
32
|
e.exitCode = 2;
|
|
@@ -38,6 +44,34 @@ export function assertNoShellQuoting(command) {
|
|
|
38
44
|
e.exitCode = 2;
|
|
39
45
|
throw e;
|
|
40
46
|
}
|
|
47
|
+
const op = findShellOperator(command);
|
|
48
|
+
if (op) {
|
|
49
|
+
const where = scheduleId ? `schedule "${scheduleId}"` : 'task command';
|
|
50
|
+
const e = new Error(
|
|
51
|
+
`${where} contains shell operator ${JSON.stringify(op.operator)} in token ` +
|
|
52
|
+
`${JSON.stringify(op.token)}: launch runs commands without a shell, so the operator ` +
|
|
53
|
+
'would reach the program as a literal argument. Wrap the chain in a script file ' +
|
|
54
|
+
'and invoke the script, or split it into separate schedules.'
|
|
55
|
+
);
|
|
56
|
+
e.exitCode = 2;
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Multi-char operators first so `&&` is reported as `&&`, not `&`.
|
|
62
|
+
const SHELL_OPERATORS = ['$(', '&&', '||', ';', '|', '>', '<', '`', '&'];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} command
|
|
66
|
+
* @returns {{ operator: string, token: string } | null}
|
|
67
|
+
*/
|
|
68
|
+
export function findShellOperator(command) {
|
|
69
|
+
for (const token of command.trim().split(/\s+/)) {
|
|
70
|
+
for (const operator of SHELL_OPERATORS) {
|
|
71
|
+
if (token.includes(operator)) return { operator, token };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
41
75
|
}
|
|
42
76
|
|
|
43
77
|
export async function loadTask(repoRoot, taskId) {
|
|
@@ -74,7 +108,7 @@ export async function loadTask(repoRoot, taskId) {
|
|
|
74
108
|
}
|
|
75
109
|
|
|
76
110
|
try {
|
|
77
|
-
assertNoShellQuoting(data.command);
|
|
111
|
+
assertNoShellQuoting(data.command, data.id ?? taskId);
|
|
78
112
|
} catch (err) {
|
|
79
113
|
const e = new Error(`task "${taskId}": ${err.message}`);
|
|
80
114
|
e.exitCode = err.exitCode ?? 2;
|
|
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
|
|
|
16
16
|
// rewrites this string to a relative path inside the single-bundled
|
|
17
17
|
// tarball.
|
|
18
18
|
import { detect, install, list, remove, verify } from '../../../engineering-pa-core/src/scheduler/index.mjs';
|
|
19
|
+
import { parseTimeoutSeconds } from '../../../engineering-pa-core/src/scheduler/verify.mjs';
|
|
19
20
|
|
|
20
21
|
const USAGE = `scheduler — operator-facing scheduling primitives
|
|
21
22
|
|
|
@@ -32,7 +33,9 @@ Usage:
|
|
|
32
33
|
field wins (OS-native detect is the fallback).
|
|
33
34
|
The label form the list verb prints as a
|
|
34
35
|
secondary line is accepted here directly.
|
|
35
|
-
scheduler verify <schedule.json>
|
|
36
|
+
scheduler verify <schedule.json> [--backend N] [--timeout <seconds>]
|
|
37
|
+
Verify a schedule fires correctly.
|
|
38
|
+
--timeout is in seconds (default 120).
|
|
36
39
|
scheduler -h, --help Show this help
|
|
37
40
|
|
|
38
41
|
Schedule files live at <repoRoot>/schedules/<id>.json. The canonical
|
|
@@ -189,7 +192,14 @@ export async function run(argv) {
|
|
|
189
192
|
const schedulePath = resolve(positionals[0]);
|
|
190
193
|
const opts = {};
|
|
191
194
|
if (values.backend) opts.backend = values.backend;
|
|
192
|
-
if (values.timeout
|
|
195
|
+
if (values.timeout !== undefined) {
|
|
196
|
+
const ms = parseTimeoutSeconds(values.timeout);
|
|
197
|
+
if (ms === null) {
|
|
198
|
+
process.stderr.write(`[scheduler] verify: --timeout requires a positive number of seconds, got: ${values.timeout}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
opts.timeoutMs = ms;
|
|
202
|
+
}
|
|
193
203
|
const result = await verify(schedulePath, opts);
|
|
194
204
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
195
205
|
return result.exitCode ?? (result.ok ? 0 : 2);
|
|
@@ -108,9 +108,11 @@ The failure mode is silent: launchd records exit code 78 (`EX_CONFIG`), the Stan
|
|
|
108
108
|
|
|
109
109
|
Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process provides a small fixed environment (`SHELL`, a narrow system `PATH`; `/usr/bin:/bin`; `HOME`, `LOGNAME`/`USER`), NOT a login shell's env. A schedule whose `command` relies on `pnpm`/`node` being on PATH via nvm silently fails to spawn once actually installed, even if it "worked" when tested from an interactive terminal.
|
|
110
110
|
|
|
111
|
-
The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c
|
|
111
|
+
The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c '{ date -u; . $HOME/.nvm/nvm.sh && cd <workDir> && <command>; } >> /tmp/<agent>-<id>.log 2>&1'`; the `{ ...; }` braces are load-bearing: without them the `>> log 2>&1` binds only to `<command>` and any earlier link failing (nvm-source, cd) sends its stderr to cron mail, which hosts with no MTA silently discard (w-2026-09-13-dave-042; 0.1.39 fix, smarthome01 crons fired but wrote no log 2026-09-09 -> 13). The leading `date -u` prints an ISO timestamp so a run that dies mid-chain still leaves dated evidence. If no nvm install is found, the preamble is simply omitted; nothing to do for schedules that only need system binaries. `environment.*` values are emitted as `export VAR='val';` statements ahead of the command (not a bare `VAR='val' cd ...` prefix, which in POSIX shell only scopes to the `cd` builtin and never actually reaches the command after `&&`; a real, silent bug the old generator had).
|
|
112
112
|
|
|
113
|
-
`scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron.
|
|
113
|
+
`scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron. The cron verify PATH is cron's base `/usr/bin:/bin`, then `/usr/local/bin`, then the PATH entries of `/etc/environment` when that file exists, because Debian cron reads `/etc/environment` through pam_env (w-2026-10-17-dave-007). A `PATH` in the schedule's own `environment` still replaces it. The result JSON carries the PATH used as `path`.
|
|
114
|
+
|
|
115
|
+
**Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The tag must end the line (w-2026-10-17-dave-007), so an id that is a prefix of another (`dave-foo` against `dave-foo-bar`) matches only its own line. When a schedule's `agent` field changes, `install` also removes the line with the same id under the old agent prefix and prints one line naming both tags. The systemd backend escapes `%` as `%%` and `$` as `$$` in `ExecStart` (w-2026-10-17-dave-007), because systemd resolves `%` specifiers and expands `$VAR` and `${VAR}` itself, inside single quotes too, before bash sees the command; bash then receives the command exactly as written.
|
|
114
116
|
|
|
115
117
|
### Verify your schedule before walking away
|
|
116
118
|
|
|
@@ -154,7 +156,7 @@ After installing, run the schedule's command once under the same environment the
|
|
|
154
156
|
node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is
|
|
159
|
+
If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 120 seconds; override with `--timeout`, which is in seconds on both `pa scheduler verify` and `verify.mjs` (w-2026-10-17-dave-007; the `pa` CLI used to read it as milliseconds):
|
|
158
160
|
|
|
159
161
|
```bash
|
|
160
162
|
node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
|
|
@@ -371,16 +371,24 @@ export function defaultCronLogPath(sched) {
|
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
373
|
* Build the INNER shell script a cron entry (or a `scheduler verify` run
|
|
374
|
-
* simulating one) actually executes,
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* — an argv array, never a re-parsed/re-split string — while `install`
|
|
378
|
-
* embeds the wrapper form into the crontab line.
|
|
374
|
+
* simulating one) actually executes, the `bash -c '<inner>'` wrapper
|
|
375
|
+
* string, and the crontab command field (the wrapper with every `%`
|
|
376
|
+
* escaped as `\%`).
|
|
379
377
|
*
|
|
380
|
-
*
|
|
381
|
-
* `
|
|
382
|
-
*
|
|
383
|
-
*
|
|
378
|
+
* The wrapper is single-quoted, embedded single quotes rendered as
|
|
379
|
+
* `'\''`, exactly like the systemd backend's ExecStart. cron hands the
|
|
380
|
+
* command field to `/bin/sh -c`; inside the old double-quoted form sh
|
|
381
|
+
* expanded every `$(...)`, `$N` and `${VAR}` in the cron user's HOME
|
|
382
|
+
* before bash ever ran (smarthome01 workspace-refresh, w-2026-10-17-dave-006).
|
|
383
|
+
* Single quotes hand the inner script to bash untouched.
|
|
384
|
+
*
|
|
385
|
+
* cron turns an unescaped `%` into a newline and feeds the rest of the
|
|
386
|
+
* line to stdin, so `cronField` escapes every `%` as `\%`; cron strips
|
|
387
|
+
* the backslash before sh sees the line. `wrapped` is the string sh
|
|
388
|
+
* receives after that unescaping, which is what `verify` runs.
|
|
389
|
+
*
|
|
390
|
+
* Shape: `bash -c '{ date -u; [. $HOME/.nvm/nvm.sh && ]cd <workDir> &&
|
|
391
|
+
* <command>; } >> <log> 2>&1'`:
|
|
384
392
|
*
|
|
385
393
|
* [. $HOME/.nvm/nvm.sh && ]<export VAR='val'; >*cd <workDir> && <command> >> <logPath> 2>&1
|
|
386
394
|
*
|
|
@@ -397,7 +405,7 @@ export function defaultCronLogPath(sched) {
|
|
|
397
405
|
* @param {object} [opts]
|
|
398
406
|
* @param {boolean} [opts.hasNvm]
|
|
399
407
|
* @param {string} [opts.logPath]
|
|
400
|
-
* @returns {{ inner: string, wrapped: string }}
|
|
408
|
+
* @returns {{ inner: string, wrapped: string, cronField: string }}
|
|
401
409
|
*/
|
|
402
410
|
/**
|
|
403
411
|
* When the schedule's argv[0] is a bare `node`, swap in the absolute
|
|
@@ -444,14 +452,31 @@ export function buildCronCommand(sched, workDir, env, opts = {}) {
|
|
|
444
452
|
// file empty — the exact silent-failure class w-2026-09-13-dave-030 tracks
|
|
445
453
|
// (smarthome01 crons fired but never wrote a log line, 2026-09-09 -> 13).
|
|
446
454
|
// A leading `date -u` inside the group prints an ISO timestamp so a run
|
|
447
|
-
// that dies mid-chain still leaves dated evidence.
|
|
448
|
-
// the crontab line through /bin/sh -c '...' which parses the double-quoted
|
|
449
|
-
// bash-c argument and would eagerly expand $(...) at dispatch time, not
|
|
450
|
-
// at run time.
|
|
455
|
+
// that dies mid-chain still leaves dated evidence.
|
|
451
456
|
const chain = `date -u; ${preamble}${exportStmts}cd ${workDir} && ${resolvedCommand}`;
|
|
452
457
|
const inner = `{ ${chain}; } >> ${logPath} 2>&1`;
|
|
453
|
-
const wrapped = `bash -c
|
|
454
|
-
return { inner, wrapped };
|
|
458
|
+
const wrapped = `bash -c '${inner.replace(/'/g, `'\\''`)}'`;
|
|
459
|
+
return { inner, wrapped, cronField: escapeCronPercent(wrapped) };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Escape every `%` as `\%` for a crontab command field. cron otherwise
|
|
464
|
+
* treats `%` as a newline and sends the remainder to the command's stdin.
|
|
465
|
+
* @param {string} s
|
|
466
|
+
* @returns {string}
|
|
467
|
+
*/
|
|
468
|
+
export function escapeCronPercent(s) {
|
|
469
|
+
return s.replace(/%/g, '\\%');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Undo cron's own `\%` unescaping, yielding the string cron passes to
|
|
474
|
+
* `/bin/sh -c`. Inverse of escapeCronPercent.
|
|
475
|
+
* @param {string} s
|
|
476
|
+
* @returns {string}
|
|
477
|
+
*/
|
|
478
|
+
export function unescapeCronPercent(s) {
|
|
479
|
+
return s.replace(/\\%/g, '%');
|
|
455
480
|
}
|
|
456
481
|
|
|
457
482
|
/**
|
|
@@ -465,8 +490,46 @@ export function buildCronCommand(sched, workDir, env, opts = {}) {
|
|
|
465
490
|
*/
|
|
466
491
|
export function buildCronLine(sched, workDir, env, opts = {}) {
|
|
467
492
|
const tag = `${schedAgent(sched)}-${sched.id}`;
|
|
468
|
-
const {
|
|
469
|
-
return `${sched.schedule} ${
|
|
493
|
+
const { cronField } = buildCronCommand(sched, workDir, env, opts);
|
|
494
|
+
return `${sched.schedule} ${cronField} # ${tag}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* True when a crontab line carries `tag` (`# <agent>-<id>`) as its trailing
|
|
499
|
+
* comment. Anchored to the end of the line so `# dave-foo` does not also
|
|
500
|
+
* match `# dave-foo-bar` (w-2026-10-17-dave-007 item 2).
|
|
501
|
+
* @param {string} line
|
|
502
|
+
* @param {string} tag
|
|
503
|
+
* @returns {boolean}
|
|
504
|
+
*/
|
|
505
|
+
export function cronLineHasTag(line, tag) {
|
|
506
|
+
return line.trimEnd().endsWith(tag);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* True when a crontab line ends in any `# <agent>-<suffix>` tag for `agent`.
|
|
511
|
+
* @param {string} line
|
|
512
|
+
* @param {string} agent
|
|
513
|
+
* @returns {boolean}
|
|
514
|
+
*/
|
|
515
|
+
export function cronLineHasAgentTag(line, agent) {
|
|
516
|
+
return /# (\S+)$/.exec(line.trimEnd())?.[1].startsWith(`${agent}-`) ?? false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The tag of a crontab line installed for the same schedule id under a
|
|
521
|
+
* different agent (`# sam-<id>` when installing `# dave-<id>`), or null.
|
|
522
|
+
* The other agent prefix must be a single hyphen-free word so `# dave-foo-bar`
|
|
523
|
+
* is never read as agent `dave-foo` with id `bar` (w-2026-10-17-dave-007 item 5).
|
|
524
|
+
* @param {string} line
|
|
525
|
+
* @param {string} agent
|
|
526
|
+
* @param {string} id
|
|
527
|
+
* @returns {string | null}
|
|
528
|
+
*/
|
|
529
|
+
export function otherAgentCronTag(line, agent, id) {
|
|
530
|
+
const m = /# ([A-Za-z0-9_]+)-(\S+)$/.exec(line.trimEnd());
|
|
531
|
+
if (!m || m[2] !== id || m[1] === agent) return null;
|
|
532
|
+
return `${m[1]}-${m[2]}`;
|
|
470
533
|
}
|
|
471
534
|
|
|
472
535
|
function installCron(sched, workDir) {
|
|
@@ -483,10 +546,17 @@ function installCron(sched, workDir) {
|
|
|
483
546
|
// no crontab yet
|
|
484
547
|
}
|
|
485
548
|
|
|
486
|
-
// Remove any existing entry for this schedule
|
|
549
|
+
// Remove any existing entry for this schedule, including one left under
|
|
550
|
+
// another agent prefix when the schedule's `agent` field changed.
|
|
487
551
|
const filtered = existing
|
|
488
552
|
.split('\n')
|
|
489
|
-
.filter(line =>
|
|
553
|
+
.filter((line) => {
|
|
554
|
+
if (cronLineHasTag(line, tag)) return false;
|
|
555
|
+
const other = otherAgentCronTag(line, agent, sched.id);
|
|
556
|
+
if (!other) return true;
|
|
557
|
+
console.log(`Removed cron line tagged ${other}; replaced by ${agent}-${sched.id} (agent changed)`);
|
|
558
|
+
return false;
|
|
559
|
+
})
|
|
490
560
|
.join('\n')
|
|
491
561
|
.replace(/^\n+|\n+$/g, '');
|
|
492
562
|
|
|
@@ -818,6 +888,23 @@ function installLaunchd(sched, workDir, cron) {
|
|
|
818
888
|
|
|
819
889
|
// ── Backend: systemd ─────────────────────────────────────────────────
|
|
820
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Escape a command for systemd's ExecStart parser. systemd resolves `%`
|
|
893
|
+
* specifiers when the unit is loaded and expands `$VAR` / `${VAR}` itself,
|
|
894
|
+
* inside single quotes too, before bash ever sees the line. `%%` is a
|
|
895
|
+
* literal percent and `$$` a literal dollar (systemd.unit(5) Specifiers,
|
|
896
|
+
* systemd.service(5) Command lines). The single quote is escaped for the
|
|
897
|
+
* `bash -c '...'` wrapper.
|
|
898
|
+
* @param {string} command
|
|
899
|
+
* @returns {string}
|
|
900
|
+
*/
|
|
901
|
+
export function escapeSystemdExecCommand(command) {
|
|
902
|
+
return command
|
|
903
|
+
.replace(/%/g, '%%')
|
|
904
|
+
.replace(/\$/g, '$$$$')
|
|
905
|
+
.replace(/'/g, `'\\''`);
|
|
906
|
+
}
|
|
907
|
+
|
|
821
908
|
/**
|
|
822
909
|
* Build the .service unit contents.
|
|
823
910
|
* @param {object} sched
|
|
@@ -837,7 +924,7 @@ Description=${description}
|
|
|
837
924
|
[Service]
|
|
838
925
|
Type=oneshot
|
|
839
926
|
WorkingDirectory=${workDir}
|
|
840
|
-
${envBlock}ExecStart=/usr/bin/env bash -c '${sched.command
|
|
927
|
+
${envBlock}ExecStart=/usr/bin/env bash -c '${escapeSystemdExecCommand(sched.command)}'
|
|
841
928
|
|
|
842
929
|
[Install]
|
|
843
930
|
WantedBy=default.target
|
|
@@ -24,7 +24,7 @@ import { resolveHome } from '../home/resolve.mjs';
|
|
|
24
24
|
import { execSync } from 'node:child_process';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { detect } from './detect.mjs';
|
|
27
|
-
import { launchdLabelPrefix } from './install.mjs';
|
|
27
|
+
import { launchdLabelPrefix, cronLineHasAgentTag } from './install.mjs';
|
|
28
28
|
import { resolveAgent } from './resolve-agent.mjs';
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -70,12 +70,10 @@ export function list(backend, opts = {}) {
|
|
|
70
70
|
function listCron(agent) {
|
|
71
71
|
const lines = [`${headerName(agent)} schedules (cron):`, '─'.repeat(37)];
|
|
72
72
|
let found = false;
|
|
73
|
-
const tag = `# ${agent}-`;
|
|
74
|
-
|
|
75
73
|
try {
|
|
76
74
|
const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
|
|
77
75
|
for (const line of crontab.split('\n')) {
|
|
78
|
-
if (line
|
|
76
|
+
if (cronLineHasAgentTag(line, agent)) {
|
|
79
77
|
lines.push(` ${line}`);
|
|
80
78
|
found = true;
|
|
81
79
|
}
|
|
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
23
23
|
import { resolveHome } from '../home/resolve.mjs';
|
|
24
24
|
import { execSync } from 'node:child_process';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
-
import { launchdLabelPrefix } from './install.mjs';
|
|
26
|
+
import { launchdLabelPrefix, cronLineHasTag } from './install.mjs';
|
|
27
27
|
import { findRepoRootSync, walkUpForMarker } from '../instance-root/find.mjs';
|
|
28
28
|
import { resolveAgent } from './resolve-agent.mjs';
|
|
29
29
|
import { detect } from './detect.mjs';
|
|
@@ -44,7 +44,7 @@ function removeCron(schedId, agent) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const lines = existing.split('\n');
|
|
47
|
-
const filtered = lines.filter(line => !line
|
|
47
|
+
const filtered = lines.filter(line => !cronLineHasTag(line, tag)).join('\n').replace(/^\n+|\n+$/g, '');
|
|
48
48
|
|
|
49
49
|
if (filtered === existing.replace(/^\n+|\n+$/g, '')) {
|
|
50
50
|
console.log(`No cron entry found for ${agent}-${schedId}`);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The verify deliberately does not require the schedule to be installed already —
|
|
13
13
|
* it derives the env the same way install.mjs does and runs the command once. A
|
|
14
|
-
* timeout (default
|
|
14
|
+
* timeout (default 120s) bounds the run; verify is meant for "does this schedule
|
|
15
15
|
* even start?" not "does it complete its full work?".
|
|
16
16
|
*
|
|
17
17
|
* Backend defaults to the auto-detected backend for the host (launchd on macOS,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* 4 timeout — command did not finish within the timeout
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { readFileSync, realpathSync, existsSync } from 'node:fs';
|
|
29
29
|
import { spawn } from 'node:child_process';
|
|
30
30
|
import { resolve, dirname, join } from 'node:path';
|
|
31
31
|
import { resolveHome } from '../home/resolve.mjs';
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
resolveEnvironment,
|
|
37
37
|
withLaunchdDefaults,
|
|
38
38
|
buildCronCommand,
|
|
39
|
+
unescapeCronPercent,
|
|
39
40
|
detectNvm,
|
|
40
41
|
schedAgent,
|
|
41
42
|
} from './install.mjs';
|
|
@@ -67,6 +68,54 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
|
|
|
67
68
|
};
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/** Default `scheduler verify --timeout`, in seconds (both CLI paths). */
|
|
72
|
+
export const DEFAULT_VERIFY_TIMEOUT_SECONDS = 120;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a `--timeout` value given in seconds into milliseconds. Both the
|
|
76
|
+
* `pa scheduler verify` CLI and this module's own CLI read seconds
|
|
77
|
+
* (w-2026-10-17-dave-007 item 3).
|
|
78
|
+
* @param {string | number} value
|
|
79
|
+
* @returns {number | null} milliseconds, or null when not a positive number
|
|
80
|
+
*/
|
|
81
|
+
export function parseTimeoutSeconds(value) {
|
|
82
|
+
const n = Number(value);
|
|
83
|
+
if (value === '' || value == null || !Number.isFinite(n) || n <= 0) return null;
|
|
84
|
+
return Math.round(n * 1000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the PATH entries from an `/etc/environment`-format file (pam_env
|
|
89
|
+
* hands this to cron jobs on Debian). Returns [] when the file is absent
|
|
90
|
+
* or carries no PATH line.
|
|
91
|
+
* @param {string} file
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function readEtcEnvironmentPath(file) {
|
|
95
|
+
if (!existsSync(file)) return [];
|
|
96
|
+
let pathValue = null;
|
|
97
|
+
for (const raw of readFileSync(file, 'utf8').split('\n')) {
|
|
98
|
+
const m = /^\s*(?:export\s+)?PATH\s*=\s*(.*?)\s*$/.exec(raw);
|
|
99
|
+
if (m) pathValue = m[1].replace(/^(["'])(.*)\1$/, '$2');
|
|
100
|
+
}
|
|
101
|
+
return pathValue ? pathValue.split(':').filter(Boolean) : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The PATH verify uses for the cron backend: cron's minimal base, then
|
|
106
|
+
* `/usr/local/bin`, then the PATH entries of `/etc/environment` when it
|
|
107
|
+
* exists, without duplicates (w-2026-10-17-dave-007 item 4). Real cron on
|
|
108
|
+
* Debian gets `/etc/environment` through pam_env, so the bare base alone
|
|
109
|
+
* was stricter than the runtime.
|
|
110
|
+
* @param {string} basePath
|
|
111
|
+
* @param {string} [etcEnvironmentFile]
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function composeCronVerifyPath(basePath, etcEnvironmentFile = '/etc/environment') {
|
|
115
|
+
const entries = [...basePath.split(':'), '/usr/local/bin', ...readEtcEnvironmentPath(etcEnvironmentFile)];
|
|
116
|
+
return [...new Set(entries.filter(Boolean))].join(':');
|
|
117
|
+
}
|
|
118
|
+
|
|
70
119
|
/**
|
|
71
120
|
* Compute the env the named backend would install for this schedule.
|
|
72
121
|
*
|
|
@@ -87,9 +136,11 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
|
|
|
87
136
|
*
|
|
88
137
|
* @param {object} sched
|
|
89
138
|
* @param {string} backend
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} [opts.etcEnvironmentFile] - cron only; defaults to /etc/environment
|
|
90
141
|
* @returns {Record<string,string>}
|
|
91
142
|
*/
|
|
92
|
-
export function computeBackendEnv(sched, backend) {
|
|
143
|
+
export function computeBackendEnv(sched, backend, opts = {}) {
|
|
93
144
|
const explicit = resolveEnvironment(sched);
|
|
94
145
|
if (backend === 'launchd') {
|
|
95
146
|
// launchd builds the agent env from EnvironmentVariables alone. Verify
|
|
@@ -97,7 +148,9 @@ export function computeBackendEnv(sched, backend) {
|
|
|
97
148
|
return withLaunchdDefaults(explicit);
|
|
98
149
|
}
|
|
99
150
|
if (backend === 'cron') {
|
|
100
|
-
|
|
151
|
+
const base = minimalCronEnv();
|
|
152
|
+
base.PATH = composeCronVerifyPath(base.PATH, opts.etcEnvironmentFile);
|
|
153
|
+
return { ...base, ...explicit };
|
|
101
154
|
}
|
|
102
155
|
// systemd/schtasks: "parent env then explicit overrides". Replicate by
|
|
103
156
|
// layering explicit over process.env, dropping any keys the parent has
|
|
@@ -250,8 +303,12 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
250
303
|
* For backend === 'cron', this does NOT run `sched.command` directly — it
|
|
251
304
|
* builds the exact GENERATED inner cron script (via install.mjs's
|
|
252
305
|
* `buildCronCommand`, the same function `scheduler install` uses to write
|
|
253
|
-
* the actual crontab line) and runs
|
|
254
|
-
*
|
|
306
|
+
* the actual crontab line) and runs it the way cron does: the crontab
|
|
307
|
+
* command field (`bash -c '...'`, `%` escaped as `\%`) has cron's own
|
|
308
|
+
* `\%` unescaping undone and is handed to `/bin/sh -c` as one argv
|
|
309
|
+
* element, from the working directory. Running the inner script under
|
|
310
|
+
* bash directly would skip the sh pass that cron performs, so quoting or
|
|
311
|
+
* `%` defects in the wrapper could never show (w-2026-10-17-dave-006). This is the "verification is honest
|
|
255
312
|
* about cron reality" fix: verifying `sched.command` alone, under an env
|
|
256
313
|
* that inherits the verifying process's PATH, never exercised the nvm
|
|
257
314
|
* preamble or the minimal-PATH reality a real cron invocation faces —
|
|
@@ -261,7 +318,9 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
261
318
|
* @param {string} scheduleFile - Path to the schedule JSON
|
|
262
319
|
* @param {object} [opts]
|
|
263
320
|
* @param {string} [opts.backend] - Override backend (default: auto-detect)
|
|
264
|
-
* @param {number} [opts.timeoutMs] - Timeout in ms (default:
|
|
321
|
+
* @param {number} [opts.timeoutMs] - Timeout in ms (default: 120000)
|
|
322
|
+
* @param {string} [opts.etcEnvironmentFile] - Test-only override for the
|
|
323
|
+
* cron backend's `/etc/environment` path.
|
|
265
324
|
* @param {boolean} [opts.hasNvm] - Test-only override; defaults to a real
|
|
266
325
|
* filesystem check of $HOME/.nvm/nvm.sh, matching install-time detection.
|
|
267
326
|
* @param {string} [opts.cronLogPath] - Test-only override for the cron
|
|
@@ -269,7 +328,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
269
328
|
* `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
|
|
270
329
|
* schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
|
|
271
330
|
* clobbers/interleaves with real cron output).
|
|
272
|
-
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, workDir: string, run: object }>}
|
|
331
|
+
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, path: string | null, workDir: string, run: object, argv: string[] | null }>}
|
|
273
332
|
*/
|
|
274
333
|
export async function verify(scheduleFile, opts = {}) {
|
|
275
334
|
const sched = readSchedule(scheduleFile);
|
|
@@ -281,18 +340,20 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
281
340
|
}
|
|
282
341
|
|
|
283
342
|
const backend = opts.backend || sched.backend || detectDefaultBackend();
|
|
284
|
-
const timeoutMs = opts.timeoutMs ??
|
|
343
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_SECONDS * 1000;
|
|
285
344
|
|
|
286
345
|
const workDir = resolveWorkDir(sched.workingDirectory);
|
|
287
|
-
const env = computeBackendEnv(sched, backend);
|
|
346
|
+
const env = computeBackendEnv(sched, backend, { etcEnvironmentFile: opts.etcEnvironmentFile });
|
|
288
347
|
|
|
289
348
|
let run;
|
|
349
|
+
let argv = null;
|
|
290
350
|
if (backend === 'cron') {
|
|
291
351
|
const explicit = resolveEnvironment(sched);
|
|
292
352
|
const hasNvm = opts.hasNvm ?? detectNvm();
|
|
293
353
|
const logPath = opts.cronLogPath ?? `/tmp/${schedAgent(sched)}-${sched.id}-verify.log`;
|
|
294
|
-
const {
|
|
295
|
-
|
|
354
|
+
const { cronField } = buildCronCommand(sched, workDir, explicit, { hasNvm, logPath });
|
|
355
|
+
argv = ['/bin/sh', '-c', unescapeCronPercent(cronField)];
|
|
356
|
+
run = await runVerifyArgv(argv[0], argv.slice(1), workDir, env, timeoutMs);
|
|
296
357
|
} else {
|
|
297
358
|
run = await runVerify(sched.command, workDir, env, timeoutMs);
|
|
298
359
|
}
|
|
@@ -313,7 +374,7 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
313
374
|
exitCode = 2;
|
|
314
375
|
}
|
|
315
376
|
|
|
316
|
-
return { result, exitCode, sched, backend, env, workDir, run };
|
|
377
|
+
return { result, exitCode, sched, backend, env, path: env.PATH ?? null, workDir, run, argv };
|
|
317
378
|
}
|
|
318
379
|
|
|
319
380
|
// ── CLI entry point ──────────────────────────────────────────────────
|
|
@@ -332,7 +393,7 @@ Arguments:
|
|
|
332
393
|
systemd, schtasks). Defaults to auto-detected backend.
|
|
333
394
|
|
|
334
395
|
Flags:
|
|
335
|
-
--timeout <seconds> Kill the verify run after this many seconds (default:
|
|
396
|
+
--timeout <seconds> Kill the verify run after this many seconds (default: 120).
|
|
336
397
|
-h, --help Print this message and exit 0.
|
|
337
398
|
|
|
338
399
|
Exit codes:
|
|
@@ -368,7 +429,7 @@ if (isDirectlyInvoked()) {
|
|
|
368
429
|
// Parse: positional <schedule.json>, optional positional <backend>, optional --timeout N.
|
|
369
430
|
let scheduleFile = null;
|
|
370
431
|
let backend = null;
|
|
371
|
-
let timeoutSeconds =
|
|
432
|
+
let timeoutSeconds = DEFAULT_VERIFY_TIMEOUT_SECONDS;
|
|
372
433
|
|
|
373
434
|
for (let i = 0; i < args.length; i++) {
|
|
374
435
|
const a = args[i];
|
package/package.json
CHANGED