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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -5
  2. package/dist/asset-registry.js +29 -5
  3. package/dist/asset-spec.js +12 -5
  4. package/dist/cli-hints.js +300 -0
  5. package/dist/cli.js +218 -1357
  6. package/dist/common.js +147 -50
  7. package/dist/config.js +224 -13
  8. package/dist/create-provider-registry.js +1 -1
  9. package/dist/curate.js +258 -0
  10. package/dist/{local-search.js → db-search.js} +30 -19
  11. package/dist/db.js +168 -62
  12. package/dist/embedder.js +49 -273
  13. package/dist/embedders/cache.js +47 -0
  14. package/dist/embedders/local.js +152 -0
  15. package/dist/embedders/remote.js +121 -0
  16. package/dist/embedders/types.js +39 -0
  17. package/dist/errors.js +14 -3
  18. package/dist/frontmatter.js +61 -7
  19. package/dist/indexer.js +38 -7
  20. package/dist/info.js +2 -2
  21. package/dist/install-audit.js +16 -1
  22. package/dist/{installed-kits.js → installed-stashes.js} +48 -22
  23. package/dist/llm-client.js +92 -0
  24. package/dist/llm.js +14 -126
  25. package/dist/lockfile.js +28 -1
  26. package/dist/matchers.js +1 -1
  27. package/dist/metadata-enhance.js +53 -0
  28. package/dist/migration-help.js +75 -44
  29. package/dist/output-context.js +77 -0
  30. package/dist/output-shapes.js +198 -0
  31. package/dist/output-text.js +520 -0
  32. package/dist/paths.js +4 -4
  33. package/dist/providers/index.js +11 -0
  34. package/dist/providers/skills-sh.js +1 -1
  35. package/dist/providers/static-index.js +47 -45
  36. package/dist/registry-build-index.js +36 -29
  37. package/dist/registry-factory.js +2 -2
  38. package/dist/registry-resolve.js +8 -4
  39. package/dist/registry-search.js +62 -5
  40. package/dist/remember.js +172 -0
  41. package/dist/renderers.js +52 -0
  42. package/dist/search-source.js +73 -42
  43. package/dist/setup-steps.js +45 -0
  44. package/dist/setup.js +149 -76
  45. package/dist/stash-add.js +94 -38
  46. package/dist/stash-clone.js +4 -4
  47. package/dist/stash-provider-factory.js +2 -2
  48. package/dist/stash-provider.js +3 -1
  49. package/dist/stash-providers/filesystem.js +31 -1
  50. package/dist/stash-providers/git.js +209 -8
  51. package/dist/stash-providers/index.js +1 -0
  52. package/dist/stash-providers/npm.js +159 -0
  53. package/dist/stash-providers/provider-utils.js +162 -0
  54. package/dist/stash-providers/sync-from-ref.js +45 -0
  55. package/dist/stash-providers/tar-utils.js +151 -0
  56. package/dist/stash-providers/website.js +80 -4
  57. package/dist/stash-resolve.js +5 -5
  58. package/dist/stash-search.js +4 -4
  59. package/dist/stash-show.js +3 -3
  60. package/dist/wiki.js +6 -6
  61. package/dist/workflow-authoring.js +12 -4
  62. package/dist/workflow-markdown.js +9 -0
  63. package/dist/workflow-runs.js +12 -2
  64. package/docs/README.md +30 -0
  65. package/docs/migration/release-notes/0.0.13.md +4 -0
  66. package/docs/migration/release-notes/0.1.0.md +6 -0
  67. package/docs/migration/release-notes/0.2.0.md +6 -0
  68. package/docs/migration/release-notes/0.3.0.md +5 -0
  69. package/docs/migration/release-notes/0.5.0.md +6 -0
  70. package/docs/migration/release-notes/0.6.0.md +29 -0
  71. package/docs/migration/release-notes/README.md +21 -0
  72. package/package.json +3 -2
  73. package/dist/registry-install.js +0 -532
  74. /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/kit`, `akm add github:owner/repo`, `akm add ./local/path`");
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 addRegistryKit(ref, stashDir, input.trustThisInstall, input.writable, wikiName);
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 kit from a registry (npm, github, git).
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 addRegistryKit(ref, stashDir, trustThisInstall, writable, wikiName) {
179
- const installed = await installRegistryRef(ref, { trustThisInstall, writable });
180
- const replaced = (loadConfig().installed ?? []).find((entry) => entry.id === installed.id);
181
- const config = upsertInstalledRegistryEntry({
182
- id: installed.id,
183
- source: installed.source,
184
- ref: installed.ref,
185
- artifactUrl: installed.artifactUrl,
186
- resolvedVersion: installed.resolvedVersion,
187
- resolvedRevision: installed.resolvedRevision,
188
- stashRoot: installed.stashRoot,
189
- cacheDir: installed.cacheDir,
190
- installedAt: installed.installedAt,
191
- writable: installed.writable,
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: installed.id,
196
- source: installed.source,
197
- ref: installed.ref,
198
- resolvedVersion: installed.resolvedVersion,
199
- resolvedRevision: installed.resolvedRevision,
200
- integrity: installed.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 !== installed.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: installed.id,
218
- source: installed.source,
219
- ref: installed.ref,
220
- artifactUrl: installed.artifactUrl,
221
- resolvedVersion: installed.resolvedVersion,
222
- resolvedRevision: installed.resolvedRevision,
223
- stashRoot: installed.stashRoot,
224
- cacheDir: installed.cacheDir,
225
- extractedDir: installed.extractedDir,
226
- installedAt: installed.installedAt,
227
- audit: installed.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: config.stashes?.length ?? 0,
231
- installedKitCount: config.installed?.length ?? 0,
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)) {
@@ -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 installRegistryRef(parsed.origin);
34
+ const installResult = await syncFromRef(parsed.origin);
35
35
  const syntheticSource = {
36
- path: installResult.stashRoot,
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.stashRoot,
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 kit-discovery registries (registry-factory.ts) and the
9
- * installed-kit operations (installed-kits.ts).
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 ─────────────────────────────────────────────────────────────
@@ -1 +1,3 @@
1
- export {};
1
+ export function isSyncable(p) {
2
+ return p.kind === "syncable";
3
+ }
@@ -1,11 +1,14 @@
1
1
  import { resolveStashDir } from "../common";
2
2
  import { loadConfig } from "../config";
3
- import { searchLocal } from "../local-search";
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", "context-hub", "github"]);
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 rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
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
@@ -7,5 +7,6 @@
7
7
  */
8
8
  import "./filesystem";
9
9
  import "./git";
10
+ import "./npm";
10
11
  import "./openviking";
11
12
  import "./website";