akm-cli 0.9.1 → 0.9.2-alpha.2

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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -13,26 +13,71 @@
13
13
  * a lock entry is "managed" (installed from a registry and overwritten on
14
14
  * `akm bundle update`); a bundle with no lock is a plain filesystem/git/website source.
15
15
  */
16
+ import { createHash } from "node:crypto";
16
17
  import fs from "node:fs";
17
18
  import path from "node:path";
18
19
  import { isWithin, resolveStashDir } from "../../core/common.js";
19
- import { getSources, loadConfig } from "../../core/config/config.js";
20
- import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
20
+ import { acquireConfigReadFence, bundleComponentConfig, getSources, loadConfig } from "../../core/config/config.js";
21
+ import { AkmError, ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
21
22
  import { isPathAbsent } from "../../core/path-access.js";
22
- import { getDbPath } from "../../core/paths.js";
23
+ import { getDbPath, getRegistryCacheDir } from "../../core/paths.js";
24
+ import { beginImmediateTransaction, getStateDbPath, openStateDatabase } from "../../core/state-db.js";
23
25
  import { warn } from "../../core/warn.js";
26
+ import { resolveGitContentRoot } from "../../core/write-source.js";
24
27
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
25
28
  import { akmIndex } from "../../indexer/indexer.js";
26
- import { compareAndSwapLockfile, readLockfile } from "../../integrations/lockfile.js";
29
+ import { compareAndSwapLockfileSnapshot, publishLockfileUpdate, readLockfile, readLockfileForUpdate, } from "../../integrations/lockfile.js";
27
30
  import { parseRegistryRef } from "../../registry/resolve.js";
28
31
  import { sha256Hex } from "../../runtime.js";
29
- import { parseGitRepoUrl, syncMirroredRepo } from "../../sources/providers/git.js";
32
+ import { getCachePaths, parseGitRepoUrl, runGit, syncMirroredRepo } from "../../sources/providers/git.js";
30
33
  import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
31
- import { ensureWebsiteMirror, shouldAllowPrivateWebsiteUrlForTests, } from "../../sources/snapshot-fetchers/website-ingest.js";
32
- import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
34
+ import { createWebsiteProvider } from "../../sources/providers/website.js";
35
+ import { storeSecretResolver } from "../../sources/snapshot-fetchers/secret-seam.js";
36
+ import { ensureWebsiteMirror } from "../../sources/snapshot-fetchers/website-ingest.js";
37
+ import { getWebsiteCachePaths } from "../../sources/website-url.js";
38
+ import { closeDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../../storage/repositories/index-connection.js";
33
39
  import { getAllEntries } from "../../storage/repositories/index-entries-repository.js";
40
+ import { auditStashForDangerousKeys, scanStashForDangerousKeys } from "./dangerous-env-audit.js";
34
41
  import { removeInstalledRegistryEntry } from "./source-add.js";
35
42
  import { removeStash } from "./source-manage.js";
43
+ import { cleanupStagingParent, createStagingParent, prepareDirectoryPublication, prepareWritableGitPublication, } from "./update-transaction.js";
44
+ function canonicalJson(value) {
45
+ if (Array.isArray(value))
46
+ return `[${value.map(canonicalJson).join(",")}]`;
47
+ if (value && typeof value === "object") {
48
+ const entries = Object.entries(value)
49
+ .filter(([, entry]) => entry !== undefined)
50
+ .sort(([left], [right]) => left.localeCompare(right));
51
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
52
+ }
53
+ return JSON.stringify(value);
54
+ }
55
+ function bundleAuditGeneration(entry) {
56
+ return sha256Hex(canonicalJson(entry ?? null));
57
+ }
58
+ function bundleAuditFence(config, id) {
59
+ const bundle = config.bundles?.[id];
60
+ if (!bundle)
61
+ throw new ConfigError(`Source "${id}" disappeared before update staging.`);
62
+ return {
63
+ componentRoot: bundleComponentConfig(bundle)?.root ?? ".",
64
+ generation: bundleAuditGeneration(bundle),
65
+ writable: bundleComponentConfig(bundle)?.writable ?? bundle.writable === true,
66
+ };
67
+ }
68
+ function assertBundleAuditFence(config, id, expectedGeneration) {
69
+ const current = config.bundles?.[id];
70
+ if (bundleAuditGeneration(current) !== expectedGeneration) {
71
+ throw new ConfigError(`Source "${id}" config changed after its staged bytes were audited; refusing to publish without re-staging and re-auditing.`);
72
+ }
73
+ }
74
+ function currentPlainSource(config, id, type) {
75
+ const source = getSources(config).find((candidate) => candidate.name === id && candidate.type === type);
76
+ if (!source) {
77
+ throw new ConfigError(`Source "${id}" changed or disappeared before update staging; retry to re-resolve it.`);
78
+ }
79
+ return source;
80
+ }
36
81
  /** Enumerate the registry-managed installs (lock-backed bundles) in a config. */
37
82
  function listManagedInstalls(config) {
38
83
  const bundles = config.bundles ?? {};
@@ -43,6 +88,7 @@ function listManagedInstalls(config) {
43
88
  if (!lock)
44
89
  continue; // only lock-backed bundles are registry-managed
45
90
  const componentWritable = Object.values(bundle.components ?? {})[0]?.writable;
91
+ const componentRoot = bundleComponentConfig(bundle)?.root ?? ".";
46
92
  out.push({
47
93
  bundleKey: key,
48
94
  installId: bundle.registryId ?? key,
@@ -52,9 +98,9 @@ function listManagedInstalls(config) {
52
98
  resolvedVersion: lock.resolvedVersion,
53
99
  resolvedRevision: lock.resolvedRevision,
54
100
  writable: componentWritable ?? bundle.writable === true,
55
- requiredRoots: lock.localRoot
56
- ? Object.values(bundle.components ?? {}).map((component) => path.resolve(lock.localRoot, component.root ?? "."))
57
- : [],
101
+ componentRoot,
102
+ requiredRoots: lock.localRoot ? [path.resolve(lock.localRoot, componentRoot)] : [],
103
+ auditConfigGeneration: bundleAuditGeneration(bundle),
58
104
  });
59
105
  }
60
106
  return out;
@@ -272,10 +318,26 @@ export async function akmRemove(input) {
272
318
  },
273
319
  };
274
320
  }
275
- // ── akmUpdate helpers ────────────────────────────────────────────────────────
276
- /** Build a standard UpdateResponse summary block from the current config and index run. */
277
- async function buildUpdateResponse(stashDir, target, all, processed, opts) {
278
- const index = opts?.index ?? (await akmIndex({ stashDir, ...(opts?.full ? { full: true } : {}) }));
321
+ /** Read the current index generation without creating or hydrating anything. */
322
+ function readCurrentIndexSummary() {
323
+ const db = openReadonlyExistingDatabase(getDbPath());
324
+ if (!db)
325
+ return { mode: "incremental", totalEntries: 0, directoriesScanned: 0, directoriesSkipped: 0 };
326
+ try {
327
+ return {
328
+ mode: "incremental",
329
+ totalEntries: getAllEntries(db).length,
330
+ directoriesScanned: 0,
331
+ directoriesSkipped: 0,
332
+ };
333
+ }
334
+ finally {
335
+ closeDatabase(db);
336
+ }
337
+ }
338
+ /** Build a standard UpdateResponse summary block from a committed or unchanged index generation. */
339
+ function buildUpdateResponse(stashDir, target, all, processed, opts) {
340
+ const index = opts.index;
279
341
  const finalConfig = loadConfig();
280
342
  return {
281
343
  schemaVersion: 1,
@@ -293,37 +355,569 @@ async function buildUpdateResponse(stashDir, target, all, processed, opts) {
293
355
  totalEntries: index.totalEntries,
294
356
  directoriesScanned: index.directoriesScanned,
295
357
  directoriesSkipped: index.directoriesSkipped,
358
+ ...(index.scanComplete !== undefined ? { scanComplete: index.scanComplete } : {}),
296
359
  },
297
360
  };
298
361
  }
299
- /**
300
- * Sync a git-mirrored (plain) source in place. Returns the {@link UpdatePlainSyncedItem}
301
- * record instead of building the full response, so `--all` can batch several of
302
- * these into one response alongside managed installs (R-015).
303
- */
304
- async function syncGitPlainSource(gitSource) {
305
- await syncMirroredRepo(gitSource, { force: true, writable: gitSource.writable === true });
306
- return { id: gitSource.name ?? gitSource.url ?? "", kind: "git", ref: gitSource.url ?? "" };
307
- }
308
- /** Re-crawl a website (plain) source in place. See {@link syncGitPlainSource}. */
309
- async function syncWebsitePlainSource(websiteSource) {
310
- // TODO: full incremental re-crawl with delta tracking (#19)
311
- await ensureWebsiteMirror(websiteSource, {
312
- requireStashDir: true,
313
- force: true,
314
- ...(shouldAllowPrivateWebsiteUrlForTests(websiteSource.url ?? "") ? { allowPrivateHosts: true } : {}),
362
+ function incompleteFilesystemOutcome(id) {
363
+ return {
364
+ id,
365
+ kind: "filesystem",
366
+ status: "skipped",
367
+ code: "SOURCE_SCAN_INCOMPLETE",
368
+ reason: `Filesystem source "${id}" was not scanned completely; preserving its last-known-good index rows.`,
369
+ };
370
+ }
371
+ let updateTransactionHookForTests;
372
+ /** TEST-ONLY. Inject faults at source-update transaction boundaries. */
373
+ export function _setUpdateTransactionHookForTests(hook) {
374
+ updateTransactionHookForTests = hook;
375
+ }
376
+ function updateTransactionHook(point, id, context) {
377
+ updateTransactionHookForTests?.(point, id, context);
378
+ }
379
+ function gitHead(repoDir) {
380
+ const result = runGit(["-C", repoDir, "rev-parse", "HEAD"]);
381
+ return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : undefined;
382
+ }
383
+ function invalidWritableGitPublication(repoDir) {
384
+ return {
385
+ publish() {
386
+ throw new ConfigError(`Writable Git source at ${repoDir} is not a valid checkout; refusing to replace its live directory.`);
387
+ },
388
+ rollback() { },
389
+ commit() { },
390
+ };
391
+ }
392
+ function writableGitPublication(opts) {
393
+ const auditedTargetHead = opts.auditedTargetHead ?? gitHead(opts.stagedRepo);
394
+ if (!opts.expectedOldHead || !auditedTargetHead)
395
+ return invalidWritableGitPublication(opts.liveRepo);
396
+ return prepareWritableGitPublication({
397
+ stagedRepo: opts.stagedRepo,
398
+ liveRepo: opts.liveRepo,
399
+ expectedOldHead: opts.expectedOldHead,
400
+ auditedTargetHead,
401
+ });
402
+ }
403
+ const UPDATE_STATE_SCHEMA = "akm_update_state";
404
+ function sqliteStringLiteral(value) {
405
+ return `'${value.replaceAll("'", "''")}'`;
406
+ }
407
+ function openUnifiedUpdateTransaction() {
408
+ // Keep canonical state activity registered for the entire attached
409
+ // transaction so maintenance/migration cannot replace state.db underneath
410
+ // the borrowed index connection.
411
+ const stateActivity = openStateDatabase();
412
+ try {
413
+ const statePath = getStateDbPath();
414
+ const db = openIndexDatabase(undefined, {
415
+ beforeSchema: (candidate) => {
416
+ candidate.exec(`ATTACH DATABASE ${sqliteStringLiteral(statePath)} AS "${UPDATE_STATE_SCHEMA}"`);
417
+ // openIndexDatabase invokes this before ensureSchema, so the outer
418
+ // transaction begins before the first update-owned index mutation.
419
+ beginImmediateTransaction(candidate);
420
+ },
421
+ });
422
+ return {
423
+ db,
424
+ stateActivity,
425
+ deferred: { db, stateSchema: UPDATE_STATE_SCHEMA },
426
+ };
427
+ }
428
+ catch (error) {
429
+ stateActivity.close();
430
+ throw error;
431
+ }
432
+ }
433
+ function rollbackUnifiedUpdateTransaction(transaction) {
434
+ if (!transaction.db.inTransaction)
435
+ return undefined;
436
+ try {
437
+ transaction.db.exec("ROLLBACK");
438
+ return undefined;
439
+ }
440
+ catch (error) {
441
+ return error;
442
+ }
443
+ }
444
+ function commitUnifiedUpdateTransaction(transaction) {
445
+ if (!transaction.db.inTransaction) {
446
+ throw new Error("Source update index returned without its active unified index/state transaction.");
447
+ }
448
+ transaction.db.exec("COMMIT");
449
+ }
450
+ function closeUnifiedUpdateTransaction(transaction, committed) {
451
+ let closeError = rollbackUnifiedUpdateTransaction(transaction);
452
+ try {
453
+ transaction.db.close();
454
+ }
455
+ catch (error) {
456
+ closeError ??= error;
457
+ }
458
+ try {
459
+ transaction.stateActivity.close();
460
+ }
461
+ catch (error) {
462
+ closeError ??= error;
463
+ }
464
+ if (closeError) {
465
+ warn(committed
466
+ ? `[akm bundle update] committed, but closing its database handles failed: ${String(closeError)}`
467
+ : `[akm bundle update] rolled back, but closing its database handles failed: ${String(closeError)}`);
468
+ }
469
+ }
470
+ function pathAtOrBelow(candidate, root) {
471
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
472
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
473
+ }
474
+ function remapStagedPath(candidate, stagedRoot, liveRoot) {
475
+ if (!pathAtOrBelow(candidate, stagedRoot))
476
+ return candidate;
477
+ return path.join(liveRoot, path.relative(stagedRoot, candidate));
478
+ }
479
+ function dangerousFindingGeneration(findings) {
480
+ return JSON.stringify(findings
481
+ .map(({ envRef, keyName, relPath }) => ({ envRef, keyName, relPath }))
482
+ .sort((left, right) => `${left.relPath}\0${left.envRef}\0${left.keyName}`.localeCompare(`${right.relPath}\0${right.envRef}\0${right.keyName}`)));
483
+ }
484
+ async function contentGeneration(root) {
485
+ const hash = createHash("sha256");
486
+ const resolvedRoot = path.resolve(root);
487
+ const walk = async (directory) => {
488
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
489
+ for (const entry of entries) {
490
+ if (entry.name === ".git")
491
+ continue;
492
+ const absolute = path.join(directory, entry.name);
493
+ const relative = path.relative(resolvedRoot, absolute).replaceAll(path.sep, "/");
494
+ const before = fs.lstatSync(absolute);
495
+ if (before.isDirectory()) {
496
+ hash.update(`D\0${relative}\0${before.mode & 0o777}\0`);
497
+ await walk(absolute);
498
+ }
499
+ else if (before.isSymbolicLink()) {
500
+ hash.update(`L\0${relative}\0${fs.readlinkSync(absolute)}\0`);
501
+ }
502
+ else if (before.isFile()) {
503
+ hash.update(`F\0${relative}\0${before.mode & 0o777}\0${before.size}\0`);
504
+ for await (const chunk of fs.createReadStream(absolute))
505
+ hash.update(chunk);
506
+ const after = fs.lstatSync(absolute);
507
+ if (after.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
508
+ throw new ConfigError(`Source content changed while ${relative} was being audited; retry the update.`);
509
+ }
510
+ hash.update("\0");
511
+ }
512
+ else {
513
+ throw new ConfigError(`Unsupported filesystem entry ${absolute}; refusing to audit an unstable source tree.`);
514
+ }
515
+ }
516
+ };
517
+ if (!fs.statSync(resolvedRoot).isDirectory())
518
+ throw new ConfigError(`Expected ${resolvedRoot} to be a directory.`);
519
+ await walk(resolvedRoot);
520
+ return hash.digest("hex");
521
+ }
522
+ async function verifyPublishedAudit(prepared, id) {
523
+ const liveRoot = prepared.publishedAuditRoot;
524
+ if (!liveRoot)
525
+ return;
526
+ const resolveLiveAuditRoot = () => {
527
+ const physicalRoot = canonicalWritablePath(liveRoot, `published component root for "${id}"`);
528
+ const containmentRoot = prepared.publishedAuditContainmentRoot;
529
+ if (containmentRoot && !pathAtOrBelow(physicalRoot, containmentRoot)) {
530
+ throw new ConfigError(`Published component root ${liveRoot} resolves outside its physical checkout at ${containmentRoot}.`, "DANGEROUS_ENV_AUDIT_FAILED");
531
+ }
532
+ const expected = prepared.publishedAuditExpectedPhysicalRoot;
533
+ if (expected && path.resolve(physicalRoot) !== path.resolve(expected)) {
534
+ throw new ConfigError(`Published component root ${liveRoot} resolves to ${physicalRoot}, not its audited staged target ${expected}.`, "DANGEROUS_ENV_AUDIT_FAILED");
535
+ }
536
+ return physicalRoot;
537
+ };
538
+ const beforeRoot = resolveLiveAuditRoot();
539
+ const beforeAudit = await contentGeneration(beforeRoot);
540
+ let findings;
541
+ try {
542
+ const scanRoot = resolveLiveAuditRoot();
543
+ if (scanRoot !== beforeRoot)
544
+ throw new Error("configured component symlink changed during re-audit");
545
+ findings = scanStashForDangerousKeys(scanRoot);
546
+ }
547
+ catch (error) {
548
+ throw new ConfigError(`Dangerous environment-key re-audit failed after materializing "${id}"; rolling the update back. ${error instanceof Error ? error.message : String(error)}`, "DANGEROUS_ENV_AUDIT_FAILED");
549
+ }
550
+ const afterRoot = resolveLiveAuditRoot();
551
+ const afterAudit = await contentGeneration(afterRoot);
552
+ if (afterRoot !== beforeRoot ||
553
+ beforeAudit !== prepared.auditedContentGeneration ||
554
+ afterAudit !== prepared.auditedContentGeneration ||
555
+ dangerousFindingGeneration(findings) !== prepared.approvedDangerousFindings) {
556
+ throw new ConfigError(`Writable source "${id}" changed while its audited Git target was materialized; refusing to activate unaudited bytes.`, "DANGEROUS_ENV_AUDIT_FAILED");
557
+ }
558
+ }
559
+ async function auditPreparedUpdate(prepared, id, ref, allowInsecure) {
560
+ updateTransactionHook("staged", id);
561
+ const decision = await auditStashForDangerousKeys({
562
+ stashRoot: prepared.auditRoot,
563
+ ref,
564
+ allowDangerousKeys: allowInsecure,
565
+ isTTY: process.stdin.isTTY === true,
566
+ operation: "update",
567
+ renderBlockedError: false,
315
568
  });
316
- return { id: websiteSource.name ?? websiteSource.url ?? "", kind: "website", ref: websiteSource.url ?? "" };
569
+ if (decision.blocked)
570
+ throw new NotFoundError(decision.error, decision.code);
571
+ prepared.approvedDangerousFindings = dangerousFindingGeneration(decision.findings);
572
+ prepared.auditedContentGeneration = await contentGeneration(prepared.auditRoot);
573
+ updateTransactionHook("audited", id);
574
+ }
575
+ function canonicalWritablePath(candidate, description) {
576
+ try {
577
+ return fs.realpathSync(candidate);
578
+ }
579
+ catch (error) {
580
+ throw new ConfigError(`Cannot resolve ${description} at ${candidate}; refusing writable Git update. ${error instanceof Error ? error.message : String(error)}`);
581
+ }
582
+ }
583
+ function containedWritablePath(candidate, physicalRepo, description) {
584
+ const physicalCandidate = canonicalWritablePath(candidate, description);
585
+ if (!pathAtOrBelow(physicalCandidate, physicalRepo)) {
586
+ throw new ConfigError(`${description} ${candidate} resolves outside its physical checkout at ${physicalRepo}.`);
587
+ }
588
+ return physicalCandidate;
589
+ }
590
+ function prepareWritableManagedUpdate(managed, force) {
591
+ const physicalLocalRoot = canonicalWritablePath(managed.localRoot, "managed content root");
592
+ const topLevel = runGit(["-C", physicalLocalRoot, "rev-parse", "--show-toplevel"]);
593
+ // Production writable installs are Git checkouts. Falling back to the
594
+ // locked root keeps the provider seam testable; the real Git provider still
595
+ // refuses a copied non-checkout before publication.
596
+ const liveRepo = topLevel.status === 0 && topLevel.stdout.trim()
597
+ ? canonicalWritablePath(topLevel.stdout.trim(), "managed Git top-level")
598
+ : physicalLocalRoot;
599
+ if (!pathAtOrBelow(physicalLocalRoot, liveRepo)) {
600
+ throw new ConfigError(`Writable Git content root ${managed.localRoot} resolves outside its physical checkout at ${liveRepo}.`);
601
+ }
602
+ // Keep the configured component path as a lexical identity relative to the
603
+ // content root. Resolving it to today's symlink target here would pin the old
604
+ // target and miss an upstream commit that retargets the symlink.
605
+ const lexicalLocalRoot = path.resolve(managed.localRoot);
606
+ const configuredLiveRoots = managed.requiredRoots.map((root) => {
607
+ const lexicalRoot = path.resolve(root);
608
+ if (!pathAtOrBelow(lexicalRoot, lexicalLocalRoot)) {
609
+ throw new ConfigError(`Configured writable Git component root ${lexicalRoot} escapes content root ${lexicalLocalRoot}.`);
610
+ }
611
+ return path.resolve(physicalLocalRoot, path.relative(lexicalLocalRoot, lexicalRoot));
612
+ });
613
+ const physicalRequiredRoots = configuredLiveRoots.map((root) => containedWritablePath(root, liveRepo, `Configured component root for "${managed.installId}"`));
614
+ // The Git provider must preserve ordinary component directories, but not a
615
+ // symlink's current physical target. The configured lexical path is audited
616
+ // again after sync, so retaining the old target here would reject a valid
617
+ // upstream retarget before the new target can reach that audit.
618
+ const preservedPhysicalRequiredRoots = physicalRequiredRoots.filter((root, index) => configuredLiveRoots[index] === root);
619
+ const expectedOldHead = gitHead(liveRepo);
620
+ const stagingParent = createStagingParent(path.dirname(liveRepo));
621
+ const stagedRepo = path.join(stagingParent, path.basename(liveRepo));
622
+ fs.cpSync(liveRepo, stagedRepo, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
623
+ const stagedContentRoot = remapStagedPath(physicalLocalRoot, liveRepo, stagedRepo);
624
+ const stagedPhysicalRequiredRoots = preservedPhysicalRequiredRoots.map((root) => remapStagedPath(root, liveRepo, stagedRepo));
625
+ const stagedConfiguredRoots = configuredLiveRoots.map((root) => remapStagedPath(root, liveRepo, stagedRepo));
626
+ return syncFromRef(managed.ref, {
627
+ force,
628
+ writable: true,
629
+ writableRoot: stagedContentRoot,
630
+ ...(stagedPhysicalRequiredRoots.length > 0 ? { writableRequiredRoots: stagedPhysicalRequiredRoots } : {}),
631
+ })
632
+ .then((staged) => {
633
+ const auditedTargetHead = gitHead(stagedRepo);
634
+ const physicalStagedRepo = canonicalWritablePath(stagedRepo, "staged managed Git checkout");
635
+ const auditedConfiguredRoots = stagedConfiguredRoots.map((root) => containedWritablePath(root, physicalStagedRepo, `Post-sync component root for "${managed.installId}"`));
636
+ const expectedPublishedRoots = auditedConfiguredRoots.map((root) => path.join(liveRepo, path.relative(physicalStagedRepo, root)));
637
+ const synced = {
638
+ ...staged,
639
+ resolvedRevision: auditedTargetHead ?? staged.resolvedRevision,
640
+ contentDir: remapStagedPath(staged.contentDir, stagedRepo, liveRepo),
641
+ cacheDir: remapStagedPath(staged.cacheDir, stagedRepo, liveRepo),
642
+ extractedDir: remapStagedPath(staged.extractedDir, stagedRepo, liveRepo),
643
+ };
644
+ return {
645
+ synced,
646
+ auditRoot: auditedConfiguredRoots[0] ?? staged.contentDir,
647
+ publishedAuditRoot: configuredLiveRoots[0] ?? synced.contentDir,
648
+ publishedAuditContainmentRoot: liveRepo,
649
+ publishedAuditExpectedPhysicalRoot: expectedPublishedRoots[0] ?? synced.contentDir,
650
+ publication: writableGitPublication({ stagedRepo, liveRepo, expectedOldHead, auditedTargetHead }),
651
+ cleanup: () => cleanupStagingParent(stagingParent),
652
+ };
653
+ })
654
+ .catch((error) => {
655
+ cleanupStagingParent(stagingParent);
656
+ throw error;
657
+ });
658
+ }
659
+ async function prepareManagedUpdate(managed, force) {
660
+ if (managed.writable && managed.localRoot)
661
+ return prepareWritableManagedUpdate(managed, force);
662
+ if (managed.source === "local") {
663
+ const synced = await syncFromRef(managed.ref, { force, writable: managed.writable });
664
+ return {
665
+ synced,
666
+ auditRoot: resolveComponentAuditRoot(synced.contentDir, managed.componentRoot, managed.installId),
667
+ cleanup: () => { },
668
+ };
669
+ }
670
+ const canonicalCacheRoot = getRegistryCacheDir();
671
+ const stagingParent = createStagingParent(canonicalCacheRoot);
672
+ try {
673
+ const staged = await syncFromRef(managed.ref, {
674
+ force,
675
+ writable: false,
676
+ cacheRootDir: stagingParent,
677
+ });
678
+ if (!pathAtOrBelow(staged.cacheDir, stagingParent)) {
679
+ // Test/provider seams may return an already-isolated candidate. It remains
680
+ // invisible until the lock points at it, so no directory promotion is needed.
681
+ return {
682
+ synced: staged,
683
+ auditRoot: resolveComponentAuditRoot(staged.contentDir, managed.componentRoot, managed.installId),
684
+ cleanup: () => cleanupStagingParent(stagingParent),
685
+ };
686
+ }
687
+ const liveCacheDir = path.join(canonicalCacheRoot, path.relative(stagingParent, staged.cacheDir));
688
+ const synced = {
689
+ ...staged,
690
+ contentDir: remapStagedPath(staged.contentDir, staged.cacheDir, liveCacheDir),
691
+ cacheDir: liveCacheDir,
692
+ extractedDir: remapStagedPath(staged.extractedDir, staged.cacheDir, liveCacheDir),
693
+ };
694
+ return {
695
+ synced,
696
+ auditRoot: resolveComponentAuditRoot(staged.contentDir, managed.componentRoot, managed.installId),
697
+ publication: prepareDirectoryPublication(staged.cacheDir, liveCacheDir),
698
+ cleanup: () => cleanupStagingParent(stagingParent),
699
+ };
700
+ }
701
+ catch (error) {
702
+ cleanupStagingParent(stagingParent);
703
+ throw error;
704
+ }
705
+ }
706
+ function resolveComponentAuditRoot(contentRoot, componentRoot, id) {
707
+ const resolved = path.resolve(contentRoot, componentRoot);
708
+ if (!pathAtOrBelow(resolved, contentRoot)) {
709
+ throw new ConfigError(`Component root "${componentRoot}" escapes staged bundle "${id}".`);
710
+ }
711
+ return resolved;
712
+ }
713
+ async function publishPreparedPlainUpdate(id, ref, prepared, stashDir, allowInsecure, full, expectedConfigGeneration) {
714
+ try {
715
+ await auditPreparedUpdate(prepared, id, ref, allowInsecure);
716
+ return await withAssetMutationLease("source-update", async () => {
717
+ let transaction;
718
+ let releaseConfigFence;
719
+ let contentPublished = false;
720
+ let committed = false;
721
+ try {
722
+ const configFence = acquireConfigReadFence();
723
+ releaseConfigFence = configFence.release;
724
+ assertBundleAuditFence(configFence.config, id, expectedConfigGeneration);
725
+ updateTransactionHook("fenced", id);
726
+ if (prepared.publication) {
727
+ prepared.publication.publish();
728
+ contentPublished = true;
729
+ }
730
+ await verifyPublishedAudit(prepared, id);
731
+ updateTransactionHook("published", id);
732
+ updateTransactionHook("before-index", id);
733
+ transaction = openUnifiedUpdateTransaction();
734
+ const index = await akmIndex({
735
+ stashDir,
736
+ ...(full ? { full: true } : {}),
737
+ hydrateSources: false,
738
+ persistDetectedAdapters: false,
739
+ deferredUpdateTransaction: transaction.deferred,
740
+ });
741
+ updateTransactionHook("indexed", id, { db: transaction.db });
742
+ // Re-fence the exact published worktree after the potentially long
743
+ // index pass and immediately before the coordinator commit. This
744
+ // catches cooperating or observable external writes that land after
745
+ // the first post-materialization audit.
746
+ await verifyPublishedAudit(prepared, id);
747
+ updateTransactionHook("before-commit", id, { db: transaction.db });
748
+ commitUnifiedUpdateTransaction(transaction);
749
+ committed = true;
750
+ try {
751
+ transaction.deferred.afterCommit?.();
752
+ }
753
+ catch (error) {
754
+ warn(`[akm bundle update] committed, but semantic status refresh failed: ${String(error)}`);
755
+ }
756
+ prepared.publication?.commit();
757
+ return index;
758
+ }
759
+ catch (error) {
760
+ let recoveryError = transaction ? rollbackUnifiedUpdateTransaction(transaction) : undefined;
761
+ try {
762
+ if (contentPublished)
763
+ prepared.publication?.rollback();
764
+ }
765
+ catch (rollbackError) {
766
+ recoveryError ??= rollbackError;
767
+ }
768
+ if (recoveryError)
769
+ throw recoveryError;
770
+ throw error;
771
+ }
772
+ finally {
773
+ try {
774
+ if (transaction)
775
+ closeUnifiedUpdateTransaction(transaction, committed);
776
+ }
777
+ finally {
778
+ releaseConfigFence?.();
779
+ }
780
+ }
781
+ });
782
+ }
783
+ finally {
784
+ prepared.cleanup();
785
+ }
786
+ }
787
+ async function prepareGitPlainUpdate(gitSource, componentRoot, writable) {
788
+ if (!gitSource.url)
789
+ throw new ConfigError(`Git source "${gitSource.name ?? "git"}" has no URL.`);
790
+ const repo = parseGitRepoUrl(gitSource.url);
791
+ const livePaths = getCachePaths(repo.canonicalUrl);
792
+ const stagingParent = createStagingParent(path.dirname(livePaths.rootDir));
793
+ const stagedPaths = getCachePaths(repo.canonicalUrl, stagingParent);
794
+ try {
795
+ const expectedOldHead = writable ? gitHead(livePaths.repoDir) : undefined;
796
+ if (writable && fs.existsSync(livePaths.rootDir)) {
797
+ fs.cpSync(livePaths.rootDir, stagedPaths.rootDir, { recursive: true, preserveTimestamps: true });
798
+ }
799
+ const staged = await syncMirroredRepo(gitSource, {
800
+ force: true,
801
+ writable,
802
+ cacheRootDir: stagingParent,
803
+ });
804
+ const configuredStagedAuditRoot = resolveComponentAuditRoot(resolveGitContentRoot(staged.contentDir), componentRoot, gitSource.name ?? "git");
805
+ if (!pathAtOrBelow(staged.cacheDir, stagingParent)) {
806
+ if (pathAtOrBelow(configuredStagedAuditRoot, livePaths.rootDir)) {
807
+ throw new ConfigError(`Git provider for "${gitSource.name ?? gitSource.url}" wrote into the active cache instead of the update staging root; refusing to index it.`);
808
+ }
809
+ // A provider test seam may hand back an already-isolated candidate.
810
+ // Audit it, but do not move an unowned directory into the canonical
811
+ // cache. Real providers honor cacheRootDir and use the promotion below.
812
+ return {
813
+ auditRoot: configuredStagedAuditRoot,
814
+ cleanup: () => cleanupStagingParent(stagingParent),
815
+ };
816
+ }
817
+ let auditRoot = configuredStagedAuditRoot;
818
+ let publishedAuditRoot;
819
+ let publishedAuditContainmentRoot;
820
+ let publishedAuditExpectedPhysicalRoot;
821
+ if (writable) {
822
+ const physicalStagedRepo = canonicalWritablePath(stagedPaths.repoDir, "staged plain Git checkout");
823
+ auditRoot = containedWritablePath(configuredStagedAuditRoot, physicalStagedRepo, `Post-sync component root for "${gitSource.name ?? "git"}"`);
824
+ publishedAuditRoot = remapStagedPath(configuredStagedAuditRoot, staged.cacheDir, livePaths.rootDir);
825
+ publishedAuditContainmentRoot = canonicalWritablePath(livePaths.repoDir, "plain Git checkout");
826
+ publishedAuditExpectedPhysicalRoot = path.join(publishedAuditContainmentRoot, path.relative(physicalStagedRepo, auditRoot));
827
+ }
828
+ return {
829
+ auditRoot,
830
+ ...(publishedAuditRoot ? { publishedAuditRoot } : {}),
831
+ ...(publishedAuditContainmentRoot ? { publishedAuditContainmentRoot } : {}),
832
+ ...(publishedAuditExpectedPhysicalRoot ? { publishedAuditExpectedPhysicalRoot } : {}),
833
+ publication: writable
834
+ ? writableGitPublication({
835
+ stagedRepo: stagedPaths.repoDir,
836
+ liveRepo: livePaths.repoDir,
837
+ expectedOldHead,
838
+ })
839
+ : prepareDirectoryPublication(staged.cacheDir, livePaths.rootDir),
840
+ cleanup: () => cleanupStagingParent(stagingParent),
841
+ };
842
+ }
843
+ catch (error) {
844
+ cleanupStagingParent(stagingParent);
845
+ throw error;
846
+ }
847
+ }
848
+ async function syncGitPlainSource(gitSource, stashDir, allowInsecure) {
849
+ const id = gitSource.name ?? gitSource.url ?? "";
850
+ const currentConfig = loadConfig();
851
+ const currentSource = currentPlainSource(currentConfig, id, "git");
852
+ const ref = currentSource.url ?? "";
853
+ const fence = bundleAuditFence(currentConfig, id);
854
+ const prepared = await prepareGitPlainUpdate(currentSource, fence.componentRoot, fence.writable);
855
+ const index = await publishPreparedPlainUpdate(id, ref, prepared, stashDir, allowInsecure, true, fence.generation);
856
+ return { item: { id, kind: "git", ref }, index };
857
+ }
858
+ async function prepareWebsitePlainUpdate(websiteSource, componentRoot) {
859
+ if (!websiteSource.url)
860
+ throw new ConfigError(`Website source "${websiteSource.name ?? "website"}" has no URL.`);
861
+ const livePaths = getWebsiteCachePaths(websiteSource.url);
862
+ const stagingParent = createStagingParent(path.dirname(livePaths.rootDir));
863
+ const stagedPaths = getWebsiteCachePaths(websiteSource.url, stagingParent);
864
+ try {
865
+ const provider = createWebsiteProvider(websiteSource);
866
+ await provider.sync({
867
+ force: true,
868
+ secrets: storeSecretResolver,
869
+ ensureWebsiteMirror: (config, options) => ensureWebsiteMirror(config, {
870
+ ...options,
871
+ resolveSecret: options?.resolveSecret ?? storeSecretResolver.resolveSecret,
872
+ cacheRootDir: stagingParent,
873
+ }),
874
+ });
875
+ return {
876
+ auditRoot: resolveComponentAuditRoot(stagedPaths.stashDir, componentRoot, websiteSource.name ?? "website"),
877
+ publication: prepareDirectoryPublication(stagedPaths.rootDir, livePaths.rootDir),
878
+ cleanup: () => cleanupStagingParent(stagingParent),
879
+ };
880
+ }
881
+ catch (error) {
882
+ cleanupStagingParent(stagingParent);
883
+ throw error;
884
+ }
885
+ }
886
+ async function syncWebsitePlainSource(websiteSource, stashDir, allowInsecure) {
887
+ const id = websiteSource.name ?? websiteSource.url ?? "";
888
+ const currentConfig = loadConfig();
889
+ const currentSource = currentPlainSource(currentConfig, id, "website");
890
+ const ref = currentSource.url ?? "";
891
+ const fence = bundleAuditFence(currentConfig, id);
892
+ const prepared = await prepareWebsitePlainUpdate(currentSource, fence.componentRoot);
893
+ const index = await publishPreparedPlainUpdate(id, ref, prepared, stashDir, allowInsecure, true, fence.generation);
894
+ return { item: { id, kind: "website", ref }, index };
317
895
  }
318
896
  /** Sync a git-mirrored (plain) source and return an UpdateResponse (single-target path). */
319
- async function updateGitSource(stashDir, target, all, gitSource) {
320
- const synced = await syncGitPlainSource(gitSource);
321
- return buildUpdateResponse(stashDir, target, all, [], { full: true, plainSynced: [synced] });
897
+ async function updateGitSource(stashDir, target, all, gitSource, allowInsecure) {
898
+ const synced = await syncGitPlainSource(gitSource, stashDir, allowInsecure);
899
+ return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
322
900
  }
323
901
  /** Re-crawl a website (plain) source and return an UpdateResponse (single-target path). */
324
- async function updateWebsiteSource(stashDir, target, all, websiteSource) {
325
- const synced = await syncWebsitePlainSource(websiteSource);
326
- return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced] });
902
+ async function updateWebsiteSource(stashDir, target, all, websiteSource, allowInsecure) {
903
+ const synced = await syncWebsitePlainSource(websiteSource, stashDir, allowInsecure);
904
+ return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
905
+ }
906
+ /** Reconcile a filesystem source's current bytes without provider hydration. */
907
+ async function updateFilesystemSource(stashDir, target, all, filesystemSource) {
908
+ const id = filesystemSource.name ?? filesystemSource.path ?? target;
909
+ const ref = filesystemSource.path ?? target;
910
+ const index = await akmIndex({ stashDir, hydrateSources: false });
911
+ if (!index.scanComplete) {
912
+ return buildUpdateResponse(stashDir, target, all, [], {
913
+ skipped: [incompleteFilesystemOutcome(id)],
914
+ index,
915
+ });
916
+ }
917
+ return buildUpdateResponse(stashDir, target, all, [], {
918
+ plainSynced: [{ id, kind: "filesystem", ref }],
919
+ index,
920
+ });
327
921
  }
328
922
  /**
329
923
  * A plain (lockless) npm bundle has no deterministic content path — unlike
@@ -338,9 +932,12 @@ async function updateWebsiteSource(stashDir, target, all, websiteSource) {
338
932
  * bookkeeping.
339
933
  */
340
934
  function managedInstallViewOfPlainNpm(npmSource) {
341
- const spec = npmSource.path ?? "";
935
+ const id = npmSource.name ?? npmSource.path ?? "";
936
+ const config = loadConfig();
937
+ const currentSource = currentPlainSource(config, id, "npm");
938
+ const spec = currentSource.path ?? "";
342
939
  const ref = spec.startsWith("npm:") ? spec : `npm:${spec}`;
343
- const id = npmSource.name ?? ref;
940
+ const fence = bundleAuditFence(config, id);
344
941
  return {
345
942
  bundleKey: id,
346
943
  installId: id,
@@ -350,7 +947,9 @@ function managedInstallViewOfPlainNpm(npmSource) {
350
947
  resolvedVersion: undefined,
351
948
  resolvedRevision: undefined,
352
949
  writable: false,
950
+ componentRoot: fence.componentRoot,
353
951
  requiredRoots: [],
952
+ auditConfigGeneration: fence.generation,
354
953
  };
355
954
  }
356
955
  /**
@@ -364,101 +963,193 @@ function managedInstallViewOfPlainNpm(npmSource) {
364
963
  * scripts; only the branch that can `rm -rf` needs a gate, not the whole
365
964
  * command).
366
965
  */
367
- async function updateManagedInstall(managed, force, yes, stashDir) {
966
+ async function updateManagedInstall(managed, force, yes, stashDir, allowInsecure) {
368
967
  // No pre-cleanup of the old root, even under --force: the providers already
369
968
  // re-materialize staging-first (git clones into a `.tmp-*` sibling and swaps
370
969
  // only on success), so destroying `managed.localRoot` BEFORE `syncFromRef`
371
970
  // succeeds would turn any sync failure (network down, bad ref) into losing a
372
971
  // previously-working install. The old root is cleaned up below only after
373
972
  // config/lock publication and reindexing both succeed.
374
- const synced = await syncFromRef(managed.ref, {
375
- force,
376
- writable: managed.writable,
377
- ...(managed.writable && managed.localRoot ? { writableRoot: managed.localRoot } : {}),
378
- ...(managed.writable && managed.requiredRoots.length > 0 ? { writableRequiredRoots: managed.requiredRoots } : {}),
379
- });
380
- const installedEntry = {
381
- id: managed.installId,
382
- // Preserve the original source classification. syncFromRef() re-derives the
383
- // source type from the ref scheme (e.g. "github:" → source: "github"), but
384
- // an update should not reclassify an existing entry.
385
- source: managed.source,
386
- ref: synced.ref,
387
- artifactUrl: synced.artifactUrl,
388
- resolvedVersion: synced.resolvedVersion,
389
- resolvedRevision: synced.resolvedRevision,
390
- stashRoot: synced.contentDir,
391
- cacheDir: synced.cacheDir,
392
- installedAt: synced.syncedAt,
393
- writable: synced.writable ?? managed.writable,
394
- };
395
- const movedRoot = managed.localRoot !== "" &&
396
- path.resolve(managed.localRoot) !== path.resolve(synced.contentDir) &&
397
- managed.source !== "local" &&
398
- !managed.writable;
399
- if (movedRoot) {
400
- const { confirmDestructive } = await import("../../cli/confirm.js");
401
- const confirmed = await confirmDestructive(`Update resolved a new content directory for "${managed.installId}" (${synced.contentDir}) and would delete the previous install directory at ${managed.localRoot}. This cannot be undone.`, { yes });
402
- if (!confirmed)
403
- throw new UsageError(`Update cancelled for "${managed.installId}"; no configuration was changed.`);
404
- }
405
- return withAssetMutationLease("source-update", async () => {
406
- const config = loadConfig();
407
- const oldLocks = readLockfile();
408
- if (!config.bundles?.[managed.bundleKey]) {
409
- throw new ConfigError(`Managed source "${managed.installId}" disappeared before update publication.`);
410
- }
411
- const desiredLock = {
412
- id: managed.bundleKey,
413
- // Preserve the STORED install kind: a `github:`-ref entry recorded as
414
- // source "git" must not be reclassified by the sync flow's re-derivation.
973
+ const prepared = await prepareManagedUpdate(managed, force);
974
+ try {
975
+ await auditPreparedUpdate(prepared, managed.installId, managed.ref, allowInsecure);
976
+ const synced = prepared.synced;
977
+ const installedEntry = {
978
+ id: managed.installId,
979
+ // Preserve the original source classification. syncFromRef() re-derives the
980
+ // source type from the ref scheme (e.g. "github:" → source: "github"), but
981
+ // an update should not reclassify an existing entry.
415
982
  source: managed.source,
416
983
  ref: synced.ref,
984
+ artifactUrl: synced.artifactUrl,
417
985
  resolvedVersion: synced.resolvedVersion,
418
986
  resolvedRevision: synced.resolvedRevision,
419
- integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
420
- localRoot: synced.contentDir,
987
+ stashRoot: synced.contentDir,
988
+ cacheDir: synced.cacheDir,
421
989
  installedAt: synced.syncedAt,
990
+ writable: synced.writable ?? managed.writable,
422
991
  };
423
- const desiredLocks = [...oldLocks.filter((entry) => entry.id !== desiredLock.id), desiredLock];
424
- const desiredLockGeneration = generationHash(desiredLocks);
425
- if (!(await compareAndSwapLockfile(oldLocks, desiredLocks)))
426
- throw concurrentGenerationError(managed);
427
- const index = await akmIndex({ stashDir });
428
- if (generationHash(readLockfile()) !== desiredLockGeneration)
429
- throw concurrentGenerationError(managed);
992
+ const movedRoot = managed.localRoot !== "" &&
993
+ path.resolve(managed.localRoot) !== path.resolve(synced.contentDir) &&
994
+ managed.source !== "local" &&
995
+ !managed.writable;
430
996
  if (movedRoot) {
431
- const currentConfig = loadConfig();
432
- const currentLocks = readLockfile();
433
- if (referencesRoot(managed.localRoot, currentConfig, currentLocks)) {
434
- warn(`[akm bundle update] kept previous install directory at ${managed.localRoot} because another configured or locked bundle still references it.`);
435
- }
436
- else {
437
- cleanupDirectoryBestEffort(managed.localRoot, "update");
438
- }
997
+ const { confirmDestructive } = await import("../../cli/confirm.js");
998
+ const confirmed = await confirmDestructive(`Update resolved a new content directory for "${managed.installId}" (${synced.contentDir}) and would delete the previous install directory at ${managed.localRoot}. This cannot be undone.`, { yes });
999
+ if (!confirmed)
1000
+ throw new UsageError(`Update cancelled for "${managed.installId}"; no configuration was changed.`);
439
1001
  }
440
- const versionChanged = (managed.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
441
- const revisionChanged = (managed.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
442
- return {
443
- index,
444
- item: {
445
- id: managed.installId,
1002
+ return await withAssetMutationLease("source-update", async () => {
1003
+ const oldLockSnapshot = readLockfileForUpdate();
1004
+ const oldLocks = oldLockSnapshot.entries;
1005
+ const desiredLock = {
1006
+ id: managed.bundleKey,
1007
+ // Preserve the STORED install kind: a `github:`-ref entry recorded as
1008
+ // source "git" must not be reclassified by the sync flow's re-derivation.
446
1009
  source: managed.source,
447
- ref: managed.ref,
448
- previous: {
449
- resolvedVersion: managed.resolvedVersion,
450
- resolvedRevision: managed.resolvedRevision,
451
- cacheDir: managed.localRoot,
452
- },
453
- installed: { ...installedEntry, extractedDir: synced.extractedDir },
454
- changed: {
455
- version: versionChanged,
456
- revision: revisionChanged,
457
- any: versionChanged || revisionChanged,
1010
+ ref: synced.ref,
1011
+ resolvedVersion: synced.resolvedVersion,
1012
+ resolvedRevision: synced.resolvedRevision,
1013
+ integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
1014
+ localRoot: synced.contentDir,
1015
+ installedAt: synced.syncedAt,
1016
+ };
1017
+ const desiredLocks = [...oldLocks.filter((entry) => entry.id !== desiredLock.id), desiredLock];
1018
+ const desiredLockGeneration = generationHash(desiredLocks);
1019
+ let transaction;
1020
+ let releaseConfigFence;
1021
+ let contentPublished = false;
1022
+ let lockPublished = false;
1023
+ let publishedLockSnapshot;
1024
+ let committed = false;
1025
+ let index;
1026
+ try {
1027
+ const configFence = acquireConfigReadFence();
1028
+ releaseConfigFence = configFence.release;
1029
+ assertBundleAuditFence(configFence.config, managed.bundleKey, managed.auditConfigGeneration);
1030
+ updateTransactionHook("fenced", managed.installId);
1031
+ if (prepared.publication) {
1032
+ prepared.publication.publish();
1033
+ contentPublished = true;
1034
+ }
1035
+ await verifyPublishedAudit(prepared, managed.installId);
1036
+ publishedLockSnapshot = await publishLockfileUpdate(oldLockSnapshot, desiredLocks);
1037
+ if (!publishedLockSnapshot)
1038
+ throw concurrentGenerationError(managed);
1039
+ lockPublished = true;
1040
+ updateTransactionHook("published", managed.installId);
1041
+ updateTransactionHook("before-index", managed.installId);
1042
+ transaction = openUnifiedUpdateTransaction();
1043
+ index = await akmIndex({
1044
+ stashDir,
1045
+ full: true,
1046
+ hydrateSources: false,
1047
+ persistDetectedAdapters: false,
1048
+ deferredUpdateTransaction: transaction.deferred,
1049
+ });
1050
+ const currentLockSnapshot = readLockfileForUpdate();
1051
+ if (generationHash(currentLockSnapshot.entries) !== desiredLockGeneration ||
1052
+ currentLockSnapshot.raw !== publishedLockSnapshot.raw ||
1053
+ currentLockSnapshot.mode !== publishedLockSnapshot.mode) {
1054
+ throw concurrentGenerationError(managed);
1055
+ }
1056
+ updateTransactionHook("indexed", managed.installId, { db: transaction.db });
1057
+ // Indexing can be long enough for a local writer to race the first
1058
+ // post-materialization audit. Re-verify the exact bytes/findings at the
1059
+ // final cross-store commit boundary while every AKM fence is held.
1060
+ await verifyPublishedAudit(prepared, managed.installId);
1061
+ updateTransactionHook("before-commit", managed.installId, { db: transaction.db });
1062
+ commitUnifiedUpdateTransaction(transaction);
1063
+ committed = true;
1064
+ try {
1065
+ transaction.deferred.afterCommit?.();
1066
+ }
1067
+ catch (error) {
1068
+ warn(`[akm bundle update] committed, but semantic status refresh failed: ${String(error)}`);
1069
+ }
1070
+ }
1071
+ catch (error) {
1072
+ let recoveryError = transaction ? rollbackUnifiedUpdateTransaction(transaction) : undefined;
1073
+ let concurrentGeneration = false;
1074
+ let concurrentGenerationOwnsPublishedRoot = false;
1075
+ if (lockPublished) {
1076
+ try {
1077
+ if (!publishedLockSnapshot ||
1078
+ !(await compareAndSwapLockfileSnapshot(publishedLockSnapshot, oldLockSnapshot))) {
1079
+ concurrentGeneration = true;
1080
+ const concurrentLocks = readLockfileForUpdate().entries;
1081
+ const concurrentEntry = concurrentLocks.find((entry) => entry.id === desiredLock.id);
1082
+ concurrentGenerationOwnsPublishedRoot =
1083
+ prepared.publication !== undefined &&
1084
+ concurrentEntry?.localRoot !== undefined &&
1085
+ path.resolve(concurrentEntry.localRoot) === path.resolve(synced.contentDir);
1086
+ }
1087
+ }
1088
+ catch (compensationError) {
1089
+ recoveryError ??= compensationError;
1090
+ }
1091
+ }
1092
+ if (!concurrentGenerationOwnsPublishedRoot) {
1093
+ try {
1094
+ if (contentPublished)
1095
+ prepared.publication?.rollback();
1096
+ }
1097
+ catch (rollbackError) {
1098
+ recoveryError ??= rollbackError;
1099
+ }
1100
+ }
1101
+ if (recoveryError)
1102
+ throw recoveryError;
1103
+ if (concurrentGeneration)
1104
+ throw concurrentGenerationError(managed);
1105
+ throw error;
1106
+ }
1107
+ finally {
1108
+ try {
1109
+ if (transaction)
1110
+ closeUnifiedUpdateTransaction(transaction, committed);
1111
+ }
1112
+ finally {
1113
+ releaseConfigFence?.();
1114
+ }
1115
+ }
1116
+ prepared.publication?.commit();
1117
+ if (movedRoot) {
1118
+ const currentConfig = loadConfig();
1119
+ const currentLocks = readLockfile();
1120
+ if (referencesRoot(managed.localRoot, currentConfig, currentLocks)) {
1121
+ warn(`[akm bundle update] kept previous install directory at ${managed.localRoot} because another configured or locked bundle still references it.`);
1122
+ }
1123
+ else {
1124
+ cleanupDirectoryBestEffort(managed.localRoot, "update");
1125
+ }
1126
+ }
1127
+ const versionChanged = (managed.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
1128
+ const revisionChanged = (managed.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
1129
+ return {
1130
+ index,
1131
+ item: {
1132
+ id: managed.installId,
1133
+ source: managed.source,
1134
+ ref: managed.ref,
1135
+ previous: {
1136
+ resolvedVersion: managed.resolvedVersion,
1137
+ resolvedRevision: managed.resolvedRevision,
1138
+ cacheDir: managed.localRoot,
1139
+ },
1140
+ installed: { ...installedEntry, extractedDir: synced.extractedDir },
1141
+ changed: {
1142
+ version: versionChanged,
1143
+ revision: revisionChanged,
1144
+ any: versionChanged || revisionChanged,
1145
+ },
458
1146
  },
459
- },
460
- };
461
- });
1147
+ };
1148
+ });
1149
+ }
1150
+ finally {
1151
+ prepared.cleanup();
1152
+ }
462
1153
  }
463
1154
  function generationHash(value) {
464
1155
  return sha256Hex(JSON.stringify(value));
@@ -494,13 +1185,14 @@ export async function akmUpdate(input) {
494
1185
  const all = input?.all === true;
495
1186
  const force = input?.force === true;
496
1187
  const yes = input?.yes === true;
1188
+ const allowInsecure = input?.allowInsecure === true;
497
1189
  const config = loadConfig();
498
1190
  const managedInstalls = listManagedInstalls(config);
499
1191
  if (target && !all) {
500
1192
  // Registry-managed install (lock-backed) — re-download from its locator.
501
1193
  const managed = resolveManagedTarget(config, target);
502
1194
  if (managed) {
503
- const updated = await updateManagedInstall(managed, force, yes, stashDir);
1195
+ const updated = await updateManagedInstall(managed, force, yes, stashDir, allowInsecure);
504
1196
  return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
505
1197
  }
506
1198
  // Plain git / website source (bundles without a lock) — provider re-sync.
@@ -529,7 +1221,7 @@ export async function akmUpdate(input) {
529
1221
  return false;
530
1222
  });
531
1223
  if (gitMatch)
532
- return updateGitSource(stashDir, target, all, gitMatch);
1224
+ return updateGitSource(stashDir, target, all, gitMatch, allowInsecure);
533
1225
  const websiteMatch = stashes.find((s) => {
534
1226
  if (s.type !== "website")
535
1227
  return false;
@@ -542,7 +1234,7 @@ export async function akmUpdate(input) {
542
1234
  return false;
543
1235
  });
544
1236
  if (websiteMatch)
545
- return updateWebsiteSource(stashDir, target, all, websiteMatch);
1237
+ return updateWebsiteSource(stashDir, target, all, websiteMatch, allowInsecure);
546
1238
  // Plain npm source (bundle without a lock) — sync via the registry
547
1239
  // pipeline and promote to a managed install (see
548
1240
  // managedInstallViewOfPlainNpm's doc comment for why npm can't stay
@@ -557,80 +1249,132 @@ export async function akmUpdate(input) {
557
1249
  return false;
558
1250
  });
559
1251
  if (npmMatch) {
560
- const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(npmMatch), force, yes, stashDir);
1252
+ const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(npmMatch), force, yes, stashDir, allowInsecure);
561
1253
  return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
562
1254
  }
1255
+ const filesystemMatch = stashes.find((source) => {
1256
+ if (source.type !== "filesystem")
1257
+ return false;
1258
+ if (source.name === target)
1259
+ return true;
1260
+ return resolvedPath !== undefined && source.path !== undefined && path.resolve(source.path) === resolvedPath;
1261
+ });
1262
+ if (filesystemMatch)
1263
+ return updateFilesystemSource(stashDir, target, all, filesystemMatch);
563
1264
  }
564
1265
  const enabledManagedInstalls = all
565
1266
  ? managedInstalls.filter((managed) => config.bundles?.[managed.bundleKey]?.enabled !== false)
566
1267
  : managedInstalls;
567
1268
  const selected = selectManagedTargets(config, enabledManagedInstalls, target, all);
568
1269
  const processed = [];
569
- let latestManagedIndex;
1270
+ const plainSynced = [];
1271
+ const skipped = [];
1272
+ let latestIndex;
570
1273
  for (const managed of selected) {
571
- const updated = await updateManagedInstall(managed, force, yes, stashDir);
572
- processed.push(updated.item);
573
- latestManagedIndex = updated.index;
1274
+ try {
1275
+ const updated = await updateManagedInstall(managed, force, yes, stashDir, allowInsecure);
1276
+ processed.push(updated.item);
1277
+ latestIndex = updated.index;
1278
+ }
1279
+ catch (error) {
1280
+ if (!all)
1281
+ throw error;
1282
+ skipped.push(updateFailureOutcome(managed.installId, sourceKindForInstall(managed.source), error));
1283
+ }
574
1284
  }
575
1285
  // `--all` must account for EVERY configured source, not only the
576
1286
  // registry-managed (lock-backed) ones (R-015) — `selectManagedTargets`
577
1287
  // above returns only `installs` for `all`, so plain sources were
578
1288
  // previously never even looked at. Git/npm plain sources are synced here
579
- // too (git in place; npm promoted to managed, same as the single-target
580
- // path above); website/filesystem sources have no `--all` sync path, so
581
- // they are reported as skipped with the same explanatory wording
582
- // `selectManagedTargets` already uses for a single unmatched target.
583
- let plainSynced;
584
- let skipped;
585
- let sawGitSync = false;
1289
+ // too (git and website through staged promotion; npm promoted to managed,
1290
+ // same as the single-target path above). Filesystem sources reflect their
1291
+ // files in place and are reported as intentionally skipped.
586
1292
  if (all) {
587
1293
  const managedKeys = new Set(managedInstalls.map((m) => m.bundleKey));
588
1294
  const plainSources = getSources(config).filter((source) => source.enabled !== false && !managedKeys.has(source.name ?? ""));
589
- plainSynced = [];
590
- skipped = [];
1295
+ const filesystemSources = [];
591
1296
  for (const plain of plainSources) {
592
1297
  const id = plain.name ?? plain.path ?? plain.url ?? "";
593
1298
  try {
594
1299
  if (plain.type === "git") {
595
- plainSynced.push(await syncGitPlainSource(plain));
596
- sawGitSync = true;
1300
+ const updated = await syncGitPlainSource(plain, stashDir, allowInsecure);
1301
+ plainSynced.push(updated.item);
1302
+ latestIndex = updated.index;
597
1303
  }
598
1304
  else if (plain.type === "npm") {
599
- const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(plain), force, yes, stashDir);
1305
+ const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(plain), force, yes, stashDir, allowInsecure);
600
1306
  processed.push(updated.item);
601
- latestManagedIndex = updated.index;
1307
+ latestIndex = updated.index;
602
1308
  }
603
1309
  else if (plain.type === "website") {
604
- skipped.push({
605
- id,
606
- kind: "website",
607
- reason: `website caching not yet implemented for --all; run \`akm bundle update ${id}\` to re-mirror this source individually.`,
608
- });
1310
+ const updated = await syncWebsitePlainSource(plain, stashDir, allowInsecure);
1311
+ plainSynced.push(updated.item);
1312
+ latestIndex = updated.index;
609
1313
  }
610
1314
  else {
611
- skipped.push({
612
- id,
613
- kind: plain.type,
614
- reason: "reflects your files in place and has no remote to sync; run `akm index` to refresh the search index.",
615
- });
1315
+ filesystemSources.push(plain);
616
1316
  }
617
1317
  }
618
- catch (err) {
619
- skipped.push({
620
- id,
621
- kind: plain.type,
622
- reason: `sync failed: ${err instanceof Error ? err.message : String(err)}`,
623
- });
1318
+ catch (error) {
1319
+ skipped.push(updateFailureOutcome(id, plain.type, error));
1320
+ }
1321
+ }
1322
+ if (filesystemSources.length > 0) {
1323
+ try {
1324
+ // A remote source may have reconciled before a later source finished
1325
+ // hydrating, so an incomplete intermediate result is not authoritative
1326
+ // for the final `--all` outcome. Retry once after every source update;
1327
+ // a genuinely missing filesystem root remains incomplete and is
1328
+ // reported below without claiming reconciliation.
1329
+ if (!latestIndex?.scanComplete)
1330
+ latestIndex = await akmIndex({ stashDir, hydrateSources: false });
1331
+ if (latestIndex.scanComplete) {
1332
+ for (const source of filesystemSources) {
1333
+ plainSynced.push({
1334
+ id: source.name ?? source.path ?? "",
1335
+ kind: "filesystem",
1336
+ ref: source.path ?? source.name ?? "",
1337
+ });
1338
+ }
1339
+ }
1340
+ else {
1341
+ for (const source of filesystemSources) {
1342
+ skipped.push(incompleteFilesystemOutcome(source.name ?? source.path ?? ""));
1343
+ }
1344
+ }
1345
+ }
1346
+ catch (error) {
1347
+ for (const source of filesystemSources) {
1348
+ skipped.push(updateFailureOutcome(source.name ?? source.path ?? "", "filesystem", error));
1349
+ }
624
1350
  }
625
1351
  }
626
1352
  }
627
1353
  return buildUpdateResponse(stashDir, target, all, processed, {
628
- full: sawGitSync,
629
1354
  plainSynced,
630
1355
  skipped,
631
- ...(!sawGitSync && latestManagedIndex ? { index: latestManagedIndex } : {}),
1356
+ index: latestIndex ?? readCurrentIndexSummary(),
632
1357
  });
633
1358
  }
1359
+ function sourceKindForInstall(source) {
1360
+ if (source === "local")
1361
+ return "filesystem";
1362
+ if (source === "github")
1363
+ return "git";
1364
+ return source;
1365
+ }
1366
+ function updateFailureOutcome(id, kind, error) {
1367
+ const code = error instanceof AkmError ? error.code : undefined;
1368
+ const status = code === "DANGEROUS_ENV_KEY" || code === "DANGEROUS_ENV_AUDIT_FAILED" ? "blocked" : "failed";
1369
+ const message = error instanceof Error ? error.message : String(error);
1370
+ return {
1371
+ id,
1372
+ kind,
1373
+ status,
1374
+ ...(code ? { code } : {}),
1375
+ reason: `${status}: ${message}`,
1376
+ };
1377
+ }
634
1378
  function selectManagedTargets(config, installs, target, all) {
635
1379
  if (all && target) {
636
1380
  throw new UsageError("Specify either <target> or --all, not both.", "MISSING_OR_AMBIGUOUS_TARGET");
@@ -643,26 +1387,6 @@ function selectManagedTargets(config, installs, target, all) {
643
1387
  const found = resolveManagedTarget(config, target);
644
1388
  if (found)
645
1389
  return [found];
646
- // Give a helpful message when the target names a plain (non-managed) source.
647
- const stashes = getSources(config);
648
- const isUrl = target.startsWith("http://") || target.startsWith("https://");
649
- const resolvedPath = !isUrl ? path.resolve(target) : undefined;
650
- const stashMatch = stashes.find((s) => {
651
- if (isUrl && s.url === target)
652
- return true;
653
- if (resolvedPath && s.path && path.resolve(s.path) === resolvedPath)
654
- return true;
655
- if (s.name === target)
656
- return true;
657
- return false;
658
- });
659
- if (stashMatch) {
660
- if (stashMatch.type === "website") {
661
- throw new UsageError(`"${target}" is a website source — website caching not yet implemented for --all. ` +
662
- `Run \`akm bundle update ${target}\` to re-mirror this source individually.`, "TARGET_NOT_UPDATABLE");
663
- }
664
- throw new UsageError(`"${target}" is a local directory — it reflects your files in place. To refresh the search index, run: akm index`, "TARGET_NOT_UPDATABLE");
665
- }
666
1390
  throw new NotFoundError(`No matching source for target: ${target}`, "SOURCE_NOT_FOUND");
667
1391
  }
668
1392
  /**