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/stash-add.js
CHANGED
|
@@ -4,9 +4,11 @@ import { isHttpUrl, resolveStashDir } from "./common";
|
|
|
4
4
|
import { loadConfig, loadUserConfig, saveConfig } from "./config";
|
|
5
5
|
import { UsageError } from "./errors";
|
|
6
6
|
import { akmIndex } from "./indexer";
|
|
7
|
+
import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
|
|
7
8
|
import { upsertLockEntry } from "./lockfile";
|
|
8
|
-
import { detectStashRoot, installRegistryRef, upsertInstalledRegistryEntry } from "./registry-install";
|
|
9
9
|
import { parseRegistryRef } from "./registry-resolve";
|
|
10
|
+
import { detectStashRoot } from "./stash-providers/provider-utils";
|
|
11
|
+
import { syncFromRef } from "./stash-providers/sync-from-ref";
|
|
10
12
|
import { ensureWebsiteMirror, validateWebsiteInputUrl } from "./stash-providers/website";
|
|
11
13
|
import { warn } from "./warn";
|
|
12
14
|
import { ensureWikiNameAvailable, validateWikiName } from "./wiki";
|
|
@@ -15,7 +17,7 @@ export async function akmAdd(input) {
|
|
|
15
17
|
const ref = input.ref.trim();
|
|
16
18
|
if (!ref)
|
|
17
19
|
throw new UsageError("Install ref or local directory is required. " +
|
|
18
|
-
"Examples: `akm add @scope/
|
|
20
|
+
"Examples: `akm add @scope/stash`, `akm add github:owner/repo`, `akm add ./local/path`");
|
|
19
21
|
// Validate and resolve wiki name when --type wiki is used
|
|
20
22
|
let wikiName;
|
|
21
23
|
if (input.overrideType) {
|
|
@@ -45,7 +47,7 @@ export async function akmAdd(input) {
|
|
|
45
47
|
catch {
|
|
46
48
|
// Not a local ref — fall through to registry install
|
|
47
49
|
}
|
|
48
|
-
return
|
|
50
|
+
return addRegistryStash(ref, stashDir, input.trustThisInstall, input.writable, wikiName);
|
|
49
51
|
}
|
|
50
52
|
export async function registerWikiSource(input) {
|
|
51
53
|
const stashDir = resolveStashDir();
|
|
@@ -173,34 +175,59 @@ async function addWebsiteStashSource(ref, stashDir, name, options, wikiName) {
|
|
|
173
175
|
};
|
|
174
176
|
}
|
|
175
177
|
/**
|
|
176
|
-
* Install a
|
|
178
|
+
* Install a stash from a registry (npm, github, git) by dispatching to the
|
|
179
|
+
* matching syncable provider, then running the post-sync install audit and
|
|
180
|
+
* persisting the lock entry.
|
|
177
181
|
*/
|
|
178
|
-
async function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
182
|
+
async function addRegistryStash(ref, stashDir, trustThisInstall, writable, wikiName) {
|
|
183
|
+
// Pre-sync registry-policy enforcement uses just the parsed ref (no fetch needed),
|
|
184
|
+
// so we keep parity with the historical behavior where `enforceRegistryInstallPolicy`
|
|
185
|
+
// ran before `extractTarGzSecure` etc.
|
|
186
|
+
const config = loadConfig();
|
|
187
|
+
const synced = await syncFromRef(ref, { trustThisInstall, writable });
|
|
188
|
+
const registryLabels = deriveRegistryLabels({
|
|
189
|
+
source: synced.source,
|
|
190
|
+
ref: synced.ref,
|
|
191
|
+
artifactUrl: synced.artifactUrl,
|
|
192
|
+
});
|
|
193
|
+
enforceRegistryInstallPolicy(registryLabels, config, ref);
|
|
194
|
+
// Post-sync hook: install audit. Throws when blocked unless `--trust` is set
|
|
195
|
+
// (in which case the audit report still surfaces in the response).
|
|
196
|
+
const audit = auditInstallCandidate({
|
|
197
|
+
rootDir: synced.extractedDir,
|
|
198
|
+
source: synced.source,
|
|
199
|
+
ref: synced.ref,
|
|
200
|
+
registryLabels,
|
|
201
|
+
config,
|
|
202
|
+
trustThisInstall,
|
|
203
|
+
});
|
|
204
|
+
if (audit.blocked) {
|
|
205
|
+
throw new Error(formatInstallAuditFailure(synced.ref, audit));
|
|
206
|
+
}
|
|
207
|
+
const replaced = (loadConfig().installed ?? []).find((entry) => entry.id === synced.id);
|
|
208
|
+
const updatedConfig = upsertInstalledRegistryEntry({
|
|
209
|
+
id: synced.id,
|
|
210
|
+
source: synced.source,
|
|
211
|
+
ref: synced.ref,
|
|
212
|
+
artifactUrl: synced.artifactUrl,
|
|
213
|
+
resolvedVersion: synced.resolvedVersion,
|
|
214
|
+
resolvedRevision: synced.resolvedRevision,
|
|
215
|
+
stashRoot: synced.contentDir,
|
|
216
|
+
cacheDir: synced.cacheDir,
|
|
217
|
+
installedAt: synced.syncedAt,
|
|
218
|
+
writable: synced.writable,
|
|
192
219
|
...(wikiName ? { wikiName } : {}),
|
|
193
220
|
});
|
|
194
221
|
await upsertLockEntry({
|
|
195
|
-
id:
|
|
196
|
-
source:
|
|
197
|
-
ref:
|
|
198
|
-
resolvedVersion:
|
|
199
|
-
resolvedRevision:
|
|
200
|
-
integrity:
|
|
222
|
+
id: synced.id,
|
|
223
|
+
source: synced.source,
|
|
224
|
+
ref: synced.ref,
|
|
225
|
+
resolvedVersion: synced.resolvedVersion,
|
|
226
|
+
resolvedRevision: synced.resolvedRevision,
|
|
227
|
+
integrity: synced.integrity,
|
|
201
228
|
});
|
|
202
229
|
// Clean up old cache directory on re-install
|
|
203
|
-
if (replaced && replaced.cacheDir !==
|
|
230
|
+
if (replaced && replaced.cacheDir !== synced.cacheDir) {
|
|
204
231
|
try {
|
|
205
232
|
fs.rmSync(replaced.cacheDir, { recursive: true, force: true });
|
|
206
233
|
}
|
|
@@ -214,21 +241,21 @@ async function addRegistryKit(ref, stashDir, trustThisInstall, writable, wikiNam
|
|
|
214
241
|
stashDir,
|
|
215
242
|
ref,
|
|
216
243
|
installed: {
|
|
217
|
-
id:
|
|
218
|
-
source:
|
|
219
|
-
ref:
|
|
220
|
-
artifactUrl:
|
|
221
|
-
resolvedVersion:
|
|
222
|
-
resolvedRevision:
|
|
223
|
-
stashRoot:
|
|
224
|
-
cacheDir:
|
|
225
|
-
extractedDir:
|
|
226
|
-
installedAt:
|
|
227
|
-
audit
|
|
244
|
+
id: synced.id,
|
|
245
|
+
source: synced.source,
|
|
246
|
+
ref: synced.ref,
|
|
247
|
+
artifactUrl: synced.artifactUrl,
|
|
248
|
+
resolvedVersion: synced.resolvedVersion,
|
|
249
|
+
resolvedRevision: synced.resolvedRevision,
|
|
250
|
+
stashRoot: synced.contentDir,
|
|
251
|
+
cacheDir: synced.cacheDir,
|
|
252
|
+
extractedDir: synced.extractedDir,
|
|
253
|
+
installedAt: synced.syncedAt,
|
|
254
|
+
audit,
|
|
228
255
|
},
|
|
229
256
|
config: {
|
|
230
|
-
stashCount:
|
|
231
|
-
installedKitCount:
|
|
257
|
+
stashCount: updatedConfig.stashes?.length ?? 0,
|
|
258
|
+
installedKitCount: updatedConfig.installed?.length ?? 0,
|
|
232
259
|
},
|
|
233
260
|
index: {
|
|
234
261
|
mode: index.mode,
|
|
@@ -239,6 +266,35 @@ async function addRegistryKit(ref, stashDir, trustThisInstall, writable, wikiNam
|
|
|
239
266
|
},
|
|
240
267
|
};
|
|
241
268
|
}
|
|
269
|
+
/** Persist or replace an installed stash entry in the user config. */
|
|
270
|
+
export function upsertInstalledRegistryEntry(entry) {
|
|
271
|
+
const current = loadUserConfig();
|
|
272
|
+
const currentInstalled = current.installed ?? [];
|
|
273
|
+
const withoutExisting = currentInstalled.filter((item) => item.id !== entry.id);
|
|
274
|
+
const nextInstalled = [...withoutExisting, normalizeInstalledEntry(entry)];
|
|
275
|
+
const nextConfig = { ...current, installed: nextInstalled };
|
|
276
|
+
saveConfig(nextConfig);
|
|
277
|
+
return nextConfig;
|
|
278
|
+
}
|
|
279
|
+
/** Remove an installed stash entry from the user config. */
|
|
280
|
+
export function removeInstalledRegistryEntry(id) {
|
|
281
|
+
const current = loadUserConfig();
|
|
282
|
+
const currentInstalled = current.installed ?? [];
|
|
283
|
+
const nextInstalled = currentInstalled.filter((item) => item.id !== id);
|
|
284
|
+
const nextConfig = {
|
|
285
|
+
...current,
|
|
286
|
+
installed: nextInstalled.length > 0 ? nextInstalled : undefined,
|
|
287
|
+
};
|
|
288
|
+
saveConfig(nextConfig);
|
|
289
|
+
return nextConfig;
|
|
290
|
+
}
|
|
291
|
+
function normalizeInstalledEntry(entry) {
|
|
292
|
+
return {
|
|
293
|
+
...entry,
|
|
294
|
+
stashRoot: path.resolve(entry.stashRoot),
|
|
295
|
+
cacheDir: path.resolve(entry.cacheDir),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
242
298
|
function toReadableId(resolvedPath) {
|
|
243
299
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
244
300
|
if (home && resolvedPath.startsWith(home + path.sep)) {
|
package/dist/stash-clone.js
CHANGED
|
@@ -3,8 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { TYPE_DIRS } from "./asset-spec";
|
|
4
4
|
import { UsageError } from "./errors";
|
|
5
5
|
import { isRemoteOrigin, resolveSourcesForOrigin } from "./origin-resolve";
|
|
6
|
-
import { installRegistryRef } from "./registry-install";
|
|
7
6
|
import { findSourceForPath, getPrimarySource, resolveStashSources } from "./search-source";
|
|
7
|
+
import { syncFromRef } from "./stash-providers/sync-from-ref";
|
|
8
8
|
import { makeAssetRef, parseAssetRef } from "./stash-ref";
|
|
9
9
|
import { resolveAssetPath } from "./stash-resolve";
|
|
10
10
|
export async function akmClone(options) {
|
|
@@ -31,16 +31,16 @@ export async function akmClone(options) {
|
|
|
31
31
|
// Remote fetch fallback: if no local source matched and origin looks remote, fetch it
|
|
32
32
|
let remoteFetched;
|
|
33
33
|
if (searchSources.length === 0 && parsed.origin && isRemoteOrigin(parsed.origin, allSources)) {
|
|
34
|
-
const installResult = await
|
|
34
|
+
const installResult = await syncFromRef(parsed.origin);
|
|
35
35
|
const syntheticSource = {
|
|
36
|
-
path: installResult.
|
|
36
|
+
path: installResult.contentDir,
|
|
37
37
|
registryId: installResult.id,
|
|
38
38
|
};
|
|
39
39
|
searchSources = [syntheticSource];
|
|
40
40
|
allSources = [...allSources, syntheticSource];
|
|
41
41
|
remoteFetched = {
|
|
42
42
|
origin: parsed.origin,
|
|
43
|
-
stashRoot: installResult.
|
|
43
|
+
stashRoot: installResult.contentDir,
|
|
44
44
|
cacheDir: installResult.cacheDir,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* factory functions that create StashProvider instances from a StashConfigEntry.
|
|
6
6
|
*
|
|
7
7
|
* "Stash providers" are runtime data sources for the search and show commands —
|
|
8
|
-
* distinct from the
|
|
9
|
-
* installed-
|
|
8
|
+
* distinct from the stash-discovery registries (registry-factory.ts) and the
|
|
9
|
+
* installed-stash operations (installed-stashes.ts).
|
|
10
10
|
*/
|
|
11
11
|
import { createProviderRegistry } from "./create-provider-registry";
|
|
12
12
|
// ── Factory map ─────────────────────────────────────────────────────────────
|
package/dist/stash-provider.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { resolveStashDir } from "../common";
|
|
2
2
|
import { loadConfig } from "../config";
|
|
3
|
-
import { searchLocal } from "../
|
|
3
|
+
import { searchLocal } from "../db-search";
|
|
4
|
+
import { ConfigError } from "../errors";
|
|
4
5
|
import { resolveStashSources } from "../search-source";
|
|
5
6
|
import { registerStashProvider } from "../stash-provider-factory";
|
|
6
7
|
import { showLocal } from "../stash-show";
|
|
8
|
+
import { detectStashRoot } from "./provider-utils";
|
|
7
9
|
class FilesystemStashProvider {
|
|
8
10
|
type = "filesystem";
|
|
11
|
+
kind = "syncable";
|
|
9
12
|
name;
|
|
10
13
|
stashDir;
|
|
11
14
|
constructor(entry) {
|
|
@@ -36,6 +39,33 @@ class FilesystemStashProvider {
|
|
|
36
39
|
canShow(ref) {
|
|
37
40
|
return !ref.includes("://");
|
|
38
41
|
}
|
|
42
|
+
/** No-op: a filesystem stash already lives on disk. */
|
|
43
|
+
async sync(config, options) {
|
|
44
|
+
if (!config.path) {
|
|
45
|
+
throw new ConfigError("filesystem stash entry must include a `path`");
|
|
46
|
+
}
|
|
47
|
+
const stashRoot = detectStashRoot(config.path);
|
|
48
|
+
const syncedAt = (options?.now ?? new Date()).toISOString();
|
|
49
|
+
return {
|
|
50
|
+
id: stashRoot,
|
|
51
|
+
source: "local",
|
|
52
|
+
ref: stashRoot,
|
|
53
|
+
artifactUrl: stashRoot,
|
|
54
|
+
contentDir: stashRoot,
|
|
55
|
+
cacheDir: stashRoot,
|
|
56
|
+
extractedDir: stashRoot,
|
|
57
|
+
syncedAt,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
getContentDir(config) {
|
|
61
|
+
if (!config.path) {
|
|
62
|
+
throw new ConfigError("filesystem stash entry must include a `path`");
|
|
63
|
+
}
|
|
64
|
+
return config.path;
|
|
65
|
+
}
|
|
66
|
+
async remove(_config) {
|
|
67
|
+
// Filesystem stashes are user-managed; never delete the source on `akm remove`.
|
|
68
|
+
}
|
|
39
69
|
}
|
|
40
70
|
// ── Self-register ───────────────────────────────────────────────────────────
|
|
41
71
|
registerStashProvider("filesystem", (config) => new FilesystemStashProvider(config));
|
|
@@ -5,19 +5,27 @@ import path from "node:path";
|
|
|
5
5
|
import { resolveStashDir } from "../common";
|
|
6
6
|
import { loadConfig } from "../config";
|
|
7
7
|
import { ConfigError, UsageError } from "../errors";
|
|
8
|
-
import { getRegistryIndexCacheDir } from "../paths";
|
|
9
|
-
import { validateGitUrl } from "../registry-resolve";
|
|
8
|
+
import { getRegistryCacheDir, getRegistryIndexCacheDir } from "../paths";
|
|
9
|
+
import { parseRegistryRef, resolveRegistryArtifact, validateGitRef, validateGitUrl } from "../registry-resolve";
|
|
10
10
|
import { registerStashProvider } from "../stash-provider-factory";
|
|
11
|
-
import { isExpired, sanitizeString } from "./provider-utils";
|
|
11
|
+
import { applyAkmIncludeConfig, buildInstallCacheDir, copyDirectoryContents, detectStashRoot, isDirectory, isExpired, sanitizeString, } from "./provider-utils";
|
|
12
12
|
/** Cache TTL before refreshing the mirrored repo (12 hours). */
|
|
13
13
|
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
14
14
|
/** Maximum stale age allowed when refresh fails (7 days). */
|
|
15
15
|
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
-
const GIT_STASH_TYPES = new Set(["git"
|
|
16
|
+
const GIT_STASH_TYPES = new Set(["git"]);
|
|
17
|
+
/**
|
|
18
|
+
* Git stash provider. Implements {@link SyncableStashProvider} (which extends
|
|
19
|
+
* the LiveStashProvider surface) — the indexer walks the cloned repo, and
|
|
20
|
+
* `sync()` clones/pulls into a local mirror.
|
|
21
|
+
*/
|
|
17
22
|
class GitStashProvider {
|
|
18
23
|
type = "git";
|
|
24
|
+
kind = "syncable";
|
|
19
25
|
name;
|
|
26
|
+
config;
|
|
20
27
|
constructor(config) {
|
|
28
|
+
this.config = config;
|
|
21
29
|
this.name = config.name ?? "git";
|
|
22
30
|
}
|
|
23
31
|
/** Content is indexed through the standard FTS5 pipeline. */
|
|
@@ -32,15 +40,68 @@ class GitStashProvider {
|
|
|
32
40
|
canShow(_ref) {
|
|
33
41
|
return false;
|
|
34
42
|
}
|
|
43
|
+
async sync(config, options) {
|
|
44
|
+
// Two execution modes:
|
|
45
|
+
// 1. Long-lived configured stash (config.url) — mirror into the
|
|
46
|
+
// registry-index cache and serve as a read-only working tree.
|
|
47
|
+
// 2. One-shot install ref (config.options.ref like "git:..." / "github:...") —
|
|
48
|
+
// run the historical clone-into-cache flow used by `akm add`.
|
|
49
|
+
if (typeof config.options?.ref === "string" && config.options.ref) {
|
|
50
|
+
return syncRegistryGitRef(String(config.options.ref), options);
|
|
51
|
+
}
|
|
52
|
+
return syncMirroredRepo(config, options);
|
|
53
|
+
}
|
|
54
|
+
getContentDir(config) {
|
|
55
|
+
if (config.path)
|
|
56
|
+
return config.path;
|
|
57
|
+
if (config.url) {
|
|
58
|
+
const repo = parseGitRepoUrl(config.url);
|
|
59
|
+
return getCachePaths(repo.canonicalUrl).repoDir;
|
|
60
|
+
}
|
|
61
|
+
throw new ConfigError("git stash entry must have either `path` or `url`");
|
|
62
|
+
}
|
|
63
|
+
async remove(config) {
|
|
64
|
+
if (config.path && isDirectory(config.path)) {
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(path.dirname(config.path), { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* best-effort */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (config.url) {
|
|
73
|
+
const repo = parseGitRepoUrl(config.url);
|
|
74
|
+
const paths = getCachePaths(repo.canonicalUrl);
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(paths.rootDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* best-effort */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
35
83
|
}
|
|
36
84
|
// ── Self-register ───────────────────────────────────────────────────────────
|
|
37
85
|
registerStashProvider("git", (config) => new GitStashProvider(config));
|
|
38
|
-
registerStashProvider("context-hub", (config) => new GitStashProvider(config));
|
|
39
|
-
registerStashProvider("github", (config) => new GitStashProvider(config));
|
|
40
86
|
// ── Cache management ────────────────────────────────────────────────────────
|
|
41
87
|
function getCachePaths(repoUrl) {
|
|
42
88
|
const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
|
|
43
|
-
const
|
|
89
|
+
const cacheRoot = getRegistryIndexCacheDir();
|
|
90
|
+
const rootDir = path.join(cacheRoot, `git-${key}`);
|
|
91
|
+
// One-time silent migration: legacy `context-hub-${key}` directories were
|
|
92
|
+
// created for ALL git stashes (not just the andrewyng/context-hub repo). If
|
|
93
|
+
// the new path doesn't yet exist but the legacy one does, rename it in place
|
|
94
|
+
// so existing clones aren't silently invalidated. Failures are non-fatal —
|
|
95
|
+
// worst case the repo is re-cloned on the next refresh.
|
|
96
|
+
try {
|
|
97
|
+
const legacyRootDir = path.join(cacheRoot, `context-hub-${key}`);
|
|
98
|
+
if (!fs.existsSync(rootDir) && fs.existsSync(legacyRootDir)) {
|
|
99
|
+
fs.renameSync(legacyRootDir, rootDir);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* migration is best-effort */
|
|
104
|
+
}
|
|
44
105
|
return {
|
|
45
106
|
rootDir,
|
|
46
107
|
repoDir: path.join(rootDir, "repo"),
|
|
@@ -50,6 +111,7 @@ function getCachePaths(repoUrl) {
|
|
|
50
111
|
async function ensureGitMirror(repo, cachePaths, options) {
|
|
51
112
|
const requireRepoDir = options?.requireRepoDir === true;
|
|
52
113
|
const writable = options?.writable === true;
|
|
114
|
+
const force = options?.force === true;
|
|
53
115
|
// Check if cache is fresh
|
|
54
116
|
let mtime = 0;
|
|
55
117
|
try {
|
|
@@ -58,7 +120,7 @@ async function ensureGitMirror(repo, cachePaths, options) {
|
|
|
58
120
|
catch {
|
|
59
121
|
/* no cached index */
|
|
60
122
|
}
|
|
61
|
-
if (mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
123
|
+
if (!force && mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
62
124
|
return;
|
|
63
125
|
}
|
|
64
126
|
try {
|
|
@@ -80,6 +142,145 @@ async function ensureGitMirror(repo, cachePaths, options) {
|
|
|
80
142
|
throw err;
|
|
81
143
|
}
|
|
82
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Sync mode for a long-lived configured git stash. Mirrors the repo into the
|
|
147
|
+
* shared registry-index cache (12h TTL) and exposes the working tree as the
|
|
148
|
+
* stash content directory.
|
|
149
|
+
*/
|
|
150
|
+
async function syncMirroredRepo(config, options) {
|
|
151
|
+
if (!config.url) {
|
|
152
|
+
throw new ConfigError("git stash entry requires a URL when no install ref is supplied");
|
|
153
|
+
}
|
|
154
|
+
const repo = parseGitRepoUrl(config.url);
|
|
155
|
+
const cachePaths = getCachePaths(repo.canonicalUrl);
|
|
156
|
+
await ensureGitMirror(repo, cachePaths, {
|
|
157
|
+
requireRepoDir: true,
|
|
158
|
+
writable: options?.writable ?? config.writable === true,
|
|
159
|
+
force: options?.force,
|
|
160
|
+
});
|
|
161
|
+
const syncedAt = (options?.now ?? new Date()).toISOString();
|
|
162
|
+
const contentDir = cachePaths.repoDir;
|
|
163
|
+
return {
|
|
164
|
+
id: repo.canonicalUrl,
|
|
165
|
+
source: "git",
|
|
166
|
+
ref: repo.canonicalUrl,
|
|
167
|
+
artifactUrl: repo.canonicalUrl,
|
|
168
|
+
contentDir,
|
|
169
|
+
cacheDir: cachePaths.rootDir,
|
|
170
|
+
extractedDir: contentDir,
|
|
171
|
+
writable: options?.writable ?? config.writable === true,
|
|
172
|
+
syncedAt,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Sync mode for a one-shot install ref (`akm add github:owner/repo` or
|
|
177
|
+
* `akm add git:url`). Runs the clone → strip → include-filter pipeline that
|
|
178
|
+
* historically lived in `installRegistryRef()`.
|
|
179
|
+
*/
|
|
180
|
+
export async function syncRegistryGitRef(ref, options) {
|
|
181
|
+
const parsed = parseRegistryRef(ref);
|
|
182
|
+
if (parsed.source === "github") {
|
|
183
|
+
const githubRef = {
|
|
184
|
+
source: "git",
|
|
185
|
+
ref: parsed.ref,
|
|
186
|
+
id: parsed.id,
|
|
187
|
+
url: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
|
|
188
|
+
requestedRef: parsed.requestedRef,
|
|
189
|
+
};
|
|
190
|
+
const result = await doSyncGit(githubRef, options);
|
|
191
|
+
return { ...result, source: "github" };
|
|
192
|
+
}
|
|
193
|
+
if (parsed.source !== "git") {
|
|
194
|
+
throw new UsageError(`syncRegistryGitRef requires a git: or github: ref, got "${ref}"`);
|
|
195
|
+
}
|
|
196
|
+
return doSyncGit(parsed, options);
|
|
197
|
+
}
|
|
198
|
+
async function doSyncGit(parsed, options) {
|
|
199
|
+
const resolved = await resolveRegistryArtifact(parsed);
|
|
200
|
+
const syncedAt = (options?.now ?? new Date()).toISOString();
|
|
201
|
+
const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheDir();
|
|
202
|
+
const cacheDir = buildInstallCacheDir(cacheRootDir, parsed.source, parsed.id, resolved.resolvedRevision);
|
|
203
|
+
const cloneDir = path.join(cacheDir, "clone");
|
|
204
|
+
const extractedDir = path.join(cacheDir, "extracted");
|
|
205
|
+
// Cache hit
|
|
206
|
+
if (!options?.force && isDirectory(extractedDir)) {
|
|
207
|
+
try {
|
|
208
|
+
const provisionalKitRoot = detectStashRoot(extractedDir);
|
|
209
|
+
const installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
|
|
210
|
+
const stashRoot = detectStashRoot(installRoot);
|
|
211
|
+
if (stashRoot) {
|
|
212
|
+
return {
|
|
213
|
+
id: resolved.id,
|
|
214
|
+
source: resolved.source,
|
|
215
|
+
ref: resolved.ref,
|
|
216
|
+
artifactUrl: resolved.artifactUrl,
|
|
217
|
+
resolvedVersion: resolved.resolvedVersion,
|
|
218
|
+
resolvedRevision: resolved.resolvedRevision,
|
|
219
|
+
contentDir: stashRoot,
|
|
220
|
+
cacheDir,
|
|
221
|
+
extractedDir,
|
|
222
|
+
writable: options?.writable,
|
|
223
|
+
syncedAt,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Cache invalid, re-clone
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
232
|
+
// Validate URL and ref before passing to git to prevent command injection
|
|
233
|
+
validateGitUrl(parsed.url);
|
|
234
|
+
if (parsed.requestedRef)
|
|
235
|
+
validateGitRef(parsed.requestedRef);
|
|
236
|
+
let provisionalKitRoot;
|
|
237
|
+
let installRoot;
|
|
238
|
+
let stashRoot;
|
|
239
|
+
try {
|
|
240
|
+
const cloneArgs = ["clone", "--depth", "1"];
|
|
241
|
+
if (parsed.requestedRef) {
|
|
242
|
+
cloneArgs.push("--branch", parsed.requestedRef);
|
|
243
|
+
}
|
|
244
|
+
cloneArgs.push(parsed.url, cloneDir);
|
|
245
|
+
const cloneResult = spawnSync("git", cloneArgs, { encoding: "utf8", timeout: 120_000 });
|
|
246
|
+
if (cloneResult.status !== 0) {
|
|
247
|
+
const err = cloneResult.stderr?.trim() || cloneResult.error?.message || "unknown error";
|
|
248
|
+
throw new Error(`Failed to clone ${parsed.url}: ${err}`);
|
|
249
|
+
}
|
|
250
|
+
// Copy contents to extracted dir without .git
|
|
251
|
+
fs.mkdirSync(extractedDir, { recursive: true });
|
|
252
|
+
copyDirectoryContents(cloneDir, extractedDir);
|
|
253
|
+
// Clean up the clone dir
|
|
254
|
+
fs.rmSync(cloneDir, { recursive: true, force: true });
|
|
255
|
+
provisionalKitRoot = detectStashRoot(extractedDir);
|
|
256
|
+
installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
|
|
257
|
+
stashRoot = detectStashRoot(installRoot);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
// Clean up the cache directory so stale or partially-cloned artifacts
|
|
261
|
+
// don't cause false cache hits on the next install attempt.
|
|
262
|
+
try {
|
|
263
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* best-effort */
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
id: resolved.id,
|
|
272
|
+
source: resolved.source,
|
|
273
|
+
ref: resolved.ref,
|
|
274
|
+
artifactUrl: resolved.artifactUrl,
|
|
275
|
+
resolvedVersion: resolved.resolvedVersion,
|
|
276
|
+
resolvedRevision: resolved.resolvedRevision,
|
|
277
|
+
contentDir: stashRoot,
|
|
278
|
+
cacheDir,
|
|
279
|
+
extractedDir,
|
|
280
|
+
writable: options?.writable,
|
|
281
|
+
syncedAt,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
83
284
|
export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
|
|
84
285
|
// Stage the clone into a sibling temp dir so that a failed clone never
|
|
85
286
|
// destroys a previously-valid destDir (e.g. when the remote is temporarily
|