akm-cli 0.6.1 → 0.7.0

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 (333) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{cli.js → src/cli.js} +712 -34
  3. package/dist/{commands → src/commands}/config-cli.js +47 -4
  4. package/dist/src/commands/distill.js +283 -0
  5. package/dist/src/commands/events.js +108 -0
  6. package/dist/src/commands/history.js +191 -0
  7. package/dist/{commands → src/commands}/installed-stashes.js +1 -1
  8. package/dist/src/commands/proposal.js +119 -0
  9. package/dist/src/commands/propose.js +171 -0
  10. package/dist/src/commands/reflect.js +193 -0
  11. package/dist/{commands → src/commands}/registry-search.js +71 -7
  12. package/dist/{commands → src/commands}/remember.js +12 -0
  13. package/dist/{commands → src/commands}/search.js +104 -4
  14. package/dist/{commands → src/commands}/self-update.js +4 -3
  15. package/dist/{commands → src/commands}/show.js +73 -0
  16. package/dist/{commands → src/commands}/source-add.js +5 -1
  17. package/dist/{commands → src/commands}/source-manage.js +7 -1
  18. package/dist/{core → src/core}/asset-ref.js +5 -5
  19. package/dist/{core → src/core}/asset-spec.js +12 -0
  20. package/dist/{core → src/core}/common.js +1 -1
  21. package/dist/{core → src/core}/config.js +203 -121
  22. package/dist/{core → src/core}/errors.js +4 -0
  23. package/dist/src/core/events.js +239 -0
  24. package/dist/src/core/lesson-lint.js +86 -0
  25. package/dist/src/core/proposals.js +406 -0
  26. package/dist/src/core/warn.js +72 -0
  27. package/dist/{core → src/core}/write-source.js +80 -5
  28. package/dist/{indexer → src/indexer}/db-search.js +114 -24
  29. package/dist/{indexer → src/indexer}/db.js +76 -23
  30. package/dist/{indexer → src/indexer}/file-context.js +0 -3
  31. package/dist/src/indexer/graph-boost.js +179 -0
  32. package/dist/src/indexer/graph-extraction.js +212 -0
  33. package/dist/{indexer → src/indexer}/indexer.js +88 -7
  34. package/dist/{indexer → src/indexer}/matchers.js +1 -1
  35. package/dist/src/indexer/memory-inference.js +263 -0
  36. package/dist/{indexer → src/indexer}/metadata.js +111 -3
  37. package/dist/{indexer → src/indexer}/search-source.js +4 -2
  38. package/dist/src/integrations/agent/config.js +292 -0
  39. package/dist/src/integrations/agent/detect.js +94 -0
  40. package/dist/src/integrations/agent/index.js +17 -0
  41. package/dist/src/integrations/agent/profiles.js +65 -0
  42. package/dist/src/integrations/agent/prompts.js +167 -0
  43. package/dist/src/integrations/agent/spawn.js +272 -0
  44. package/dist/{integrations → src/integrations}/github.js +9 -3
  45. package/dist/{integrations → src/integrations}/lockfile.js +0 -26
  46. package/dist/{llm → src/llm}/client.js +33 -2
  47. package/dist/{llm → src/llm}/embedders/remote.js +37 -3
  48. package/dist/src/llm/feature-gate.js +108 -0
  49. package/dist/src/llm/graph-extract.js +107 -0
  50. package/dist/src/llm/index-passes.js +35 -0
  51. package/dist/src/llm/memory-infer.js +86 -0
  52. package/dist/{output → src/output}/cli-hints.js +15 -2
  53. package/dist/{output → src/output}/renderers.js +63 -2
  54. package/dist/src/output/shapes.js +523 -0
  55. package/dist/src/output/text.js +1116 -0
  56. package/dist/{registry → src/registry}/build-index.js +19 -8
  57. package/dist/{registry → src/registry}/factory.js +0 -8
  58. package/dist/{registry → src/registry}/providers/static-index.js +6 -3
  59. package/dist/{registry → src/registry}/resolve.js +68 -2
  60. package/dist/{setup → src/setup}/setup.js +52 -5
  61. package/dist/{sources → src/sources}/providers/git.js +7 -15
  62. package/dist/{wiki → src/wiki}/wiki.js +54 -6
  63. package/dist/{workflows → src/workflows}/runs.js +37 -3
  64. package/dist/tests/add-website-source.test.js +119 -0
  65. package/dist/tests/agent/agent-config-loader.test.js +70 -0
  66. package/dist/tests/agent/agent-config.test.js +221 -0
  67. package/dist/tests/agent/agent-detect.test.js +100 -0
  68. package/dist/tests/agent/agent-spawn.test.js +234 -0
  69. package/dist/tests/agent-output.test.js +186 -0
  70. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +103 -0
  71. package/dist/tests/architecture/agent-spawn-seam.test.js +193 -0
  72. package/dist/tests/architecture/llm-stateless-seam.test.js +112 -0
  73. package/dist/tests/asset-ref.test.js +192 -0
  74. package/dist/tests/asset-registry.test.js +103 -0
  75. package/dist/tests/asset-spec.test.js +241 -0
  76. package/dist/tests/bench/attribution.test.js +996 -0
  77. package/dist/tests/bench/cleanup-sigint.test.js +83 -0
  78. package/dist/tests/bench/cleanup.js +234 -0
  79. package/dist/tests/bench/cleanup.test.js +166 -0
  80. package/dist/tests/bench/cli.js +1018 -0
  81. package/dist/tests/bench/cli.test.js +445 -0
  82. package/dist/tests/bench/compare.test.js +556 -0
  83. package/dist/tests/bench/corpus.js +317 -0
  84. package/dist/tests/bench/corpus.test.js +258 -0
  85. package/dist/tests/bench/doctor.js +525 -0
  86. package/dist/tests/bench/driver.js +401 -0
  87. package/dist/tests/bench/driver.test.js +584 -0
  88. package/dist/tests/bench/environment.js +233 -0
  89. package/dist/tests/bench/environment.test.js +199 -0
  90. package/dist/tests/bench/evolve-metrics.js +179 -0
  91. package/dist/tests/bench/evolve-metrics.test.js +187 -0
  92. package/dist/tests/bench/evolve.js +647 -0
  93. package/dist/tests/bench/evolve.test.js +624 -0
  94. package/dist/tests/bench/failure-modes.test.js +349 -0
  95. package/dist/tests/bench/feedback-integrity.test.js +457 -0
  96. package/dist/tests/bench/leakage.test.js +228 -0
  97. package/dist/tests/bench/learning-curve.test.js +134 -0
  98. package/dist/tests/bench/metrics.js +2395 -0
  99. package/dist/tests/bench/metrics.test.js +1150 -0
  100. package/dist/tests/bench/no-os-tmpdir-invariant.test.js +43 -0
  101. package/dist/tests/bench/opencode-config.js +194 -0
  102. package/dist/tests/bench/opencode-config.test.js +370 -0
  103. package/dist/tests/bench/report.js +1885 -0
  104. package/dist/tests/bench/report.test.js +1038 -0
  105. package/dist/tests/bench/run-config.js +355 -0
  106. package/dist/tests/bench/run-config.test.js +298 -0
  107. package/dist/tests/bench/run-curate-test.js +32 -0
  108. package/dist/tests/bench/run-failing-tasks.js +56 -0
  109. package/dist/tests/bench/run-full-bench.js +51 -0
  110. package/dist/tests/bench/run-items36-targeted.js +69 -0
  111. package/dist/tests/bench/run-nano-quick.js +42 -0
  112. package/dist/tests/bench/run-waveg-targeted.js +62 -0
  113. package/dist/tests/bench/runner.js +699 -0
  114. package/dist/tests/bench/runner.test.js +958 -0
  115. package/dist/tests/bench/search-bridge.test.js +331 -0
  116. package/dist/tests/bench/tmp.js +131 -0
  117. package/dist/tests/bench/trajectory.js +116 -0
  118. package/dist/tests/bench/trajectory.test.js +127 -0
  119. package/dist/tests/bench/verifier.js +114 -0
  120. package/dist/tests/bench/verifier.test.js +118 -0
  121. package/dist/tests/bench/workflow-evaluator.js +557 -0
  122. package/dist/tests/bench/workflow-evaluator.test.js +421 -0
  123. package/dist/tests/bench/workflow-spec.js +345 -0
  124. package/dist/tests/bench/workflow-spec.test.js +363 -0
  125. package/dist/tests/bench/workflow-trace.js +472 -0
  126. package/dist/tests/bench/workflow-trace.test.js +254 -0
  127. package/dist/tests/benchmark-search-quality.js +536 -0
  128. package/dist/tests/benchmark-suite.js +1441 -0
  129. package/dist/tests/capture-cli.test.js +112 -0
  130. package/dist/tests/cli-errors.test.js +204 -0
  131. package/dist/tests/commands/events.test.js +370 -0
  132. package/dist/tests/commands/history.test.js +418 -0
  133. package/dist/tests/commands/import.test.js +103 -0
  134. package/dist/tests/commands/proposal-cli.test.js +209 -0
  135. package/dist/tests/commands/reflect-propose-cli.test.js +333 -0
  136. package/dist/tests/commands/remember.test.js +97 -0
  137. package/dist/tests/commands/scope-flags.test.js +300 -0
  138. package/dist/tests/commands/search.test.js +537 -0
  139. package/dist/tests/commands/show-indexer-parity.test.js +117 -0
  140. package/dist/tests/commands/show.test.js +294 -0
  141. package/dist/tests/common.test.js +266 -0
  142. package/dist/tests/completions.test.js +142 -0
  143. package/dist/tests/config-cli.test.js +193 -0
  144. package/dist/tests/config-llm-features.test.js +139 -0
  145. package/dist/tests/config.test.js +569 -0
  146. package/dist/tests/contracts/migration-baseline.test.js +43 -0
  147. package/dist/tests/contracts/reflect-propose-envelope.test.js +139 -0
  148. package/dist/tests/contracts/spec-helpers.js +46 -0
  149. package/dist/tests/contracts/v1-spec-section-11-proposal-queue.test.js +228 -0
  150. package/dist/tests/contracts/v1-spec-section-12-agent-config.test.js +56 -0
  151. package/dist/tests/contracts/v1-spec-section-13-lesson-type.test.js +34 -0
  152. package/dist/tests/contracts/v1-spec-section-14-llm-features.test.js +94 -0
  153. package/dist/tests/contracts/v1-spec-section-4-1-asset-types.test.js +39 -0
  154. package/dist/tests/contracts/v1-spec-section-4-2-quality-rules.test.js +44 -0
  155. package/dist/tests/contracts/v1-spec-section-5-configuration.test.js +47 -0
  156. package/dist/tests/contracts/v1-spec-section-6-orchestration.test.js +40 -0
  157. package/dist/tests/contracts/v1-spec-section-7-module-layout.test.js +58 -0
  158. package/dist/tests/contracts/v1-spec-section-8-extension-points.test.js +34 -0
  159. package/dist/tests/contracts/v1-spec-section-9-4-cli-surface.test.js +75 -0
  160. package/dist/tests/contracts/v1-spec-section-9-7-llm-agent-boundary.test.js +36 -0
  161. package/dist/tests/core/write-source.test.js +366 -0
  162. package/dist/tests/curate-command.test.js +87 -0
  163. package/dist/tests/db-scoring.test.js +201 -0
  164. package/dist/tests/db.test.js +654 -0
  165. package/dist/tests/distill-cli-flag.test.js +208 -0
  166. package/dist/tests/distill.test.js +515 -0
  167. package/dist/tests/docker-install.test.js +120 -0
  168. package/dist/tests/e2e.test.js +1419 -0
  169. package/dist/tests/embedder.test.js +340 -0
  170. package/dist/tests/embedding-model-config.test.js +379 -0
  171. package/dist/tests/feedback-command.test.js +172 -0
  172. package/dist/tests/file-context.test.js +552 -0
  173. package/dist/tests/fixtures/scripts/git/summarize-diff.js +9 -0
  174. package/dist/tests/fixtures/scripts/lint/eslint-check.js +7 -0
  175. package/dist/tests/fixtures/stashes/load.js +166 -0
  176. package/dist/tests/fixtures/stashes/load.test.js +97 -0
  177. package/dist/tests/fixtures/stashes/ranking-baseline/scripts/mem0-search.js +12 -0
  178. package/dist/tests/frontmatter.test.js +190 -0
  179. package/dist/tests/fts-field-weighting.test.js +254 -0
  180. package/dist/tests/fuzzy-search.test.js +230 -0
  181. package/dist/tests/git-provider-clone.test.js +45 -0
  182. package/dist/tests/github.test.js +161 -0
  183. package/dist/tests/graph-boost-ranking.test.js +305 -0
  184. package/dist/tests/graph-extraction.test.js +282 -0
  185. package/dist/tests/helpers/usage-events.js +8 -0
  186. package/dist/tests/index-pass-llm.test.js +161 -0
  187. package/dist/tests/indexer.test.js +570 -0
  188. package/dist/tests/info-command.test.js +166 -0
  189. package/dist/tests/init.test.js +69 -0
  190. package/dist/tests/install-script.test.js +246 -0
  191. package/dist/tests/integration/agent-real-profile.test.js +94 -0
  192. package/dist/tests/issue-36-repro.test.js +304 -0
  193. package/dist/tests/issues-191-194.test.js +160 -0
  194. package/dist/tests/lesson-lint.test.js +111 -0
  195. package/dist/tests/llm-client.test.js +115 -0
  196. package/dist/tests/llm-feature-gate.test.js +151 -0
  197. package/dist/tests/llm.test.js +139 -0
  198. package/dist/tests/lockfile.test.js +216 -0
  199. package/dist/tests/manifest.test.js +205 -0
  200. package/dist/tests/markdown.test.js +126 -0
  201. package/dist/tests/matchers-unit.test.js +189 -0
  202. package/dist/tests/memory-inference.test.js +299 -0
  203. package/dist/tests/merge-scoring.test.js +136 -0
  204. package/dist/tests/metadata.test.js +313 -0
  205. package/dist/tests/migration-help.test.js +89 -0
  206. package/dist/tests/origin-resolve.test.js +124 -0
  207. package/dist/tests/output-baseline.test.js +218 -0
  208. package/dist/tests/output-shapes-unit.test.js +478 -0
  209. package/dist/tests/parallel-search.test.js +272 -0
  210. package/dist/tests/parameter-metadata.test.js +365 -0
  211. package/dist/tests/paths.test.js +177 -0
  212. package/dist/tests/progressive-disclosure.test.js +280 -0
  213. package/dist/tests/proposals.test.js +279 -0
  214. package/dist/tests/proposed-quality.test.js +271 -0
  215. package/dist/tests/provider-registry.test.js +32 -0
  216. package/dist/tests/ranking-regression.test.js +548 -0
  217. package/dist/tests/reflect-propose.test.js +455 -0
  218. package/dist/tests/registry-build-index.test.js +394 -0
  219. package/dist/tests/registry-cli.test.js +290 -0
  220. package/dist/tests/registry-index-v2.test.js +430 -0
  221. package/dist/tests/registry-install.test.js +728 -0
  222. package/dist/tests/registry-providers/parity.test.js +189 -0
  223. package/dist/tests/registry-providers/skills-sh.test.js +309 -0
  224. package/dist/tests/registry-providers/static-index.test.js +238 -0
  225. package/dist/tests/registry-resolve.test.js +126 -0
  226. package/dist/tests/registry-search.test.js +923 -0
  227. package/dist/tests/remember-frontmatter.test.js +378 -0
  228. package/dist/tests/remember-unit.test.js +123 -0
  229. package/dist/tests/ripgrep-install.test.js +251 -0
  230. package/dist/tests/ripgrep-resolve.test.js +108 -0
  231. package/dist/tests/ripgrep.test.js +163 -0
  232. package/dist/tests/save-command.test.js +94 -0
  233. package/dist/tests/save-trust-qa-fixes.test.js +270 -0
  234. package/dist/tests/scoring-pipeline.test.js +648 -0
  235. package/dist/tests/search-include-proposed-cli.test.js +118 -0
  236. package/dist/tests/self-update.test.js +442 -0
  237. package/dist/tests/semantic-search-e2e.test.js +512 -0
  238. package/dist/tests/semantic-status.test.js +471 -0
  239. package/dist/tests/setup-run.integration.js +877 -0
  240. package/dist/tests/setup-wizard.test.js +198 -0
  241. package/dist/tests/setup.test.js +131 -0
  242. package/dist/tests/source-add.test.js +11 -0
  243. package/dist/tests/source-clone.test.js +254 -0
  244. package/dist/tests/source-manage.test.js +366 -0
  245. package/dist/tests/source-providers/filesystem.test.js +82 -0
  246. package/dist/tests/source-providers/git.test.js +252 -0
  247. package/dist/tests/source-providers/website.test.js +128 -0
  248. package/dist/tests/source-qa-fixes.test.js +286 -0
  249. package/dist/tests/source-registry.test.js +350 -0
  250. package/dist/tests/source-resolve.test.js +100 -0
  251. package/dist/tests/source-source.test.js +281 -0
  252. package/dist/tests/source.test.js +533 -0
  253. package/dist/tests/tar-utils-scan.test.js +73 -0
  254. package/dist/tests/toggle-components.test.js +73 -0
  255. package/dist/tests/usage-telemetry.test.js +265 -0
  256. package/dist/tests/utility-scoring.test.js +558 -0
  257. package/dist/tests/vault-load-error.test.js +78 -0
  258. package/dist/tests/vault-qa-fixes.test.js +194 -0
  259. package/dist/tests/vault.test.js +429 -0
  260. package/dist/tests/vector-search.test.js +608 -0
  261. package/dist/tests/walker.test.js +252 -0
  262. package/dist/tests/wave2-cluster-bc.test.js +228 -0
  263. package/dist/tests/wave2-cluster-d.test.js +180 -0
  264. package/dist/tests/wave2-cluster-e.test.js +179 -0
  265. package/dist/tests/wiki-qa-fixes.test.js +270 -0
  266. package/dist/tests/wiki.test.js +529 -0
  267. package/dist/tests/workflow-cli.test.js +271 -0
  268. package/dist/tests/workflow-markdown.test.js +171 -0
  269. package/dist/tests/workflow-path-escape.test.js +132 -0
  270. package/dist/tests/workflow-qa-fixes.test.js +395 -0
  271. package/dist/tests/workflows/indexer-rejection.test.js +213 -0
  272. package/docs/README.md +8 -0
  273. package/docs/migration/release-notes/0.7.0.md +244 -0
  274. package/package.json +2 -2
  275. package/dist/core/warn.js +0 -27
  276. package/dist/output/shapes.js +0 -212
  277. package/dist/output/text.js +0 -520
  278. /package/dist/{commands → src/commands}/completions.js +0 -0
  279. /package/dist/{commands → src/commands}/curate.js +0 -0
  280. /package/dist/{commands → src/commands}/info.js +0 -0
  281. /package/dist/{commands → src/commands}/init.js +0 -0
  282. /package/dist/{commands → src/commands}/install-audit.js +0 -0
  283. /package/dist/{commands → src/commands}/migration-help.js +0 -0
  284. /package/dist/{commands → src/commands}/source-clone.js +0 -0
  285. /package/dist/{commands → src/commands}/vault.js +0 -0
  286. /package/dist/{core → src/core}/asset-registry.js +0 -0
  287. /package/dist/{core → src/core}/frontmatter.js +0 -0
  288. /package/dist/{core → src/core}/markdown.js +0 -0
  289. /package/dist/{core → src/core}/paths.js +0 -0
  290. /package/dist/{indexer → src/indexer}/manifest.js +0 -0
  291. /package/dist/{indexer → src/indexer}/search-fields.js +0 -0
  292. /package/dist/{indexer → src/indexer}/semantic-status.js +0 -0
  293. /package/dist/{indexer → src/indexer}/usage-events.js +0 -0
  294. /package/dist/{indexer → src/indexer}/walker.js +0 -0
  295. /package/dist/{llm → src/llm}/embedder.js +0 -0
  296. /package/dist/{llm → src/llm}/embedders/cache.js +0 -0
  297. /package/dist/{llm → src/llm}/embedders/local.js +0 -0
  298. /package/dist/{llm → src/llm}/embedders/types.js +0 -0
  299. /package/dist/{llm → src/llm}/metadata-enhance.js +0 -0
  300. /package/dist/{output → src/output}/context.js +0 -0
  301. /package/dist/{registry → src/registry}/create-provider-registry.js +0 -0
  302. /package/dist/{registry → src/registry}/origin-resolve.js +0 -0
  303. /package/dist/{registry → src/registry}/providers/index.js +0 -0
  304. /package/dist/{registry → src/registry}/providers/skills-sh.js +0 -0
  305. /package/dist/{registry → src/registry}/providers/types.js +0 -0
  306. /package/dist/{registry → src/registry}/types.js +0 -0
  307. /package/dist/{setup → src/setup}/detect.js +0 -0
  308. /package/dist/{setup → src/setup}/ripgrep-install.js +0 -0
  309. /package/dist/{setup → src/setup}/ripgrep-resolve.js +0 -0
  310. /package/dist/{setup → src/setup}/steps.js +0 -0
  311. /package/dist/{sources → src/sources}/include.js +0 -0
  312. /package/dist/{sources → src/sources}/provider-factory.js +0 -0
  313. /package/dist/{sources → src/sources}/provider.js +0 -0
  314. /package/dist/{sources → src/sources}/providers/filesystem.js +0 -0
  315. /package/dist/{sources → src/sources}/providers/index.js +0 -0
  316. /package/dist/{sources → src/sources}/providers/install-types.js +0 -0
  317. /package/dist/{sources → src/sources}/providers/npm.js +0 -0
  318. /package/dist/{sources → src/sources}/providers/provider-utils.js +0 -0
  319. /package/dist/{sources → src/sources}/providers/sync-from-ref.js +0 -0
  320. /package/dist/{sources → src/sources}/providers/tar-utils.js +0 -0
  321. /package/dist/{sources → src/sources}/providers/website.js +0 -0
  322. /package/dist/{sources → src/sources}/resolve.js +0 -0
  323. /package/dist/{sources → src/sources}/types.js +0 -0
  324. /package/dist/{templates → src/templates}/wiki-templates.js +0 -0
  325. /package/dist/{version.js → src/version.js} +0 -0
  326. /package/dist/{workflows → src/workflows}/authoring.js +0 -0
  327. /package/dist/{workflows → src/workflows}/cli.js +0 -0
  328. /package/dist/{workflows → src/workflows}/db.js +0 -0
  329. /package/dist/{workflows → src/workflows}/document-cache.js +0 -0
  330. /package/dist/{workflows → src/workflows}/parser.js +0 -0
  331. /package/dist/{workflows → src/workflows}/renderer.js +0 -0
  332. /package/dist/{workflows → src/workflows}/schema.js +0 -0
  333. /package/dist/{workflows → src/workflows}/validator.js +0 -0
@@ -0,0 +1,194 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { loadEnv, setKey } from "../src/commands/vault";
7
+ // ── Helpers ──────────────────────────────────────────────────────────────────
8
+ const tempDirs = [];
9
+ function makeTempDir(prefix = "akm-vqa-") {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ }
14
+ afterAll(() => {
15
+ for (const dir of tempDirs) {
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ });
19
+ const xdgCache = makeTempDir("akm-vqa-cache-");
20
+ const xdgConfig = makeTempDir("akm-vqa-config-");
21
+ const isolatedHome = makeTempDir("akm-vqa-home-");
22
+ const repoRoot = path.resolve(import.meta.dir, "..");
23
+ const cliPath = path.join(repoRoot, "src", "cli.ts");
24
+ function runCli(args, extraEnv = {}) {
25
+ const result = spawnSync("bun", [cliPath, ...args], {
26
+ encoding: "utf8",
27
+ timeout: 30_000,
28
+ cwd: repoRoot,
29
+ env: {
30
+ ...process.env,
31
+ HOME: isolatedHome,
32
+ XDG_CACHE_HOME: xdgCache,
33
+ XDG_CONFIG_HOME: xdgConfig,
34
+ AKM_STASH_DIR: undefined,
35
+ ...extraEnv,
36
+ },
37
+ });
38
+ return {
39
+ stdout: result.stdout ?? "",
40
+ stderr: result.stderr ?? "",
41
+ status: result.status ?? 1,
42
+ };
43
+ }
44
+ // ── setKey comment parameter (unit tests) ────────────────────────────────────
45
+ describe("setKey: comment parameter", () => {
46
+ test("1. writes a new key with a leading comment line when comment provided", () => {
47
+ const dir = makeTempDir();
48
+ const fp = path.join(dir, "v.env");
49
+ setKey(fp, "DB_URL", "postgres://localhost/mydb", "database connection string");
50
+ const text = fs.readFileSync(fp, "utf8");
51
+ const lines = text.split("\n").filter((l) => l.length > 0);
52
+ const commentIdx = lines.indexOf("# database connection string");
53
+ const keyIdx = lines.findIndex((l) => l.startsWith("DB_URL="));
54
+ expect(commentIdx).toBeGreaterThanOrEqual(0);
55
+ expect(keyIdx).toBe(commentIdx + 1);
56
+ expect(loadEnv(fp).DB_URL).toBe("postgres://localhost/mydb");
57
+ });
58
+ test("2. updates an existing comment line in-place when the key is overwritten with a new comment", () => {
59
+ const dir = makeTempDir();
60
+ const fp = path.join(dir, "v.env");
61
+ fs.writeFileSync(fp, "# old comment\nDB_URL=postgres://old\n");
62
+ setKey(fp, "DB_URL", "postgres://new", "new comment");
63
+ const text = fs.readFileSync(fp, "utf8");
64
+ expect(text).toContain("# new comment");
65
+ expect(text).not.toContain("# old comment");
66
+ expect(loadEnv(fp).DB_URL).toBe("postgres://new");
67
+ // Comment line should immediately precede the key line
68
+ const lines = text.split("\n").filter((l) => l.length > 0);
69
+ const commentIdx = lines.indexOf("# new comment");
70
+ const keyIdx = lines.findIndex((l) => l.startsWith("DB_URL="));
71
+ expect(keyIdx).toBe(commentIdx + 1);
72
+ });
73
+ test("3. inserts a new comment before an existing key that lacks one", () => {
74
+ const dir = makeTempDir();
75
+ const fp = path.join(dir, "v.env");
76
+ fs.writeFileSync(fp, "FOO=bar\nDB_URL=postgres://old\nBAZ=qux\n");
77
+ setKey(fp, "DB_URL", "postgres://new", "injected comment");
78
+ const text = fs.readFileSync(fp, "utf8");
79
+ expect(text).toContain("# injected comment");
80
+ const lines = text.split("\n").filter((l) => l.length > 0);
81
+ const commentIdx = lines.indexOf("# injected comment");
82
+ const keyIdx = lines.findIndex((l) => l.startsWith("DB_URL="));
83
+ expect(keyIdx).toBe(commentIdx + 1);
84
+ // Other keys preserved
85
+ expect(loadEnv(fp).FOO).toBe("bar");
86
+ expect(loadEnv(fp).BAZ).toBe("qux");
87
+ });
88
+ test("4. without comment does not modify surrounding comments", () => {
89
+ const dir = makeTempDir();
90
+ const fp = path.join(dir, "v.env");
91
+ fs.writeFileSync(fp, "# original comment\nDB_URL=old\n");
92
+ setKey(fp, "DB_URL", "new");
93
+ const text = fs.readFileSync(fp, "utf8");
94
+ expect(text).toContain("# original comment");
95
+ expect(loadEnv(fp).DB_URL).toBe("new");
96
+ });
97
+ });
98
+ // ── vault show alias (CLI tests) ─────────────────────────────────────────────
99
+ describe("vault show: alias for vault list <ref>", () => {
100
+ test("5. vault show vault:prod matches vault list vault:prod output", () => {
101
+ const stashDir = makeTempDir("akm-vqa-stash-");
102
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
103
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "# production keys\nAPI_KEY=secret\nDB_PASS=hidden\n", "utf8");
104
+ const listResult = runCli(["vault", "list", "vault:prod"], { AKM_STASH_DIR: stashDir });
105
+ const showResult = runCli(["vault", "show", "vault:prod"], { AKM_STASH_DIR: stashDir });
106
+ expect(listResult.status).toBe(0);
107
+ expect(showResult.status).toBe(0);
108
+ const listParsed = JSON.parse(listResult.stdout.trim());
109
+ const showParsed = JSON.parse(showResult.stdout.trim());
110
+ expect(showParsed).toEqual(listParsed);
111
+ });
112
+ });
113
+ describe("vault list: output flags with optional ref", () => {
114
+ test("6. vault list --format json returns the vault list, not a vault:json error", () => {
115
+ const stashDir = makeTempDir("akm-vqa-stash-");
116
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
117
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "API_KEY=secret\n", "utf8");
118
+ const result = runCli(["vault", "list", "--format", "json"], { AKM_STASH_DIR: stashDir });
119
+ expect(result.status).toBe(0);
120
+ expect(result.stderr).not.toContain("Vault not found: vault:json");
121
+ const parsed = JSON.parse(result.stdout.trim());
122
+ expect(parsed.vaults).toEqual([
123
+ expect.objectContaining({
124
+ ref: "vault:prod",
125
+ path: path.join(stashDir, "vaults", "prod.env"),
126
+ keyCount: 1,
127
+ }),
128
+ ]);
129
+ });
130
+ test("7. vault list json --format json still treats json as the ref", () => {
131
+ const stashDir = makeTempDir("akm-vqa-stash-");
132
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
133
+ fs.writeFileSync(path.join(stashDir, "vaults", "json.env"), "# json vault\nAPI_KEY=secret\n", "utf8");
134
+ const result = runCli(["vault", "list", "json", "--format", "json"], { AKM_STASH_DIR: stashDir });
135
+ expect(result.status).toBe(0);
136
+ const parsed = JSON.parse(result.stdout.trim());
137
+ expect(parsed).toMatchObject({
138
+ ref: "vault:json",
139
+ path: path.join(stashDir, "vaults", "json.env"),
140
+ entries: [expect.objectContaining({ key: "API_KEY" })],
141
+ });
142
+ });
143
+ });
144
+ // ── vault set combined KEY=VALUE form (CLI tests) ────────────────────────────
145
+ describe("vault set: KEY=VALUE combined form", () => {
146
+ test("8. vault set prod KEY=value succeeds and writes KEY=value", () => {
147
+ const stashDir = makeTempDir("akm-vqa-stash-");
148
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
149
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "", "utf8");
150
+ const result = runCli(["vault", "set", "prod", "MY_KEY=myvalue"], { AKM_STASH_DIR: stashDir });
151
+ expect(result.status).toBe(0);
152
+ const vaultPath = path.join(stashDir, "vaults", "prod.env");
153
+ expect(loadEnv(vaultPath).MY_KEY).toBe("myvalue");
154
+ });
155
+ test("9. vault set prod KEY value (3-arg form) still works", () => {
156
+ const stashDir = makeTempDir("akm-vqa-stash-");
157
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
158
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "", "utf8");
159
+ const result = runCli(["vault", "set", "prod", "ANOTHER_KEY", "anothervalue"], { AKM_STASH_DIR: stashDir });
160
+ expect(result.status).toBe(0);
161
+ const vaultPath = path.join(stashDir, "vaults", "prod.env");
162
+ expect(loadEnv(vaultPath).ANOTHER_KEY).toBe("anothervalue");
163
+ });
164
+ test("10. vault set prod KEY=val1=val2 writes KEY with value val1=val2 (split on first =)", () => {
165
+ const stashDir = makeTempDir("akm-vqa-stash-");
166
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
167
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "", "utf8");
168
+ const result = runCli(["vault", "set", "prod", "COMPLEX_KEY=val1=val2"], { AKM_STASH_DIR: stashDir });
169
+ expect(result.status).toBe(0);
170
+ const vaultPath = path.join(stashDir, "vaults", "prod.env");
171
+ expect(loadEnv(vaultPath).COMPLEX_KEY).toBe("val1=val2");
172
+ });
173
+ });
174
+ // ── vault set --comment flag (CLI tests) ─────────────────────────────────────
175
+ describe("vault set: --comment flag", () => {
176
+ test("11. vault set prod KEY val --comment writes a comment line above the key", () => {
177
+ const stashDir = makeTempDir("akm-vqa-stash-");
178
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
179
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "", "utf8");
180
+ const result = runCli(["vault", "set", "prod", "AUTH_TOKEN", "tok123", "--comment", "auth secret"], {
181
+ AKM_STASH_DIR: stashDir,
182
+ });
183
+ expect(result.status).toBe(0);
184
+ const vaultPath = path.join(stashDir, "vaults", "prod.env");
185
+ const text = fs.readFileSync(vaultPath, "utf8");
186
+ expect(text).toContain("# auth secret");
187
+ const lines = text.split("\n").filter((l) => l.length > 0);
188
+ const commentIdx = lines.indexOf("# auth secret");
189
+ const keyIdx = lines.findIndex((l) => l.startsWith("AUTH_TOKEN="));
190
+ expect(commentIdx).toBeGreaterThanOrEqual(0);
191
+ expect(keyIdx).toBe(commentIdx + 1);
192
+ expect(loadEnv(vaultPath).AUTH_TOKEN).toBe("tok123");
193
+ });
194
+ });
@@ -0,0 +1,429 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { buildShellExportScript, createVault, injectIntoEnv, listKeys, loadEnv, setKey, unsetKey, } from "../src/commands/vault";
6
+ import { getDbPath } from "../src/core/paths";
7
+ import { closeDatabase, getAllEntries, openDatabase } from "../src/indexer/db";
8
+ import { akmIndex } from "../src/indexer/indexer";
9
+ // ── Test fixtures ───────────────────────────────────────────────────────────
10
+ const createdTmpDirs = [];
11
+ function tmpDir(label = "vault") {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `akm-${label}-`));
13
+ createdTmpDirs.push(dir);
14
+ return dir;
15
+ }
16
+ afterAll(() => {
17
+ for (const dir of createdTmpDirs) {
18
+ fs.rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ });
21
+ // ── listKeys ────────────────────────────────────────────────────────────────
22
+ describe("listKeys", () => {
23
+ test("returns keys + comments only, no values", () => {
24
+ const dir = tmpDir();
25
+ const fp = path.join(dir, "v.env");
26
+ fs.writeFileSync(fp, ["# top comment", "DB_URL=postgres://example", "API_TOKEN=secret-value-do-not-leak", "# bottom comment"].join("\n"));
27
+ const result = listKeys(fp);
28
+ expect(result.keys).toEqual(["DB_URL", "API_TOKEN"]);
29
+ expect(result.comments).toEqual(["top comment", "bottom comment"]);
30
+ // Sanity: the function's return shape has no value-bearing field
31
+ expect(Object.keys(result).sort()).toEqual(["comments", "keys"]);
32
+ });
33
+ test("captures only start-of-line comments, never trailing/inline", () => {
34
+ const dir = tmpDir();
35
+ const fp = path.join(dir, "v.env");
36
+ fs.writeFileSync(fp, [
37
+ "# header comment",
38
+ " # indented comment",
39
+ "FOO=bar # trailing-comment-not-extracted",
40
+ "BAZ=qux",
41
+ "# footer comment",
42
+ ].join("\n"));
43
+ const result = listKeys(fp);
44
+ expect(result.comments).toEqual(["header comment", "indented comment", "footer comment"]);
45
+ // The trailing-comment text must not leak in via comments
46
+ expect(result.comments.join(" ")).not.toContain("trailing-comment-not-extracted");
47
+ });
48
+ test("preserves key order and de-duplicates", () => {
49
+ const dir = tmpDir();
50
+ const fp = path.join(dir, "v.env");
51
+ fs.writeFileSync(fp, "FOO=first\nBAR=middle\nFOO=second\n");
52
+ expect(listKeys(fp).keys).toEqual(["FOO", "BAR"]);
53
+ });
54
+ test("recognises `export KEY=value`", () => {
55
+ const dir = tmpDir();
56
+ const fp = path.join(dir, "v.env");
57
+ fs.writeFileSync(fp, "export FOO=bar\n");
58
+ expect(listKeys(fp).keys).toEqual(["FOO"]);
59
+ });
60
+ test("returns empty result for missing file", () => {
61
+ const result = listKeys(path.join(tmpDir(), "missing.env"));
62
+ expect(result).toEqual({ keys: [], comments: [] });
63
+ });
64
+ });
65
+ // ── loadEnv (delegates to dotenv) ───────────────────────────────────────────
66
+ describe("loadEnv", () => {
67
+ test("returns parsed key/value pairs via dotenv", () => {
68
+ const dir = tmpDir();
69
+ const fp = path.join(dir, "v.env");
70
+ fs.writeFileSync(fp, 'FOO=bar\nQUOTED="hello world"\nMULTI="line1\\nline2"\n');
71
+ const env = loadEnv(fp);
72
+ expect(env.FOO).toBe("bar");
73
+ expect(env.QUOTED).toBe("hello world");
74
+ expect(env.MULTI).toBe("line1\nline2");
75
+ });
76
+ test("returns empty object for missing file", () => {
77
+ expect(loadEnv(path.join(tmpDir(), "missing.env"))).toEqual({});
78
+ });
79
+ });
80
+ // ── setKey / unsetKey ───────────────────────────────────────────────────────
81
+ describe("setKey", () => {
82
+ test("creates the file and parent directory if missing", () => {
83
+ const dir = tmpDir();
84
+ const fp = path.join(dir, "vaults", "new.env");
85
+ setKey(fp, "FOO", "bar");
86
+ expect(fs.existsSync(fp)).toBe(true);
87
+ expect(fs.readFileSync(fp, "utf8")).toContain("FOO=bar");
88
+ });
89
+ test("preserves comments and order when adding a new key", () => {
90
+ const dir = tmpDir();
91
+ const fp = path.join(dir, "v.env");
92
+ fs.writeFileSync(fp, "# top\nFOO=one\n# middle\nBAR=two\n");
93
+ setKey(fp, "BAZ", "three");
94
+ const text = fs.readFileSync(fp, "utf8");
95
+ expect(text).toContain("# top");
96
+ expect(text).toContain("# middle");
97
+ expect(text).toContain("FOO=one");
98
+ expect(text).toContain("BAR=two");
99
+ expect(text).toContain("BAZ=three");
100
+ // New key appended after existing content (order preserved)
101
+ expect(text.indexOf("BAR=two")).toBeLessThan(text.indexOf("BAZ=three"));
102
+ });
103
+ test("replaces existing key in place", () => {
104
+ const dir = tmpDir();
105
+ const fp = path.join(dir, "v.env");
106
+ fs.writeFileSync(fp, "FOO=old\nBAR=keep\n");
107
+ setKey(fp, "FOO", "new");
108
+ const result = listKeys(fp);
109
+ expect(result.keys).toEqual(["FOO", "BAR"]);
110
+ expect(loadEnv(fp).FOO).toBe("new");
111
+ expect(loadEnv(fp).BAR).toBe("keep");
112
+ });
113
+ test("round-trips values containing whitespace, quotes, and special characters", () => {
114
+ const dir = tmpDir();
115
+ const fp = path.join(dir, "v.env");
116
+ setKey(fp, "FOO", "hello world");
117
+ setKey(fp, "BAR", 'has"quote');
118
+ setKey(fp, "BAZ", "trailing # not a comment");
119
+ setKey(fp, "EQ", "a=b=c");
120
+ setKey(fp, "BACK", "C:\\path\\to\\file");
121
+ setKey(fp, "APOS", "it's fine");
122
+ const env = loadEnv(fp);
123
+ expect(env.FOO).toBe("hello world");
124
+ expect(env.BAR).toBe('has"quote');
125
+ expect(env.BAZ).toBe("trailing # not a comment");
126
+ expect(env.EQ).toBe("a=b=c");
127
+ expect(env.BACK).toBe("C:\\path\\to\\file");
128
+ expect(env.APOS).toBe("it's fine");
129
+ });
130
+ test("rejects values containing newlines", () => {
131
+ const dir = tmpDir();
132
+ const fp = path.join(dir, "v.env");
133
+ expect(() => setKey(fp, "FOO", "line1\nline2")).toThrow();
134
+ });
135
+ test("rejects values containing both single and double quotes", () => {
136
+ const dir = tmpDir();
137
+ const fp = path.join(dir, "v.env");
138
+ expect(() => setKey(fp, "FOO", 'it\'s "complicated"')).toThrow();
139
+ });
140
+ test("rejects invalid key names", () => {
141
+ const dir = tmpDir();
142
+ const fp = path.join(dir, "v.env");
143
+ expect(() => setKey(fp, "1BAD", "x")).toThrow();
144
+ expect(() => setKey(fp, "WITH-DASH", "x")).toThrow();
145
+ expect(() => setKey(fp, "WITH SPACE", "x")).toThrow();
146
+ });
147
+ test("file is written with mode 0600", () => {
148
+ if (process.platform === "win32")
149
+ return; // chmod is best-effort on win32
150
+ const dir = tmpDir();
151
+ const fp = path.join(dir, "v.env");
152
+ setKey(fp, "FOO", "bar");
153
+ const stat = fs.statSync(fp);
154
+ expect(stat.mode & 0o777).toBe(0o600);
155
+ });
156
+ });
157
+ describe("unsetKey", () => {
158
+ test("removes a key and returns true", () => {
159
+ const dir = tmpDir();
160
+ const fp = path.join(dir, "v.env");
161
+ fs.writeFileSync(fp, "FOO=one\nBAR=two\n");
162
+ expect(unsetKey(fp, "FOO")).toBe(true);
163
+ expect(listKeys(fp).keys).toEqual(["BAR"]);
164
+ });
165
+ test("returns false when the key is absent", () => {
166
+ const dir = tmpDir();
167
+ const fp = path.join(dir, "v.env");
168
+ fs.writeFileSync(fp, "FOO=one\n");
169
+ expect(unsetKey(fp, "NOPE")).toBe(false);
170
+ });
171
+ test("returns false when the file does not exist", () => {
172
+ expect(unsetKey(path.join(tmpDir(), "missing.env"), "FOO")).toBe(false);
173
+ });
174
+ });
175
+ // ── createVault ─────────────────────────────────────────────────────────────
176
+ describe("createVault", () => {
177
+ test("creates an empty file", () => {
178
+ const dir = tmpDir();
179
+ const fp = path.join(dir, "vaults", "prod.env");
180
+ createVault(fp);
181
+ expect(fs.existsSync(fp)).toBe(true);
182
+ expect(fs.readFileSync(fp, "utf8")).toBe("");
183
+ });
184
+ test("does not overwrite an existing file", () => {
185
+ const dir = tmpDir();
186
+ const fp = path.join(dir, "v.env");
187
+ fs.writeFileSync(fp, "FOO=existing\n");
188
+ createVault(fp);
189
+ expect(loadEnv(fp).FOO).toBe("existing");
190
+ });
191
+ });
192
+ // ── injectIntoEnv ───────────────────────────────────────────────────────────
193
+ describe("injectIntoEnv", () => {
194
+ test("assigns values into the supplied target and returns the list of keys set", () => {
195
+ const dir = tmpDir();
196
+ const fp = path.join(dir, "v.env");
197
+ fs.writeFileSync(fp, "ALPHA=one\nBETA=two\n");
198
+ const target = { PRE_EXISTING: "kept" };
199
+ const keys = injectIntoEnv(fp, target);
200
+ expect(keys.sort()).toEqual(["ALPHA", "BETA"]);
201
+ expect(target.ALPHA).toBe("one");
202
+ expect(target.BETA).toBe("two");
203
+ expect(target.PRE_EXISTING).toBe("kept");
204
+ });
205
+ test("returns empty list when the file is missing", () => {
206
+ const target = {};
207
+ expect(injectIntoEnv(path.join(tmpDir(), "missing.env"), target)).toEqual([]);
208
+ expect(target).toEqual({});
209
+ });
210
+ });
211
+ // ── quoteValue hardening (shell-metachar defence-in-depth) ──────────────────
212
+ //
213
+ // Even though `vault load` no longer `source`s the raw vault file (it
214
+ // parses with dotenv and sources a safely-escaped temp file), the on-disk
215
+ // vault format itself must be robust to direct `source` by any future
216
+ // caller. These tests lock in that every non-trivial value is quoted.
217
+ describe("setKey: shell-metachar hardening", () => {
218
+ test("values containing $, backticks, or $(...) are quoted on disk", () => {
219
+ const dir = tmpDir();
220
+ const fp = path.join(dir, "v.env");
221
+ setKey(fp, "DOLLAR", "abc$DEF");
222
+ setKey(fp, "BACKTICK", "pre`whoami`post");
223
+ setKey(fp, "CMDSUB", "pre$(id)post");
224
+ const raw = fs.readFileSync(fp, "utf8");
225
+ // None of these should appear as an unquoted assignment that a shell
226
+ // would expand on `source`. Our impl single-quotes them.
227
+ expect(raw).toMatch(/^DOLLAR='abc\$DEF'$/m);
228
+ expect(raw).toMatch(/^BACKTICK='pre`whoami`post'$/m);
229
+ expect(raw).toMatch(/^CMDSUB='pre\$\(id\)post'$/m);
230
+ });
231
+ test("values with shell-special chars ; & | * ? ( ) { } [ ] > < ~ ! are quoted", () => {
232
+ const dir = tmpDir();
233
+ const fp = path.join(dir, "v.env");
234
+ for (const [k, v] of Object.entries({
235
+ SEMI: "a;b",
236
+ AMP: "a&b",
237
+ PIPE: "a|b",
238
+ GLOB: "abc*",
239
+ QMARK: "abc?",
240
+ PAREN: "a(b)c",
241
+ BRACE: "a{b}c",
242
+ BRACK: "a[b]c",
243
+ REDIR: "a>b",
244
+ REDIR2: "a<b",
245
+ TILDE: "~/foo",
246
+ BANG: "a!b",
247
+ })) {
248
+ setKey(fp, k, v);
249
+ }
250
+ const raw = fs.readFileSync(fp, "utf8");
251
+ for (const line of raw.split("\n")) {
252
+ if (!line.includes("="))
253
+ continue;
254
+ const [, val] = line.match(/^[A-Z_]+=(.*)$/) ?? [];
255
+ if (!val)
256
+ continue;
257
+ // Any non-empty value must be quoted (either '...' or "...").
258
+ expect(val[0] === "'" || val[0] === '"').toBe(true);
259
+ }
260
+ });
261
+ test("round-trip preserves exact values through dotenv.parse", () => {
262
+ const dir = tmpDir();
263
+ const fp = path.join(dir, "v.env");
264
+ const payloads = {
265
+ DOLLAR: "abc$HOME",
266
+ BACKTICK: "pre`whoami`",
267
+ CMDSUB: "pre$(rm -rf /tmp/shouldnothappen)post",
268
+ GLOB: "*.env",
269
+ TILDE: "~/root",
270
+ SEMI: "a;b",
271
+ BANG: "echo!123",
272
+ AMPERSAND: "x && y",
273
+ NESTED: 'it has "double" quotes only',
274
+ };
275
+ for (const [k, v] of Object.entries(payloads))
276
+ setKey(fp, k, v);
277
+ const env = loadEnv(fp);
278
+ for (const [k, v] of Object.entries(payloads)) {
279
+ expect(env[k]).toBe(v);
280
+ }
281
+ });
282
+ });
283
+ // ── buildShellExportScript (vault load safety) ──────────────────────────────
284
+ describe("buildShellExportScript", () => {
285
+ test("emits export lines with `'\\''` escaping; no expansion-triggering syntax", () => {
286
+ const dir = tmpDir();
287
+ const fp = path.join(dir, "v.env");
288
+ setKey(fp, "PLAIN", "hello");
289
+ setKey(fp, "DOLLAR", "abc$HOME");
290
+ setKey(fp, "APOS", "it's fine");
291
+ const script = buildShellExportScript(fp);
292
+ // Every line must be a single-quoted export assignment.
293
+ for (const line of script.split("\n").filter(Boolean)) {
294
+ expect(line).toMatch(/^export [A-Za-z_][A-Za-z0-9_]*='.*'$/);
295
+ }
296
+ expect(script).toContain("export PLAIN='hello'");
297
+ expect(script).toContain("export DOLLAR='abc$HOME'");
298
+ // Single quote inside value must be encoded as '\''
299
+ expect(script).toContain("export APOS='it'\\''s fine'");
300
+ });
301
+ test("sourcing the emitted script populates env without executing payloads", () => {
302
+ const dir = tmpDir();
303
+ const fp = path.join(dir, "v.env");
304
+ // The "value" is designed to execute `touch evidence` if it ever
305
+ // reaches a shell without quoting; a safe implementation must keep it
306
+ // literal.
307
+ const evidence = path.join(dir, "evidence");
308
+ setKey(fp, "EVIL", `$(touch ${evidence})`);
309
+ setKey(fp, "OK", "ok-value");
310
+ const script = buildShellExportScript(fp);
311
+ const scriptPath = path.join(dir, "source-me.sh");
312
+ fs.writeFileSync(scriptPath, script);
313
+ const { spawnSync } = require("node:child_process");
314
+ const result = spawnSync("bash", ["-c", `set -eu; . '${scriptPath}'; printf '%s\\n' "$EVIL" "$OK"`], {
315
+ encoding: "utf8",
316
+ });
317
+ expect(result.status).toBe(0);
318
+ const [evilOut, okOut] = (result.stdout ?? "").split("\n");
319
+ // EVIL must come back as the literal string — not executed.
320
+ expect(evilOut).toBe(`$(touch ${evidence})`);
321
+ expect(okOut).toBe("ok-value");
322
+ // The command substitution must NOT have run.
323
+ expect(fs.existsSync(evidence)).toBe(false);
324
+ });
325
+ });
326
+ // ── Indexer leakage safety (the critical security test) ─────────────────────
327
+ const originalXdgConfig = process.env.XDG_CONFIG_HOME;
328
+ const originalXdgCache = process.env.XDG_CACHE_HOME;
329
+ const originalAkmStash = process.env.AKM_STASH_DIR;
330
+ let testConfigDir = "";
331
+ let testCacheDir = "";
332
+ beforeEach(() => {
333
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-vault-config-"));
334
+ testCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-vault-cache-"));
335
+ process.env.XDG_CONFIG_HOME = testConfigDir;
336
+ process.env.XDG_CACHE_HOME = testCacheDir;
337
+ const dbPath = getDbPath();
338
+ for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
339
+ try {
340
+ fs.unlinkSync(f);
341
+ }
342
+ catch {
343
+ /* ignore */
344
+ }
345
+ }
346
+ });
347
+ afterEach(() => {
348
+ if (originalXdgConfig === undefined)
349
+ delete process.env.XDG_CONFIG_HOME;
350
+ else
351
+ process.env.XDG_CONFIG_HOME = originalXdgConfig;
352
+ if (originalXdgCache === undefined)
353
+ delete process.env.XDG_CACHE_HOME;
354
+ else
355
+ process.env.XDG_CACHE_HOME = originalXdgCache;
356
+ if (originalAkmStash === undefined)
357
+ delete process.env.AKM_STASH_DIR;
358
+ else
359
+ process.env.AKM_STASH_DIR = originalAkmStash;
360
+ if (testConfigDir)
361
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
362
+ if (testCacheDir)
363
+ fs.rmSync(testCacheDir, { recursive: true, force: true });
364
+ });
365
+ const SECRET_VALUE = "correct-horse-battery-staple-do-not-leak";
366
+ describe("vault indexer safety", () => {
367
+ test("vault values never appear in the FTS index, search_text, or entry_json", async () => {
368
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-vault-stash-"));
369
+ createdTmpDirs.push(stashDir);
370
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
371
+ const vaultPath = path.join(stashDir, "vaults", "prod.env");
372
+ fs.writeFileSync(vaultPath, [
373
+ "# Production secrets",
374
+ `SECRET_TOKEN=${SECRET_VALUE}`,
375
+ "DB_PASSWORD=another-secret-pa55w0rd",
376
+ "# Last rotated 2026-04-01",
377
+ ].join("\n"));
378
+ process.env.AKM_STASH_DIR = stashDir;
379
+ const result = await akmIndex({ stashDir, full: true });
380
+ expect(result.totalEntries).toBe(1);
381
+ const db = openDatabase();
382
+ try {
383
+ const entries = getAllEntries(db);
384
+ expect(entries.length).toBe(1);
385
+ const vaultEntry = entries[0];
386
+ // 1. The entry is classified as vault
387
+ expect(vaultEntry.entry.type).toBe("vault");
388
+ expect(vaultEntry.entry.name).toBe("prod");
389
+ // 2. Keys are exposed via searchHints
390
+ expect(vaultEntry.entry.searchHints).toContain("SECRET_TOKEN");
391
+ expect(vaultEntry.entry.searchHints).toContain("DB_PASSWORD");
392
+ // 3. Comments are surfaced in the description
393
+ expect(vaultEntry.entry.description).toContain("Production secrets");
394
+ // 4. CRITICAL: the secret value is nowhere in the persisted record
395
+ const json = JSON.stringify(vaultEntry);
396
+ expect(json).not.toContain(SECRET_VALUE);
397
+ expect(json).not.toContain("another-secret-pa55w0rd");
398
+ const rows = db.query("SELECT search_text, entry_json FROM entries WHERE entry_type = ?").all("vault");
399
+ expect(rows.length).toBe(1);
400
+ expect(rows[0].search_text ?? "").not.toContain(SECRET_VALUE);
401
+ expect(rows[0].entry_json).not.toContain(SECRET_VALUE);
402
+ const ftsHit = db
403
+ .query("SELECT count(*) AS c FROM entries_fts WHERE entries_fts MATCH ?")
404
+ .get("correct");
405
+ expect(ftsHit.c).toBe(0);
406
+ }
407
+ finally {
408
+ closeDatabase(db);
409
+ }
410
+ });
411
+ test("vault entries are searchable by key name", async () => {
412
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-vault-stash-"));
413
+ createdTmpDirs.push(stashDir);
414
+ fs.mkdirSync(path.join(stashDir, "vaults"), { recursive: true });
415
+ fs.writeFileSync(path.join(stashDir, "vaults", "prod.env"), "STRIPE_API_KEY=sk_test_xxx\n");
416
+ process.env.AKM_STASH_DIR = stashDir;
417
+ await akmIndex({ stashDir, full: true });
418
+ const db = openDatabase();
419
+ try {
420
+ const hit = db
421
+ .query("SELECT count(*) AS c FROM entries_fts WHERE entries_fts MATCH ?")
422
+ .get("STRIPE_API_KEY");
423
+ expect(hit.c).toBe(1);
424
+ }
425
+ finally {
426
+ closeDatabase(db);
427
+ }
428
+ });
429
+ });