akm-cli 0.10.261019 → 0.10.261021
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 +46 -0
- package/STABILITY.md +0 -8
- package/dist/assets/templates/html/metrics.html +14 -8
- package/dist/assets/ui/overview.js +2 -2
- package/dist/commands/health/llm-usage.js +16 -2
- package/dist/commands/health/plugin-staleness.js +126 -112
- package/dist/commands/health/stash-exposure.js +23 -5
- package/dist/commands/health/type-directory-check.js +17 -49
- package/dist/commands/health.js +45 -15
- package/dist/commands/metrics/collect.js +9 -1
- package/dist/commands/metrics/metrics-cli.js +1 -1
- package/dist/commands/metrics/report-view.js +20 -9
- package/dist/commands/sources/plugin-channels.js +147 -0
- package/dist/commands/sources/plugin-upgrade.js +341 -59
- package/dist/commands/sources/self-update.js +6 -3
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/sources-cli.js +15 -2
- package/dist/core/common.js +4 -1
- package/dist/indexer/passes/metadata.js +8 -6
- package/dist/indexer/walk/matchers.js +10 -0
- package/dist/output/text/command-format.js +10 -3
- package/dist/scripts/akm-migrate-node.js +69 -40
- package/dist/scripts/akm-migrate.js +69 -40
- package/dist/workflows/exec/param-secrets.js +29 -1
- package/docs/architecture/internals/health-advisories.md +3 -3
- package/docs/architecture/testing/testing-workflow.md +13 -7
- package/docs/reference/cli.md +39 -11
- package/package.json +3 -1
|
@@ -22,28 +22,21 @@
|
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
25
|
+
import { buildFileContext } from "../../indexer/walk/file-context.js";
|
|
26
|
+
import { declaredTypeForPath } from "../../indexer/walk/matchers.js";
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* extensions it accepts. Keep in sync if `DIR_TYPE_MAP` gains, renames, or
|
|
30
|
-
* removes a directory.
|
|
28
|
+
* The type the entry's location declares under the `akm` stash rules, derived
|
|
29
|
+
* with the indexer's own directory predicates (see `declaredTypeForPath`).
|
|
30
|
+
* Other adapters, and paths outside the component root, declare nothing.
|
|
31
31
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
sessions: "session",
|
|
41
|
-
instructions: "instruction",
|
|
42
|
-
scripts: "script",
|
|
43
|
-
env: "env",
|
|
44
|
-
secrets: "secret",
|
|
45
|
-
tasks: "task",
|
|
46
|
-
};
|
|
32
|
+
function declaredTypeFor(entry) {
|
|
33
|
+
if (entry.adapterId !== "akm" || !entry.root)
|
|
34
|
+
return undefined;
|
|
35
|
+
const rel = path.relative(entry.root, entry.filePath);
|
|
36
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
37
|
+
return undefined;
|
|
38
|
+
return declaredTypeForPath(buildFileContext(entry.root, entry.filePath));
|
|
39
|
+
}
|
|
47
40
|
const realReadFile = (absPath) => {
|
|
48
41
|
try {
|
|
49
42
|
return fs.readFileSync(absPath, "utf8");
|
|
@@ -52,31 +45,6 @@ const realReadFile = (absPath) => {
|
|
|
52
45
|
return undefined;
|
|
53
46
|
}
|
|
54
47
|
};
|
|
55
|
-
/**
|
|
56
|
-
* The type a file's directory declares, mirroring the `classifyByDirectory` /
|
|
57
|
-
* `classifyByParentDirHint` precedence in matchers.ts: the immediate parent
|
|
58
|
-
* directory wins when it is itself typed (parentDirHint, specificity 15);
|
|
59
|
-
* otherwise the outermost typed ancestor wins (directoryMatcher, specificity
|
|
60
|
-
* 10, which walks root-to-leaf and returns on the first hit).
|
|
61
|
-
*/
|
|
62
|
-
function declaredTypeForPath(absPath) {
|
|
63
|
-
const segments = path
|
|
64
|
-
.dirname(absPath)
|
|
65
|
-
.split(path.sep)
|
|
66
|
-
.filter((seg) => seg.length > 0);
|
|
67
|
-
const immediateParent = segments.at(-1);
|
|
68
|
-
if (immediateParent) {
|
|
69
|
-
const parentType = DECLARED_DIR_TYPES[immediateParent];
|
|
70
|
-
if (parentType)
|
|
71
|
-
return { dir: immediateParent, type: parentType };
|
|
72
|
-
}
|
|
73
|
-
for (const seg of segments) {
|
|
74
|
-
const type = DECLARED_DIR_TYPES[seg];
|
|
75
|
-
if (type)
|
|
76
|
-
return { dir: seg, type };
|
|
77
|
-
}
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
48
|
/**
|
|
81
49
|
* Best-effort explanation for why `classifyBySmartMd` (matchers.ts) would
|
|
82
50
|
* have produced `resolvedType` for this content, in the SAME precedence
|
|
@@ -110,7 +78,7 @@ function explainOverride(resolvedType, content) {
|
|
|
110
78
|
}
|
|
111
79
|
/**
|
|
112
80
|
* Compare every indexed entry's resolved type against the type its
|
|
113
|
-
* directory declares (see {@link
|
|
81
|
+
* directory declares (see {@link declaredTypeFor}), and return one
|
|
114
82
|
* {@link TypeDirectoryDisagreement} per mismatch, sorted by path. Entries
|
|
115
83
|
* outside any declared-type directory are not checked — this is only the
|
|
116
84
|
* "directory declares type" invariant.
|
|
@@ -118,15 +86,15 @@ function explainOverride(resolvedType, content) {
|
|
|
118
86
|
export function collectTypeDirectoryDisagreements(entries, readFile = realReadFile) {
|
|
119
87
|
const disagreements = [];
|
|
120
88
|
for (const entry of entries) {
|
|
121
|
-
const declared =
|
|
122
|
-
if (!declared || declared
|
|
89
|
+
const declared = declaredTypeFor(entry);
|
|
90
|
+
if (!declared || declared === entry.type)
|
|
123
91
|
continue;
|
|
124
92
|
const content = readFile(entry.filePath);
|
|
125
93
|
const explanation = content === undefined ? undefined : explainOverride(entry.type, content);
|
|
126
94
|
disagreements.push({
|
|
127
95
|
path: entry.filePath,
|
|
128
96
|
resolved: entry.type,
|
|
129
|
-
expected: declared
|
|
97
|
+
expected: declared,
|
|
130
98
|
winner: explanation?.winner ?? "unknown",
|
|
131
99
|
knownGoodOverride: explanation?.knownGoodOverride ?? false,
|
|
132
100
|
});
|
package/dist/commands/health.js
CHANGED
|
@@ -10,6 +10,7 @@ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-acces
|
|
|
10
10
|
import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
|
|
11
11
|
import { listExistingTableNames, listPendingStateMigrations, openStateDatabaseWithReport, withStateDb, } from "../core/state-db.js";
|
|
12
12
|
import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
|
|
13
|
+
import { resolveSourceEntries } from "../indexer/search/search-source.js";
|
|
13
14
|
import { probeLlmEndpoint } from "../llm/client.js";
|
|
14
15
|
import { openDatabase } from "../storage/database.js";
|
|
15
16
|
import { insertEvent } from "../storage/repositories/events-repository.js";
|
|
@@ -251,24 +252,36 @@ function gatherAncillaryAdvisories(db, options, egressConfigView) {
|
|
|
251
252
|
const indexStateMismatch = detectIndexStateGenerationMismatch(db);
|
|
252
253
|
if (indexStateMismatch)
|
|
253
254
|
advisories.push(indexStateMismatch);
|
|
254
|
-
// Surface a `stash-git-exposure` advisory
|
|
255
|
-
// git-tracked AND a remote
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
255
|
+
// Surface a `stash-git-exposure` advisory for each bundle whose env/secret
|
|
256
|
+
// assets are git-tracked AND that has a remote (the leak moment). Every
|
|
257
|
+
// configured bundle is checked, not just the working stash; each honours the
|
|
258
|
+
// intentional-tracking marker in its own `.gitignore`. Best-effort.
|
|
259
|
+
// Cheap guard: only shell out to git for a bundle root with its OWN `.git`
|
|
260
|
+
// (or when a test injected a fake seam), so the hot path never spawns for
|
|
261
|
+
// non-git bundles. Trade-off: a bundle pointed at a bare subdirectory of a
|
|
262
|
+
// parent git repo (no `.git` of its own) is not checked; akm-init and
|
|
263
|
+
// writable git installs put `.git` at the bundle root.
|
|
264
|
+
let exposureRoots = [];
|
|
262
265
|
try {
|
|
263
266
|
const exposureStashDir = options.stashDir ?? resolveStashDir();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (stashExposure)
|
|
267
|
-
advisories.push(stashExposure);
|
|
268
|
-
}
|
|
267
|
+
exposureRoots = [exposureStashDir];
|
|
268
|
+
exposureRoots = resolveSourceEntries(exposureStashDir).map((source) => source.path);
|
|
269
269
|
}
|
|
270
270
|
catch {
|
|
271
|
-
//
|
|
271
|
+
// An unreadable working stash skips the advisory; an unreadable bundle
|
|
272
|
+
// config still checks the working stash. Neither aborts the report.
|
|
273
|
+
}
|
|
274
|
+
for (const root of new Set(exposureRoots.map((dir) => path.resolve(dir)))) {
|
|
275
|
+
try {
|
|
276
|
+
if (options.stashExposureGit || fs.existsSync(path.join(root, ".git"))) {
|
|
277
|
+
const stashExposure = collectStashExposureAdvisory(root, options.stashExposureGit);
|
|
278
|
+
if (stashExposure)
|
|
279
|
+
advisories.push(stashExposure);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Non-fatal — a git/probe failure must not abort the health report.
|
|
284
|
+
}
|
|
272
285
|
}
|
|
273
286
|
// 08 surfaces: the remaining read-only advisory group (binary-config-skew,
|
|
274
287
|
// egress-endpoints). Best-effort — a filesystem probe failure must not abort
|
|
@@ -346,7 +359,24 @@ function detectTypeDirectoryDisagreements(stashRoot) {
|
|
|
346
359
|
indexDb = openReadonlyExistingDatabase(getDbPath());
|
|
347
360
|
if (!indexDb)
|
|
348
361
|
return undefined;
|
|
349
|
-
|
|
362
|
+
// Directory intent is read only from inside the component root that owns the entry.
|
|
363
|
+
// Both spellings of each root: indexed paths may be realpaths (a symlinked
|
|
364
|
+
// stash, macOS /var -> /private/var) while config holds the configured path.
|
|
365
|
+
const roots = resolveSourceEntries(stashRoot).flatMap((source) => {
|
|
366
|
+
const resolved = path.resolve(source.path);
|
|
367
|
+
try {
|
|
368
|
+
return [resolved, fs.realpathSync.native(resolved)];
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return [resolved];
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
const entries = getAllEntries(indexDb).map((entry) => ({
|
|
375
|
+
filePath: entry.filePath,
|
|
376
|
+
type: entry.type,
|
|
377
|
+
adapterId: entry.adapterId,
|
|
378
|
+
root: roots.filter((root) => entry.filePath.startsWith(root + path.sep)).sort((a, b) => b.length - a.length)[0] ?? "",
|
|
379
|
+
}));
|
|
350
380
|
return buildTypeDirectoryAdvisory(entries, undefined, (absPath) => absPath.startsWith(stashRoot) ? path.relative(stashRoot, absPath) : absPath);
|
|
351
381
|
}
|
|
352
382
|
catch {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
|
|
5
5
|
import { decodeTaskHistoryMetadata } from "../../storage/repositories/task-history-repository.js";
|
|
6
6
|
import { computeWallTimeStats } from "../health/improve-metrics.js";
|
|
7
|
+
import { hasTokenUsage } from "../health/llm-usage.js";
|
|
7
8
|
import { computeValenceScore } from "../improve/feedback-valence.js";
|
|
8
9
|
const DAY_MS = 86_400_000;
|
|
9
10
|
/** Utility is a score in [0, 1], reported in ten equal buckets. */
|
|
@@ -111,6 +112,8 @@ export function llmRowsFromEvents(events) {
|
|
|
111
112
|
completionTokens: 0,
|
|
112
113
|
totalTokens: 0,
|
|
113
114
|
reasoningTokens: 0,
|
|
115
|
+
usageCalls: 0,
|
|
116
|
+
reasoningCalls: 0,
|
|
114
117
|
};
|
|
115
118
|
rows.set(key, row);
|
|
116
119
|
}
|
|
@@ -121,6 +124,10 @@ export function llmRowsFromEvents(events) {
|
|
|
121
124
|
// A call without a total counts as prompt + completion, as the dashboard did per call.
|
|
122
125
|
row.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
|
|
123
126
|
row.reasoningTokens += record.reasoningTokens ?? 0;
|
|
127
|
+
if (hasTokenUsage(record))
|
|
128
|
+
row.usageCalls += 1;
|
|
129
|
+
if (record.reasoningTokens !== undefined)
|
|
130
|
+
row.reasoningCalls += 1;
|
|
124
131
|
}
|
|
125
132
|
return [...rows.values()];
|
|
126
133
|
}
|
|
@@ -260,10 +267,11 @@ function buildUsage(input, rows) {
|
|
|
260
267
|
curates,
|
|
261
268
|
selects: input.selects.length,
|
|
262
269
|
zeroResultSearches,
|
|
270
|
+
searchesWithHits,
|
|
263
271
|
distinctAssets: assets.size,
|
|
264
272
|
distinctQueries: queries.size,
|
|
265
273
|
},
|
|
266
|
-
|
|
274
|
+
selectsPerSearch: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
|
|
267
275
|
searchMedianMs: median(searchMs),
|
|
268
276
|
daily: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
|
269
277
|
topAssets: [...assets.values()]
|
|
@@ -131,7 +131,7 @@ export async function akmMetrics(options = {}) {
|
|
|
131
131
|
if (index.note)
|
|
132
132
|
notes.push(index.note);
|
|
133
133
|
const filter = { sinceIso, untilIso, source: SOURCE };
|
|
134
|
-
notes.push("Selects come from the events stream, which records no source, so they are counted for every source.");
|
|
134
|
+
notes.push("Selects come from the events stream, which records no source or search id, so they are counted for every source and selects per search is a ratio, not a conversion rate.");
|
|
135
135
|
const input = {
|
|
136
136
|
window: { since: sinceIso, until: untilIso },
|
|
137
137
|
source: SOURCE,
|
|
@@ -20,18 +20,27 @@ function sourceFacts(counts) {
|
|
|
20
20
|
const entries = Object.entries(counts);
|
|
21
21
|
return entries.length === 0 ? "-" : entries.map(([k, v]) => `${k}=${v}`).join(" ");
|
|
22
22
|
}
|
|
23
|
+
/** A token sum, or `unknown` when calls were made and none reported usage (never a measured zero). */
|
|
24
|
+
function tokens(n, a) {
|
|
25
|
+
return a.calls > 0 && a.usageCalls === 0 ? "unknown" : int(n);
|
|
26
|
+
}
|
|
27
|
+
/** `reported/calls`; `-` for an aggregate that carries no coverage. */
|
|
28
|
+
function usageCoverage(a) {
|
|
29
|
+
return a.usageCalls === undefined ? "-" : `${a.usageCalls}/${a.calls}`;
|
|
30
|
+
}
|
|
23
31
|
function llmRows(group) {
|
|
24
32
|
return Object.entries(group).map(([name, a]) => [
|
|
25
33
|
name,
|
|
26
34
|
int(a.calls),
|
|
27
35
|
int(a.failures),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
tokens(a.promptTokens, a),
|
|
37
|
+
tokens(a.completionTokens, a),
|
|
38
|
+
tokens(a.totalTokens, a),
|
|
39
|
+
usageCoverage(a),
|
|
31
40
|
ms(a.totalDurationMs),
|
|
32
41
|
]);
|
|
33
42
|
}
|
|
34
|
-
const LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "time"];
|
|
43
|
+
const LLM_HEADERS = ["name", "calls", "failed", "prompt", "completion", "total", "calls with usage", "time"];
|
|
35
44
|
export function buildMetricsView(r, detail) {
|
|
36
45
|
const brief = detail === "brief";
|
|
37
46
|
const cut = (items) => (brief ? items.slice(0, BRIEF_LIST_LIMIT) : items);
|
|
@@ -45,7 +54,8 @@ export function buildMetricsView(r, detail) {
|
|
|
45
54
|
["shows", int(usage.totals.shows)],
|
|
46
55
|
["curates", int(usage.totals.curates)],
|
|
47
56
|
["selects", int(usage.totals.selects)],
|
|
48
|
-
["
|
|
57
|
+
["searches with hits", int(usage.totals.searchesWithHits)],
|
|
58
|
+
["selects per search with hits", fixed(usage.selectsPerSearch, 2)],
|
|
49
59
|
[
|
|
50
60
|
"zero-result searches",
|
|
51
61
|
`${usage.totals.zeroResultSearches} (${pct(searches === 0 ? null : usage.totals.zeroResultSearches / searches)})`,
|
|
@@ -154,10 +164,11 @@ export function buildMetricsView(r, detail) {
|
|
|
154
164
|
facts: [
|
|
155
165
|
["calls", int(llm.calls)],
|
|
156
166
|
["failed", int(llm.failures)],
|
|
157
|
-
["
|
|
158
|
-
["
|
|
159
|
-
["
|
|
160
|
-
["
|
|
167
|
+
["calls with token usage", usageCoverage(llm)],
|
|
168
|
+
["prompt tokens", tokens(llm.promptTokens, llm)],
|
|
169
|
+
["completion tokens", tokens(llm.completionTokens, llm)],
|
|
170
|
+
["reasoning tokens", llm.calls > 0 && llm.reasoningCalls === 0 ? "unknown" : tokens(llm.reasoningTokens, llm)],
|
|
171
|
+
["total tokens", tokens(llm.totalTokens, llm)],
|
|
161
172
|
["time", ms(llm.totalDurationMs)],
|
|
162
173
|
],
|
|
163
174
|
tables: [
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { applyEdits, modify } from "jsonc-parser";
|
|
8
|
+
import { parse as parseToml } from "smol-toml";
|
|
9
|
+
import { existingFileMode, parseJsonc, timestampForFilename, writeFileAtomic } from "../../core/common.js";
|
|
10
|
+
/** Preserve unrelated settings and comments; only the named native key changes. */
|
|
11
|
+
export function editChannelJson(file, keys, value, content) {
|
|
12
|
+
const before = content ?? fs.readFileSync(file, "utf8");
|
|
13
|
+
parseJsonc(before);
|
|
14
|
+
const after = applyEdits(before, modify(before, keys, value, { formattingOptions: { insertSpaces: true, tabSize: 2 } }));
|
|
15
|
+
parseJsonc(after);
|
|
16
|
+
return { file, content: after };
|
|
17
|
+
}
|
|
18
|
+
export function backupChannelFile(file) {
|
|
19
|
+
const backup = `${file}.akm-channel-${timestampForFilename()}-${process.pid}.bak`;
|
|
20
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
21
|
+
fs.chmodSync(backup, existingFileMode(file) ?? 0o600);
|
|
22
|
+
return backup;
|
|
23
|
+
}
|
|
24
|
+
export class ChannelWriteError extends Error {
|
|
25
|
+
backups;
|
|
26
|
+
writtenFiles;
|
|
27
|
+
constructor(cause, backups, writtenFiles) {
|
|
28
|
+
super(`Channel settings could not all be written: ${String(cause)}. Backups: ${backups.join(", ") || "none"}`);
|
|
29
|
+
this.backups = backups;
|
|
30
|
+
this.writtenFiles = writtenFiles;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function writeChannelEdits(edits) {
|
|
34
|
+
const backups = [];
|
|
35
|
+
const writtenFiles = [];
|
|
36
|
+
try {
|
|
37
|
+
const changed = edits.filter(({ file, content }) => fs.readFileSync(file, "utf8") !== content);
|
|
38
|
+
for (const { file } of changed)
|
|
39
|
+
backups.push(backupChannelFile(file));
|
|
40
|
+
for (const { file, content } of changed) {
|
|
41
|
+
writeFileAtomic(file, content, existingFileMode(file));
|
|
42
|
+
writtenFiles.push(file);
|
|
43
|
+
}
|
|
44
|
+
return backups;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new ChannelWriteError(error, backups, writtenFiles);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Claude has no command to change a registered marketplace's ref in place. Never remove the marketplace. */
|
|
51
|
+
export function claudeChannelEdits(configDir, pluginsRoot, channel, projectPaths = []) {
|
|
52
|
+
const ref = channel === "next" ? "next" : undefined;
|
|
53
|
+
const knownFile = path.join(pluginsRoot, "known_marketplaces.json");
|
|
54
|
+
const known = parseJsonc(fs.readFileSync(knownFile, "utf8"));
|
|
55
|
+
const source = known["akm-plugins"]?.source;
|
|
56
|
+
if (!source || !["github", "git"].includes(source.source ?? "")) {
|
|
57
|
+
throw new Error("the akm-plugins marketplace has no Git source whose channel can be switched");
|
|
58
|
+
}
|
|
59
|
+
const edits = [editChannelJson(knownFile, ["akm-plugins", "source", "ref"], ref)];
|
|
60
|
+
const settingsFiles = [
|
|
61
|
+
path.join(configDir, "settings.json"),
|
|
62
|
+
...[process.cwd(), ...projectPaths].flatMap((project) => [
|
|
63
|
+
path.join(project, ".claude", "settings.json"),
|
|
64
|
+
path.join(project, ".claude", "settings.local.json"),
|
|
65
|
+
]),
|
|
66
|
+
];
|
|
67
|
+
for (const file of new Set(settingsFiles)) {
|
|
68
|
+
if (!fs.existsSync(file))
|
|
69
|
+
continue;
|
|
70
|
+
const settings = parseJsonc(fs.readFileSync(file, "utf8"));
|
|
71
|
+
if (!settings.extraKnownMarketplaces?.["akm-plugins"])
|
|
72
|
+
continue;
|
|
73
|
+
edits.push(editChannelJson(file, ["extraKnownMarketplaces", "akm-plugins", "source", "ref"], ref));
|
|
74
|
+
}
|
|
75
|
+
return edits;
|
|
76
|
+
}
|
|
77
|
+
/** Read native TOML, including quoted keys and inline tables. */
|
|
78
|
+
export function codexMarketplace(file) {
|
|
79
|
+
const config = parseToml(fs.readFileSync(file, "utf8"));
|
|
80
|
+
const marketplaces = config.marketplaces;
|
|
81
|
+
const marketplace = marketplaces?.["akm-plugins"];
|
|
82
|
+
return marketplace && typeof marketplace === "object" ? marketplace : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Codex's add refuses a changed ref on an existing source; preserve the installed plugin and edit only its ref. */
|
|
85
|
+
export function codexChannelEdit(file, channel) {
|
|
86
|
+
const before = fs.readFileSync(file, "utf8");
|
|
87
|
+
const expected = parseToml(before);
|
|
88
|
+
const entry = expected.marketplaces?.["akm-plugins"];
|
|
89
|
+
if (!entry || entry.source_type !== "git")
|
|
90
|
+
throw new Error("the akm-plugins marketplace has no Git source whose channel can be switched");
|
|
91
|
+
if (channel === "next")
|
|
92
|
+
entry.ref = "next";
|
|
93
|
+
else
|
|
94
|
+
delete entry.ref;
|
|
95
|
+
const headers = [...before.matchAll(/^[ \t]*\[(?!\[)([^\]\r\n]+)\][ \t]*(?:#[^\r\n]*)?\r?$/gm)];
|
|
96
|
+
let after = before;
|
|
97
|
+
let edited = false;
|
|
98
|
+
for (const [index, header] of headers.entries()) {
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = parseToml(`[${header[1]}]`);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const target = parsed.marketplaces?.["akm-plugins"];
|
|
107
|
+
if (!target || Object.keys(target).length !== 0)
|
|
108
|
+
continue;
|
|
109
|
+
const start = header.index + header[0].length;
|
|
110
|
+
const end = headers[index + 1]?.index ?? before.length;
|
|
111
|
+
const section = before.slice(start, end);
|
|
112
|
+
const refLine = /^[ \t]*(?:ref|"ref"|'ref')[ \t]*=[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')([ \t]*(?:#[^\r\n]*)?)(\r?\n|$)/m;
|
|
113
|
+
const newline = before.includes("\r\n") ? "\r\n" : "\n";
|
|
114
|
+
const nextSection = refLine.test(section)
|
|
115
|
+
? section.replace(refLine, (_line, comment, end) => channel === "next" ? `ref = "next"${comment}${end}` : comment.trim() ? `${comment.trimStart()}${end}` : "")
|
|
116
|
+
: channel === "next"
|
|
117
|
+
? `${newline}ref = "next"${section}`
|
|
118
|
+
: section;
|
|
119
|
+
after = before.slice(0, start) + nextSection + before.slice(end);
|
|
120
|
+
edited = true;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (!edited) {
|
|
124
|
+
// Inline tables have no section to insert into. Change just this table's ref and retain every other byte.
|
|
125
|
+
const inline = /((?:"akm-plugins"|'akm-plugins'|akm-plugins)\s*=\s*\{)([^\n]*?)(\})/g;
|
|
126
|
+
for (const match of before.matchAll(inline)) {
|
|
127
|
+
const [, open, body, close] = match;
|
|
128
|
+
const ref = /(?:^|,)\s*(?:ref|"ref"|'ref')\s*=\s*(?:"(?:\\.|[^"\\])*"|'[^']*')\s*/;
|
|
129
|
+
const stripped = body.replace(ref, "").replace(/^\s*,/, "");
|
|
130
|
+
const next = channel === "next" ? `${stripped.trimEnd()}${stripped.trim() ? ", " : ""}ref = "next" ` : stripped;
|
|
131
|
+
const candidate = before.slice(0, match.index) + `${open}${next}${close}` + before.slice(match.index + match[0].length);
|
|
132
|
+
try {
|
|
133
|
+
if (isDeepStrictEqual(parseToml(candidate), expected)) {
|
|
134
|
+
after = candidate;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* Try the next table with this name. */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Validate the whole semantic document: an unusual layout is never partially rewritten.
|
|
144
|
+
if (!isDeepStrictEqual(parseToml(after), expected))
|
|
145
|
+
throw new Error("could not locate only the akm-plugins ref in Codex config.toml; no settings were changed");
|
|
146
|
+
return { file, content: after };
|
|
147
|
+
}
|