@stravica-ai/digital-operator-pa 0.1.62 → 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 +3 -3
- package/dist/engineering-pa-core/src/scheduler/install.mjs +65 -3
- 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 +65 -11
- 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);
|
|
@@ -110,9 +110,9 @@ Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process pro
|
|
|
110
110
|
|
|
111
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
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 systemd backend
|
|
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.
|
|
116
116
|
|
|
117
117
|
### Verify your schedule before walking away
|
|
118
118
|
|
|
@@ -156,7 +156,7 @@ After installing, run the schedule's command once under the same environment the
|
|
|
156
156
|
node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
-
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):
|
|
160
160
|
|
|
161
161
|
```bash
|
|
162
162
|
node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
|
|
@@ -494,6 +494,44 @@ export function buildCronLine(sched, workDir, env, opts = {}) {
|
|
|
494
494
|
return `${sched.schedule} ${cronField} # ${tag}`;
|
|
495
495
|
}
|
|
496
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]}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
497
535
|
function installCron(sched, workDir) {
|
|
498
536
|
const agent = schedAgent(sched);
|
|
499
537
|
const env = resolveEnvironment(sched);
|
|
@@ -508,10 +546,17 @@ function installCron(sched, workDir) {
|
|
|
508
546
|
// no crontab yet
|
|
509
547
|
}
|
|
510
548
|
|
|
511
|
-
// 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.
|
|
512
551
|
const filtered = existing
|
|
513
552
|
.split('\n')
|
|
514
|
-
.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
|
+
})
|
|
515
560
|
.join('\n')
|
|
516
561
|
.replace(/^\n+|\n+$/g, '');
|
|
517
562
|
|
|
@@ -843,6 +888,23 @@ function installLaunchd(sched, workDir, cron) {
|
|
|
843
888
|
|
|
844
889
|
// ── Backend: systemd ─────────────────────────────────────────────────
|
|
845
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
|
+
|
|
846
908
|
/**
|
|
847
909
|
* Build the .service unit contents.
|
|
848
910
|
* @param {object} sched
|
|
@@ -862,7 +924,7 @@ Description=${description}
|
|
|
862
924
|
[Service]
|
|
863
925
|
Type=oneshot
|
|
864
926
|
WorkingDirectory=${workDir}
|
|
865
|
-
${envBlock}ExecStart=/usr/bin/env bash -c '${sched.command
|
|
927
|
+
${envBlock}ExecStart=/usr/bin/env bash -c '${escapeSystemdExecCommand(sched.command)}'
|
|
866
928
|
|
|
867
929
|
[Install]
|
|
868
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';
|
|
@@ -68,6 +68,54 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
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
|
+
|
|
71
119
|
/**
|
|
72
120
|
* Compute the env the named backend would install for this schedule.
|
|
73
121
|
*
|
|
@@ -88,9 +136,11 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
|
|
|
88
136
|
*
|
|
89
137
|
* @param {object} sched
|
|
90
138
|
* @param {string} backend
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} [opts.etcEnvironmentFile] - cron only; defaults to /etc/environment
|
|
91
141
|
* @returns {Record<string,string>}
|
|
92
142
|
*/
|
|
93
|
-
export function computeBackendEnv(sched, backend) {
|
|
143
|
+
export function computeBackendEnv(sched, backend, opts = {}) {
|
|
94
144
|
const explicit = resolveEnvironment(sched);
|
|
95
145
|
if (backend === 'launchd') {
|
|
96
146
|
// launchd builds the agent env from EnvironmentVariables alone. Verify
|
|
@@ -98,7 +148,9 @@ export function computeBackendEnv(sched, backend) {
|
|
|
98
148
|
return withLaunchdDefaults(explicit);
|
|
99
149
|
}
|
|
100
150
|
if (backend === 'cron') {
|
|
101
|
-
|
|
151
|
+
const base = minimalCronEnv();
|
|
152
|
+
base.PATH = composeCronVerifyPath(base.PATH, opts.etcEnvironmentFile);
|
|
153
|
+
return { ...base, ...explicit };
|
|
102
154
|
}
|
|
103
155
|
// systemd/schtasks: "parent env then explicit overrides". Replicate by
|
|
104
156
|
// layering explicit over process.env, dropping any keys the parent has
|
|
@@ -266,7 +318,9 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
266
318
|
* @param {string} scheduleFile - Path to the schedule JSON
|
|
267
319
|
* @param {object} [opts]
|
|
268
320
|
* @param {string} [opts.backend] - Override backend (default: auto-detect)
|
|
269
|
-
* @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.
|
|
270
324
|
* @param {boolean} [opts.hasNvm] - Test-only override; defaults to a real
|
|
271
325
|
* filesystem check of $HOME/.nvm/nvm.sh, matching install-time detection.
|
|
272
326
|
* @param {string} [opts.cronLogPath] - Test-only override for the cron
|
|
@@ -274,7 +328,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
274
328
|
* `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
|
|
275
329
|
* schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
|
|
276
330
|
* clobbers/interleaves with real cron output).
|
|
277
|
-
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, workDir: string, run: object, argv: string[] | null }>}
|
|
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 }>}
|
|
278
332
|
*/
|
|
279
333
|
export async function verify(scheduleFile, opts = {}) {
|
|
280
334
|
const sched = readSchedule(scheduleFile);
|
|
@@ -286,10 +340,10 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
286
340
|
}
|
|
287
341
|
|
|
288
342
|
const backend = opts.backend || sched.backend || detectDefaultBackend();
|
|
289
|
-
const timeoutMs = opts.timeoutMs ??
|
|
343
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_SECONDS * 1000;
|
|
290
344
|
|
|
291
345
|
const workDir = resolveWorkDir(sched.workingDirectory);
|
|
292
|
-
const env = computeBackendEnv(sched, backend);
|
|
346
|
+
const env = computeBackendEnv(sched, backend, { etcEnvironmentFile: opts.etcEnvironmentFile });
|
|
293
347
|
|
|
294
348
|
let run;
|
|
295
349
|
let argv = null;
|
|
@@ -320,7 +374,7 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
320
374
|
exitCode = 2;
|
|
321
375
|
}
|
|
322
376
|
|
|
323
|
-
return { result, exitCode, sched, backend, env, workDir, run, argv };
|
|
377
|
+
return { result, exitCode, sched, backend, env, path: env.PATH ?? null, workDir, run, argv };
|
|
324
378
|
}
|
|
325
379
|
|
|
326
380
|
// ── CLI entry point ──────────────────────────────────────────────────
|
|
@@ -339,7 +393,7 @@ Arguments:
|
|
|
339
393
|
systemd, schtasks). Defaults to auto-detected backend.
|
|
340
394
|
|
|
341
395
|
Flags:
|
|
342
|
-
--timeout <seconds> Kill the verify run after this many seconds (default:
|
|
396
|
+
--timeout <seconds> Kill the verify run after this many seconds (default: 120).
|
|
343
397
|
-h, --help Print this message and exit 0.
|
|
344
398
|
|
|
345
399
|
Exit codes:
|
|
@@ -375,7 +429,7 @@ if (isDirectlyInvoked()) {
|
|
|
375
429
|
// Parse: positional <schedule.json>, optional positional <backend>, optional --timeout N.
|
|
376
430
|
let scheduleFile = null;
|
|
377
431
|
let backend = null;
|
|
378
|
-
let timeoutSeconds =
|
|
432
|
+
let timeoutSeconds = DEFAULT_VERIFY_TIMEOUT_SECONDS;
|
|
379
433
|
|
|
380
434
|
for (let i = 0; i < args.length; i++) {
|
|
381
435
|
const a = args[i];
|
package/package.json
CHANGED