akm-cli 0.5.0 → 0.6.0-rc1
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 +32 -5
- package/dist/asset-registry.js +29 -5
- package/dist/asset-spec.js +12 -5
- package/dist/cli-hints.js +300 -0
- package/dist/cli.js +218 -1357
- package/dist/common.js +147 -50
- package/dist/config.js +224 -13
- package/dist/create-provider-registry.js +1 -1
- package/dist/curate.js +258 -0
- package/dist/{local-search.js → db-search.js} +30 -19
- package/dist/db.js +168 -62
- package/dist/embedder.js +49 -273
- package/dist/embedders/cache.js +47 -0
- package/dist/embedders/local.js +152 -0
- package/dist/embedders/remote.js +121 -0
- package/dist/embedders/types.js +39 -0
- package/dist/errors.js +14 -3
- package/dist/frontmatter.js +61 -7
- package/dist/indexer.js +38 -7
- package/dist/info.js +2 -2
- package/dist/install-audit.js +16 -1
- package/dist/{installed-kits.js → installed-stashes.js} +48 -22
- package/dist/llm-client.js +92 -0
- package/dist/llm.js +14 -126
- package/dist/lockfile.js +28 -1
- package/dist/matchers.js +1 -1
- package/dist/metadata-enhance.js +53 -0
- package/dist/migration-help.js +75 -44
- package/dist/output-context.js +77 -0
- package/dist/output-shapes.js +198 -0
- package/dist/output-text.js +520 -0
- package/dist/paths.js +4 -4
- package/dist/providers/index.js +11 -0
- package/dist/providers/skills-sh.js +1 -1
- package/dist/providers/static-index.js +47 -45
- package/dist/registry-build-index.js +36 -29
- package/dist/registry-factory.js +2 -2
- package/dist/registry-resolve.js +8 -4
- package/dist/registry-search.js +62 -5
- package/dist/remember.js +172 -0
- package/dist/renderers.js +52 -0
- package/dist/search-source.js +73 -42
- package/dist/setup-steps.js +45 -0
- package/dist/setup.js +149 -76
- package/dist/stash-add.js +94 -38
- package/dist/stash-clone.js +4 -4
- package/dist/stash-provider-factory.js +2 -2
- package/dist/stash-provider.js +3 -1
- package/dist/stash-providers/filesystem.js +31 -1
- package/dist/stash-providers/git.js +209 -8
- package/dist/stash-providers/index.js +1 -0
- package/dist/stash-providers/npm.js +159 -0
- package/dist/stash-providers/provider-utils.js +162 -0
- package/dist/stash-providers/sync-from-ref.js +45 -0
- package/dist/stash-providers/tar-utils.js +151 -0
- package/dist/stash-providers/website.js +80 -4
- package/dist/stash-resolve.js +5 -5
- package/dist/stash-search.js +4 -4
- package/dist/stash-show.js +3 -3
- package/dist/wiki.js +6 -6
- package/dist/workflow-authoring.js +12 -4
- package/dist/workflow-markdown.js +9 -0
- package/dist/workflow-runs.js +12 -2
- package/docs/README.md +30 -0
- package/docs/migration/release-notes/0.0.13.md +4 -0
- package/docs/migration/release-notes/0.1.0.md +6 -0
- package/docs/migration/release-notes/0.2.0.md +6 -0
- package/docs/migration/release-notes/0.3.0.md +5 -0
- package/docs/migration/release-notes/0.5.0.md +6 -0
- package/docs/migration/release-notes/0.6.0.md +29 -0
- package/docs/migration/release-notes/README.md +21 -0
- package/package.json +3 -2
- package/dist/registry-install.js +0 -532
- /package/dist/{kit-include.js → stash-include.js} +0 -0
package/dist/common.js
CHANGED
|
@@ -24,21 +24,17 @@ export function isAssetType(type) {
|
|
|
24
24
|
* 2. stashDir field in config.json
|
|
25
25
|
* 3. Platform default (~/akm or ~/Documents/akm on Windows)
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* path into config.json on disk.
|
|
27
|
+
* Pure read: never writes to disk. The legacy `readOnly` option is accepted
|
|
28
|
+
* (and ignored) for one release cycle so older callers continue to compile;
|
|
29
|
+
* it can be removed in the next minor bump.
|
|
31
30
|
*
|
|
32
31
|
* Throws if no valid stash directory is found.
|
|
33
32
|
*/
|
|
34
|
-
export function resolveStashDir(
|
|
33
|
+
export function resolveStashDir(_options) {
|
|
35
34
|
// 1. Env var override (for CI, scripts, testing)
|
|
36
35
|
const envDir = process.env.AKM_STASH_DIR?.trim();
|
|
37
36
|
if (envDir) {
|
|
38
|
-
|
|
39
|
-
if (!options?.readOnly)
|
|
40
|
-
persistStashDirToConfig(resolved);
|
|
41
|
-
return resolved;
|
|
37
|
+
return validateStashDir(envDir);
|
|
42
38
|
}
|
|
43
39
|
// 2. Config file stashDir field
|
|
44
40
|
const configStashDir = readStashDirFromConfig();
|
|
@@ -50,7 +46,7 @@ export function resolveStashDir(options) {
|
|
|
50
46
|
return defaultDir;
|
|
51
47
|
}
|
|
52
48
|
throw new ConfigError(`No stash directory found. Run "akm init" to create one at ${defaultDir}, ` +
|
|
53
|
-
`or set stashDir in ${getConfigPath()}
|
|
49
|
+
`or set stashDir in ${getConfigPath()}.`, "STASH_DIR_NOT_FOUND");
|
|
54
50
|
}
|
|
55
51
|
function validateStashDir(raw) {
|
|
56
52
|
const stashDir = path.resolve(raw);
|
|
@@ -59,10 +55,10 @@ function validateStashDir(raw) {
|
|
|
59
55
|
stat = fs.statSync(stashDir);
|
|
60
56
|
}
|
|
61
57
|
catch {
|
|
62
|
-
throw new ConfigError(`Unable to read stash directory at "${stashDir}"
|
|
58
|
+
throw new ConfigError(`Unable to read stash directory at "${stashDir}".`, "STASH_DIR_UNREADABLE");
|
|
63
59
|
}
|
|
64
60
|
if (!stat.isDirectory()) {
|
|
65
|
-
throw new ConfigError(`Stash path must point to a directory: "${stashDir}"
|
|
61
|
+
throw new ConfigError(`Stash path must point to a directory: "${stashDir}".`, "STASH_DIR_NOT_A_DIRECTORY");
|
|
66
62
|
}
|
|
67
63
|
return stashDir;
|
|
68
64
|
}
|
|
@@ -92,42 +88,6 @@ function readStashDirFromConfig() {
|
|
|
92
88
|
}
|
|
93
89
|
return undefined;
|
|
94
90
|
}
|
|
95
|
-
/**
|
|
96
|
-
* Persist stashDir to config.json if not already set, so users can
|
|
97
|
-
* transition away from relying on the AKM_STASH_DIR env var.
|
|
98
|
-
*
|
|
99
|
-
* WARNING: This function writes to disk (config.json). It is called as a side
|
|
100
|
-
* effect of `resolveStashDir()` when AKM_STASH_DIR is set and `readOnly` is
|
|
101
|
-
* not true. Callers that must not touch the filesystem should pass
|
|
102
|
-
* `{ readOnly: true }` to `resolveStashDir()`.
|
|
103
|
-
*/
|
|
104
|
-
function persistStashDirToConfig(stashDir) {
|
|
105
|
-
try {
|
|
106
|
-
const configPath = getConfigPath();
|
|
107
|
-
let raw = {};
|
|
108
|
-
try {
|
|
109
|
-
const text = fs.readFileSync(configPath, "utf8");
|
|
110
|
-
const parsed = JSON.parse(text);
|
|
111
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
112
|
-
raw = parsed;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
// No existing config or invalid — start fresh
|
|
117
|
-
}
|
|
118
|
-
if (!raw.stashDir) {
|
|
119
|
-
raw.stashDir = stashDir;
|
|
120
|
-
const dir = path.dirname(configPath);
|
|
121
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
122
|
-
const tmpPath = `${configPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
123
|
-
fs.writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
124
|
-
fs.renameSync(tmpPath, configPath);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
// Non-fatal: best-effort persistence
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
91
|
export function toPosix(input) {
|
|
132
92
|
return input.replace(/\\/g, "/");
|
|
133
93
|
}
|
|
@@ -144,12 +104,39 @@ export function isWithin(candidate, root) {
|
|
|
144
104
|
const rel = path.relative(normalizedRoot, normalizedCandidate);
|
|
145
105
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
146
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve symlinks on `p`, walking up to the closest existing ancestor when
|
|
109
|
+
* `p` itself does not exist. This ensures that comparisons between an
|
|
110
|
+
* existing directory and a not-yet-created child path inside it are
|
|
111
|
+
* consistent even when the directory hierarchy contains symlinks (e.g.
|
|
112
|
+
* macOS /tmp → /private/tmp, or a HOME that is itself a symlink).
|
|
113
|
+
*/
|
|
147
114
|
function safeRealpath(p) {
|
|
115
|
+
const resolved = path.resolve(p);
|
|
148
116
|
try {
|
|
149
|
-
return fs.realpathSync(
|
|
117
|
+
return fs.realpathSync(resolved);
|
|
150
118
|
}
|
|
151
119
|
catch {
|
|
152
|
-
|
|
120
|
+
// Path doesn't exist — resolve symlinks on the nearest existing ancestor
|
|
121
|
+
// and reconstruct the full path from there.
|
|
122
|
+
const suffix = [];
|
|
123
|
+
let current = resolved;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const parent = path.dirname(current);
|
|
126
|
+
if (parent === current) {
|
|
127
|
+
// Reached filesystem root without finding an existing entry.
|
|
128
|
+
return resolved;
|
|
129
|
+
}
|
|
130
|
+
suffix.unshift(path.basename(current));
|
|
131
|
+
current = parent;
|
|
132
|
+
try {
|
|
133
|
+
const realParent = fs.realpathSync(current);
|
|
134
|
+
return path.join(realParent, ...suffix);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// parent also doesn't exist; keep walking up
|
|
138
|
+
}
|
|
139
|
+
}
|
|
153
140
|
}
|
|
154
141
|
}
|
|
155
142
|
function normalizeFsPathForComparison(value) {
|
|
@@ -207,6 +194,116 @@ export async function fetchWithRetry(url, init, options) {
|
|
|
207
194
|
function shouldRetry(status) {
|
|
208
195
|
return status === 429 || status >= 500;
|
|
209
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Read stdin as UTF-8 text if something is piped in. Returns `undefined`
|
|
199
|
+
* when stdin is a TTY (no pipe) or when the piped content is empty.
|
|
200
|
+
*/
|
|
201
|
+
export function tryReadStdinText() {
|
|
202
|
+
if (process.stdin.isTTY)
|
|
203
|
+
return undefined;
|
|
204
|
+
const input = fs.readFileSync(0, "utf8");
|
|
205
|
+
return input.length > 0 ? input : undefined;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Default byte cap for untrusted network responses (10 MB).
|
|
209
|
+
*
|
|
210
|
+
* Applies to website scraping, registry index fetches, and any other
|
|
211
|
+
* response that is read into memory from a source the CLI does not fully
|
|
212
|
+
* control. A compromised or malicious endpoint that streams an unbounded
|
|
213
|
+
* response would otherwise exhaust RAM — this cap ensures the process
|
|
214
|
+
* aborts with a clean error instead of crashing.
|
|
215
|
+
*/
|
|
216
|
+
export const DEFAULT_RESPONSE_BYTE_CAP = 10 * 1024 * 1024;
|
|
217
|
+
/**
|
|
218
|
+
* Thrown by {@link readBodyWithByteCap} and its helpers when a response
|
|
219
|
+
* body exceeds the caller's byte cap. Callers can catch this specifically
|
|
220
|
+
* to surface a targeted error to the user.
|
|
221
|
+
*/
|
|
222
|
+
export class ResponseTooLargeError extends Error {
|
|
223
|
+
url;
|
|
224
|
+
maxBytes;
|
|
225
|
+
observedBytes;
|
|
226
|
+
constructor(url, maxBytes, observedBytes) {
|
|
227
|
+
const observed = observedBytes === null ? "unknown" : `${observedBytes} bytes`;
|
|
228
|
+
super(`Response body exceeded ${maxBytes} bytes (observed: ${observed}): ${url}`);
|
|
229
|
+
this.name = "ResponseTooLargeError";
|
|
230
|
+
this.url = url;
|
|
231
|
+
this.maxBytes = maxBytes;
|
|
232
|
+
this.observedBytes = observedBytes;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Read a Response body as a UTF-8 string with a byte-count cap.
|
|
237
|
+
*
|
|
238
|
+
* Streams the body so we abort as soon as the cap is exceeded, without
|
|
239
|
+
* buffering the full response first. If the server sent a
|
|
240
|
+
* `Content-Length` larger than the cap, we refuse before reading any
|
|
241
|
+
* bytes. `response.body` is consumed and cancelled on cap breach.
|
|
242
|
+
*
|
|
243
|
+
* `maxBytes` defaults to {@link DEFAULT_RESPONSE_BYTE_CAP} (10 MB).
|
|
244
|
+
*/
|
|
245
|
+
export async function readBodyWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE_CAP) {
|
|
246
|
+
const url = response.url || "(unknown URL)";
|
|
247
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
248
|
+
if (contentLengthHeader) {
|
|
249
|
+
const declared = Number(contentLengthHeader);
|
|
250
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
251
|
+
// Don't even start reading.
|
|
252
|
+
await response.body?.cancel?.().catch(() => undefined);
|
|
253
|
+
throw new ResponseTooLargeError(url, maxBytes, declared);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const body = response.body;
|
|
257
|
+
if (!body) {
|
|
258
|
+
// No streaming body available (e.g., some mock environments). Fall
|
|
259
|
+
// back to text() but still enforce the cap post-hoc.
|
|
260
|
+
const text = await response.text();
|
|
261
|
+
if (text.length > maxBytes)
|
|
262
|
+
throw new ResponseTooLargeError(url, maxBytes, text.length);
|
|
263
|
+
return text;
|
|
264
|
+
}
|
|
265
|
+
const reader = body.getReader();
|
|
266
|
+
const chunks = [];
|
|
267
|
+
let total = 0;
|
|
268
|
+
try {
|
|
269
|
+
while (true) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done)
|
|
272
|
+
break;
|
|
273
|
+
if (!value)
|
|
274
|
+
continue;
|
|
275
|
+
total += value.byteLength;
|
|
276
|
+
if (total > maxBytes) {
|
|
277
|
+
await reader.cancel().catch(() => undefined);
|
|
278
|
+
throw new ResponseTooLargeError(url, maxBytes, total);
|
|
279
|
+
}
|
|
280
|
+
chunks.push(value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
reader.releaseLock?.();
|
|
285
|
+
}
|
|
286
|
+
if (chunks.length === 0)
|
|
287
|
+
return "";
|
|
288
|
+
if (chunks.length === 1)
|
|
289
|
+
return new TextDecoder().decode(chunks[0]);
|
|
290
|
+
const combined = new Uint8Array(total);
|
|
291
|
+
let offset = 0;
|
|
292
|
+
for (const chunk of chunks) {
|
|
293
|
+
combined.set(chunk, offset);
|
|
294
|
+
offset += chunk.byteLength;
|
|
295
|
+
}
|
|
296
|
+
return new TextDecoder().decode(combined);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Parse a Response body as JSON with a byte-count cap. A cheap wrapper
|
|
300
|
+
* around {@link readBodyWithByteCap}; prefer this for registry index
|
|
301
|
+
* fetches, GitHub API responses, and any other untrusted JSON source.
|
|
302
|
+
*/
|
|
303
|
+
export async function jsonWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE_CAP) {
|
|
304
|
+
const text = await readBodyWithByteCap(response, maxBytes);
|
|
305
|
+
return JSON.parse(text);
|
|
306
|
+
}
|
|
210
307
|
function parseRetryAfter(response) {
|
|
211
308
|
const header = response.headers.get("retry-after");
|
|
212
309
|
if (!header)
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { filterNonEmptyStrings } from "./common";
|
|
@@ -29,22 +30,56 @@ export function resetConfigCache() {
|
|
|
29
30
|
cachedConfig = undefined;
|
|
30
31
|
cachedUserConfig = undefined;
|
|
31
32
|
}
|
|
33
|
+
function hashString(text) {
|
|
34
|
+
// Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
|
|
35
|
+
// content changes between config writes when filesystem mtime resolution is
|
|
36
|
+
// too coarse to reflect rapid back-to-back writes (common in tests).
|
|
37
|
+
let hash = 0x811c9dc5;
|
|
38
|
+
for (let i = 0; i < text.length; i++) {
|
|
39
|
+
hash ^= text.charCodeAt(i);
|
|
40
|
+
hash = Math.imul(hash, 0x01000193);
|
|
41
|
+
}
|
|
42
|
+
return (hash >>> 0).toString(16);
|
|
43
|
+
}
|
|
32
44
|
export function loadUserConfig() {
|
|
33
45
|
const configPath = getConfigPath();
|
|
34
46
|
let stat;
|
|
35
47
|
try {
|
|
36
48
|
stat = fs.statSync(configPath);
|
|
37
|
-
if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
|
|
38
|
-
return cachedUserConfig.config;
|
|
39
|
-
}
|
|
40
49
|
}
|
|
41
50
|
catch {
|
|
42
51
|
cachedUserConfig = undefined;
|
|
43
52
|
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
44
53
|
}
|
|
45
|
-
|
|
54
|
+
// Cache key combines mtimeMs + size + content hash. mtimeMs alone is unreliable
|
|
55
|
+
// when tests write multiple times within the filesystem mtime resolution
|
|
56
|
+
// window (often 1ms+). Reading + hashing on cache miss is cheap and ensures
|
|
57
|
+
// we never serve stale config.
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = fs.readFileSync(configPath, "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
cachedUserConfig = undefined;
|
|
64
|
+
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
65
|
+
}
|
|
66
|
+
const contentHash = hashString(text);
|
|
67
|
+
if (cachedUserConfig &&
|
|
68
|
+
cachedUserConfig.path === configPath &&
|
|
69
|
+
cachedUserConfig.mtime === stat.mtimeMs &&
|
|
70
|
+
cachedUserConfig.size === stat.size &&
|
|
71
|
+
cachedUserConfig.contentHash === contentHash) {
|
|
72
|
+
return cachedUserConfig.config;
|
|
73
|
+
}
|
|
74
|
+
const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfigFromText(configPath, text));
|
|
46
75
|
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
47
|
-
cachedUserConfig = {
|
|
76
|
+
cachedUserConfig = {
|
|
77
|
+
config: finalConfig,
|
|
78
|
+
path: configPath,
|
|
79
|
+
mtime: stat.mtimeMs,
|
|
80
|
+
size: stat.size,
|
|
81
|
+
contentHash,
|
|
82
|
+
};
|
|
48
83
|
return finalConfig;
|
|
49
84
|
}
|
|
50
85
|
export function loadConfig() {
|
|
@@ -66,6 +101,7 @@ export function loadConfig() {
|
|
|
66
101
|
}
|
|
67
102
|
export function saveConfig(config) {
|
|
68
103
|
cachedConfig = undefined;
|
|
104
|
+
cachedUserConfig = undefined;
|
|
69
105
|
const configPath = getConfigPath();
|
|
70
106
|
const dir = path.dirname(configPath);
|
|
71
107
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -175,7 +211,13 @@ function pickKnownKeys(raw) {
|
|
|
175
211
|
const registries = parseRegistriesConfig(raw.registries);
|
|
176
212
|
if (registries)
|
|
177
213
|
config.registries = registries;
|
|
178
|
-
|
|
214
|
+
// Prefer the new `stashInheritance` field; fall back to the legacy boolean
|
|
215
|
+
// `disableGlobalStashes` so existing config files keep working unchanged.
|
|
216
|
+
if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
|
|
217
|
+
config.stashInheritance = raw.stashInheritance;
|
|
218
|
+
}
|
|
219
|
+
else if (typeof raw.disableGlobalStashes === "boolean") {
|
|
220
|
+
config.stashInheritance = raw.disableGlobalStashes ? "replace" : "merge";
|
|
179
221
|
config.disableGlobalStashes = raw.disableGlobalStashes;
|
|
180
222
|
}
|
|
181
223
|
const stashes = parseStashesConfig(raw.stashes);
|
|
@@ -197,6 +239,19 @@ function readNormalizedConfig(configPath) {
|
|
|
197
239
|
const expanded = raw ? expandEnvVars(raw) : undefined;
|
|
198
240
|
return expanded ? pickKnownKeys(expanded) : undefined;
|
|
199
241
|
}
|
|
242
|
+
function readNormalizedConfigFromText(_configPath, text) {
|
|
243
|
+
let raw;
|
|
244
|
+
try {
|
|
245
|
+
raw = JSON.parse(stripJsonComments(text));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
251
|
+
return undefined;
|
|
252
|
+
const expanded = expandEnvVars(raw);
|
|
253
|
+
return pickKnownKeys(expanded);
|
|
254
|
+
}
|
|
200
255
|
function parseOutputConfig(value) {
|
|
201
256
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
202
257
|
return undefined;
|
|
@@ -428,11 +483,11 @@ function parseInstalledEntries(value) {
|
|
|
428
483
|
if (!Array.isArray(value))
|
|
429
484
|
return undefined;
|
|
430
485
|
const entries = value
|
|
431
|
-
.map((entry) =>
|
|
486
|
+
.map((entry) => parseInstalledStashEntry(entry))
|
|
432
487
|
.filter((entry) => entry !== undefined);
|
|
433
488
|
return entries.length > 0 ? entries : undefined;
|
|
434
489
|
}
|
|
435
|
-
function
|
|
490
|
+
function parseInstalledStashEntry(value) {
|
|
436
491
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
437
492
|
return undefined;
|
|
438
493
|
const obj = value;
|
|
@@ -470,6 +525,14 @@ function parseInstalledKitEntry(value) {
|
|
|
470
525
|
function asNonEmptyString(value) {
|
|
471
526
|
return typeof value === "string" && value ? value : undefined;
|
|
472
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Validate a legacy lockfile/installed-entry source string.
|
|
530
|
+
*
|
|
531
|
+
* Restricted to the four kinds that the install pipeline produces
|
|
532
|
+
* (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
|
|
533
|
+
* wider, but persisted `installed[]` entries should never carry the runtime
|
|
534
|
+
* provider kinds (`"filesystem" | "website" | "openviking"`).
|
|
535
|
+
*/
|
|
473
536
|
function asKitSource(value) {
|
|
474
537
|
if (value === "npm" || value === "github" || value === "git" || value === "local")
|
|
475
538
|
return value;
|
|
@@ -550,13 +613,24 @@ function parseInstallAuditAllowedFinding(value) {
|
|
|
550
613
|
finding.reason = reason;
|
|
551
614
|
return finding;
|
|
552
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Legacy stash type aliases that are normalized to canonical types at
|
|
618
|
+
* config-load time. Both "context-hub" and "github" were never distinct
|
|
619
|
+
* provider types — they were always git stashes — so we normalize them in
|
|
620
|
+
* memory to "git" without rewriting `config.json` on disk.
|
|
621
|
+
*/
|
|
622
|
+
const STASH_TYPE_ALIASES = {
|
|
623
|
+
"context-hub": "git",
|
|
624
|
+
github: "git",
|
|
625
|
+
};
|
|
553
626
|
function parseStashConfigEntry(value) {
|
|
554
627
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
555
628
|
return undefined;
|
|
556
629
|
const obj = value;
|
|
557
|
-
const
|
|
558
|
-
if (!
|
|
630
|
+
const rawType = asNonEmptyString(obj.type);
|
|
631
|
+
if (!rawType)
|
|
559
632
|
return undefined;
|
|
633
|
+
const type = STASH_TYPE_ALIASES[rawType] ?? rawType;
|
|
560
634
|
const entry = { type };
|
|
561
635
|
const entryPath = asNonEmptyString(obj.path);
|
|
562
636
|
if (entryPath)
|
|
@@ -571,6 +645,8 @@ function parseStashConfigEntry(value) {
|
|
|
571
645
|
entry.enabled = obj.enabled;
|
|
572
646
|
if (typeof obj.writable === "boolean")
|
|
573
647
|
entry.writable = obj.writable;
|
|
648
|
+
if (typeof obj.primary === "boolean")
|
|
649
|
+
entry.primary = obj.primary;
|
|
574
650
|
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
575
651
|
entry.options = obj.options;
|
|
576
652
|
}
|
|
@@ -579,6 +655,124 @@ function parseStashConfigEntry(value) {
|
|
|
579
655
|
entry.wikiName = wikiName;
|
|
580
656
|
return entry;
|
|
581
657
|
}
|
|
658
|
+
// ── StashEntry runtime construction ─────────────────────────────────────────
|
|
659
|
+
/**
|
|
660
|
+
* Synthesize a stable identifier when a {@link StashConfigEntry} omits its
|
|
661
|
+
* `name`. Uses a short hash of the discriminating fields so two equivalent
|
|
662
|
+
* entries collapse to the same generated name.
|
|
663
|
+
*/
|
|
664
|
+
function deriveStashEntryName(entry) {
|
|
665
|
+
if (entry.name)
|
|
666
|
+
return entry.name;
|
|
667
|
+
const seed = JSON.stringify({
|
|
668
|
+
type: entry.type,
|
|
669
|
+
path: entry.path ?? null,
|
|
670
|
+
url: entry.url ?? null,
|
|
671
|
+
});
|
|
672
|
+
const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
|
|
673
|
+
return `${entry.type}-${hash}`;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Convert a persisted {@link StashConfigEntry} into the runtime
|
|
677
|
+
* {@link StashSource} discriminated union. Returns `undefined` when the
|
|
678
|
+
* entry is missing the fields its provider type requires (e.g. a
|
|
679
|
+
* `filesystem` entry with no `path`); callers should drop or warn for those.
|
|
680
|
+
*
|
|
681
|
+
* Unknown provider types fall back to `{ type: "filesystem", path: ... }` so
|
|
682
|
+
* legacy aliases (`"context-hub"`, `"github"` for git) still produce a usable
|
|
683
|
+
* runtime value when a path/url is supplied.
|
|
684
|
+
*/
|
|
685
|
+
export function parseStashEntrySource(entry) {
|
|
686
|
+
switch (entry.type) {
|
|
687
|
+
case "filesystem":
|
|
688
|
+
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
689
|
+
case "git":
|
|
690
|
+
case "context-hub":
|
|
691
|
+
case "github":
|
|
692
|
+
// Note: a configured `github` provider entry historically meant "git
|
|
693
|
+
// repo over the GitHub web URL", not the registry-install `github:` ref.
|
|
694
|
+
return entry.url ? { type: "git", url: entry.url } : undefined;
|
|
695
|
+
case "website":
|
|
696
|
+
return entry.url
|
|
697
|
+
? {
|
|
698
|
+
type: "website",
|
|
699
|
+
url: entry.url,
|
|
700
|
+
...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
|
|
701
|
+
}
|
|
702
|
+
: undefined;
|
|
703
|
+
case "openviking":
|
|
704
|
+
return entry.url ? { type: "openviking", url: entry.url } : undefined;
|
|
705
|
+
case "npm":
|
|
706
|
+
// Persisted `npm` stash entries are unusual but supported for symmetry.
|
|
707
|
+
return entry.path ? { type: "npm", package: entry.path } : undefined;
|
|
708
|
+
default:
|
|
709
|
+
// Unknown provider — best-effort fallback so callers still get something.
|
|
710
|
+
return entry.path ? { type: "filesystem", path: entry.path } : undefined;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Build the full ordered list of runtime {@link StashEntry} values from a
|
|
715
|
+
* loaded {@link AkmConfig}. Order is the canonical iteration order:
|
|
716
|
+
*
|
|
717
|
+
* 1. The entry marked `primary: true` (or, as a backwards-compat shim,
|
|
718
|
+
* a synthetic filesystem entry built from the top-level `stashDir`).
|
|
719
|
+
* 2. Remaining `stashes[]` entries in declared order.
|
|
720
|
+
* 3. Legacy `installed[]` entries, mapped into runtime entries.
|
|
721
|
+
*
|
|
722
|
+
* Entries with `enabled: false` are still emitted — callers decide whether
|
|
723
|
+
* to honour the flag (mirrors how `installed[]` entries have always been
|
|
724
|
+
* unconditional). Entries that fail {@link parseStashEntrySource} are
|
|
725
|
+
* dropped silently.
|
|
726
|
+
*/
|
|
727
|
+
export function resolveStashEntries(config) {
|
|
728
|
+
const entries = [];
|
|
729
|
+
const stashes = config.stashes ?? [];
|
|
730
|
+
// (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
|
|
731
|
+
let primary = stashes.find((entry) => entry.primary === true);
|
|
732
|
+
if (!primary && config.stashDir) {
|
|
733
|
+
primary = { type: "filesystem", path: config.stashDir, primary: true };
|
|
734
|
+
}
|
|
735
|
+
if (primary) {
|
|
736
|
+
const runtime = toStashEntry(primary, true);
|
|
737
|
+
if (runtime)
|
|
738
|
+
entries.push(runtime);
|
|
739
|
+
}
|
|
740
|
+
// (2) Declared stashes (skip the primary entry — already added).
|
|
741
|
+
for (const entry of stashes) {
|
|
742
|
+
if (entry === primary)
|
|
743
|
+
continue;
|
|
744
|
+
const runtime = toStashEntry(entry, false);
|
|
745
|
+
if (runtime)
|
|
746
|
+
entries.push(runtime);
|
|
747
|
+
}
|
|
748
|
+
// (3) Legacy installed[] entries.
|
|
749
|
+
for (const installed of config.installed ?? []) {
|
|
750
|
+
entries.push({
|
|
751
|
+
name: installed.id,
|
|
752
|
+
type: "filesystem",
|
|
753
|
+
source: { type: "filesystem", path: installed.stashRoot },
|
|
754
|
+
enabled: true,
|
|
755
|
+
writable: installed.writable,
|
|
756
|
+
...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return entries;
|
|
760
|
+
}
|
|
761
|
+
function toStashEntry(persisted, isPrimary) {
|
|
762
|
+
const source = parseStashEntrySource(persisted);
|
|
763
|
+
if (!source)
|
|
764
|
+
return undefined;
|
|
765
|
+
return {
|
|
766
|
+
name: deriveStashEntryName(persisted),
|
|
767
|
+
type: persisted.type,
|
|
768
|
+
source,
|
|
769
|
+
...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
|
|
770
|
+
...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
|
|
771
|
+
...(isPrimary || persisted.primary ? { primary: true } : {}),
|
|
772
|
+
...(persisted.options ? { options: persisted.options } : {}),
|
|
773
|
+
...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
582
776
|
function parseRegistryConfigEntry(value) {
|
|
583
777
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
584
778
|
return undefined;
|
|
@@ -621,7 +815,8 @@ function mergeInstallAuditConfig(base, override) {
|
|
|
621
815
|
* Scalar fields follow normal override semantics. Known nested objects are
|
|
622
816
|
* deep-merged so project config files can override individual fields without
|
|
623
817
|
* clobbering sibling settings. `stashes` are additive by default, but a later
|
|
624
|
-
* layer can set `
|
|
818
|
+
* layer can set `stashInheritance: "replace"` (or the legacy
|
|
819
|
+
* `disableGlobalStashes: true`) to drop inherited stashes first.
|
|
625
820
|
*/
|
|
626
821
|
function mergeLoadedConfig(base, override) {
|
|
627
822
|
if (!override)
|
|
@@ -642,7 +837,11 @@ function mergeLoadedConfig(base, override) {
|
|
|
642
837
|
if (base.security && override.security) {
|
|
643
838
|
merged.security = mergeSecurityConfig(base.security, override.security);
|
|
644
839
|
}
|
|
645
|
-
|
|
840
|
+
// The new `stashInheritance` field wins; fall back to the legacy
|
|
841
|
+
// `disableGlobalStashes` boolean so old config files behave identically.
|
|
842
|
+
const replaceStashes = override.stashInheritance === "replace" ||
|
|
843
|
+
(override.stashInheritance === undefined && override.disableGlobalStashes === true);
|
|
844
|
+
if (replaceStashes) {
|
|
646
845
|
merged.stashes = [...(override.stashes ?? [])];
|
|
647
846
|
}
|
|
648
847
|
else if (override.stashes) {
|
|
@@ -713,7 +912,19 @@ function isFile(filePath) {
|
|
|
713
912
|
}
|
|
714
913
|
function getFileSignatureToken(filePath) {
|
|
715
914
|
try {
|
|
716
|
-
|
|
915
|
+
const stat = fs.statSync(filePath);
|
|
916
|
+
// mtimeMs alone is unreliable on filesystems with low-resolution mtime
|
|
917
|
+
// (HFS+, some network FS, or very fast back-to-back writes in tests).
|
|
918
|
+
// Combine mtime + size + content hash so the signature actually changes
|
|
919
|
+
// when content does.
|
|
920
|
+
let contentHash = "";
|
|
921
|
+
try {
|
|
922
|
+
contentHash = hashString(fs.readFileSync(filePath, "utf8"));
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
// ignore — fall back to stat-only signature
|
|
926
|
+
}
|
|
927
|
+
return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
|
|
717
928
|
}
|
|
718
929
|
catch {
|
|
719
930
|
return "missing";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Generic factory-map utility.
|
|
3
3
|
*
|
|
4
4
|
* Creates a lightweight registry that maps string keys to factory functions.
|
|
5
|
-
* Both registry-factory.ts (
|
|
5
|
+
* Both registry-factory.ts (stash discovery) and stash-provider-factory.ts
|
|
6
6
|
* (stash source providers) are built on this utility.
|
|
7
7
|
*/
|
|
8
8
|
export function createProviderRegistry() {
|