akm-cli 0.9.17-alpha.6 → 0.9.17-alpha.8
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 +285 -0
- package/STABILITY.md +2 -2
- package/dist/akm +62 -29
- package/dist/akm-migrate +38 -19
- package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +5 -3
- package/dist/commands/improve/consolidate.js +11 -0
- package/dist/commands/improve/improve-cli.js +27 -7
- package/dist/commands/improve/ledger.js +7 -3
- package/dist/commands/improve/loop-stages.js +4 -3
- package/dist/commands/improve/preparation.js +40 -10
- package/dist/commands/improve/reflect.js +46 -22
- package/dist/commands/improve/retrieval-gate.js +127 -0
- package/dist/commands/improve/retrieval-scope.js +77 -0
- package/dist/commands/read/curate.js +41 -30
- package/dist/commands/read/show.js +55 -2
- package/dist/commands/sources/info.js +3 -0
- package/dist/commands/tasks/tasks-cli.js +10 -12
- package/dist/commands/tasks/tasks.js +57 -56
- package/dist/commands/tasks/validate.js +27 -46
- package/dist/core/adapter/adapters/akm-adapter.js +2 -0
- package/dist/core/adapter/adapters/akm-metadata.js +31 -0
- package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
- package/dist/core/improve-result.js +4 -1
- package/dist/core/non-task-input.js +20 -0
- package/dist/core/paths.js +0 -4
- package/dist/indexer/db/graph-db.js +0 -32
- package/dist/indexer/graph/graph-extraction.js +3 -1
- package/dist/indexer/indexer.js +1 -3
- package/dist/indexer/links/declared-links.js +90 -0
- package/dist/indexer/scan/doc-to-entry.js +1 -0
- package/dist/indexer/usage/usage-events.js +34 -0
- package/dist/llm/graph-extract.js +26 -37
- package/dist/output/shapes/helpers.js +3 -0
- package/dist/output/text/show-format.js +16 -0
- package/dist/scripts/akm-migrate-node.js +6377 -6437
- package/dist/scripts/akm-migrate.js +6860 -6920
- package/dist/storage/repositories/index-entries-repository.js +12 -6
- package/dist/storage/repositories/index-entry-schema.js +18 -1
- package/dist/storage/repositories/index-links-repository.js +143 -0
- package/dist/storage/repositories/index-schema.js +27 -0
- package/dist/storage/repositories/proposals-repository.js +4 -0
- package/dist/tasks/backends/cron.js +80 -43
- package/dist/tasks/backends/launchd.js +28 -15
- package/dist/tasks/backends/schtasks.js +25 -10
- package/dist/tasks/run/load-task.js +1 -1
- package/dist/tasks/scheduler-binding.js +4 -2
- package/dist/tasks/scheduler-invocation.js +127 -235
- package/dist/tasks/scheduler-sync.js +13 -8
- package/dist/tasks/source/parse-task-source.js +22 -126
- package/dist/tasks/source/task-to-v4.js +463 -87
- package/docs/migration/release-notes/0.9.17.md +7 -5
- package/docs/migration/v0.9.1-to-v0.9.2.md +7 -3
- package/docs/reference/cli.md +26 -10
- package/docs/reference/tasks.md +58 -40
- package/package.json +1 -1
- package/dist/tasks/source/task-to-v3.js +0 -507
|
@@ -21,6 +21,7 @@ import { buildSearchText } from "../../indexer/search/search-fields.js";
|
|
|
21
21
|
import { sha256Hex } from "../../runtime.js";
|
|
22
22
|
import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
|
|
23
23
|
import { deleteFtsEntries, replaceFtsEntry } from "./index-fts-repository.js";
|
|
24
|
+
import { deleteEntryLinks, replaceEntryLinks } from "./index-links-repository.js";
|
|
24
25
|
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
|
|
25
26
|
import { deleteEntryVectors } from "./index-vec-repository.js";
|
|
26
27
|
// ── Entry operations ────────────────────────────────────────────────────────
|
|
@@ -36,9 +37,9 @@ function embedHash(entry) {
|
|
|
36
37
|
* Insert or update one canonical entry and all synchronously derived search
|
|
37
38
|
* state. Returns the stable row id.
|
|
38
39
|
*
|
|
39
|
-
* The entries row, FTS projection, and stale-vector
|
|
40
|
-
* SQLite transaction. Callers therefore cannot
|
|
41
|
-
* second FTS maintenance step.
|
|
40
|
+
* The entries row, FTS projection, declared links, and stale-vector
|
|
41
|
+
* invalidation commit as one SQLite transaction. Callers therefore cannot
|
|
42
|
+
* publish an entry and forget a second FTS or links maintenance step.
|
|
42
43
|
*/
|
|
43
44
|
export function upsertEntry(db, filePath, entry, provenance, contentHash) {
|
|
44
45
|
// Hot path during indexing — cache prepared statements per database
|
|
@@ -59,6 +60,7 @@ export function upsertEntry(db, filePath, entry, provenance, contentHash) {
|
|
|
59
60
|
if (previous?.id === result.id && previous.embed_hash !== hash)
|
|
60
61
|
deleteEntryVectors(db, result.id);
|
|
61
62
|
replaceFtsEntry(db, result.id, entry, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
|
|
63
|
+
replaceEntryLinks(db, result.id, entry, provenance);
|
|
62
64
|
return result.id;
|
|
63
65
|
};
|
|
64
66
|
// Always enter the driver's transaction wrapper. Both supported SQLite
|
|
@@ -258,8 +260,10 @@ export function rekeyEntryInPlace(db, opts) {
|
|
|
258
260
|
}
|
|
259
261
|
if (row.embed_hash !== hash)
|
|
260
262
|
deleteEntryVectors(db, row.id);
|
|
261
|
-
if (document)
|
|
263
|
+
if (document) {
|
|
262
264
|
replaceFtsEntry(db, row.id, document, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
|
|
265
|
+
replaceEntryLinks(db, row.id, document, { bundleId: opts.sourceName, conceptId: opts.newRef });
|
|
266
|
+
}
|
|
263
267
|
else
|
|
264
268
|
deleteFtsEntries(db, [row.id]);
|
|
265
269
|
})();
|
|
@@ -396,9 +400,11 @@ function deleteRelatedRows(db, ids, options = {}) {
|
|
|
396
400
|
if (ids.length === 0)
|
|
397
401
|
return;
|
|
398
402
|
const numericIds = ids.map((r) => r.id);
|
|
399
|
-
// FTS
|
|
400
|
-
// dirty queue. Delete
|
|
403
|
+
// FTS and declared links are part of the canonical mutation boundary, not a
|
|
404
|
+
// caller-maintained dirty queue. Delete them before the parent row inside
|
|
405
|
+
// this transaction.
|
|
401
406
|
deleteFtsEntries(db, numericIds);
|
|
407
|
+
deleteEntryLinks(db, numericIds);
|
|
402
408
|
// Process in chunks to stay within SQLITE_MAX_VARIABLE_NUMBER
|
|
403
409
|
for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
404
410
|
const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* touching embeddings, utility scores, graph rows, or the LLM enrichment
|
|
12
12
|
* cache. A newer layout is refused, naming the upgrade.
|
|
13
13
|
*/
|
|
14
|
+
// 26: declared links (#935) live in `asset_links`, one row per link, owned by
|
|
15
|
+
// the entry that declares it. The writable opener derives them in place from
|
|
16
|
+
// each entry's stored `document_json` (`migrateToDeclaredLinks`,
|
|
17
|
+
// index-schema.ts); releases before 26 did not store them.
|
|
14
18
|
// 25: vectors live only in `embeddings`; the sqlite-vec mirror `entries_vec`
|
|
15
19
|
// is dropped (`dropVecMirror`, index-schema.ts), and so is the fragment FTS
|
|
16
20
|
// table `entry_fragments_fts`, which search no longer reads. `entries` keeps
|
|
@@ -21,7 +25,7 @@
|
|
|
21
25
|
// 23 and earlier stored a second copy of every indexed field in FTS5's own
|
|
22
26
|
// content shadow tables. `ensureFtsLayout` (index-schema.ts) rebuilds the FTS
|
|
23
27
|
// tables from the stored entries when it finds the older layout.
|
|
24
|
-
export const CANONICAL_INDEX_DB_VERSION =
|
|
28
|
+
export const CANONICAL_INDEX_DB_VERSION = 26;
|
|
25
29
|
export const CANONICAL_ENTRY_SCHEMA_SQL = `
|
|
26
30
|
CREATE TABLE IF NOT EXISTS entries (
|
|
27
31
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -47,6 +51,19 @@ export const CANONICAL_ENTRY_SCHEMA_SQL = `
|
|
|
47
51
|
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
|
48
52
|
safe_markdown TEXT NOT NULL
|
|
49
53
|
);
|
|
54
|
+
|
|
55
|
+
-- Declared links (#935). dst_bundle NULL: the declaring entry's own bundle.
|
|
56
|
+
CREATE TABLE IF NOT EXISTS asset_links (
|
|
57
|
+
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
58
|
+
ord INTEGER NOT NULL,
|
|
59
|
+
kind TEXT NOT NULL,
|
|
60
|
+
raw TEXT NOT NULL,
|
|
61
|
+
dst_bundle TEXT,
|
|
62
|
+
dst_concept TEXT NOT NULL,
|
|
63
|
+
PRIMARY KEY (entry_id, ord)
|
|
64
|
+
) WITHOUT ROWID;
|
|
65
|
+
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_asset_links_dst ON asset_links(dst_concept);
|
|
50
67
|
`;
|
|
51
68
|
// The FTS table is contentless: FTS5 keeps only the inverted index, and a row
|
|
52
69
|
// is addressed by its rowid (`entries_fts.rowid = entries.id`, see
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `index.db` declared-links repository (#935): owns every SQL statement
|
|
6
|
+
* against `asset_links`.
|
|
7
|
+
*
|
|
8
|
+
* A row belongs to the entry that declares the link and is written, replaced
|
|
9
|
+
* and deleted with that entry (`upsertEntry`, `deleteRelatedRows`). A target
|
|
10
|
+
* is stored as `dst_bundle` (NULL for a short ref, meaning the declaring
|
|
11
|
+
* entry's own bundle) plus `dst_concept`; whether it exists is a join on
|
|
12
|
+
* `entries.item_ref` at read time, so a target indexed later resolves with
|
|
13
|
+
* no rewrite of its citer and a bundle rename carries short refs along.
|
|
14
|
+
*
|
|
15
|
+
* A memory target whose own file is gone resolves to its `.derived` child, the
|
|
16
|
+
* reachability rule lint applies (#882): consolidation keeps the distilled
|
|
17
|
+
* child after the parent is pruned.
|
|
18
|
+
*/
|
|
19
|
+
import { declaredLinks } from "../../indexer/links/declared-links.js";
|
|
20
|
+
import { tableExists } from "./index-entry-schema.js";
|
|
21
|
+
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
|
|
22
|
+
const statementsByDb = new WeakMap();
|
|
23
|
+
function statements(db) {
|
|
24
|
+
const existing = statementsByDb.get(db);
|
|
25
|
+
if (existing)
|
|
26
|
+
return existing;
|
|
27
|
+
const created = {
|
|
28
|
+
deleteForEntry: db.prepare("DELETE FROM asset_links WHERE entry_id = ?"),
|
|
29
|
+
insert: db.prepare("INSERT INTO asset_links (entry_id, ord, kind, raw, dst_bundle, dst_concept) VALUES (?, ?, ?, ?, ?, ?)"),
|
|
30
|
+
};
|
|
31
|
+
statementsByDb.set(db, created);
|
|
32
|
+
return created;
|
|
33
|
+
}
|
|
34
|
+
/** Replace one entry's declared links with the ones its document names, inside the caller's transaction. */
|
|
35
|
+
export function replaceEntryLinks(db, entryId, document, owner) {
|
|
36
|
+
const { deleteForEntry, insert } = statements(db);
|
|
37
|
+
deleteForEntry.run(entryId);
|
|
38
|
+
declaredLinks(document, owner).forEach((link, ord) => {
|
|
39
|
+
insert.run(entryId, ord, link.kind, link.raw, link.bundle ?? null, link.conceptId);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Delete the declared links of entries that are being removed. */
|
|
43
|
+
export function deleteEntryLinks(db, entryIds) {
|
|
44
|
+
for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
45
|
+
const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
46
|
+
db.prepare(`DELETE FROM asset_links WHERE entry_id IN (${chunk.map(() => "?").join(",")})`).run(...chunk);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Derive every entry's links from its stored `document_json`, replacing
|
|
51
|
+
* whatever the table held — the in-place migration to layout 26, which needs
|
|
52
|
+
* no file read. An entry whose JSON does not parse keeps no links until it is
|
|
53
|
+
* next indexed.
|
|
54
|
+
*/
|
|
55
|
+
export function rebuildAllEntryLinks(db) {
|
|
56
|
+
db.exec("DELETE FROM asset_links");
|
|
57
|
+
const page = db.prepare("SELECT id, bundle_id, concept_id, document_json FROM entries WHERE id > ? ORDER BY id LIMIT 500");
|
|
58
|
+
let afterId = -1;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const rows = page.all(afterId);
|
|
61
|
+
if (rows.length === 0)
|
|
62
|
+
break;
|
|
63
|
+
afterId = rows[rows.length - 1].id;
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
let document;
|
|
66
|
+
try {
|
|
67
|
+
document = JSON.parse(row.document_json);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
replaceEntryLinks(db, row.id, document, { bundleId: row.bundle_id, conceptId: row.concept_id });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The id of the entry a link row resolves to, or NULL: the exact target, else
|
|
78
|
+
* (for a memory) its `.derived` child, never the declaring entry itself.
|
|
79
|
+
* Expects `l` (the link) and `o` (its owner) in scope.
|
|
80
|
+
*/
|
|
81
|
+
const TARGET_ID_SQL = `COALESCE(
|
|
82
|
+
(SELECT id FROM entries WHERE item_ref = COALESCE(l.dst_bundle, o.bundle_id) || '//' || l.dst_concept),
|
|
83
|
+
(SELECT id FROM entries
|
|
84
|
+
WHERE item_ref = COALESCE(l.dst_bundle, o.bundle_id) || '//' || l.dst_concept || '.derived'
|
|
85
|
+
AND substr(l.dst_concept, 1, 9) = 'memories/' AND id <> o.id))`;
|
|
86
|
+
/**
|
|
87
|
+
* The declared links of the entry `itemRef`, both ways: `outgoing` in stored
|
|
88
|
+
* order (unresolved targets carry only `raw`), `incoming` from every other
|
|
89
|
+
* entry that names it, ordered by kind then source. An index that predates
|
|
90
|
+
* the table (an older layout served as-is) has none.
|
|
91
|
+
*/
|
|
92
|
+
export function readEntryLinks(db, itemRef) {
|
|
93
|
+
if (!tableExists(db, "asset_links"))
|
|
94
|
+
return { outgoing: [], incoming: [] };
|
|
95
|
+
const owner = db.prepare("SELECT id, bundle_id, concept_id FROM entries WHERE item_ref = ?").get(itemRef);
|
|
96
|
+
if (!owner)
|
|
97
|
+
return { outgoing: [], incoming: [] };
|
|
98
|
+
const outgoing = db
|
|
99
|
+
.prepare(`SELECT l.kind AS kind, l.raw AS raw, t.bundle_id AS bundleId, t.concept_id AS conceptId, t.type AS type
|
|
100
|
+
FROM asset_links l
|
|
101
|
+
JOIN entries o ON o.id = l.entry_id
|
|
102
|
+
LEFT JOIN entries t ON t.id = ${TARGET_ID_SQL}
|
|
103
|
+
WHERE l.entry_id = ?
|
|
104
|
+
ORDER BY l.ord`)
|
|
105
|
+
.all(owner.id);
|
|
106
|
+
// A `.derived` memory also receives the links that name its parent when the
|
|
107
|
+
// parent is not indexed (the resolution rule above).
|
|
108
|
+
const parentConcept = owner.concept_id.startsWith("memories/") && owner.concept_id.endsWith(".derived")
|
|
109
|
+
? owner.concept_id.slice(0, -".derived".length)
|
|
110
|
+
: owner.concept_id;
|
|
111
|
+
const incoming = db
|
|
112
|
+
.prepare(`SELECT l.kind AS kind, o.bundle_id AS bundleId, o.concept_id AS conceptId, o.type AS type
|
|
113
|
+
FROM asset_links l
|
|
114
|
+
JOIN entries o ON o.id = l.entry_id
|
|
115
|
+
WHERE l.dst_concept IN (?, ?) AND COALESCE(l.dst_bundle, o.bundle_id) = ? AND o.id <> ?
|
|
116
|
+
AND ${TARGET_ID_SQL} = ?
|
|
117
|
+
ORDER BY l.kind, o.item_ref`)
|
|
118
|
+
.all(owner.concept_id, parentConcept, owner.bundle_id, owner.id, owner.id);
|
|
119
|
+
return {
|
|
120
|
+
outgoing: outgoing.map((row) => row.conceptId === null
|
|
121
|
+
? { kind: row.kind, raw: row.raw }
|
|
122
|
+
: {
|
|
123
|
+
kind: row.kind,
|
|
124
|
+
bundleId: row.bundleId ?? undefined,
|
|
125
|
+
conceptId: row.conceptId,
|
|
126
|
+
type: row.type ?? undefined,
|
|
127
|
+
}),
|
|
128
|
+
incoming,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Stored links per kind with how many name a target that is not indexed; empty when the index has none. */
|
|
132
|
+
export function countLinksByKind(db) {
|
|
133
|
+
if (!tableExists(db, "asset_links"))
|
|
134
|
+
return {};
|
|
135
|
+
const rows = db
|
|
136
|
+
.prepare(`SELECT l.kind AS kind, COUNT(*) AS total, SUM(${TARGET_ID_SQL} IS NULL) AS unresolved
|
|
137
|
+
FROM asset_links l
|
|
138
|
+
JOIN entries o ON o.id = l.entry_id
|
|
139
|
+
GROUP BY l.kind
|
|
140
|
+
ORDER BY l.kind`)
|
|
141
|
+
.all();
|
|
142
|
+
return Object.fromEntries(rows.map((row) => [row.kind, { total: row.total, unresolved: row.unresolved }]));
|
|
143
|
+
}
|
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* ({@link newerIndexLayoutError}).
|
|
19
19
|
*/
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
|
+
import path from "node:path";
|
|
21
22
|
import { ConfigError } from "../../core/errors.js";
|
|
22
23
|
import { warn } from "../../core/warn.js";
|
|
23
24
|
import { sha256Hex } from "../../runtime.js";
|
|
24
25
|
import { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, entriesFtsDdl, isContentlessFtsDdl, missingEntryColumns, readTableSql, supportsContentlessDelete, tableExists, } from "./index-entry-schema.js";
|
|
25
26
|
import { rebuildFts } from "./index-fts-repository.js";
|
|
27
|
+
import { rebuildAllEntryLinks } from "./index-links-repository.js";
|
|
26
28
|
import { getMeta, setMeta } from "./index-meta-repository.js";
|
|
27
29
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
28
30
|
export const DB_VERSION = CANONICAL_INDEX_DB_VERSION;
|
|
@@ -34,6 +36,8 @@ export const VACUUM_PENDING_META = "vacuumPending";
|
|
|
34
36
|
* compares it; the index layout version gates the graph tables' shape.
|
|
35
37
|
*/
|
|
36
38
|
export const GRAPH_SCHEMA_VERSION = 4;
|
|
39
|
+
/** The layout that added declared links (`asset_links`, #935). */
|
|
40
|
+
const DECLARED_LINKS_LAYOUT = 26;
|
|
37
41
|
/**
|
|
38
42
|
* The refusal for an index a newer akm wrote. Readers and the writable opener
|
|
39
43
|
* both raise it: a newer layout may lack tables or columns this release reads
|
|
@@ -170,6 +174,7 @@ function ensureEntriesLayout(db) {
|
|
|
170
174
|
for (const table of [
|
|
171
175
|
"entries_fts",
|
|
172
176
|
"entry_fragments",
|
|
177
|
+
"asset_links",
|
|
173
178
|
"embeddings",
|
|
174
179
|
"utility_scores_scoped",
|
|
175
180
|
"utility_scores",
|
|
@@ -249,6 +254,25 @@ function ensureFtsLayout(db) {
|
|
|
249
254
|
rebuildFts(db);
|
|
250
255
|
})();
|
|
251
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Layout 26 stores declared links (#935). Every relation an older layout
|
|
259
|
+
* indexed already sits in `document_json`, so the links are derived from there
|
|
260
|
+
* in place, with no file read. The exception is a workflow's step targets and
|
|
261
|
+
* a task's target, which no earlier layout stored: the directories holding
|
|
262
|
+
* workflows and tasks lose their incremental cursor, so the next `akm index`
|
|
263
|
+
* re-reads those and nothing else. One transaction.
|
|
264
|
+
*/
|
|
265
|
+
function migrateToDeclaredLinks(db) {
|
|
266
|
+
db.transaction(() => {
|
|
267
|
+
rebuildAllEntryLinks(db);
|
|
268
|
+
const rows = db
|
|
269
|
+
.prepare("SELECT DISTINCT file_path FROM entries WHERE type IN ('workflow', 'task')")
|
|
270
|
+
.all();
|
|
271
|
+
const forget = db.prepare("DELETE FROM index_dir_state WHERE dir_path = ?");
|
|
272
|
+
for (const dir of new Set(rows.map((row) => path.dirname(row.file_path))))
|
|
273
|
+
forget.run(dir);
|
|
274
|
+
})();
|
|
275
|
+
}
|
|
252
276
|
function tableHasColumn(db, table, column) {
|
|
253
277
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
254
278
|
return columns.some((existing) => existing.name === column);
|
|
@@ -368,6 +392,9 @@ export function ensureSchema(db) {
|
|
|
368
392
|
if (!hadFragmentSource && tableExists(db, "entries")) {
|
|
369
393
|
db.exec("DELETE FROM index_dir_state");
|
|
370
394
|
}
|
|
395
|
+
if (storedVersion > 0 && storedVersion < DECLARED_LINKS_LAYOUT && tableExists(db, "entries")) {
|
|
396
|
+
migrateToDeclaredLinks(db);
|
|
397
|
+
}
|
|
371
398
|
// Migrating an existing layout drops tables and columns; the next `akm index`
|
|
372
399
|
// VACUUMs the pages they leave free (`vacuumIndexDb`, indexer.ts), since a
|
|
373
400
|
// writable open may run inside a caller's transaction, where VACUUM cannot.
|
|
@@ -385,6 +385,10 @@ export function getStateProposal(db, id, stashDir) {
|
|
|
385
385
|
const row = (stashDir ? db.prepare(sql).get(id, stashDir) : db.prepare(sql).get(id));
|
|
386
386
|
return row ? proposalRowToProposal(row) : undefined;
|
|
387
387
|
}
|
|
388
|
+
/** `(ref, source)` of every proposal, any status, in one stash: no payload is read. */
|
|
389
|
+
export function listProposalRefSources(db, stashDir) {
|
|
390
|
+
return db.prepare("SELECT ref, source FROM proposals WHERE stash_dir = ?").all(stashDir);
|
|
391
|
+
}
|
|
388
392
|
/**
|
|
389
393
|
* Find PENDING proposal ids in one stash whose id starts with `idPrefix`.
|
|
390
394
|
* Backs the UUID-prefix form of `akm proposal show/accept/... <prefix>` —
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
// its other lines untouched:
|
|
8
8
|
//
|
|
9
9
|
// # akm:task <id> BEGIN
|
|
10
|
-
// [SCHED] /abs/akm
|
|
10
|
+
// [SCHED] AKM_BUNDLE_DIR=<working stash> /abs/akm task run <id> ... > <log> 2>&1
|
|
11
11
|
// # akm:task <id> END
|
|
12
12
|
//
|
|
13
|
+
// The row sets its own environment inline, as a `VAR=value` prefix (see
|
|
14
|
+
// `src/tasks/scheduler-invocation.ts`); PATH is the `# akm:env` block. A
|
|
15
|
+
// command over the portable line limit runs `sh <wrapper script>` instead,
|
|
16
|
+
// and the script holds the same environment and argv.
|
|
17
|
+
//
|
|
13
18
|
// The backend reads/writes the user's crontab via `crontab -l` and
|
|
14
19
|
// `crontab -`. Every mutation is one read → modify the akm blocks (and the
|
|
15
20
|
// `# akm:env` PATH header) → one write, with the prior crontab restored when
|
|
@@ -32,13 +37,14 @@
|
|
|
32
37
|
// Tests inject a fake exec so unit tests don't touch the real crontab.
|
|
33
38
|
import { spawnSync } from "node:child_process";
|
|
34
39
|
import { createHash } from "node:crypto";
|
|
40
|
+
import fs from "node:fs";
|
|
35
41
|
import path from "node:path";
|
|
36
42
|
import { ConfigError } from "../../core/errors.js";
|
|
37
43
|
import { getTaskLogDir } from "../../core/paths.js";
|
|
38
44
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
39
45
|
import { parseSchedule, translateToCron } from "../schedule.js";
|
|
40
46
|
import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
|
|
41
|
-
import {
|
|
47
|
+
import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
|
|
42
48
|
import { nodeFs, throwIfNotOk } from "./exec-utils.js";
|
|
43
49
|
const BEGIN = (id) => `# akm:task ${assertCronValue(id)} BEGIN`;
|
|
44
50
|
const END = (id) => `# akm:task ${assertCronValue(id)} END`;
|
|
@@ -55,9 +61,8 @@ export function CRON_BACKEND(options = {}) {
|
|
|
55
61
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
56
62
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
57
63
|
const envPath = options.envPath === false ? undefined : (options.envPath ?? process.env.PATH);
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const lineFor = (task, opts) => buildCronLineParts(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath);
|
|
64
|
+
const readFile = options.fs?.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
|
|
65
|
+
const lineFor = (task, opts) => buildCronLineParts(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment);
|
|
61
66
|
return {
|
|
62
67
|
name: "cron",
|
|
63
68
|
install(task, opts) {
|
|
@@ -92,7 +97,7 @@ export function CRON_BACKEND(options = {}) {
|
|
|
92
97
|
list() {
|
|
93
98
|
const rows = [];
|
|
94
99
|
for (const { id, body } of listBlocks(readCrontab(exec))) {
|
|
95
|
-
const parsed = extractCronInvocation(body);
|
|
100
|
+
const parsed = extractCronInvocation(body) ?? extractCronWrapperInvocation(body, readFile);
|
|
96
101
|
if (!parsed)
|
|
97
102
|
continue;
|
|
98
103
|
rows.push({
|
|
@@ -102,9 +107,8 @@ export function CRON_BACKEND(options = {}) {
|
|
|
102
107
|
signature: normalizeSignature(body),
|
|
103
108
|
...(parsed.target !== undefined ? { target: parsed.target } : {}),
|
|
104
109
|
binding: parsed.binding,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
contextPath: parsed.contextPath || defaultContextPath,
|
|
110
|
+
...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}),
|
|
111
|
+
...(parsed.environment !== undefined ? { environment: parsed.environment } : {}),
|
|
108
112
|
invocation: parsed.invocation,
|
|
109
113
|
});
|
|
110
114
|
}
|
|
@@ -117,21 +121,26 @@ export function CRON_BACKEND(options = {}) {
|
|
|
117
121
|
},
|
|
118
122
|
};
|
|
119
123
|
}
|
|
120
|
-
function buildCronLineParts(task, akmArgv, logDir,
|
|
124
|
+
function buildCronLineParts(task, akmArgv, logDir, environment) {
|
|
121
125
|
const cronExpr = translateToCron(parseSchedule(task.cron, "cron"));
|
|
122
126
|
const nativeId = schedulerBindingNativeId(task);
|
|
123
127
|
const logPath = path.join(logDir, `${nativeId}.log`);
|
|
124
|
-
const
|
|
125
|
-
const
|
|
128
|
+
const argv = buildScheduledInvocation(akmArgv, task.invocation);
|
|
129
|
+
const variables = scheduledRowEnvironmentEntries(environment);
|
|
130
|
+
// An assignment prefix: the shell cron hands the line to sets it for this
|
|
131
|
+
// command only. Only the value is quoted, or the word is not an assignment.
|
|
132
|
+
const assignments = variables.map(([name, value]) => `${name}=${quoteForCron(value)} `).join("");
|
|
133
|
+
const cmd = argv.map((part) => quoteForCron(part)).join(" ");
|
|
126
134
|
// #951: truncate (not append) so this bootstrap safety-net file always
|
|
127
135
|
// holds exactly the latest run's raw output. akm's own per-run log
|
|
128
136
|
// (src/tasks/run/task-log.ts) keeps history.
|
|
129
|
-
const directLine = `${cronExpr} ${cmd} > ${quoteForCron(logPath)} 2>&1`;
|
|
137
|
+
const directLine = `${cronExpr} ${assignments}${cmd} > ${quoteForCron(logPath)} 2>&1`;
|
|
130
138
|
if (Buffer.byteLength(directLine, "utf8") <= PORTABLE_CRON_LINE_LIMIT)
|
|
131
139
|
return { line: directLine };
|
|
132
140
|
// A command over vixie-cron's MAX_COMMAND is spilled into a short wrapper
|
|
133
141
|
// script under logDir instead of being truncated or refused.
|
|
134
|
-
const
|
|
142
|
+
const exports = variables.map(([name, value]) => `export ${name}=${quoteForShellScript(value)}\n`).join("");
|
|
143
|
+
const content = `#!/bin/sh\n${exports}exec ${argv.map(quoteForShellScript).join(" ")}\n`;
|
|
135
144
|
const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
136
145
|
const wrapperPath = path.join(logDir, `${CRON_WRAPPER_PREFIX}${nativeId}-${contentHash}.sh`);
|
|
137
146
|
const line = `${cronExpr} sh ${quoteForCron(wrapperPath)} > ${quoteForCron(logPath)} 2>&1`;
|
|
@@ -142,8 +151,8 @@ function quoteForShellScript(part) {
|
|
|
142
151
|
return part;
|
|
143
152
|
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
144
153
|
}
|
|
145
|
-
export function buildCronLine(task, akmArgv, logDir,
|
|
146
|
-
return buildCronLineParts(task, akmArgv, logDir,
|
|
154
|
+
export function buildCronLine(task, akmArgv, logDir, environment) {
|
|
155
|
+
return buildCronLineParts(task, akmArgv, logDir, environment).line;
|
|
147
156
|
}
|
|
148
157
|
/** The crontab line as it appears inside a block — commented when disabled. */
|
|
149
158
|
export function cronBlockBody(cronLine, enabled) {
|
|
@@ -196,46 +205,74 @@ function malformedBlockError(id) {
|
|
|
196
205
|
export function extractInstalledTarget(body) {
|
|
197
206
|
return extractCronInvocation(body)?.target;
|
|
198
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Parse an installed cron body: its inline `VAR=value` environment, its
|
|
210
|
+
* launcher, and its public tail — a current row, a row naming a
|
|
211
|
+
* `--scheduler-context` descriptor (0.9.0 – 0.9.17-alpha.6), or an older
|
|
212
|
+
* row with neither (#881). This only ever runs on a body already isolated
|
|
213
|
+
* between akm's own BEGIN/END markers, so no trust extends to unmarked lines.
|
|
214
|
+
*/
|
|
199
215
|
export function extractCronInvocation(body) {
|
|
200
216
|
const line = body.startsWith(DISABLED_PREFIX) ? body.slice(DISABLED_PREFIX.length) : body;
|
|
201
217
|
const fields = splitCronShellWords(line);
|
|
202
218
|
if (fields.length < 6)
|
|
203
219
|
return undefined;
|
|
204
220
|
let commandStart = 5;
|
|
205
|
-
|
|
221
|
+
const variables = {};
|
|
222
|
+
for (let field = fields[commandStart]; field !== undefined; field = fields[commandStart]) {
|
|
223
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(field);
|
|
224
|
+
if (!assignment)
|
|
225
|
+
break;
|
|
226
|
+
variables[assignment[1]] = assignment[2];
|
|
206
227
|
commandStart += 1;
|
|
228
|
+
}
|
|
207
229
|
// #951: new rows redirect with `>`; rows written before that use `>>`.
|
|
208
230
|
const redirectIndex = fields.findIndex((field, index) => index >= commandStart && (field === ">" || field === ">>"));
|
|
209
231
|
if (redirectIndex === -1)
|
|
210
232
|
return undefined;
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
if (parsed)
|
|
214
|
-
return parsed;
|
|
215
|
-
// Rows written by akm < 0.9.2 have no `--scheduler-context` argument at
|
|
216
|
-
// all — the akm argv is followed directly by the public `task run …` /
|
|
217
|
-
// `workflow run …` tail (#881). This only ever runs on a body already
|
|
218
|
-
// isolated between akm's own BEGIN/END markers, so recognizing the older
|
|
219
|
-
// shape extends no trust to unmarked crontab lines. A row that DOES carry
|
|
220
|
-
// the marker but fails to parse is never reinterpreted as legacy.
|
|
221
|
-
if (tail.includes(SCHEDULER_CONTEXT_ARG))
|
|
233
|
+
const parsed = parseScheduledInvocationArgv(fields.slice(commandStart, redirectIndex));
|
|
234
|
+
if (!parsed)
|
|
222
235
|
return undefined;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
const environment = scheduledRowEnvironmentFrom(variables);
|
|
237
|
+
return environment ? { ...parsed, environment } : parsed;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Parse a row spilled into a wrapper script (`sh <script> > <log> 2>&1`):
|
|
241
|
+
* the script's `export NAME=value` lines and its `exec` argv. Only akm's own
|
|
242
|
+
* wrapper scripts are read; one that is gone or does not parse leaves the
|
|
243
|
+
* row unlisted, as any unparsable row is.
|
|
244
|
+
*/
|
|
245
|
+
export function extractCronWrapperInvocation(body, readFile) {
|
|
246
|
+
const line = body.startsWith(DISABLED_PREFIX) ? body.slice(DISABLED_PREFIX.length) : body;
|
|
247
|
+
const fields = splitCronShellWords(line);
|
|
248
|
+
const script = fields[6];
|
|
249
|
+
if (fields[5] !== "sh" || !script || !path.basename(script).startsWith(CRON_WRAPPER_PREFIX))
|
|
250
|
+
return undefined;
|
|
251
|
+
if (fields[7] !== ">" && fields[7] !== ">>")
|
|
252
|
+
return undefined;
|
|
253
|
+
let content;
|
|
254
|
+
try {
|
|
255
|
+
content = readFile(script);
|
|
235
256
|
}
|
|
236
|
-
|
|
257
|
+
catch {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
const variables = {};
|
|
261
|
+
let parsed;
|
|
262
|
+
for (const scriptLine of content.split("\n")) {
|
|
263
|
+
const words = splitCronShellWords(scriptLine);
|
|
264
|
+
const assignment = words[0] === "export" ? /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(words[1] ?? "") : null;
|
|
265
|
+
if (assignment)
|
|
266
|
+
variables[assignment[1]] = assignment[2];
|
|
267
|
+
else if (words[0] === "exec")
|
|
268
|
+
parsed = parseScheduledInvocationArgv(words.slice(1));
|
|
269
|
+
}
|
|
270
|
+
if (!parsed)
|
|
271
|
+
return undefined;
|
|
272
|
+
const environment = scheduledRowEnvironmentFrom(variables);
|
|
273
|
+
return environment ? { ...parsed, environment } : parsed;
|
|
237
274
|
}
|
|
238
|
-
/** Reverse {@link quoteForCron}
|
|
275
|
+
/** Reverse {@link quoteForCron}: sh word splitting, where a backslash is literal inside single quotes. */
|
|
239
276
|
function splitCronShellWords(value) {
|
|
240
277
|
const words = [];
|
|
241
278
|
let current = "";
|
|
@@ -246,7 +283,7 @@ function splitCronShellWords(value) {
|
|
|
246
283
|
quoted = !quoted;
|
|
247
284
|
continue;
|
|
248
285
|
}
|
|
249
|
-
if (char === "\\" && index + 1 < value.length) {
|
|
286
|
+
if (!quoted && char === "\\" && index + 1 < value.length) {
|
|
250
287
|
current += value[index + 1];
|
|
251
288
|
index += 1;
|
|
252
289
|
continue;
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* when the user is logged out should be installed as system Daemons,
|
|
18
18
|
* which is out of scope.
|
|
19
19
|
* • launchd strips the environment; the syncing shell's PATH goes into the
|
|
20
|
-
* plist's `EnvironmentVariables` so task bodies find the same binaries
|
|
20
|
+
* plist's `EnvironmentVariables` so task bodies find the same binaries,
|
|
21
|
+
* next to the AKM directory environment the row sets (see
|
|
22
|
+
* `src/tasks/scheduler-invocation.ts`).
|
|
21
23
|
*
|
|
22
24
|
* Tests inject a fake exec + filesystem so the backend can be unit-tested
|
|
23
25
|
* without touching the host launchctl.
|
|
@@ -33,7 +35,7 @@ import { warn } from "../../core/warn.js";
|
|
|
33
35
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
34
36
|
import { parseSchedule, translateToLaunchd } from "../schedule.js";
|
|
35
37
|
import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
|
|
36
|
-
import {
|
|
38
|
+
import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
|
|
37
39
|
import { escapeXml, nodeExec, nodeFs, runOrThrow } from "./exec-utils.js";
|
|
38
40
|
export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
|
|
39
41
|
const LAUNCHD_AKM_LABEL_RE = /^com\.akm\.task\.[A-Za-z0-9._-]{1,1024}$/u;
|
|
@@ -43,9 +45,7 @@ export function LAUNCHD_BACKEND(options = {}) {
|
|
|
43
45
|
const agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
44
46
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
45
47
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
46
|
-
const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
|
|
47
48
|
const envPath = options.envPath === false ? undefined : typeof options.envPath === "string" ? options.envPath : process.env.PATH;
|
|
48
|
-
const defaultContextPath = schedulerContextPath(schedulerContextDescriptor(scheduledContext));
|
|
49
49
|
const plistPath = (nativeId) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${nativeId}.plist`);
|
|
50
50
|
const label = (nativeId) => `${LAUNCHD_LABEL_PREFIX}${nativeId}`;
|
|
51
51
|
const target = (nativeId) => `gui/${exec.uid()}/${label(nativeId)}`;
|
|
@@ -59,7 +59,7 @@ export function LAUNCHD_BACKEND(options = {}) {
|
|
|
59
59
|
isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
|
|
60
60
|
message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
|
|
61
61
|
});
|
|
62
|
-
const xmlFor = (task, opts) => buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.
|
|
62
|
+
const xmlFor = (task, opts) => buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment, envPath);
|
|
63
63
|
return {
|
|
64
64
|
name: "launchd",
|
|
65
65
|
install(task, opts) {
|
|
@@ -130,7 +130,8 @@ export function LAUNCHD_BACKEND(options = {}) {
|
|
|
130
130
|
signature: launchdFingerprint(raw, enabled, loaded),
|
|
131
131
|
...(parsed.target !== undefined ? { target: parsed.target } : {}),
|
|
132
132
|
binding: parsed.binding,
|
|
133
|
-
contextPath: parsed.contextPath,
|
|
133
|
+
...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}),
|
|
134
|
+
...(parsed.environment !== undefined ? { environment: parsed.environment } : {}),
|
|
134
135
|
invocation: parsed.invocation,
|
|
135
136
|
});
|
|
136
137
|
}
|
|
@@ -150,7 +151,16 @@ export function extractPlistInvocation(xml) {
|
|
|
150
151
|
if (!block)
|
|
151
152
|
return undefined;
|
|
152
153
|
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => decodeXmlEntities(m[1]));
|
|
153
|
-
|
|
154
|
+
const parsed = parseScheduledInvocationArgv(args);
|
|
155
|
+
if (!parsed)
|
|
156
|
+
return undefined;
|
|
157
|
+
const variables = {};
|
|
158
|
+
const dict = xml.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/)?.[1] ?? "";
|
|
159
|
+
for (const entry of dict.matchAll(/<key>([^<]*)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
160
|
+
variables[decodeXmlEntities(entry[1])] = decodeXmlEntities(entry[2]);
|
|
161
|
+
}
|
|
162
|
+
const environment = scheduledRowEnvironmentFrom(variables);
|
|
163
|
+
return environment ? { ...parsed, environment } : parsed;
|
|
154
164
|
}
|
|
155
165
|
function decodeXmlEntities(value) {
|
|
156
166
|
return value
|
|
@@ -161,22 +171,25 @@ function decodeXmlEntities(value) {
|
|
|
161
171
|
.replaceAll("&", "&");
|
|
162
172
|
}
|
|
163
173
|
// ── XML builder (exported for tests) ────────────────────────────────────────
|
|
164
|
-
function renderPlistEnvironment(envPath) {
|
|
165
|
-
|
|
174
|
+
function renderPlistEnvironment(envPath, environment) {
|
|
175
|
+
const variables = [
|
|
176
|
+
...(envPath ? [["PATH", envPath]] : []),
|
|
177
|
+
...scheduledRowEnvironmentEntries(environment),
|
|
178
|
+
];
|
|
179
|
+
if (variables.length === 0)
|
|
166
180
|
return "";
|
|
167
181
|
return [
|
|
168
182
|
" <key>EnvironmentVariables</key>",
|
|
169
183
|
" <dict>",
|
|
170
|
-
|
|
171
|
-
` <string>${escapeXml(envPath)}</string>`,
|
|
184
|
+
...variables.flatMap(([name, value]) => [` <key>${name}</key>`, ` <string>${escapeXml(value)}</string>`]),
|
|
172
185
|
" </dict>",
|
|
173
186
|
"",
|
|
174
187
|
].join("\n");
|
|
175
188
|
}
|
|
176
|
-
export function buildPlistXml(task, akmArgv, logDir,
|
|
189
|
+
export function buildPlistXml(task, akmArgv, logDir, environment, envPath) {
|
|
177
190
|
const trigger = translateToLaunchd(parseSchedule(task.cron, "launchd"));
|
|
178
|
-
const
|
|
179
|
-
const programArgs =
|
|
191
|
+
const argv = buildScheduledInvocation(akmArgv, task.invocation);
|
|
192
|
+
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
180
193
|
const nativeId = schedulerBindingNativeId(task);
|
|
181
194
|
const logPath = path.join(logDir, `${nativeId}.log`);
|
|
182
195
|
const xml = launchdTemplate
|
|
@@ -184,7 +197,7 @@ export function buildPlistXml(task, akmArgv, logDir, contextPath, envPath) {
|
|
|
184
197
|
.replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(nativeId))
|
|
185
198
|
.replace("{{PROGRAM_ARGS}}", programArgs)
|
|
186
199
|
.replaceAll("{{LOG_PATH}}", escapeXml(logPath))
|
|
187
|
-
.replace("{{ENV_VARS}}", renderPlistEnvironment(envPath))
|
|
200
|
+
.replace("{{ENV_VARS}}", () => renderPlistEnvironment(envPath, environment))
|
|
188
201
|
.replace("{{TRIGGER_XML}}", renderLaunchdTrigger(trigger));
|
|
189
202
|
for (const char of xml) {
|
|
190
203
|
const code = char.codePointAt(0) ?? 0;
|