akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -22,13 +22,14 @@ import { backupExistingConfig } from "../core/config/config-io.js";
22
22
  import { ConfigError, UsageError } from "../core/errors.js";
23
23
  import { assertSafeStashDir, getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js";
24
24
  import { warn } from "../core/warn.js";
25
- import { closeDatabase, isVecAvailable, openDatabase } from "../indexer/db/db.js";
25
+ import { closeDatabase, isVecAvailable, openIndexDatabase } from "../indexer/db/db.js";
26
26
  import { akmIndex } from "../indexer/indexer.js";
27
27
  import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "../indexer/search/semantic-status.js";
28
28
  import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent/index.js";
29
29
  import { defaultProfileName, v1ProfilePlatform } from "../integrations/harnesses/index.js";
30
30
  import { probeLlmCapabilities } from "../llm/client.js";
31
31
  import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "../llm/embedder.js";
32
+ import { getOutputMode } from "../output/context.js";
32
33
  import { getDirname, spawn } from "../runtime.js";
33
34
  import { saveGitStash } from "../sources/providers/git.js";
34
35
  import { backendNameForPlatform } from "../tasks/backends/index.js";
@@ -461,7 +462,7 @@ async function prepareSemanticSearchAssets(config) {
461
462
  let probeDir;
462
463
  try {
463
464
  probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-setup-vec-probe-"));
464
- db = openDatabase(path.join(probeDir, "probe.db"), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
465
+ db = openIndexDatabase(path.join(probeDir, "probe.db"), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
465
466
  if (isVecAvailable(db)) {
466
467
  p.log.info("sqlite-vec is available for fast vector search.");
467
468
  }
@@ -1791,7 +1792,7 @@ export async function runSetupWizard(opts) {
1791
1792
  // Bootstrap directory structure before any prompts so the stash exists
1792
1793
  // even if the wizard is interrupted after this point.
1793
1794
  if (!opts?.noInit) {
1794
- await akmInit({ dir: resolvedStashDir });
1795
+ await akmInit({ dir: resolvedStashDir, setDefault: true });
1795
1796
  }
1796
1797
  // Quick connectivity check — skip network-dependent steps when offline
1797
1798
  const online = await isOnline();
@@ -1967,11 +1968,31 @@ export async function runSetupWizard(opts) {
1967
1968
  */
1968
1969
  function backupAndAnnounce(configPath) {
1969
1970
  const result = backupExistingConfig(configPath);
1970
- if (result) {
1971
- p.log.info(`Config backed up to ${result.timestamped}`);
1971
+ const message = result ? `Config backed up to ${result.timestamped}` : "No existing config to back up.";
1972
+ // In JSON output mode the structured envelope (which already carries
1973
+ // `configPath`) MUST be the only thing on stdout — `setup --yes | jq` is a
1974
+ // supported scripting contract. @clack's `p.log.info` writes to stdout, which
1975
+ // would corrupt that envelope, so route this human-progress notice to stderr
1976
+ // when emitting JSON. Interactive/text runs keep the inline clack banner.
1977
+ if (isJsonOutputMode()) {
1978
+ process.stderr.write(`${message}\n`);
1972
1979
  }
1973
1980
  else {
1974
- p.log.info("No existing config to back up.");
1981
+ p.log.info(message);
1982
+ }
1983
+ }
1984
+ /**
1985
+ * True when the process-level output mode is JSON (the default machine format).
1986
+ * Defensive: setup is also invoked programmatically (tests) where the output
1987
+ * mode singleton may not be initialized — treat that as "not JSON" so the
1988
+ * human-readable clack banner is used.
1989
+ */
1990
+ function isJsonOutputMode() {
1991
+ try {
1992
+ return getOutputMode().format === "json";
1993
+ }
1994
+ catch {
1995
+ return false;
1975
1996
  }
1976
1997
  }
1977
1998
  /**
@@ -1986,7 +2007,7 @@ export async function runSetupWithDefaults(opts) {
1986
2007
  // Bootstrap directory structure first
1987
2008
  let initResult;
1988
2009
  if (!opts.noInit) {
1989
- initResult = await akmInit({ dir: stashDir });
2010
+ initResult = await akmInit({ dir: stashDir, setDefault: true });
1990
2011
  }
1991
2012
  // Run steps in non-interactive mode (applies defaults, skips prompts)
1992
2013
  const ctx = createSetupContext(current, { nonInteractive: true });
@@ -2283,7 +2304,7 @@ export async function runSetupFromConfig(opts) {
2283
2304
  // Bootstrap directory structure
2284
2305
  let initResult;
2285
2306
  if (!opts.noInit) {
2286
- initResult = await akmInit({ dir: stashDir });
2307
+ initResult = await akmInit({ dir: stashDir, setDefault: true });
2287
2308
  }
2288
2309
  // Optional probe
2289
2310
  const mergedLlm = getDefaultLlmConfig(merged);
@@ -101,10 +101,14 @@ function copyDirectoryContents(sourceDir, destinationDir) {
101
101
  }
102
102
  }
103
103
  function copyPath(sourcePath, destinationPath) {
104
- const stat = fs.statSync(sourcePath);
104
+ const stat = fs.lstatSync(sourcePath);
105
+ if (stat.isSymbolicLink()) {
106
+ throw new Error(`Path in akm.include must not be a symlink: ${sourcePath}`);
107
+ }
105
108
  fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
106
109
  if (stat.isDirectory()) {
107
- fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
110
+ fs.mkdirSync(destinationPath, { recursive: true });
111
+ copyDirectoryContents(sourcePath, destinationPath);
108
112
  return;
109
113
  }
110
114
  fs.copyFileSync(sourcePath, destinationPath);
@@ -23,7 +23,6 @@ registerSourceProvider("filesystem", (entry) => {
23
23
  return {
24
24
  kind: "filesystem",
25
25
  name,
26
- async init(_ctx) { },
27
26
  path() {
28
27
  return stashDir;
29
28
  },
@@ -0,0 +1,210 @@
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 { spawnSync } from "node:child_process";
5
+ import { randomBytes } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { UsageError } from "../../core/errors.js";
9
+ import { getRegistryCacheDir } from "../../core/paths.js";
10
+ import { parseRegistryRef, resolveRegistryArtifact, validateGitRef, validateGitUrl } from "../../registry/resolve.js";
11
+ import { applyAkmIncludeConfig, buildInstallCacheDir, copyDirectoryContents, detectStashRoot, isDirectory, } from "./provider-utils.js";
12
+ /**
13
+ * Shared subprocess wrapper for `git` invocations. Disables git's interactive
14
+ * terminal prompt so a missing credential never hangs the process.
15
+ */
16
+ export function runGit(args, options) {
17
+ return spawnSync("git", args, {
18
+ encoding: "utf8",
19
+ ...options,
20
+ env: { ...process.env, ...options?.env, GIT_TERMINAL_PROMPT: "0" },
21
+ });
22
+ }
23
+ /**
24
+ * Sync mode for a one-shot install ref (`akm add github:owner/repo` or
25
+ * `akm add git:url`). Runs the clone → strip → include-filter pipeline that
26
+ * historically lived in `installRegistryRef()`.
27
+ */
28
+ export async function syncRegistryGitRef(ref, options) {
29
+ const parsed = parseRegistryRef(ref);
30
+ if (parsed.source === "github") {
31
+ const githubRef = {
32
+ source: "git",
33
+ ref: parsed.ref,
34
+ id: parsed.id,
35
+ url: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
36
+ requestedRef: parsed.requestedRef,
37
+ };
38
+ const result = await doSyncGit(githubRef, options);
39
+ return { ...result, source: "github" };
40
+ }
41
+ if (parsed.source !== "git") {
42
+ throw new UsageError(`syncRegistryGitRef requires a git: or github: ref, got "${ref}"`);
43
+ }
44
+ return doSyncGit(parsed, options);
45
+ }
46
+ async function doSyncGit(parsed, options) {
47
+ const resolved = await resolveRegistryArtifact(parsed);
48
+ const syncedAt = (options?.now ?? new Date()).toISOString();
49
+ const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheDir();
50
+ const cacheDir = buildInstallCacheDir(cacheRootDir, parsed.source, parsed.id, resolved.resolvedRevision);
51
+ const cloneDir = path.join(cacheDir, "clone");
52
+ const extractedDir = path.join(cacheDir, "extracted");
53
+ // Cache hit
54
+ if (!options?.force && isDirectory(extractedDir)) {
55
+ try {
56
+ const provisionalKitRoot = detectStashRoot(extractedDir);
57
+ const installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
58
+ const stashRoot = detectStashRoot(installRoot);
59
+ if (stashRoot) {
60
+ return {
61
+ id: resolved.id,
62
+ source: resolved.source,
63
+ ref: resolved.ref,
64
+ artifactUrl: resolved.artifactUrl,
65
+ resolvedVersion: resolved.resolvedVersion,
66
+ resolvedRevision: resolved.resolvedRevision,
67
+ contentDir: stashRoot,
68
+ cacheDir,
69
+ extractedDir,
70
+ writable: options?.writable,
71
+ syncedAt,
72
+ };
73
+ }
74
+ }
75
+ catch {
76
+ // Cache invalid, re-clone
77
+ }
78
+ }
79
+ fs.mkdirSync(cacheDir, { recursive: true });
80
+ // Validate URL and ref before passing to git to prevent command injection
81
+ validateGitUrl(parsed.url);
82
+ if (parsed.requestedRef)
83
+ validateGitRef(parsed.requestedRef);
84
+ let provisionalKitRoot;
85
+ let installRoot;
86
+ let stashRoot;
87
+ try {
88
+ const cloneArgs = ["clone", "--depth", "1"];
89
+ if (parsed.requestedRef) {
90
+ cloneArgs.push("--branch", parsed.requestedRef);
91
+ }
92
+ cloneArgs.push(parsed.url, cloneDir);
93
+ const cloneResult = runGit(cloneArgs, { timeout: 120_000 });
94
+ if (cloneResult.status !== 0) {
95
+ throw new Error(classifyCloneFailure(parsed.url, cloneResult.stderr, cloneResult.error));
96
+ }
97
+ // Copy contents to extracted dir without .git
98
+ fs.mkdirSync(extractedDir, { recursive: true });
99
+ copyDirectoryContents(cloneDir, extractedDir);
100
+ // Clean up the clone dir
101
+ fs.rmSync(cloneDir, { recursive: true, force: true });
102
+ provisionalKitRoot = detectStashRoot(extractedDir);
103
+ installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
104
+ stashRoot = detectStashRoot(installRoot);
105
+ }
106
+ catch (err) {
107
+ // Clean up the cache directory so stale or partially-cloned artifacts
108
+ // don't cause false cache hits on the next install attempt.
109
+ try {
110
+ fs.rmSync(cacheDir, { recursive: true, force: true });
111
+ }
112
+ catch {
113
+ /* best-effort */
114
+ }
115
+ throw err;
116
+ }
117
+ return {
118
+ id: resolved.id,
119
+ source: resolved.source,
120
+ ref: resolved.ref,
121
+ artifactUrl: resolved.artifactUrl,
122
+ resolvedVersion: resolved.resolvedVersion,
123
+ resolvedRevision: resolved.resolvedRevision,
124
+ contentDir: stashRoot,
125
+ cacheDir,
126
+ extractedDir,
127
+ writable: options?.writable,
128
+ syncedAt,
129
+ };
130
+ }
131
+ export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
132
+ // Stage the clone into a sibling temp dir so that a failed clone never
133
+ // destroys a previously-valid destDir (e.g. when the remote is temporarily
134
+ // unreachable and we have a valid cached copy).
135
+ const tmpDir = `${destDir}.tmp-${randomBytes(4).toString("hex")}`;
136
+ const args = ["clone", "--depth", "1"];
137
+ if (ref)
138
+ args.push("--branch", ref);
139
+ args.push(cloneUrl, tmpDir);
140
+ const result = runGit(args, { timeout: 120_000 });
141
+ if (result.status !== 0) {
142
+ // Clean up the (possibly partial) temp dir but leave destDir untouched.
143
+ fs.rmSync(tmpDir, { recursive: true, force: true });
144
+ throw new Error(classifyCloneFailure(cloneUrl, result.stderr, result.error));
145
+ }
146
+ try {
147
+ if (!writable) {
148
+ // Remove .git directory — we only need the working tree for read-only stashes
149
+ const gitDir = path.join(tmpDir, ".git");
150
+ if (fs.existsSync(gitDir))
151
+ fs.rmSync(gitDir, { recursive: true, force: true });
152
+ }
153
+ // Swap: remove the old destDir (if any) then atomically rename tmpDir into place.
154
+ if (fs.existsSync(destDir))
155
+ fs.rmSync(destDir, { recursive: true, force: true });
156
+ fs.renameSync(tmpDir, destDir);
157
+ }
158
+ catch (err) {
159
+ // Post-clone steps failed — clean up the temp dir to avoid orphaned dirs.
160
+ fs.rmSync(tmpDir, { recursive: true, force: true });
161
+ throw err;
162
+ }
163
+ }
164
+ // ── Clone-failure classification (#487) ─────────────────────────────────────
165
+ /**
166
+ * Translate git's stderr into an actionable message. Without this, a user
167
+ * who passes a nonexistent or private repo to `akm add` sees:
168
+ *
169
+ * "could not read Username for 'https://github.com': No such device or
170
+ * address"
171
+ *
172
+ * That is git falling through to its auth-prompt path — the actual cause
173
+ * is "repo doesn't exist (or is private)". We classify the common patterns
174
+ * and emit a message that names the cause and the fix.
175
+ */
176
+ export function classifyCloneFailure(url, stderr, spawnError) {
177
+ const safeUrl = redactUrlUserinfo(url);
178
+ const raw = (stderr ?? "").trim();
179
+ const spawnMsg = spawnError?.message ?? "";
180
+ // `git` binary not on PATH.
181
+ if (spawnError?.code === "ENOENT") {
182
+ return `Failed to clone ${safeUrl}: 'git' is not installed or not on PATH. Install git, then re-run.`;
183
+ }
184
+ // Auth-prompt fall-through (the headline #487 case).
185
+ if (/could not read Username|terminal prompts disabled|Authentication failed|fatal: Authentication/i.test(raw)) {
186
+ return (`Failed to clone ${safeUrl}: repository not found or private. ` +
187
+ `If the repository is public, double-check the URL and try again. ` +
188
+ `If it is private, set GH_TOKEN (or configure a git credential helper) before re-running.`);
189
+ }
190
+ // 404-style messages from git http.
191
+ if (/repository '.*' not found|HTTP 404|fatal: remote error|not found:|Not Found/i.test(raw)) {
192
+ return (`Failed to clone ${safeUrl}: repository not found. ` +
193
+ `Check the URL — for GitHub, the form is 'owner/repo' or 'github:owner/repo'.`);
194
+ }
195
+ // SSH connection issues.
196
+ if (/Permission denied \(publickey\)|kex_exchange_identification|Connection refused|Connection timed out/i.test(raw)) {
197
+ return (`Failed to clone ${safeUrl}: network or SSH failure. ` +
198
+ `Check connectivity, your SSH agent, and the remote host's availability.`);
199
+ }
200
+ // Branch / ref-specific failures.
201
+ if (/Remote branch .* not found in upstream origin|couldn't find remote ref/i.test(raw)) {
202
+ return (`Failed to clone ${safeUrl}: the requested branch/tag does not exist on the remote. ` +
203
+ `Verify the ref name and re-run.`);
204
+ }
205
+ const detail = raw || spawnMsg || "unknown error";
206
+ return `Failed to clone ${safeUrl}: ${redactUrlUserinfo(detail)}`;
207
+ }
208
+ function redactUrlUserinfo(text) {
209
+ return text.replace(/\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^\s/@]+)@/g, "$1[REDACTED]@");
210
+ }
@@ -0,0 +1,234 @@
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 { createHash } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { TYPE_DIRS } from "../../core/asset/asset-spec.js";
8
+ import { ConfigError, UsageError } from "../../core/errors.js";
9
+ import { getRegistryIndexCacheDir } from "../../core/paths.js";
10
+ import { validateGitUrl } from "../../registry/resolve.js";
11
+ import { registerSourceProvider } from "../provider-factory.js";
12
+ import { cloneRepo, runGit, syncRegistryGitRef } from "./git-install.js";
13
+ import { isExpired, sanitizeString } from "./provider-utils.js";
14
+ /** Cache TTL before refreshing the mirrored repo (12 hours). */
15
+ const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
16
+ /** Maximum stale age allowed when refresh fails (7 days). */
17
+ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
18
+ /**
19
+ * Git source provider — clones (and re-pulls) a remote repo into a local
20
+ * cache directory. Implements the v1 {@link SourceProvider} interface (spec
21
+ * §2.1, §2.5): `{ name, kind, init, path, sync }`.
22
+ *
23
+ * Reading is the indexer's job — this class doesn't implement `search` or
24
+ * `show`. The install-time helpers `syncRegistryGitRef` / `syncMirroredRepo`
25
+ * live below as standalone functions used by `akm add` / `akm update`.
26
+ */
27
+ export class GitSourceProvider {
28
+ kind = "git";
29
+ name;
30
+ #config;
31
+ #path = null;
32
+ constructor(config) {
33
+ this.#config = config;
34
+ this.name = config.name ?? "git";
35
+ }
36
+ path() {
37
+ if (this.#path == null) {
38
+ // Lazy resolution: providers are sometimes constructed without an
39
+ // explicit init() call (e.g. by legacy callers that just want the
40
+ // path). Resolve on demand and cache.
41
+ this.#path = resolveGitContentDir(this.#config);
42
+ }
43
+ return this.#path;
44
+ }
45
+ async sync(options) {
46
+ // Two execution modes:
47
+ // 1. Long-lived configured source (config.url) — mirror into the
48
+ // registry-index cache and serve as a read-only working tree.
49
+ // 2. One-shot install ref (options.ref like "git:..." / "github:...") —
50
+ // delegate to the install-time pipeline.
51
+ if (typeof this.#config.options?.ref === "string" && this.#config.options.ref) {
52
+ await syncRegistryGitRef(String(this.#config.options.ref), { force: options?.force });
53
+ return;
54
+ }
55
+ await syncMirroredRepo(this.#config, { force: options?.force });
56
+ }
57
+ }
58
+ /** Resolve the on-disk content directory for a configured git source. */
59
+ function resolveGitContentDir(config) {
60
+ if (config.path)
61
+ return config.path;
62
+ if (config.url) {
63
+ const repo = parseGitRepoUrl(config.url);
64
+ return getCachePaths(repo.canonicalUrl).repoDir;
65
+ }
66
+ throw new ConfigError("git source entry must have either `path` or `url`");
67
+ }
68
+ // ── Self-register ───────────────────────────────────────────────────────────
69
+ registerSourceProvider("git", (config) => new GitSourceProvider(config));
70
+ // ── Cache management ────────────────────────────────────────────────────────
71
+ export function getCachePaths(repoUrl) {
72
+ const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
73
+ const cacheRoot = getRegistryIndexCacheDir();
74
+ const rootDir = path.join(cacheRoot, `git-${key}`);
75
+ return {
76
+ rootDir,
77
+ repoDir: path.join(rootDir, "repo"),
78
+ indexPath: path.join(rootDir, "index.json"),
79
+ };
80
+ }
81
+ export async function ensureGitMirror(repo, cachePaths, options) {
82
+ const requireRepoDir = options?.requireRepoDir === true;
83
+ const writable = options?.writable === true;
84
+ const force = options?.force === true;
85
+ // Check if cache is fresh
86
+ let mtime = 0;
87
+ try {
88
+ mtime = fs.statSync(cachePaths.indexPath).mtimeMs;
89
+ }
90
+ catch {
91
+ /* no cached index */
92
+ }
93
+ if (!force && mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
94
+ return;
95
+ }
96
+ try {
97
+ fs.mkdirSync(cachePaths.rootDir, { recursive: true });
98
+ if (writable && fs.existsSync(path.join(cachePaths.repoDir, ".git"))) {
99
+ // Writable repo already cloned — pull instead of re-clone to preserve local changes
100
+ pullRepo(cachePaths.repoDir);
101
+ }
102
+ else {
103
+ cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable);
104
+ }
105
+ // Touch index file to track freshness
106
+ fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
107
+ }
108
+ catch (err) {
109
+ if (mtime && !isExpired(mtime, CACHE_STALE_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
110
+ return;
111
+ }
112
+ throw err;
113
+ }
114
+ }
115
+ /**
116
+ * Sync mode for a long-lived configured git stash. Mirrors the repo into the
117
+ * shared registry-index cache (12h TTL) and exposes the working tree as the
118
+ * stash content directory.
119
+ */
120
+ export async function syncMirroredRepo(config, options) {
121
+ if (!config.url) {
122
+ throw new ConfigError("git stash entry requires a URL when no install ref is supplied");
123
+ }
124
+ const repo = parseGitRepoUrl(config.url);
125
+ const cachePaths = getCachePaths(repo.canonicalUrl);
126
+ await ensureGitMirror(repo, cachePaths, {
127
+ requireRepoDir: true,
128
+ writable: options?.writable ?? config.writable === true,
129
+ force: options?.force,
130
+ });
131
+ const syncedAt = (options?.now ?? new Date()).toISOString();
132
+ const contentDir = cachePaths.repoDir;
133
+ return {
134
+ id: repo.canonicalUrl,
135
+ source: "git",
136
+ ref: repo.canonicalUrl,
137
+ artifactUrl: repo.canonicalUrl,
138
+ contentDir,
139
+ cacheDir: cachePaths.rootDir,
140
+ extractedDir: contentDir,
141
+ writable: options?.writable ?? config.writable === true,
142
+ syncedAt,
143
+ };
144
+ }
145
+ function pullRepo(repoDir) {
146
+ const result = runGit(["-C", repoDir, "pull", "--ff-only"], {
147
+ timeout: 120_000,
148
+ });
149
+ if (result.status !== 0) {
150
+ const err = result.stderr?.trim() || result.error?.message || "unknown error";
151
+ throw new Error(`Failed to pull ${repoDir}: ${err}`);
152
+ }
153
+ }
154
+ function hasExtractedRepo(repoDir) {
155
+ try {
156
+ if (!fs.statSync(repoDir).isDirectory())
157
+ return false;
158
+ if (fs.statSync(path.join(repoDir, "content")).isDirectory())
159
+ return true;
160
+ }
161
+ catch {
162
+ /* fall through to root-layout detection */
163
+ }
164
+ try {
165
+ if (!fs.statSync(repoDir).isDirectory())
166
+ return false;
167
+ return Object.values(TYPE_DIRS).some((dirName) => fs.existsSync(path.join(repoDir, dirName)));
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ export function parseGitRepoUrl(rawUrl) {
174
+ if (!rawUrl) {
175
+ throw new ConfigError("Git provider requires a repository URL");
176
+ }
177
+ // SSH shorthand: git@host:path — valid as-is, delegated to system git credentials
178
+ if (/^git@[^:]+:.+$/.test(rawUrl)) {
179
+ return { cloneUrl: rawUrl, ref: null, canonicalUrl: rawUrl };
180
+ }
181
+ // Validate URL scheme is safe before parsing
182
+ try {
183
+ validateGitUrl(rawUrl);
184
+ }
185
+ catch (err) {
186
+ if (err instanceof UsageError)
187
+ throw new ConfigError(err.message);
188
+ throw err;
189
+ }
190
+ let parsed;
191
+ try {
192
+ parsed = new URL(rawUrl);
193
+ }
194
+ catch {
195
+ throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
196
+ }
197
+ // GitHub web URLs: extract a clean clone URL and optional branch from /tree/<ref>
198
+ if (parsed.hostname === "github.com" && parsed.protocol === "https:") {
199
+ const segments = parsed.pathname.split("/").filter(Boolean);
200
+ if (segments.length < 2) {
201
+ throw new ConfigError(`Git provider URL must point to a repository, got "${rawUrl}"`);
202
+ }
203
+ const owner = sanitizeString(segments[0]);
204
+ const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
205
+ if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
206
+ throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
207
+ }
208
+ let ref = null;
209
+ if (segments[2] === "tree" && segments.length >= 4) {
210
+ const rawRef = sanitizeString(segments.slice(3).join("/"), 255);
211
+ if (rawRef && !rawRef.includes("..") && /^[A-Za-z0-9._/-]+$/.test(rawRef)) {
212
+ ref = rawRef;
213
+ }
214
+ }
215
+ const cloneUrl = `https://github.com/${owner}/${repo}`;
216
+ const canonicalUrl = ref ? `${cloneUrl}/tree/${ref}` : cloneUrl;
217
+ return { cloneUrl, ref, canonicalUrl };
218
+ }
219
+ // Any other valid git URL: use as-is for cloning, but strip embedded credentials
220
+ // from canonicalUrl so secrets don't leak into cache keys or warning messages.
221
+ let canonicalUrl = rawUrl;
222
+ try {
223
+ const u = new URL(rawUrl);
224
+ u.username = "";
225
+ u.password = "";
226
+ u.search = "";
227
+ u.hash = "";
228
+ canonicalUrl = u.toString();
229
+ }
230
+ catch {
231
+ // URL failed to parse — fall back to raw (validateGitUrl already accepted it)
232
+ }
233
+ return { cloneUrl: rawUrl, ref: null, canonicalUrl };
234
+ }