akm-cli 0.7.5 → 0.8.0-rc2
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/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +853 -479
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +285 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +121 -17
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +8 -26
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +378 -1
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +194 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// crontab backend for `akm tasks` (Linux default).
|
|
2
|
+
//
|
|
3
|
+
// Each akm-owned entry is wrapped in markers so a hand-edited crontab keeps
|
|
4
|
+
// its other lines untouched:
|
|
5
|
+
//
|
|
6
|
+
// # akm:task <id> BEGIN
|
|
7
|
+
// [SCHED] /abs/akm tasks run <id> >> /home/.../tasks/logs/<id>.log 2>&1
|
|
8
|
+
// # akm:task <id> END
|
|
9
|
+
//
|
|
10
|
+
// The backend reads/writes the user's crontab via `crontab -l` and
|
|
11
|
+
// `crontab -`. Disabling a task comments the entry with `# akm:disabled `
|
|
12
|
+
// rather than removing it, so re-enabling preserves the original schedule.
|
|
13
|
+
//
|
|
14
|
+
// Platform notes:
|
|
15
|
+
// • Operates on the *per-user* crontab — system-wide /etc/cron.d entries
|
|
16
|
+
// are out of scope.
|
|
17
|
+
// • Cron runs jobs with a stripped environment (`SHELL`, `PATH`, `HOME`,
|
|
18
|
+
// `LOGNAME`/`USER` only). The cron line uses an absolute akm path
|
|
19
|
+
// resolved at install time so it doesn't rely on the inherited PATH.
|
|
20
|
+
// • BSD `crontab -l` returns exit 1 with "no crontab for <user>" on a
|
|
21
|
+
// fresh user; we treat that as an empty crontab rather than an error.
|
|
22
|
+
//
|
|
23
|
+
// Tests inject a fake exec so unit tests don't touch the real crontab.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { ConfigError } from "../../core/errors";
|
|
28
|
+
import { getTaskLogDir } from "../../core/paths";
|
|
29
|
+
import { resolveAkmInvocation } from "../resolveAkmBin";
|
|
30
|
+
import { parseSchedule, translateToCron } from "../schedule";
|
|
31
|
+
const BEGIN = (id) => `# akm:task ${id} BEGIN`;
|
|
32
|
+
const END = (id) => `# akm:task ${id} END`;
|
|
33
|
+
const DISABLED_PREFIX = "# akm:disabled ";
|
|
34
|
+
const BLOCK_RE = /^# akm:task ([\w.@:_-]+) BEGIN$/;
|
|
35
|
+
export function CRON_BACKEND(options = {}) {
|
|
36
|
+
const exec = options.exec ?? defaultCronExec();
|
|
37
|
+
const logDir = options.logDir ?? getTaskLogDir();
|
|
38
|
+
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
39
|
+
return {
|
|
40
|
+
name: "cron",
|
|
41
|
+
install(task) {
|
|
42
|
+
// Create the log directory before writing the crontab line — cron
|
|
43
|
+
// appends with `>>` and the surrounding shell will fail the entire
|
|
44
|
+
// entry if the parent directory doesn't exist.
|
|
45
|
+
ensureDir(logDir);
|
|
46
|
+
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
47
|
+
const existing = readCrontab(exec);
|
|
48
|
+
const block = renderBlock(task.id, cronLine, task.enabled);
|
|
49
|
+
const next = upsertBlock(existing, task.id, block);
|
|
50
|
+
writeCrontab(exec, next);
|
|
51
|
+
},
|
|
52
|
+
uninstall(id) {
|
|
53
|
+
const existing = readCrontab(exec);
|
|
54
|
+
const next = removeBlock(existing, id);
|
|
55
|
+
writeCrontab(exec, next);
|
|
56
|
+
},
|
|
57
|
+
setEnabled(id, enabled) {
|
|
58
|
+
const existing = readCrontab(exec);
|
|
59
|
+
const next = toggleBlock(existing, id, enabled);
|
|
60
|
+
writeCrontab(exec, next);
|
|
61
|
+
},
|
|
62
|
+
list() {
|
|
63
|
+
const existing = readCrontab(exec);
|
|
64
|
+
const ids = [];
|
|
65
|
+
for (const line of existing.split(/\r?\n/)) {
|
|
66
|
+
const m = line.match(BLOCK_RE);
|
|
67
|
+
if (m)
|
|
68
|
+
ids.push(m[1]);
|
|
69
|
+
}
|
|
70
|
+
return ids.map((id) => ({ id }));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ── helpers (exported for tests) ────────────────────────────────────────────
|
|
75
|
+
export function buildCronLine(task, akmArgv, logDir) {
|
|
76
|
+
const spec = parseSchedule(task.schedule, "cron");
|
|
77
|
+
const cronExpr = translateToCron(spec);
|
|
78
|
+
const logPath = path.join(logDir, `${task.id}.log`);
|
|
79
|
+
const cmd = [...akmArgv, "tasks", "run", task.id].map((part) => quoteForCron(part)).join(" ");
|
|
80
|
+
return `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
|
|
81
|
+
}
|
|
82
|
+
export function renderBlock(id, cronLine, enabled) {
|
|
83
|
+
const body = enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`;
|
|
84
|
+
return [BEGIN(id), body, END(id)].join("\n");
|
|
85
|
+
}
|
|
86
|
+
export function upsertBlock(existing, id, block) {
|
|
87
|
+
const trimmed = existing.replace(/\s+$/g, "");
|
|
88
|
+
const removed = removeBlock(trimmed, id);
|
|
89
|
+
const sep = removed.length === 0 ? "" : "\n";
|
|
90
|
+
return `${removed}${sep}${block}\n`;
|
|
91
|
+
}
|
|
92
|
+
export function removeBlock(existing, id) {
|
|
93
|
+
const lines = existing.split(/\r?\n/);
|
|
94
|
+
const out = [];
|
|
95
|
+
let inBlock = false;
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (!inBlock && line === BEGIN(id)) {
|
|
98
|
+
inBlock = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inBlock && line === END(id)) {
|
|
102
|
+
inBlock = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (inBlock)
|
|
106
|
+
continue;
|
|
107
|
+
out.push(line);
|
|
108
|
+
}
|
|
109
|
+
// Collapse trailing blank lines.
|
|
110
|
+
while (out.length > 0 && out[out.length - 1] === "")
|
|
111
|
+
out.pop();
|
|
112
|
+
return out.join("\n");
|
|
113
|
+
}
|
|
114
|
+
export function toggleBlock(existing, id, enabled) {
|
|
115
|
+
const lines = existing.split(/\r?\n/);
|
|
116
|
+
const out = [];
|
|
117
|
+
let inBlock = false;
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
if (!inBlock && line === BEGIN(id)) {
|
|
120
|
+
inBlock = true;
|
|
121
|
+
out.push(line);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (inBlock && line === END(id)) {
|
|
125
|
+
inBlock = false;
|
|
126
|
+
out.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (inBlock) {
|
|
130
|
+
const isComment = line.startsWith(DISABLED_PREFIX);
|
|
131
|
+
if (enabled && isComment) {
|
|
132
|
+
out.push(line.slice(DISABLED_PREFIX.length));
|
|
133
|
+
}
|
|
134
|
+
else if (!enabled && !isComment) {
|
|
135
|
+
out.push(`${DISABLED_PREFIX}${line}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
out.push(line);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
out.push(line);
|
|
143
|
+
}
|
|
144
|
+
return out.join("\n");
|
|
145
|
+
}
|
|
146
|
+
function quoteForCron(part) {
|
|
147
|
+
// crontab passes the rest of the line to /bin/sh -c, so quote anything that
|
|
148
|
+
// isn't a plain shell-safe token. Single-quote and escape embedded single
|
|
149
|
+
// quotes via the standard shell idiom: `'foo'\''bar'`.
|
|
150
|
+
if (/^[A-Za-z0-9_\-./@:%=+,]+$/.test(part))
|
|
151
|
+
return part;
|
|
152
|
+
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
153
|
+
}
|
|
154
|
+
function readCrontab(exec) {
|
|
155
|
+
const result = exec.read();
|
|
156
|
+
if (result.status === 0)
|
|
157
|
+
return result.stdout ?? "";
|
|
158
|
+
// BSD crontab returns 1 with "no crontab for <user>" on stderr — treat as empty.
|
|
159
|
+
if (/no crontab for/i.test(result.stderr ?? ""))
|
|
160
|
+
return "";
|
|
161
|
+
if (/no crontab/i.test(result.stdout ?? ""))
|
|
162
|
+
return "";
|
|
163
|
+
throw new ConfigError(`crontab -l failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure the `crontab` binary is on PATH and your shell can read the user crontab.");
|
|
164
|
+
}
|
|
165
|
+
function writeCrontab(exec, content) {
|
|
166
|
+
const normalised = content.endsWith("\n") || content.length === 0 ? content : `${content}\n`;
|
|
167
|
+
const result = exec.write(normalised);
|
|
168
|
+
if (result.status !== 0) {
|
|
169
|
+
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.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function ensureDir(dir) {
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Best-effort: the install will surface a clearer error if the cron
|
|
178
|
+
// line later fails at runtime due to a missing redirection target.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function defaultCronExec() {
|
|
182
|
+
return {
|
|
183
|
+
read() {
|
|
184
|
+
const r = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
|
185
|
+
return {
|
|
186
|
+
status: r.status ?? 1,
|
|
187
|
+
stdout: r.stdout ?? "",
|
|
188
|
+
stderr: r.stderr ?? "",
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
write(content) {
|
|
192
|
+
const r = spawnSync("crontab", ["-"], { encoding: "utf8", input: content });
|
|
193
|
+
return {
|
|
194
|
+
status: r.status ?? 1,
|
|
195
|
+
stdout: r.stdout ?? "",
|
|
196
|
+
stderr: r.stderr ?? "",
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Run a command synchronously, normalizing null results to safe defaults.
|
|
4
|
+
* args[0] is the binary; args[1..] are its arguments.
|
|
5
|
+
*/
|
|
6
|
+
export function spawnCommand(args) {
|
|
7
|
+
const [bin, ...rest] = args;
|
|
8
|
+
const r = spawnSync(bin, rest, { encoding: "utf8" });
|
|
9
|
+
return {
|
|
10
|
+
status: r.status ?? 1,
|
|
11
|
+
stdout: r.stdout ?? "",
|
|
12
|
+
stderr: r.stderr ?? "",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Escape a string for safe embedding in an XML attribute or text node.
|
|
17
|
+
*/
|
|
18
|
+
export function escapeXml(s) {
|
|
19
|
+
return s
|
|
20
|
+
.replace(/&/g, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">")
|
|
23
|
+
.replace(/"/g, """)
|
|
24
|
+
.replace(/'/g, "'");
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend selection for the OS-native scheduler.
|
|
3
|
+
*
|
|
4
|
+
* • Linux → crontab
|
|
5
|
+
* • macOS → launchd (per-user LaunchAgent)
|
|
6
|
+
* • Windows → schtasks.exe / Task Scheduler
|
|
7
|
+
*
|
|
8
|
+
* Each backend implements {@link TaskBackend}; selection is a one-line
|
|
9
|
+
* platform check. Tests inject a fake `platform` to exercise non-host
|
|
10
|
+
* code paths.
|
|
11
|
+
*/
|
|
12
|
+
import { CRON_BACKEND } from "./cron";
|
|
13
|
+
import { LAUNCHD_BACKEND } from "./launchd";
|
|
14
|
+
import { SCHTASKS_BACKEND } from "./schtasks";
|
|
15
|
+
export function selectBackend(options = {}) {
|
|
16
|
+
const platform = options.platform ?? process.platform;
|
|
17
|
+
switch (platform) {
|
|
18
|
+
case "win32":
|
|
19
|
+
return SCHTASKS_BACKEND(options.schtasks);
|
|
20
|
+
case "darwin":
|
|
21
|
+
return LAUNCHD_BACKEND(options.launchd);
|
|
22
|
+
default:
|
|
23
|
+
return CRON_BACKEND(options.cron);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function backendNameForPlatform(platform = process.platform) {
|
|
27
|
+
if (platform === "win32")
|
|
28
|
+
return "schtasks";
|
|
29
|
+
if (platform === "darwin")
|
|
30
|
+
return "launchd";
|
|
31
|
+
return "cron";
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>{{LABEL}}</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
{{PROGRAM_ARGS}}
|
|
10
|
+
</array>
|
|
11
|
+
<key>StandardOutPath</key>
|
|
12
|
+
<string>{{LOG_PATH}}</string>
|
|
13
|
+
<key>StandardErrorPath</key>
|
|
14
|
+
<string>{{LOG_PATH}}</string>
|
|
15
|
+
<key>RunAtLoad</key>
|
|
16
|
+
<false/>
|
|
17
|
+
{{ENV_VARS}}{{TRIGGER_XML}}
|
|
18
|
+
</dict>
|
|
19
|
+
</plist>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchd backend for `akm tasks` (macOS default).
|
|
3
|
+
*
|
|
4
|
+
* Each task is written as a per-user LaunchAgent plist at
|
|
5
|
+
* `~/Library/LaunchAgents/com.akm.task.<id>.plist` and registered via
|
|
6
|
+
* `launchctl bootstrap gui/<uid> <plist>`. Disabling uses
|
|
7
|
+
* `launchctl disable gui/<uid>/<label>` and re-enabling uses `enable`.
|
|
8
|
+
*
|
|
9
|
+
* Platform notes:
|
|
10
|
+
* • The `bootstrap` / `bootout` / `enable` / `disable` subcommands require
|
|
11
|
+
* macOS 10.10 (Yosemite) or newer. On older systems the equivalents
|
|
12
|
+
* are `launchctl load -w` / `unload -w`. We only target modern macOS.
|
|
13
|
+
* • `gui/<uid>` is the per-user GUI launchd domain — agents in this
|
|
14
|
+
* domain only run while the user is logged in (no background runs at
|
|
15
|
+
* the loginwindow). Tasks that need to run when the user is logged
|
|
16
|
+
* out should be installed as system Daemons, which is out of scope.
|
|
17
|
+
*
|
|
18
|
+
* Tests inject a fake exec + filesystem so the backend can be unit-tested
|
|
19
|
+
* without touching the host launchctl.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { ConfigError } from "../../core/errors";
|
|
25
|
+
import { getTaskLogDir } from "../../core/paths";
|
|
26
|
+
import { resolveAkmInvocation } from "../resolveAkmBin";
|
|
27
|
+
import { parseSchedule, translateToLaunchd } from "../schedule";
|
|
28
|
+
import { escapeXml, spawnCommand } from "./exec-utils";
|
|
29
|
+
import launchdTemplate from "./launchd-template.xml" with { type: "text" };
|
|
30
|
+
export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
|
|
31
|
+
export function LAUNCHD_BACKEND(options = {}) {
|
|
32
|
+
const exec = options.exec ?? defaultLaunchdExec();
|
|
33
|
+
const fsLike = options.fs ?? defaultLaunchdFs();
|
|
34
|
+
const agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
35
|
+
const logDir = options.logDir ?? getTaskLogDir();
|
|
36
|
+
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
37
|
+
const plistPath = (id) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${id}.plist`);
|
|
38
|
+
const label = (id) => `${LAUNCHD_LABEL_PREFIX}${id}`;
|
|
39
|
+
const target = (id) => `gui/${exec.uid()}/${label(id)}`;
|
|
40
|
+
return {
|
|
41
|
+
name: "launchd",
|
|
42
|
+
install(task) {
|
|
43
|
+
// Capture PATH at install time so launchd (which strips the environment
|
|
44
|
+
// aggressively) can find the same binaries the user sees interactively.
|
|
45
|
+
let pathEnv;
|
|
46
|
+
if (options.envPath === false) {
|
|
47
|
+
pathEnv = undefined;
|
|
48
|
+
}
|
|
49
|
+
else if (typeof options.envPath === "string") {
|
|
50
|
+
pathEnv = options.envPath;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
pathEnv = process.env.PATH ?? "";
|
|
54
|
+
}
|
|
55
|
+
const xml = buildPlistXml(task, akmArgv, logDir, pathEnv);
|
|
56
|
+
fsLike.ensureDir(agentsDir);
|
|
57
|
+
// launchd refuses to start a job when StandardOutPath/StandardErrorPath
|
|
58
|
+
// points at a non-existent directory; create it before bootstrap.
|
|
59
|
+
fsLike.ensureDir(logDir);
|
|
60
|
+
fsLike.writeFile(plistPath(task.id), xml);
|
|
61
|
+
const bootout = exec.run(["launchctl", "bootout", target(task.id)]);
|
|
62
|
+
// bootout returning non-zero is fine — agent might not be loaded.
|
|
63
|
+
void bootout;
|
|
64
|
+
const bootstrap = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, plistPath(task.id)]);
|
|
65
|
+
if (bootstrap.status !== 0) {
|
|
66
|
+
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.");
|
|
67
|
+
}
|
|
68
|
+
if (!task.enabled) {
|
|
69
|
+
const disable = exec.run(["launchctl", "disable", target(task.id)]);
|
|
70
|
+
if (disable.status !== 0) {
|
|
71
|
+
throw new ConfigError(`launchctl disable failed: ${disable.stderr || disable.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
uninstall(id) {
|
|
76
|
+
// Bootout first (may fail if agent never loaded — that's fine).
|
|
77
|
+
exec.run(["launchctl", "bootout", target(id)]);
|
|
78
|
+
const file = plistPath(id);
|
|
79
|
+
if (fsLike.exists(file))
|
|
80
|
+
fsLike.removeFile(file);
|
|
81
|
+
},
|
|
82
|
+
setEnabled(id, enabled) {
|
|
83
|
+
const verb = enabled ? "enable" : "disable";
|
|
84
|
+
const r = exec.run(["launchctl", verb, target(id)]);
|
|
85
|
+
if (r.status !== 0) {
|
|
86
|
+
throw new ConfigError(`launchctl ${verb} failed: ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
list() {
|
|
90
|
+
if (!fsLike.exists(agentsDir))
|
|
91
|
+
return [];
|
|
92
|
+
const ids = [];
|
|
93
|
+
for (const file of fsLike.list(agentsDir)) {
|
|
94
|
+
if (file.startsWith(LAUNCHD_LABEL_PREFIX) && file.endsWith(".plist")) {
|
|
95
|
+
ids.push(file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return ids.map((id) => ({ id }));
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ── XML builder (exported for tests) ────────────────────────────────────────
|
|
103
|
+
export function buildPlistXml(task, akmArgv, logDir, pathEnv) {
|
|
104
|
+
const spec = parseSchedule(task.schedule, "launchd");
|
|
105
|
+
const trigger = translateToLaunchd(spec);
|
|
106
|
+
const argv = [...akmArgv, "tasks", "run", task.id];
|
|
107
|
+
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
108
|
+
const logPath = path.join(logDir, `${task.id}.log`);
|
|
109
|
+
const triggerXml = renderLaunchdTrigger(trigger);
|
|
110
|
+
const envVarsXml = pathEnv !== undefined
|
|
111
|
+
? ` <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${escapeXml(pathEnv)}</string>\n </dict>\n`
|
|
112
|
+
: "";
|
|
113
|
+
return launchdTemplate
|
|
114
|
+
.replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(task.id))
|
|
115
|
+
.replace("{{PROGRAM_ARGS}}", programArgs)
|
|
116
|
+
.replaceAll("{{LOG_PATH}}", escapeXml(logPath))
|
|
117
|
+
.replace("{{ENV_VARS}}", envVarsXml)
|
|
118
|
+
.replace("{{TRIGGER_XML}}", triggerXml);
|
|
119
|
+
}
|
|
120
|
+
function renderLaunchdTrigger(trigger) {
|
|
121
|
+
if (trigger.intervalSeconds !== undefined) {
|
|
122
|
+
return ` <key>StartInterval</key>
|
|
123
|
+
<integer>${trigger.intervalSeconds}</integer>`;
|
|
124
|
+
}
|
|
125
|
+
const cal = trigger.calendar ?? {};
|
|
126
|
+
const lines = [" <key>StartCalendarInterval</key>", " <dict>"];
|
|
127
|
+
if (cal.Minute !== undefined)
|
|
128
|
+
lines.push(` <key>Minute</key><integer>${cal.Minute}</integer>`);
|
|
129
|
+
if (cal.Hour !== undefined)
|
|
130
|
+
lines.push(` <key>Hour</key><integer>${cal.Hour}</integer>`);
|
|
131
|
+
if (cal.Day !== undefined)
|
|
132
|
+
lines.push(` <key>Day</key><integer>${cal.Day}</integer>`);
|
|
133
|
+
if (cal.Month !== undefined)
|
|
134
|
+
lines.push(` <key>Month</key><integer>${cal.Month}</integer>`);
|
|
135
|
+
if (cal.Weekday !== undefined)
|
|
136
|
+
lines.push(` <key>Weekday</key><integer>${cal.Weekday}</integer>`);
|
|
137
|
+
lines.push(" </dict>");
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
|
140
|
+
function defaultAgentsDir() {
|
|
141
|
+
// launchd's per-user LaunchAgents live under the user's home directory.
|
|
142
|
+
// If we can't determine HOME, refuse rather than silently producing a
|
|
143
|
+
// relative path that would write somewhere unexpected.
|
|
144
|
+
const home = os.homedir();
|
|
145
|
+
if (!home) {
|
|
146
|
+
throw new ConfigError("Cannot determine user home directory; launchd backend requires HOME to locate ~/Library/LaunchAgents.", "INVALID_CONFIG_FILE", "Set $HOME (POSIX) or the equivalent before running `akm tasks` on macOS.");
|
|
147
|
+
}
|
|
148
|
+
return path.join(home, "Library", "LaunchAgents");
|
|
149
|
+
}
|
|
150
|
+
function defaultLaunchdExec() {
|
|
151
|
+
return {
|
|
152
|
+
run(args) {
|
|
153
|
+
return spawnCommand(args);
|
|
154
|
+
},
|
|
155
|
+
uid() {
|
|
156
|
+
const fn = process.getuid;
|
|
157
|
+
return typeof fn === "function" ? fn.call(process) : 0;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function defaultLaunchdFs() {
|
|
162
|
+
return {
|
|
163
|
+
writeFile(file, content) {
|
|
164
|
+
fs.writeFileSync(file, content, { encoding: "utf8" });
|
|
165
|
+
},
|
|
166
|
+
removeFile(file) {
|
|
167
|
+
fs.rmSync(file, { force: true });
|
|
168
|
+
},
|
|
169
|
+
ensureDir(dir) {
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
},
|
|
172
|
+
list(dir) {
|
|
173
|
+
try {
|
|
174
|
+
return fs.readdirSync(dir);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
exists(file) {
|
|
181
|
+
return fs.existsSync(file);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
3
|
+
<RegistrationInfo>
|
|
4
|
+
<Description>akm scheduled task: {{TASK_ID}}</Description>
|
|
5
|
+
<URI>{{FOLDER}}{{TASK_ID}}</URI>
|
|
6
|
+
</RegistrationInfo>
|
|
7
|
+
<Triggers>
|
|
8
|
+
{{TRIGGER_XML}}
|
|
9
|
+
</Triggers>
|
|
10
|
+
<Principals>
|
|
11
|
+
<Principal id="Author">
|
|
12
|
+
<LogonType>InteractiveToken</LogonType>
|
|
13
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
14
|
+
</Principal>
|
|
15
|
+
</Principals>
|
|
16
|
+
<Settings>
|
|
17
|
+
<Enabled>{{ENABLED}}</Enabled>
|
|
18
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
19
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
20
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
21
|
+
</Settings>
|
|
22
|
+
<Actions Context="Author">
|
|
23
|
+
<Exec>
|
|
24
|
+
<Command>{{COMMAND}}</Command>
|
|
25
|
+
<Arguments>{{ARGS}}</Arguments>
|
|
26
|
+
</Exec>
|
|
27
|
+
</Actions>
|
|
28
|
+
<!-- Log target (informational only; schtasks doesn't redirect): {{LOG_PATH}} -->
|
|
29
|
+
</Task>
|