akm-cli 0.9.0-rc.2 → 0.9.0-rc.4
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/CHANGELOG.md +40 -12
- package/README.md +7 -8
- package/SECURITY.md +5 -3
- package/dist/akm +44 -33
- package/dist/akm-migrate-storage +44 -33
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/assets/improve-strategies/catchup.json +3 -1
- package/dist/assets/improve-strategies/consolidate.json +3 -1
- package/dist/assets/improve-strategies/frequent.json +3 -1
- package/dist/assets/improve-strategies/graph-refresh.json +3 -1
- package/dist/assets/improve-strategies/memory-focus.json +4 -1
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
- package/dist/assets/improve-strategies/quick.json +3 -1
- package/dist/assets/improve-strategies/recombine-only.json +2 -0
- package/dist/assets/improve-strategies/reflect-distill.json +1 -0
- package/dist/assets/improve-strategies/synthesize.json +2 -0
- package/dist/assets/tasks/core/backup.yml +2 -2
- package/dist/cli/config-migrate.js +554 -26
- package/dist/cli.js +28 -11
- package/dist/commands/backup-cli.js +6 -4
- package/dist/commands/config-cli.js +10 -3
- package/dist/commands/feedback-cli.js +24 -7
- package/dist/commands/health/checks.js +94 -15
- package/dist/commands/health/html-report.js +23 -2
- package/dist/commands/health/improve-metrics.js +45 -6
- package/dist/commands/health/md-report.js +10 -1
- package/dist/commands/health/surfaces.js +2 -1
- package/dist/commands/health/windows.js +1 -1
- package/dist/commands/health.js +1 -1
- package/dist/commands/improve/anti-collapse.js +3 -3
- package/dist/commands/improve/collapse-detector.js +5 -5
- package/dist/commands/improve/consolidate.js +123 -66
- package/dist/commands/improve/distill/promote-memory.js +8 -6
- package/dist/commands/improve/distill/quality-gate.js +1 -1
- package/dist/commands/improve/distill-guards.js +1 -1
- package/dist/commands/improve/distill-promotion-policy.js +32 -26
- package/dist/commands/improve/distill.js +52 -36
- package/dist/commands/improve/eligibility.js +9 -8
- package/dist/commands/improve/extract-prompt.js +2 -2
- package/dist/commands/improve/extract.js +43 -11
- package/dist/commands/improve/improve-auto-accept.js +14 -28
- package/dist/commands/improve/improve-cli.js +4 -2
- package/dist/commands/improve/improve-strategies.js +2 -1
- package/dist/commands/improve/improve.js +49 -8
- package/dist/commands/improve/loop-stages.js +11 -6
- package/dist/commands/improve/preparation.js +51 -17
- package/dist/commands/improve/procedural.js +10 -6
- package/dist/commands/improve/recombine.js +31 -7
- package/dist/commands/improve/reflect.js +24 -14
- package/dist/commands/improve/salience.js +5 -1
- package/dist/commands/improve/source-identity.js +43 -0
- package/dist/commands/migrate-cli.js +36 -0
- package/dist/commands/mv-cli.js +647 -258
- package/dist/commands/proposal/drain.js +16 -4
- package/dist/commands/proposal/proposal-cli.js +3 -2
- package/dist/commands/proposal/proposal.js +16 -55
- package/dist/commands/proposal/repository.js +664 -58
- package/dist/commands/read/curate.js +4 -2
- package/dist/commands/read/knowledge.js +4 -1
- package/dist/commands/read/search.js +6 -2
- package/dist/commands/read/show.js +8 -7
- package/dist/commands/sources/self-update.js +156 -112
- package/dist/commands/sources/sources-cli.js +7 -2
- package/dist/commands/tasks/default-tasks.js +5 -5
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +261 -68
- package/dist/core/common.js +36 -3
- package/dist/core/config/config-io.js +2 -2
- package/dist/core/config/config-schema.js +15 -3
- package/dist/core/config/config.js +14 -13
- package/dist/core/file-lock.js +2 -2
- package/dist/core/improve-result.js +87 -7
- package/dist/core/migration-backup.js +816 -171
- package/dist/core/migration-operation.js +44 -0
- package/dist/core/state/migrations.js +4 -7
- package/dist/core/state-db.js +23 -7
- package/dist/core/write-source.js +86 -18
- package/dist/indexer/db/db.js +109 -53
- package/dist/indexer/index-writer-lock.js +38 -37
- package/dist/indexer/index-written-assets.js +73 -56
- package/dist/indexer/indexer.js +5 -1
- package/dist/indexer/search/search-source.js +2 -2
- package/dist/indexer/usage/usage-events.js +8 -2
- package/dist/integrations/agent/engine-resolution.js +14 -7
- package/dist/scripts/migrate-storage.js +887 -992
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
- package/dist/setup/detected-engines.js +7 -13
- package/dist/setup/engine-config.js +14 -4
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +1 -1
- package/dist/setup/steps/tasks.js +1 -1
- package/dist/sources/providers/git-stash.js +58 -21
- package/dist/sources/providers/git.js +1 -1
- package/dist/storage/engines/sqlite-migrations.js +138 -3
- package/dist/storage/repositories/events-repository.js +24 -0
- package/dist/storage/repositories/proposals-repository.js +14 -0
- package/dist/storage/repositories/task-history-repository.js +30 -3
- package/dist/tasks/backends/cron.js +71 -38
- package/dist/tasks/backends/exec-utils.js +55 -3
- package/dist/tasks/backends/launchd.js +241 -50
- package/dist/tasks/backends/schtasks.js +434 -59
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/embedded.js +2 -0
- package/dist/tasks/parser.js +142 -6
- package/dist/tasks/resolve-akm-bin.js +19 -4
- package/dist/tasks/runner.js +258 -93
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +86 -0
- package/dist/tasks/task-id.js +37 -0
- package/dist/workflows/db.js +24 -13
- package/dist/workflows/validator.js +24 -2
- package/docs/README.md +103 -0
- package/docs/migration/release-notes/0.9.0.md +28 -9
- package/docs/migration/v0.8-to-v0.9.md +186 -26
- package/package.json +2 -3
|
@@ -25,60 +25,70 @@
|
|
|
25
25
|
//
|
|
26
26
|
// Tests inject a fake exec so unit tests don't touch the real crontab.
|
|
27
27
|
import { spawnSync } from "node:child_process";
|
|
28
|
-
import fs from "node:fs";
|
|
29
28
|
import path from "node:path";
|
|
30
29
|
import { ConfigError } from "../../core/errors.js";
|
|
31
30
|
import { getTaskLogDir } from "../../core/paths.js";
|
|
32
31
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
33
32
|
import { parseSchedule, translateToCron } from "../schedule.js";
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
import { buildScheduledTaskInvocation, resolveScheduledTaskContext, } from "../scheduler-invocation.js";
|
|
34
|
+
import { nodeFs } from "./exec-utils.js";
|
|
35
|
+
const BEGIN = (id) => `# akm:task ${assertCronValue(id)} BEGIN`;
|
|
36
|
+
const END = (id) => `# akm:task ${assertCronValue(id)} END`;
|
|
36
37
|
const DISABLED_PREFIX = "# akm:disabled ";
|
|
37
38
|
const BLOCK_RE = /^# akm:task ([\w.@:_-]+) BEGIN$/;
|
|
39
|
+
const BLOCK_END_RE = /^# akm:task ([\w.@:_-]+) END$/;
|
|
38
40
|
export function CRON_BACKEND(options = {}) {
|
|
39
41
|
const exec = options.exec ?? defaultCronExec();
|
|
42
|
+
const fsLike = options.fs ?? nodeFs();
|
|
40
43
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
41
44
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
45
|
+
const envPath = options.envPath === false ? undefined : (options.envPath ?? process.env.PATH);
|
|
46
|
+
const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
|
|
42
47
|
return {
|
|
43
48
|
name: "cron",
|
|
44
49
|
install(task) {
|
|
45
50
|
// Create the log directory before writing the crontab line — cron
|
|
46
51
|
// appends with `>>` and the surrounding shell will fail the entire
|
|
47
52
|
// entry if the parent directory doesn't exist.
|
|
48
|
-
ensureDir(logDir);
|
|
49
|
-
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
53
|
+
fsLike.ensureDir(logDir);
|
|
54
|
+
const cronLine = buildCronLine(task, akmArgv, logDir, envPath, scheduledContext);
|
|
50
55
|
const existing = readCrontab(exec);
|
|
51
56
|
const block = renderBlock(task.id, cronLine, task.enabled);
|
|
52
57
|
const next = upsertBlock(existing, task.id, block);
|
|
53
|
-
|
|
58
|
+
replaceCrontab(exec, existing, next);
|
|
54
59
|
},
|
|
55
60
|
uninstall(id) {
|
|
56
61
|
const existing = readCrontab(exec);
|
|
57
62
|
const next = removeBlock(existing, id);
|
|
58
|
-
|
|
63
|
+
replaceCrontab(exec, existing, next);
|
|
59
64
|
},
|
|
60
65
|
setEnabled(id, enabled) {
|
|
61
66
|
const existing = readCrontab(exec);
|
|
62
67
|
const next = toggleBlock(existing, id, enabled);
|
|
63
|
-
|
|
68
|
+
replaceCrontab(exec, existing, next);
|
|
64
69
|
},
|
|
65
70
|
list() {
|
|
66
71
|
const existing = readCrontab(exec);
|
|
67
72
|
return listBlocks(existing).map(({ id, body }) => ({ id, signature: normalizeSignature(body) }));
|
|
68
73
|
},
|
|
69
74
|
expectedSignature(task) {
|
|
70
|
-
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
75
|
+
const cronLine = buildCronLine(task, akmArgv, logDir, envPath, scheduledContext);
|
|
71
76
|
return normalizeSignature(cronBlockBody(cronLine, task.enabled));
|
|
72
77
|
},
|
|
73
78
|
};
|
|
74
79
|
}
|
|
75
80
|
// ── helpers (exported for tests) ────────────────────────────────────────────
|
|
76
|
-
export function buildCronLine(task, akmArgv, logDir) {
|
|
81
|
+
export function buildCronLine(task, akmArgv, logDir, envPath, scheduledContext) {
|
|
77
82
|
const spec = parseSchedule(task.schedule, "cron");
|
|
78
83
|
const cronExpr = translateToCron(spec);
|
|
79
84
|
const logPath = path.join(logDir, `${task.id}.log`);
|
|
80
|
-
const
|
|
81
|
-
|
|
85
|
+
const invocation = buildScheduledTaskInvocation(akmArgv, task.id, scheduledContext);
|
|
86
|
+
const cmd = invocation.argv.map((part) => quoteForCron(part)).join(" ");
|
|
87
|
+
const contextPrefix = Object.entries(invocation.environment)
|
|
88
|
+
.map(([key, value]) => `${key}=${quoteForCron(value)}`)
|
|
89
|
+
.join(" ");
|
|
90
|
+
const pathPrefix = envPath === undefined ? "" : `PATH=${quoteForCron(envPath)} `;
|
|
91
|
+
return `${cronExpr} ${contextPrefix} ${pathPrefix}${cmd} >> ${quoteForCron(logPath)} 2>&1`;
|
|
82
92
|
}
|
|
83
93
|
/** The crontab line as it appears inside a block — commented when disabled. */
|
|
84
94
|
export function cronBlockBody(cronLine, enabled) {
|
|
@@ -93,28 +103,44 @@ export function renderBlock(id, cronLine, enabled) {
|
|
|
93
103
|
* drift signature, and exported for tests.
|
|
94
104
|
*/
|
|
95
105
|
export function listBlocks(existing) {
|
|
106
|
+
return parseBlocks(existing).map(({ id, body }) => ({ id, body }));
|
|
107
|
+
}
|
|
108
|
+
function parseBlocks(existing) {
|
|
96
109
|
const out = [];
|
|
97
110
|
const lines = existing.split(/\r?\n/);
|
|
98
111
|
let currentId = null;
|
|
112
|
+
let start = -1;
|
|
99
113
|
let body = [];
|
|
100
|
-
for (const line of lines) {
|
|
114
|
+
for (const [index, line] of lines.entries()) {
|
|
101
115
|
const begin = line.match(BLOCK_RE);
|
|
102
116
|
if (begin) {
|
|
117
|
+
if (currentId !== null)
|
|
118
|
+
throw malformedBlockError(currentId);
|
|
103
119
|
currentId = begin[1];
|
|
120
|
+
start = index;
|
|
104
121
|
body = [];
|
|
105
122
|
continue;
|
|
106
123
|
}
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
const end = line.match(BLOCK_END_RE);
|
|
125
|
+
if (end) {
|
|
126
|
+
if (currentId === null || end[1] !== currentId)
|
|
127
|
+
throw malformedBlockError(currentId ?? end[1]);
|
|
128
|
+
out.push({ id: currentId, body: body.join("\n"), start, end: index });
|
|
109
129
|
currentId = null;
|
|
130
|
+
start = -1;
|
|
110
131
|
body = [];
|
|
111
132
|
continue;
|
|
112
133
|
}
|
|
113
134
|
if (currentId !== null)
|
|
114
135
|
body.push(line);
|
|
115
136
|
}
|
|
137
|
+
if (currentId !== null)
|
|
138
|
+
throw malformedBlockError(currentId);
|
|
116
139
|
return out;
|
|
117
140
|
}
|
|
141
|
+
function malformedBlockError(id) {
|
|
142
|
+
return new ConfigError(`Crontab contains a malformed akm task block for "${id}"; refusing to modify it.`, "INVALID_CONFIG_FILE");
|
|
143
|
+
}
|
|
118
144
|
/** Collapse incidental whitespace so signature comparison ignores it. */
|
|
119
145
|
function normalizeSignature(body) {
|
|
120
146
|
return body
|
|
@@ -131,27 +157,17 @@ export function upsertBlock(existing, id, block) {
|
|
|
131
157
|
}
|
|
132
158
|
export function removeBlock(existing, id) {
|
|
133
159
|
const lines = existing.split(/\r?\n/);
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
inBlock = true;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (inBlock && line === END(id)) {
|
|
142
|
-
inBlock = false;
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (inBlock)
|
|
146
|
-
continue;
|
|
147
|
-
out.push(line);
|
|
148
|
-
}
|
|
160
|
+
const blocks = parseBlocks(existing).filter((block) => block.id === id);
|
|
161
|
+
if (blocks.length === 0)
|
|
162
|
+
return existing;
|
|
163
|
+
const out = lines.filter((_, index) => !blocks.some((block) => index >= block.start && index <= block.end));
|
|
149
164
|
// Collapse trailing blank lines.
|
|
150
165
|
while (out.length > 0 && out[out.length - 1] === "")
|
|
151
166
|
out.pop();
|
|
152
167
|
return out.join("\n");
|
|
153
168
|
}
|
|
154
169
|
export function toggleBlock(existing, id, enabled) {
|
|
170
|
+
parseBlocks(existing);
|
|
155
171
|
const lines = existing.split(/\r?\n/);
|
|
156
172
|
const out = [];
|
|
157
173
|
let inBlock = false;
|
|
@@ -184,12 +200,23 @@ export function toggleBlock(existing, id, enabled) {
|
|
|
184
200
|
return out.join("\n");
|
|
185
201
|
}
|
|
186
202
|
function quoteForCron(part) {
|
|
203
|
+
assertCronValue(part);
|
|
187
204
|
// crontab passes the rest of the line to /bin/sh -c, so quote anything that
|
|
188
205
|
// isn't a plain shell-safe token. Single-quote and escape embedded single
|
|
189
|
-
// quotes via the standard shell idiom: `'foo'\''bar'`.
|
|
206
|
+
// quotes via the standard shell idiom: `'foo'\''bar'`. Cron interprets `%`
|
|
207
|
+
// before the shell, even inside quotes, so close the quote around its escape.
|
|
190
208
|
if (/^[A-Za-z0-9_\-./@:%=+,]+$/.test(part))
|
|
191
|
-
return part;
|
|
192
|
-
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
209
|
+
return part.replaceAll("%", "\\%");
|
|
210
|
+
return `'${part.replace(/'/g, `'\\''`).replace(/%/g, `'\\%'`)}'`;
|
|
211
|
+
}
|
|
212
|
+
function assertCronValue(value) {
|
|
213
|
+
for (const char of value) {
|
|
214
|
+
const code = char.codePointAt(0) ?? 0;
|
|
215
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
|
216
|
+
throw new ConfigError("Cron values must not contain control characters.", "INVALID_CONFIG_FILE");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
193
220
|
}
|
|
194
221
|
function readCrontab(exec) {
|
|
195
222
|
const result = exec.read();
|
|
@@ -209,13 +236,19 @@ function writeCrontab(exec, content) {
|
|
|
209
236
|
throw new ConfigError(`crontab - failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure the `crontab` binary is on PATH and your shell can write the user crontab.");
|
|
210
237
|
}
|
|
211
238
|
}
|
|
212
|
-
function
|
|
239
|
+
function replaceCrontab(exec, existing, next) {
|
|
213
240
|
try {
|
|
214
|
-
|
|
241
|
+
writeCrontab(exec, next);
|
|
215
242
|
}
|
|
216
|
-
catch {
|
|
217
|
-
|
|
218
|
-
|
|
243
|
+
catch (err) {
|
|
244
|
+
try {
|
|
245
|
+
writeCrontab(exec, existing);
|
|
246
|
+
}
|
|
247
|
+
catch (rollbackError) {
|
|
248
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
249
|
+
throw new AggregateError([err, rollbackError], `${message}; restoring the prior crontab also failed.`);
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
219
252
|
}
|
|
220
253
|
}
|
|
221
254
|
function defaultCronExec() {
|
|
@@ -40,13 +40,65 @@ export function nodeFs() {
|
|
|
40
40
|
*/
|
|
41
41
|
export function spawnCommand(args) {
|
|
42
42
|
const [bin, ...rest] = args;
|
|
43
|
-
const r = spawnSync(bin, rest
|
|
43
|
+
const r = spawnSync(bin, rest);
|
|
44
44
|
return {
|
|
45
45
|
status: r.status ?? 1,
|
|
46
|
-
stdout: r.stdout
|
|
47
|
-
stderr: r.stderr
|
|
46
|
+
stdout: decodeCommandOutput(r.stdout),
|
|
47
|
+
stderr: decodeCommandOutput(r.stderr),
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
/** Decode native command output, including the UTF-16 XML emitted by schtasks. */
|
|
51
|
+
export function decodeCommandOutput(output) {
|
|
52
|
+
if (output === null || output === undefined)
|
|
53
|
+
return "";
|
|
54
|
+
if (typeof output === "string")
|
|
55
|
+
return output.replace(/^\uFEFF/, "");
|
|
56
|
+
if (output.length === 0)
|
|
57
|
+
return "";
|
|
58
|
+
if (output[0] === 0xef && output[1] === 0xbb && output[2] === 0xbf) {
|
|
59
|
+
return output.toString("utf8", 3);
|
|
60
|
+
}
|
|
61
|
+
if (output[0] === 0xff && output[1] === 0xfe) {
|
|
62
|
+
return output.subarray(2).toString("utf16le");
|
|
63
|
+
}
|
|
64
|
+
if (output[0] === 0xfe && output[1] === 0xff) {
|
|
65
|
+
return decodeUtf16Be(output.subarray(2));
|
|
66
|
+
}
|
|
67
|
+
const pairs = Math.floor(output.length / 2);
|
|
68
|
+
if (pairs > 1) {
|
|
69
|
+
let evenNuls = 0;
|
|
70
|
+
let oddNuls = 0;
|
|
71
|
+
for (let i = 0; i < pairs * 2; i += 2) {
|
|
72
|
+
if (output[i] === 0)
|
|
73
|
+
evenNuls += 1;
|
|
74
|
+
if (output[i + 1] === 0)
|
|
75
|
+
oddNuls += 1;
|
|
76
|
+
}
|
|
77
|
+
if (oddNuls / pairs > 0.6)
|
|
78
|
+
return output.toString("utf16le");
|
|
79
|
+
if (evenNuls / pairs > 0.6)
|
|
80
|
+
return decodeUtf16Be(output);
|
|
81
|
+
}
|
|
82
|
+
return output.toString("utf8");
|
|
83
|
+
}
|
|
84
|
+
/** Return XML with the declaration required by Task Scheduler's UTF-16 input. */
|
|
85
|
+
export function normalizeXmlForUtf16File(xml) {
|
|
86
|
+
const source = xml.replace(/^\uFEFF/, "");
|
|
87
|
+
const declaration = '<?xml version="1.0" encoding="UTF-16"?>';
|
|
88
|
+
if (/^<\?xml\b[^?]*\?>/i.test(source)) {
|
|
89
|
+
return source.replace(/^<\?xml\b[^?]*\?>/i, declaration);
|
|
90
|
+
}
|
|
91
|
+
return `${declaration}\n${source}`;
|
|
92
|
+
}
|
|
93
|
+
function decodeUtf16Be(output) {
|
|
94
|
+
const evenLength = output.length - (output.length % 2);
|
|
95
|
+
const swapped = Buffer.allocUnsafe(evenLength);
|
|
96
|
+
for (let i = 0; i < evenLength; i += 2) {
|
|
97
|
+
swapped[i] = output[i + 1];
|
|
98
|
+
swapped[i + 1] = output[i];
|
|
99
|
+
}
|
|
100
|
+
return swapped.toString("utf16le");
|
|
101
|
+
}
|
|
50
102
|
/**
|
|
51
103
|
* Escape a string for safe embedding in an XML attribute or text node.
|
|
52
104
|
*/
|
|
@@ -29,6 +29,7 @@ import { ConfigError } from "../../core/errors.js";
|
|
|
29
29
|
import { getTaskLogDir } from "../../core/paths.js";
|
|
30
30
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
31
31
|
import { parseSchedule, translateToLaunchd } from "../schedule.js";
|
|
32
|
+
import { buildScheduledTaskInvocation, resolveScheduledTaskContext, } from "../scheduler-invocation.js";
|
|
32
33
|
import { escapeXml, nodeExec, nodeFs } from "./exec-utils.js";
|
|
33
34
|
export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
|
|
34
35
|
export function LAUNCHD_BACKEND(options = {}) {
|
|
@@ -37,57 +38,157 @@ export function LAUNCHD_BACKEND(options = {}) {
|
|
|
37
38
|
const agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
38
39
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
39
40
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
41
|
+
const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
|
|
40
42
|
const plistPath = (id) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${id}.plist`);
|
|
41
43
|
const label = (id) => `${LAUNCHD_LABEL_PREFIX}${id}`;
|
|
42
44
|
const target = (id) => `gui/${exec.uid()}/${label(id)}`;
|
|
45
|
+
const pathEnv = () => {
|
|
46
|
+
if (options.envPath === false)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (typeof options.envPath === "string")
|
|
49
|
+
return options.envPath;
|
|
50
|
+
return process.env.PATH ?? "";
|
|
51
|
+
};
|
|
52
|
+
const setEnableState = (id, enabled) => {
|
|
53
|
+
const verb = enabled ? "enable" : "disable";
|
|
54
|
+
const result = exec.run(["launchctl", verb, target(id)]);
|
|
55
|
+
if (result.status !== 0) {
|
|
56
|
+
throw new ConfigError(`launchctl ${verb} failed: ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
43
59
|
return {
|
|
44
60
|
name: "launchd",
|
|
45
61
|
install(task) {
|
|
46
62
|
// Capture PATH at install time so launchd (which strips the environment
|
|
47
63
|
// aggressively) can find the same binaries the user sees interactively.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
const xml = buildPlistXml(task, akmArgv, logDir, pathEnv(), scheduledContext);
|
|
65
|
+
const file = plistPath(task.id);
|
|
66
|
+
const previousPlist = fsLike.exists(file) ? fsLike.readFile(file) : undefined;
|
|
67
|
+
let previousEnabled = true;
|
|
68
|
+
if (previousPlist !== undefined) {
|
|
69
|
+
const disabledLabels = readDisabledLabels(exec);
|
|
70
|
+
if (disabledLabels === undefined) {
|
|
71
|
+
throw new ConfigError(`launchctl print-disabled failed; cannot safely replace existing task "${task.id}".`, "INVALID_CONFIG_FILE");
|
|
72
|
+
}
|
|
73
|
+
previousEnabled = !disabledLabels.has(label(task.id));
|
|
57
74
|
}
|
|
58
|
-
const xml = buildPlistXml(task, akmArgv, logDir, pathEnv);
|
|
59
75
|
fsLike.ensureDir(agentsDir);
|
|
60
76
|
// launchd refuses to start a job when StandardOutPath/StandardErrorPath
|
|
61
77
|
// points at a non-existent directory; create it before bootstrap.
|
|
62
78
|
fsLike.ensureDir(logDir);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
const tempFile = path.join(agentsDir, `.${task.id}.${Date.now()}.tmp`);
|
|
80
|
+
fsLike.writeFile(tempFile, xml);
|
|
81
|
+
let bootoutCompleted = false;
|
|
82
|
+
let previousWasLoaded = false;
|
|
83
|
+
let fileReplaced = false;
|
|
84
|
+
let enableStateTouched = false;
|
|
85
|
+
try {
|
|
86
|
+
const bootout = exec.run(["launchctl", "bootout", target(task.id)]);
|
|
87
|
+
if (bootout.status !== 0 && !isServiceNotFoundResult(bootout)) {
|
|
88
|
+
throw new ConfigError(`launchctl bootout failed (exit ${bootout.status}): ${bootout.stderr || bootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
89
|
+
}
|
|
90
|
+
bootoutCompleted = true;
|
|
91
|
+
previousWasLoaded = previousPlist !== undefined && bootout.status === 0;
|
|
92
|
+
fsLike.replaceFile(tempFile, file);
|
|
93
|
+
fileReplaced = true;
|
|
94
|
+
// A disable override survives bootout and plist replacement. Clear it
|
|
95
|
+
// before bootstrap, then apply the desired state after registration.
|
|
96
|
+
enableStateTouched = true;
|
|
97
|
+
setEnableState(task.id, true);
|
|
98
|
+
const bootstrap = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
|
|
99
|
+
if (bootstrap.status !== 0) {
|
|
100
|
+
throw new ConfigError(`launchctl bootstrap failed (exit ${bootstrap.status}): ${bootstrap.stderr || bootstrap.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure `launchctl` is available; on macOS it is part of the base system.");
|
|
101
|
+
}
|
|
102
|
+
if (!task.enabled) {
|
|
103
|
+
setEnableState(task.id, false);
|
|
104
|
+
}
|
|
70
105
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (!bootoutCompleted)
|
|
108
|
+
throw err;
|
|
109
|
+
const rollbackErrors = [];
|
|
110
|
+
let priorFileRestored = !fileReplaced;
|
|
111
|
+
if (fileReplaced) {
|
|
112
|
+
let replacementUnloaded = false;
|
|
113
|
+
try {
|
|
114
|
+
const rollbackBootout = exec.run(["launchctl", "bootout", target(task.id)]);
|
|
115
|
+
replacementUnloaded = rollbackBootout.status === 0 || isServiceNotFoundResult(rollbackBootout);
|
|
116
|
+
if (!replacementUnloaded) {
|
|
117
|
+
rollbackErrors.push(new ConfigError(`launchctl bootout during rollback failed: ${rollbackBootout.stderr || rollbackBootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (rollbackError) {
|
|
121
|
+
rollbackErrors.push(rollbackError);
|
|
122
|
+
}
|
|
123
|
+
if (replacementUnloaded) {
|
|
124
|
+
try {
|
|
125
|
+
if (previousPlist === undefined) {
|
|
126
|
+
if (fsLike.exists(file))
|
|
127
|
+
fsLike.removeFile(file);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
fsLike.writeFile(tempFile, previousPlist);
|
|
131
|
+
fsLike.replaceFile(tempFile, file);
|
|
132
|
+
}
|
|
133
|
+
priorFileRestored = true;
|
|
134
|
+
}
|
|
135
|
+
catch (rollbackError) {
|
|
136
|
+
rollbackErrors.push(rollbackError);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
75
139
|
}
|
|
140
|
+
if (previousPlist !== undefined && previousWasLoaded && priorFileRestored) {
|
|
141
|
+
try {
|
|
142
|
+
setEnableState(task.id, true);
|
|
143
|
+
const restore = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
|
|
144
|
+
if (restore.status !== 0) {
|
|
145
|
+
rollbackErrors.push(new ConfigError(`launchctl bootstrap during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (rollbackError) {
|
|
149
|
+
rollbackErrors.push(rollbackError);
|
|
150
|
+
}
|
|
151
|
+
if (!previousEnabled) {
|
|
152
|
+
try {
|
|
153
|
+
setEnableState(task.id, false);
|
|
154
|
+
}
|
|
155
|
+
catch (rollbackError) {
|
|
156
|
+
rollbackErrors.push(rollbackError);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (enableStateTouched) {
|
|
161
|
+
try {
|
|
162
|
+
setEnableState(task.id, previousPlist === undefined || previousEnabled);
|
|
163
|
+
}
|
|
164
|
+
catch (rollbackError) {
|
|
165
|
+
rollbackErrors.push(rollbackError);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (rollbackErrors.length > 0) {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for launchd task "${task.id}" was incomplete.`);
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (fsLike.exists(tempFile))
|
|
176
|
+
fsLike.removeFile(tempFile);
|
|
76
177
|
}
|
|
77
178
|
},
|
|
78
179
|
uninstall(id) {
|
|
79
|
-
|
|
80
|
-
|
|
180
|
+
const bootout = exec.run(["launchctl", "bootout", target(id)]);
|
|
181
|
+
if (bootout.status !== 0 && !isServiceNotFoundResult(bootout)) {
|
|
182
|
+
throw new ConfigError(`launchctl bootout failed (exit ${bootout.status}): ${bootout.stderr || bootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
183
|
+
}
|
|
184
|
+
// launchctl disable overrides persist after the plist is removed.
|
|
185
|
+
setEnableState(id, true);
|
|
81
186
|
const file = plistPath(id);
|
|
82
187
|
if (fsLike.exists(file))
|
|
83
188
|
fsLike.removeFile(file);
|
|
84
189
|
},
|
|
85
190
|
setEnabled(id, enabled) {
|
|
86
|
-
|
|
87
|
-
const r = exec.run(["launchctl", verb, target(id)]);
|
|
88
|
-
if (r.status !== 0) {
|
|
89
|
-
throw new ConfigError(`launchctl ${verb} failed: ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
90
|
-
}
|
|
191
|
+
setEnableState(id, enabled);
|
|
91
192
|
},
|
|
92
193
|
list() {
|
|
93
194
|
if (!fsLike.exists(agentsDir))
|
|
@@ -98,48 +199,132 @@ export function LAUNCHD_BACKEND(options = {}) {
|
|
|
98
199
|
ids.push(file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length));
|
|
99
200
|
}
|
|
100
201
|
}
|
|
101
|
-
|
|
202
|
+
if (ids.length === 0)
|
|
203
|
+
return [];
|
|
204
|
+
const disabledLabels = readDisabledLabels(exec);
|
|
205
|
+
return ids.map((id) => {
|
|
206
|
+
if (!disabledLabels)
|
|
207
|
+
return { id };
|
|
208
|
+
try {
|
|
209
|
+
const loaded = exec.run(["launchctl", "print", target(id)]);
|
|
210
|
+
if (loaded.status !== 0)
|
|
211
|
+
return { id };
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return { id };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const xml = fsLike
|
|
218
|
+
.readFile(plistPath(id))
|
|
219
|
+
.replace(/<!-- akm-enabled:(?:true|false) -->/, `<!-- akm-enabled:${!disabledLabels.has(label(id))} -->`);
|
|
220
|
+
return { id, signature: normalizeSignature(xml) };
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { id };
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
expectedSignature(task) {
|
|
228
|
+
return normalizeSignature(buildPlistXml(task, akmArgv, logDir, pathEnv(), scheduledContext));
|
|
102
229
|
},
|
|
103
230
|
};
|
|
104
231
|
}
|
|
105
232
|
// ── XML builder (exported for tests) ────────────────────────────────────────
|
|
106
|
-
export function buildPlistXml(task, akmArgv, logDir, pathEnv) {
|
|
233
|
+
export function buildPlistXml(task, akmArgv, logDir, pathEnv, scheduledContext) {
|
|
107
234
|
const spec = parseSchedule(task.schedule, "launchd");
|
|
108
235
|
const trigger = translateToLaunchd(spec);
|
|
109
|
-
const
|
|
236
|
+
const invocation = buildScheduledTaskInvocation(akmArgv, task.id, scheduledContext);
|
|
237
|
+
const argv = invocation.argv;
|
|
110
238
|
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
111
239
|
const logPath = path.join(logDir, `${task.id}.log`);
|
|
112
240
|
const triggerXml = renderLaunchdTrigger(trigger);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
241
|
+
const environment = { ...invocation.environment };
|
|
242
|
+
if (pathEnv !== undefined)
|
|
243
|
+
environment.PATH = pathEnv;
|
|
244
|
+
const envEntries = Object.entries(environment)
|
|
245
|
+
.map(([key, value]) => ` <key>${key}</key>\n <string>${escapeXml(value)}</string>`)
|
|
246
|
+
.join("\n");
|
|
247
|
+
const envVarsXml = ` <key>EnvironmentVariables</key>\n <dict>\n${envEntries}\n </dict>\n`;
|
|
248
|
+
const xml = launchdTemplate
|
|
249
|
+
.replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n`)
|
|
117
250
|
.replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(task.id))
|
|
118
251
|
.replace("{{PROGRAM_ARGS}}", programArgs)
|
|
119
252
|
.replaceAll("{{LOG_PATH}}", escapeXml(logPath))
|
|
120
253
|
.replace("{{ENV_VARS}}", envVarsXml)
|
|
121
254
|
.replace("{{TRIGGER_XML}}", triggerXml);
|
|
255
|
+
for (const char of xml) {
|
|
256
|
+
const code = char.codePointAt(0) ?? 0;
|
|
257
|
+
if (code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f)) {
|
|
258
|
+
throw new ConfigError("Launchd plist values must not contain XML-forbidden control characters.", "INVALID_CONFIG_FILE");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return xml;
|
|
122
262
|
}
|
|
123
263
|
function renderLaunchdTrigger(trigger) {
|
|
124
|
-
if (trigger.
|
|
125
|
-
|
|
126
|
-
|
|
264
|
+
if (trigger.calendars !== undefined) {
|
|
265
|
+
const lines = [" <key>StartCalendarInterval</key>", " <array>"];
|
|
266
|
+
for (const calendar of trigger.calendars) {
|
|
267
|
+
lines.push(...renderCalendar(calendar, " "));
|
|
268
|
+
}
|
|
269
|
+
lines.push(" </array>");
|
|
270
|
+
return lines.join("\n");
|
|
127
271
|
}
|
|
128
272
|
const cal = trigger.calendar ?? {};
|
|
129
|
-
const lines = [" <key>StartCalendarInterval</key>", "
|
|
130
|
-
if (cal.Minute !== undefined)
|
|
131
|
-
lines.push(` <key>Minute</key><integer>${cal.Minute}</integer>`);
|
|
132
|
-
if (cal.Hour !== undefined)
|
|
133
|
-
lines.push(` <key>Hour</key><integer>${cal.Hour}</integer>`);
|
|
134
|
-
if (cal.Day !== undefined)
|
|
135
|
-
lines.push(` <key>Day</key><integer>${cal.Day}</integer>`);
|
|
136
|
-
if (cal.Month !== undefined)
|
|
137
|
-
lines.push(` <key>Month</key><integer>${cal.Month}</integer>`);
|
|
138
|
-
if (cal.Weekday !== undefined)
|
|
139
|
-
lines.push(` <key>Weekday</key><integer>${cal.Weekday}</integer>`);
|
|
140
|
-
lines.push(" </dict>");
|
|
273
|
+
const lines = [" <key>StartCalendarInterval</key>", ...renderCalendar(cal, " ")];
|
|
141
274
|
return lines.join("\n");
|
|
142
275
|
}
|
|
276
|
+
function renderCalendar(calendar, indent) {
|
|
277
|
+
const valueIndent = `${indent} `;
|
|
278
|
+
const lines = [`${indent}<dict>`];
|
|
279
|
+
if (calendar.Minute !== undefined) {
|
|
280
|
+
lines.push(`${valueIndent}<key>Minute</key><integer>${calendar.Minute}</integer>`);
|
|
281
|
+
}
|
|
282
|
+
if (calendar.Hour !== undefined)
|
|
283
|
+
lines.push(`${valueIndent}<key>Hour</key><integer>${calendar.Hour}</integer>`);
|
|
284
|
+
if (calendar.Day !== undefined)
|
|
285
|
+
lines.push(`${valueIndent}<key>Day</key><integer>${calendar.Day}</integer>`);
|
|
286
|
+
if (calendar.Month !== undefined)
|
|
287
|
+
lines.push(`${valueIndent}<key>Month</key><integer>${calendar.Month}</integer>`);
|
|
288
|
+
if (calendar.Weekday !== undefined) {
|
|
289
|
+
lines.push(`${valueIndent}<key>Weekday</key><integer>${calendar.Weekday}</integer>`);
|
|
290
|
+
}
|
|
291
|
+
lines.push(`${indent}</dict>`);
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
294
|
+
function normalizeSignature(xml) {
|
|
295
|
+
return xml.replace(/\r\n/g, "\n").trim();
|
|
296
|
+
}
|
|
297
|
+
function readDisabledLabels(exec) {
|
|
298
|
+
try {
|
|
299
|
+
const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]);
|
|
300
|
+
if (result.status !== 0)
|
|
301
|
+
return undefined;
|
|
302
|
+
return parseDisabledLabels(result.stdout);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function parseDisabledLabels(output) {
|
|
309
|
+
const envelope = /^\s*disabled services\s*=\s*\{([\s\S]*)\}\s*$/.exec(output);
|
|
310
|
+
if (!envelope)
|
|
311
|
+
return undefined;
|
|
312
|
+
const disabled = new Set();
|
|
313
|
+
let body = envelope[1];
|
|
314
|
+
while (body.trim()) {
|
|
315
|
+
const entry = /^\s*"([^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)\s*/.exec(body);
|
|
316
|
+
if (!entry)
|
|
317
|
+
return undefined;
|
|
318
|
+
if (entry[2] === "true" || entry[2] === "disabled")
|
|
319
|
+
disabled.add(entry[1]);
|
|
320
|
+
body = body.slice(entry[0].length);
|
|
321
|
+
}
|
|
322
|
+
return disabled;
|
|
323
|
+
}
|
|
324
|
+
function isServiceNotFoundResult(result) {
|
|
325
|
+
const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`;
|
|
326
|
+
return /could not find service\b|service\b.*\bnot found\b|\bno such process\b/i.test(output);
|
|
327
|
+
}
|
|
143
328
|
function defaultAgentsDir() {
|
|
144
329
|
// launchd's per-user LaunchAgents live under the user's home directory.
|
|
145
330
|
// If we can't determine HOME, refuse rather than silently producing a
|
|
@@ -162,9 +347,15 @@ function defaultLaunchdExec() {
|
|
|
162
347
|
function defaultLaunchdFs() {
|
|
163
348
|
return {
|
|
164
349
|
...nodeFs(),
|
|
350
|
+
readFile(file) {
|
|
351
|
+
return fs.readFileSync(file, "utf8");
|
|
352
|
+
},
|
|
165
353
|
removeFile(file) {
|
|
166
354
|
fs.rmSync(file, { force: true });
|
|
167
355
|
},
|
|
356
|
+
replaceFile(source, destination) {
|
|
357
|
+
fs.renameSync(source, destination);
|
|
358
|
+
},
|
|
168
359
|
list(dir) {
|
|
169
360
|
try {
|
|
170
361
|
return fs.readdirSync(dir);
|