akm-cli 0.8.0-rc1 → 0.8.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 (295) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +93 -3
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2162 -1258
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +20 -12
  12. package/dist/commands/agent-support.js +11 -5
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +129 -517
  15. package/dist/commands/consolidate.js +1533 -144
  16. package/dist/commands/curate.js +44 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +5 -3
  19. package/dist/commands/distill.js +906 -100
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +3 -0
  22. package/dist/commands/events.js +3 -0
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +260 -5
  28. package/dist/commands/health.js +977 -51
  29. package/dist/commands/help/help-accept.md +6 -3
  30. package/dist/commands/help/help-improve.md +36 -8
  31. package/dist/commands/help/help-proposals.md +7 -4
  32. package/dist/commands/help/help-reject.md +5 -2
  33. package/dist/commands/history.js +51 -16
  34. package/dist/commands/improve-auto-accept.js +97 -0
  35. package/dist/commands/improve-cli.js +236 -0
  36. package/dist/commands/improve-profiles.js +184 -0
  37. package/dist/commands/improve-result-file.js +167 -0
  38. package/dist/commands/improve.js +1725 -332
  39. package/dist/commands/info.js +3 -0
  40. package/dist/commands/init.js +49 -1
  41. package/dist/commands/installed-stashes.js +6 -23
  42. package/dist/commands/knowledge.js +3 -0
  43. package/dist/commands/lint/agent-linter.js +3 -0
  44. package/dist/commands/lint/base-linter.js +233 -5
  45. package/dist/commands/lint/command-linter.js +3 -0
  46. package/dist/commands/lint/default-linter.js +3 -0
  47. package/dist/commands/lint/env-key-rules.js +154 -0
  48. package/dist/commands/lint/index.js +92 -3
  49. package/dist/commands/lint/knowledge-linter.js +3 -0
  50. package/dist/commands/lint/markdown-insertion.js +343 -0
  51. package/dist/commands/lint/memory-linter.js +3 -0
  52. package/dist/commands/lint/registry.js +3 -0
  53. package/dist/commands/lint/skill-linter.js +3 -0
  54. package/dist/commands/lint/task-linter.js +15 -12
  55. package/dist/commands/lint/types.js +3 -0
  56. package/dist/commands/lint/workflow-linter.js +3 -0
  57. package/dist/commands/lint.js +3 -0
  58. package/dist/commands/migration-help.js +5 -2
  59. package/dist/commands/proposal-drain-policies.js +128 -0
  60. package/dist/commands/proposal-drain.js +477 -0
  61. package/dist/commands/proposal.js +60 -6
  62. package/dist/commands/propose.js +24 -19
  63. package/dist/commands/reflect.js +1004 -94
  64. package/dist/commands/registry-cli.js +150 -0
  65. package/dist/commands/registry-search.js +3 -0
  66. package/dist/commands/remember-cli.js +257 -0
  67. package/dist/commands/remember.js +15 -6
  68. package/dist/commands/schema-repair.js +88 -15
  69. package/dist/commands/search.js +99 -14
  70. package/dist/commands/secret.js +173 -0
  71. package/dist/commands/self-update.js +3 -0
  72. package/dist/commands/show.js +32 -13
  73. package/dist/commands/source-add.js +7 -35
  74. package/dist/commands/source-clone.js +3 -0
  75. package/dist/commands/source-manage.js +3 -0
  76. package/dist/commands/tasks.js +161 -95
  77. package/dist/commands/url-checker.js +3 -0
  78. package/dist/core/action-contributors.js +3 -0
  79. package/dist/core/asset-ref.js +17 -2
  80. package/dist/core/asset-registry.js +9 -2
  81. package/dist/core/asset-serialize.js +88 -0
  82. package/dist/core/asset-spec.js +61 -5
  83. package/dist/core/common.js +93 -5
  84. package/dist/core/concurrent.js +3 -0
  85. package/dist/core/config-io.js +347 -0
  86. package/dist/core/config-migration.js +622 -0
  87. package/dist/core/config-schema.js +558 -0
  88. package/dist/core/config-sources.js +108 -0
  89. package/dist/core/config-types.js +4 -0
  90. package/dist/core/config-walker.js +337 -0
  91. package/dist/core/config.js +366 -1077
  92. package/dist/core/errors.js +42 -20
  93. package/dist/core/events.js +31 -25
  94. package/dist/core/file-lock.js +104 -0
  95. package/dist/core/frontmatter.js +75 -10
  96. package/dist/core/lesson-lint.js +3 -0
  97. package/dist/core/markdown.js +3 -0
  98. package/dist/core/memory-belief.js +62 -0
  99. package/dist/core/memory-contradiction-detect.js +274 -0
  100. package/dist/core/memory-improve.js +142 -14
  101. package/dist/core/parse.js +3 -0
  102. package/dist/core/paths.js +218 -50
  103. package/dist/core/proposal-quality-validators.js +380 -0
  104. package/dist/core/proposal-validators.js +11 -3
  105. package/dist/core/proposals.js +464 -5
  106. package/dist/core/state-db.js +349 -56
  107. package/dist/core/text-truncation.js +107 -0
  108. package/dist/core/time.js +3 -0
  109. package/dist/core/tty.js +59 -0
  110. package/dist/core/warn.js +7 -2
  111. package/dist/core/write-source.js +12 -0
  112. package/dist/indexer/db-backup.js +391 -0
  113. package/dist/indexer/db-search.js +136 -28
  114. package/dist/indexer/db.js +662 -166
  115. package/dist/indexer/ensure-index.js +3 -0
  116. package/dist/indexer/file-context.js +3 -0
  117. package/dist/indexer/graph-boost.js +162 -40
  118. package/dist/indexer/graph-db.js +241 -51
  119. package/dist/indexer/graph-dedup.js +3 -7
  120. package/dist/indexer/graph-extraction.js +242 -149
  121. package/dist/indexer/index-context.js +3 -9
  122. package/dist/indexer/indexer.js +84 -14
  123. package/dist/indexer/llm-cache.js +24 -19
  124. package/dist/indexer/manifest.js +3 -0
  125. package/dist/indexer/matchers.js +184 -11
  126. package/dist/indexer/memory-inference.js +94 -50
  127. package/dist/indexer/metadata-contributors.js +3 -0
  128. package/dist/indexer/metadata.js +114 -48
  129. package/dist/indexer/path-resolver.js +3 -0
  130. package/dist/indexer/project-context.js +192 -0
  131. package/dist/indexer/ranking-contributors.js +134 -7
  132. package/dist/indexer/ranking.js +8 -1
  133. package/dist/indexer/search-fields.js +5 -9
  134. package/dist/indexer/search-hit-enrichers.js +91 -2
  135. package/dist/indexer/search-source.js +20 -1
  136. package/dist/indexer/semantic-status.js +4 -1
  137. package/dist/indexer/staleness-detect.js +447 -0
  138. package/dist/indexer/usage-events.js +12 -9
  139. package/dist/indexer/walker.js +3 -0
  140. package/dist/integrations/agent/builders.js +135 -0
  141. package/dist/integrations/agent/config.js +121 -401
  142. package/dist/integrations/agent/detect.js +3 -0
  143. package/dist/integrations/agent/index.js +6 -14
  144. package/dist/integrations/agent/model-aliases.js +55 -0
  145. package/dist/integrations/agent/profiles.js +3 -0
  146. package/dist/integrations/agent/prompts.js +137 -8
  147. package/dist/integrations/agent/runner.js +208 -0
  148. package/dist/integrations/agent/sdk-runner.js +8 -2
  149. package/dist/integrations/agent/spawn.js +54 -14
  150. package/dist/integrations/github.js +3 -0
  151. package/dist/integrations/lockfile.js +22 -51
  152. package/dist/integrations/session-logs/index.js +4 -0
  153. package/dist/integrations/session-logs/inline-refs.js +35 -0
  154. package/dist/integrations/session-logs/pre-filter.js +152 -0
  155. package/dist/integrations/session-logs/providers/claude-code.js +226 -0
  156. package/dist/integrations/session-logs/providers/opencode.js +231 -25
  157. package/dist/integrations/session-logs/types.js +3 -0
  158. package/dist/llm/call-ai.js +14 -26
  159. package/dist/llm/client.js +16 -2
  160. package/dist/llm/embedder.js +20 -29
  161. package/dist/llm/embedders/cache.js +3 -7
  162. package/dist/llm/embedders/local.js +42 -1
  163. package/dist/llm/embedders/remote.js +20 -8
  164. package/dist/llm/embedders/types.js +3 -7
  165. package/dist/llm/feature-gate.js +92 -56
  166. package/dist/llm/graph-extract.js +401 -30
  167. package/dist/llm/index-passes.js +44 -29
  168. package/dist/llm/memory-infer.js +30 -2
  169. package/dist/llm/metadata-enhance.js +3 -7
  170. package/dist/llm/prompts/extract-session.md +80 -0
  171. package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
  172. package/dist/output/cli-hints-full.md +60 -32
  173. package/dist/output/cli-hints-short.md +10 -7
  174. package/dist/output/cli-hints.js +5 -2
  175. package/dist/output/context.js +60 -8
  176. package/dist/output/renderers.js +170 -194
  177. package/dist/output/shapes/curate.js +56 -0
  178. package/dist/output/shapes/distill.js +10 -0
  179. package/dist/output/shapes/env-list.js +19 -0
  180. package/dist/output/shapes/events.js +11 -0
  181. package/dist/output/shapes/helpers.js +424 -0
  182. package/dist/output/shapes/history.js +7 -0
  183. package/dist/output/shapes/passthrough.js +105 -0
  184. package/dist/output/shapes/proposal-accept.js +7 -0
  185. package/dist/output/shapes/proposal-diff.js +7 -0
  186. package/dist/output/shapes/proposal-list.js +7 -0
  187. package/dist/output/shapes/proposal-producer.js +11 -0
  188. package/dist/output/shapes/proposal-reject.js +7 -0
  189. package/dist/output/shapes/proposal-show.js +7 -0
  190. package/dist/output/shapes/registry-search.js +6 -0
  191. package/dist/output/shapes/registry.js +30 -0
  192. package/dist/output/shapes/search.js +6 -0
  193. package/dist/output/shapes/secret-list.js +19 -0
  194. package/dist/output/shapes/show.js +6 -0
  195. package/dist/output/shapes/vault-list.js +19 -0
  196. package/dist/output/shapes.js +51 -549
  197. package/dist/output/text/add.js +6 -0
  198. package/dist/output/text/clone.js +6 -0
  199. package/dist/output/text/config.js +6 -0
  200. package/dist/output/text/curate.js +6 -0
  201. package/dist/output/text/distill.js +7 -0
  202. package/dist/output/text/enable-disable.js +7 -0
  203. package/dist/output/text/events.js +10 -0
  204. package/dist/output/text/feedback.js +6 -0
  205. package/dist/output/text/helpers.js +1059 -0
  206. package/dist/output/text/history.js +7 -0
  207. package/dist/output/text/import.js +6 -0
  208. package/dist/output/text/index.js +6 -0
  209. package/dist/output/text/info.js +6 -0
  210. package/dist/output/text/init.js +6 -0
  211. package/dist/output/text/list.js +6 -0
  212. package/dist/output/text/proposal-producer.js +8 -0
  213. package/dist/output/text/proposal.js +12 -0
  214. package/dist/output/text/registry-commands.js +11 -0
  215. package/dist/output/text/registry.js +30 -0
  216. package/dist/output/text/remember.js +6 -0
  217. package/dist/output/text/remove.js +6 -0
  218. package/dist/output/text/save.js +6 -0
  219. package/dist/output/text/search.js +6 -0
  220. package/dist/output/text/show.js +6 -0
  221. package/dist/output/text/update.js +6 -0
  222. package/dist/output/text/upgrade.js +6 -0
  223. package/dist/output/text/vault.js +16 -0
  224. package/dist/output/text/wiki.js +15 -0
  225. package/dist/output/text/workflow.js +14 -0
  226. package/dist/output/text.js +44 -1329
  227. package/dist/registry/build-index.js +3 -0
  228. package/dist/registry/create-provider-registry.js +3 -0
  229. package/dist/registry/factory.js +4 -1
  230. package/dist/registry/origin-resolve.js +3 -0
  231. package/dist/registry/providers/index.js +3 -0
  232. package/dist/registry/providers/skills-sh.js +11 -2
  233. package/dist/registry/providers/static-index.js +10 -1
  234. package/dist/registry/providers/types.js +3 -24
  235. package/dist/registry/resolve.js +11 -16
  236. package/dist/registry/types.js +3 -0
  237. package/dist/scripts/migrate-storage.js +17767 -0
  238. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  239. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  240. package/dist/setup/detect.js +3 -0
  241. package/dist/setup/ripgrep-install.js +3 -0
  242. package/dist/setup/ripgrep-resolve.js +3 -0
  243. package/dist/setup/setup.js +306 -67
  244. package/dist/setup/steps.js +3 -15
  245. package/dist/sources/include.js +3 -0
  246. package/dist/sources/provider-factory.js +3 -11
  247. package/dist/sources/provider.js +3 -20
  248. package/dist/sources/providers/filesystem.js +19 -23
  249. package/dist/sources/providers/git.js +171 -21
  250. package/dist/sources/providers/index.js +3 -0
  251. package/dist/sources/providers/install-types.js +3 -13
  252. package/dist/sources/providers/npm.js +3 -4
  253. package/dist/sources/providers/provider-utils.js +3 -0
  254. package/dist/sources/providers/sync-from-ref.js +3 -11
  255. package/dist/sources/providers/tar-utils.js +3 -0
  256. package/dist/sources/providers/website.js +18 -22
  257. package/dist/sources/resolve.js +3 -0
  258. package/dist/sources/types.js +3 -0
  259. package/dist/sources/website-ingest.js +3 -0
  260. package/dist/tasks/backends/cron.js +3 -0
  261. package/dist/tasks/backends/exec-utils.js +3 -0
  262. package/dist/tasks/backends/index.js +3 -11
  263. package/dist/tasks/backends/launchd.js +3 -0
  264. package/dist/tasks/backends/schtasks.js +3 -0
  265. package/dist/tasks/parser.js +51 -38
  266. package/dist/tasks/resolveAkmBin.js +3 -0
  267. package/dist/tasks/runner.js +35 -9
  268. package/dist/tasks/schedule.js +20 -1
  269. package/dist/tasks/schema.js +5 -3
  270. package/dist/tasks/validator.js +6 -3
  271. package/dist/version.js +3 -0
  272. package/dist/wiki/wiki-templates.js +3 -0
  273. package/dist/wiki/wiki.js +3 -0
  274. package/dist/workflows/authoring.js +3 -0
  275. package/dist/workflows/cli.js +3 -0
  276. package/dist/workflows/db.js +140 -10
  277. package/dist/workflows/document-cache.js +3 -10
  278. package/dist/workflows/parser.js +3 -0
  279. package/dist/workflows/renderer.js +3 -0
  280. package/dist/workflows/runs.js +18 -1
  281. package/dist/workflows/schema.js +3 -0
  282. package/dist/workflows/scope-key.js +3 -0
  283. package/dist/workflows/validator.js +5 -9
  284. package/docs/README.md +7 -2
  285. package/docs/data-and-telemetry.md +225 -0
  286. package/docs/migration/release-notes/0.7.5.md +2 -2
  287. package/docs/migration/release-notes/0.8.0.md +57 -5
  288. package/docs/migration/v0.7-to-v0.8.md +1378 -0
  289. package/package.json +28 -11
  290. package/.github/LICENSE +0 -374
  291. package/dist/commands/install-audit.js +0 -385
  292. package/dist/commands/vault.js +0 -307
  293. package/dist/indexer/match-contributors.js +0 -141
  294. package/dist/integrations/agent/pipeline.js +0 -39
  295. package/dist/integrations/agent/runners.js +0 -31
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  import path from "node:path";
2
5
  import { buildWorkflowAction } from "../output/renderers";
3
6
  import { registerActionBuilder, registerTypeRenderer } from "./asset-registry";
@@ -63,6 +66,34 @@ const ASSET_SPECS_INTERNAL = {
63
66
  },
64
67
  script: { stashDir: "scripts", ...scriptSpec },
65
68
  memory: { stashDir: "memories", ...markdownSpec },
69
+ // Environment assets — whole `.env` files sourced/injected wholesale. Replaces
70
+ // the deprecated `vault` type (see below). Key NAMES + start-of-line comments
71
+ // are surfaced as metadata; values are never read for indexing.
72
+ env: {
73
+ stashDir: "env",
74
+ isRelevantFile: (fileName) => fileName === ".env" || fileName.endsWith(".env"),
75
+ toCanonicalName: (typeRoot, filePath) => {
76
+ const rel = toPosix(path.relative(typeRoot, filePath));
77
+ const fileName = path.basename(rel);
78
+ // Treat ".env" as the "default" env; "<name>.env" → "<name>"
79
+ if (fileName === ".env") {
80
+ const dir = path.dirname(rel);
81
+ return dir === "." || dir === "" ? "default" : `${dir}/default`;
82
+ }
83
+ const stripped = rel.endsWith(".env") ? rel.slice(0, -4) : rel;
84
+ return stripped;
85
+ },
86
+ toAssetPath: (typeRoot, name) => {
87
+ if (name === "default")
88
+ return path.join(typeRoot, ".env");
89
+ return path.join(typeRoot, name.endsWith(".env") ? name : `${name}.env`);
90
+ },
91
+ rendererName: "env-file",
92
+ actionBuilder: (ref) => `akm show ${ref} -> inspect key names; akm env run ${ref} -- <command> -> run with the whole .env injected (values never reach stdout); akm env export ${ref} --out <file> -> write a sourceable script to a file`,
93
+ },
94
+ // DEPRECATED in 0.8.0, removed in 0.9.0 — use `env` instead. Retained so the
95
+ // frozen `vaults/` copy left by the migration still resolves and so existing
96
+ // `vault:` refs keep working through the deprecation window.
66
97
  vault: {
67
98
  stashDir: "vaults",
68
99
  isRelevantFile: (fileName) => fileName === ".env" || fileName.endsWith(".env"),
@@ -83,7 +114,23 @@ const ASSET_SPECS_INTERNAL = {
83
114
  return path.join(typeRoot, name.endsWith(".env") ? name : `${name}.env`);
84
115
  },
85
116
  rendererName: "vault-env",
86
- actionBuilder: (ref) => `akm show ${ref} -> inspect keys; source "$(akm vault path ${ref})" -> load values; akm vault run ${ref} -- <command> -> run with injected env`,
117
+ actionBuilder: (ref) => `DEPRECATED (use env): akm show ${ref} -> inspect key names; akm env run ${ref} -- <command> -> run with injected env`,
118
+ },
119
+ // Secrets — a single sensitive value used on its own for authentication (a
120
+ // PEM key, API token, TLS cert). Unlike `env` (a group of related .env
121
+ // configuration), the ENTIRE file is the one secret value — there is no safe
122
+ // region to parse, so only the filename is ever surfaced as metadata. The
123
+ // value reaches a command only via `akm secret run` (injected into a child
124
+ // env var) or `akm secret path` (Docker `_FILE` convention). A secret is any
125
+ // regular file under `secrets/` except `.lock`/`.sensitive` sidecars; the
126
+ // canonical name preserves the natural filename (e.g. `id_rsa`, `team/deploy.key`).
127
+ secret: {
128
+ stashDir: "secrets",
129
+ isRelevantFile: (fileName) => !fileName.endsWith(".lock") && !fileName.endsWith(".sensitive"),
130
+ toCanonicalName: (typeRoot, filePath) => toPosix(path.relative(typeRoot, filePath)),
131
+ toAssetPath: (typeRoot, name) => path.join(typeRoot, name),
132
+ rendererName: "secret-file",
133
+ actionBuilder: (ref) => `akm show ${ref} -> name only (value never shown); akm secret path ${ref} -> file path; akm secret run ${ref} <VAR> -- <command> -> run with value injected into $VAR`,
87
134
  },
88
135
  wiki: {
89
136
  stashDir: "wikis",
@@ -104,12 +151,21 @@ const ASSET_SPECS_INTERNAL = {
104
151
  actionBuilder: (ref) => `akm show ${ref} -> read the lesson and apply when_to_use`,
105
152
  },
106
153
  // Scheduled tasks. A task file pairs a cron-style schedule with a target
107
- // (workflow ref or prompt) that `akm tasks` registers with the OS-native
108
- // scheduler (cron / launchd / schtasks). Stored under <stash>/tasks/<id>.md.
154
+ // (workflow ref, prompt, or command) that `akm tasks` registers with the
155
+ // OS-native scheduler (cron / launchd / schtasks). Stored as pure YAML
156
+ // under <stash>/tasks/<id>.yml.
109
157
  task: {
110
158
  stashDir: "tasks",
111
- ...markdownSpec,
112
- rendererName: "task-md",
159
+ isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".yml",
160
+ toCanonicalName: (typeRoot, filePath) => {
161
+ const rel = toPosix(path.relative(typeRoot, filePath));
162
+ return rel.endsWith(".yml") ? rel.slice(0, -4) : rel;
163
+ },
164
+ toAssetPath: (typeRoot, name) => {
165
+ const withExt = name.endsWith(".yml") ? name : `${name}.yml`;
166
+ return path.join(typeRoot, withExt);
167
+ },
168
+ rendererName: "task-yaml",
113
169
  actionBuilder: buildTaskAction,
114
170
  },
115
171
  };
@@ -1,8 +1,28 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import crypto from "node:crypto";
1
5
  import fs from "node:fs";
2
6
  import path from "node:path";
3
7
  import { TYPE_DIRS } from "./asset-spec";
4
8
  import { ConfigError } from "./errors";
5
9
  import { getConfigPath, getDefaultStashDir } from "./paths";
10
+ // ── Types ───────────────────────────────────────────────────────────────────
11
+ export const ASSET_TYPES = [
12
+ "skill",
13
+ "command",
14
+ "agent",
15
+ "knowledge",
16
+ "workflow",
17
+ "script",
18
+ "memory",
19
+ "env",
20
+ "vault",
21
+ "secret",
22
+ "wiki",
23
+ "lesson",
24
+ ];
25
+ export const ASSET_TYPE_SET = new Set(ASSET_TYPES);
6
26
  // ── Constants ───────────────────────────────────────────────────────────────
7
27
  export const IS_WINDOWS = process.platform === "win32";
8
28
  export function isHttpUrl(value) {
@@ -28,6 +48,15 @@ export function filterNonEmptyStrings(value) {
28
48
  return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
29
49
  }
30
50
  // ── Validators ──────────────────────────────────────────────────────────────
51
+ /**
52
+ * Returns true if `type` is a known asset type — either a built-in from
53
+ * {@link ASSET_TYPES} or one dynamically registered via `registerAssetType`.
54
+ *
55
+ * The type guard narrows to `AkmAssetType` for all built-in types. Dynamic
56
+ * types (e.g. registered by plugins) are also accepted at runtime, but the
57
+ * type system treats them as `AkmAssetType` via assertion since they are not
58
+ * part of the static union.
59
+ */
31
60
  export function isAssetType(type) {
32
61
  return Object.hasOwn(TYPE_DIRS, type);
33
62
  }
@@ -35,14 +64,52 @@ export function isAssetType(type) {
35
64
  /**
36
65
  * Write content to a file atomically via a temp file + rename.
37
66
  * Prevents partial-write corruption on crash.
38
- * An optional `mode` (e.g. 0o600) is applied with `chmod` after the rename.
67
+ * The temp file is opened with the target `mode` (default 0o600) from the
68
+ * start, so it is never world-readable even briefly.
69
+ *
70
+ * Durability: fsync'd against the May 2026 config-clobber incident (#472).
71
+ * On ext4 (data=ordered) and NVMe-with-TRIM, a power-loss inside the kernel
72
+ * writeback window could leave the renamed file truncated to zero — defeating
73
+ * the purpose of the atomic rename. We:
74
+ * 1. fdatasync the temp fd before close, so the data is on disk before the
75
+ * rename observes it.
76
+ * 2. fsync the parent directory after rename, so the directory entry change
77
+ * is durable too. Some filesystems (FAT, certain FUSE mounts) don't
78
+ * support directory fsync; we ignore EINVAL/ENOTSUP so atomic writes
79
+ * don't fail on exotic mounts.
39
80
  */
40
81
  export function writeFileAtomic(target, content, mode) {
41
- const tmp = `${target}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
42
- fs.writeFileSync(tmp, content, "utf8");
82
+ const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
83
+ const fd = fs.openSync(tmp, "w", mode ?? 0o600);
84
+ try {
85
+ fs.writeSync(fd, content);
86
+ try {
87
+ fs.fdatasyncSync(fd);
88
+ }
89
+ catch {
90
+ // Best-effort: some pseudo-filesystems lack fdatasync. Fall through
91
+ // to closeSync — the rename below still preserves atomicity even if
92
+ // the data isn't durable, and the calling code's retry will recover.
93
+ }
94
+ }
95
+ finally {
96
+ fs.closeSync(fd);
97
+ }
43
98
  fs.renameSync(tmp, target);
44
- if (mode !== undefined)
45
- fs.chmodSync(target, mode);
99
+ try {
100
+ const dirFd = fs.openSync(path.dirname(target), "r");
101
+ try {
102
+ fs.fsyncSync(dirFd);
103
+ }
104
+ finally {
105
+ fs.closeSync(dirFd);
106
+ }
107
+ }
108
+ catch {
109
+ // Directory fsync is unsupported on FAT, some FUSE mounts, and Windows
110
+ // (where directories cannot be opened for read like POSIX). Silently
111
+ // ignore so writeFileAtomic remains portable.
112
+ }
46
113
  }
47
114
  /**
48
115
  * Resolve the stash directory using a three-level fallback chain:
@@ -411,6 +478,27 @@ export function stringArray(value) {
411
478
  * Group an array of values by a string key derived from each element.
412
479
  * Returns a `Map` so insertion order within each group is preserved.
413
480
  */
481
+ /**
482
+ * Return true if a process with the given PID is currently alive.
483
+ * Uses `process.kill(pid, 0)` which does not deliver a signal but
484
+ * throws ESRCH when the process does not exist.
485
+ */
486
+ export function isProcessAlive(pid) {
487
+ try {
488
+ process.kill(pid, 0);
489
+ return true;
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ }
495
+ /**
496
+ * Convert a number of days to milliseconds. Consolidates the
497
+ * `N * 24 * 60 * 60 * 1000` pattern used throughout the cooldown logic.
498
+ */
499
+ export function daysToMs(days) {
500
+ return days * 86_400_000;
501
+ }
414
502
  export function groupBy(values, keyFn) {
415
503
  const groups = new Map();
416
504
  for (const value of values) {
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Maps over items concurrently with a pool size limit.
3
6
  * Uses Promise.allSettled semantics — one failure does not cancel others.
@@ -0,0 +1,347 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Pure I/O helpers for AKM config files.
6
+ *
7
+ * No knowledge of the AkmConfig shape — these functions just read JSON(C) text
8
+ * from disk and write JSON text back atomically. Validation and migration live
9
+ * in `./config.ts` and `./config-migrate.ts`.
10
+ *
11
+ * Split out so the load path is testable without touching the filesystem
12
+ * (`parseConfigText` is pure), and so a single atomic write path serves
13
+ * `saveConfig`, the migrate command, and the setup wizard (#464.c).
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { writeFileAtomic } from "./common";
18
+ import { ConfigError } from "./errors";
19
+ import { probeLock, releaseLock, tryAcquireLockSync } from "./file-lock";
20
+ import { getCacheDir, getConfigDir } from "./paths";
21
+ /**
22
+ * Read the raw text of a config file. Returns `undefined` when the file does
23
+ * not exist (legitimate cold-start). Other I/O errors propagate.
24
+ */
25
+ export function readConfigText(configPath) {
26
+ try {
27
+ return fs.readFileSync(configPath, "utf8");
28
+ }
29
+ catch (err) {
30
+ if (err.code === "ENOENT")
31
+ return undefined;
32
+ throw err;
33
+ }
34
+ }
35
+ /**
36
+ * Parse JSON(C) config text into a plain object. Strips `//` and `/* *​/`
37
+ * comments before parsing.
38
+ *
39
+ * Throws {@link ConfigError} when the text is unparseable or when the root is
40
+ * not a JSON object. Per #458, malformed config text is NOT silently rescued —
41
+ * the caller must surface the parse error.
42
+ */
43
+ export function parseConfigText(text, sourcePath) {
44
+ const stripped = stripJsonComments(text);
45
+ const where = sourcePath ? ` at ${sourcePath}` : "";
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(stripped);
49
+ }
50
+ catch (err) {
51
+ const detail = err instanceof Error ? err.message : String(err);
52
+ throw new ConfigError(`Failed to parse config JSON${where}: ${detail}`, "INVALID_CONFIG_FILE", "Edit the file to fix the JSON syntax error. Comments (// and /* */) are allowed; trailing commas are not.");
53
+ }
54
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
55
+ throw new ConfigError(`Config file${where} must contain a JSON object at the root, got ${describeJsonRoot(parsed)}.`, "INVALID_CONFIG_FILE");
56
+ }
57
+ return parsed;
58
+ }
59
+ function describeJsonRoot(value) {
60
+ if (value === null)
61
+ return "null";
62
+ if (Array.isArray(value))
63
+ return "an array";
64
+ if (typeof value === "string")
65
+ return "a string";
66
+ if (typeof value === "number")
67
+ return "a number";
68
+ if (typeof value === "boolean")
69
+ return "a boolean";
70
+ return typeof value;
71
+ }
72
+ /**
73
+ * Atomically write a config object to disk as pretty-printed JSON. Routes
74
+ * through {@link writeFileAtomic} so partial writes can never corrupt the
75
+ * config file (#464.c).
76
+ */
77
+ export function writeConfigAtomic(configPath, config) {
78
+ writeFileAtomic(configPath, `${JSON.stringify(config, null, 2)}\n`);
79
+ }
80
+ /** Maximum number of timestamped config backups to retain (#459). */
81
+ const MAX_CONFIG_BACKUPS = 5;
82
+ /**
83
+ * Snapshot the current config file to `<cacheDir>/config-backups/`. Writes
84
+ * both a timestamped copy and a `config.latest.json` pointer, then prunes the
85
+ * timestamped set to {@link MAX_CONFIG_BACKUPS} most-recent entries.
86
+ *
87
+ * No-op when the source file does not exist (cold-start safe).
88
+ */
89
+ export function backupExistingConfig(configPath) {
90
+ if (!fs.existsSync(configPath))
91
+ return;
92
+ const backupDir = path.join(getCacheDir(), "config-backups");
93
+ fs.mkdirSync(backupDir, { recursive: true });
94
+ const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
95
+ fs.copyFileSync(configPath, path.join(backupDir, `config-${timestamp}.json`));
96
+ fs.copyFileSync(configPath, path.join(backupDir, "config.latest.json"));
97
+ pruneOldBackups(backupDir);
98
+ }
99
+ function pruneOldBackups(backupDir) {
100
+ let entries;
101
+ try {
102
+ entries = fs.readdirSync(backupDir);
103
+ }
104
+ catch {
105
+ return;
106
+ }
107
+ const timestamped = entries
108
+ .filter((n) => n.startsWith("config-") && n.endsWith(".json") && n !== "config.latest.json")
109
+ .map((name) => {
110
+ const full = path.join(backupDir, name);
111
+ let mtime = 0;
112
+ try {
113
+ mtime = fs.statSync(full).mtimeMs;
114
+ }
115
+ catch {
116
+ // Unreadable — sorts to the end via mtime 0.
117
+ }
118
+ return { path: full, mtime };
119
+ })
120
+ .sort((a, b) => b.mtime - a.mtime);
121
+ for (const stale of timestamped.slice(MAX_CONFIG_BACKUPS)) {
122
+ try {
123
+ fs.unlinkSync(stale.path);
124
+ }
125
+ catch {
126
+ // Best-effort prune; next save will retry.
127
+ }
128
+ }
129
+ }
130
+ // ── Config write lock ────────────────────────────────────────────────────────
131
+ /**
132
+ * Path to the config write sentinel (`config.json.lck` in $CONFIG).
133
+ *
134
+ * Placed next to config.json so the lock scope is obvious and the path is
135
+ * predictable for debugging. Uses $CONFIG (not $DATA) because config.json
136
+ * itself lives in $CONFIG — they should fail together if the dir is read-only.
137
+ */
138
+ function getConfigLockPath() {
139
+ return path.join(getConfigDir(), "config.json.lck");
140
+ }
141
+ const CONFIG_LOCK_MAX_RETRIES = 10;
142
+ const CONFIG_LOCK_RETRY_DELAY_MS = 50;
143
+ /**
144
+ * Acquire an exclusive sentinel around config writes.
145
+ *
146
+ * Returns a release function. Best-effort: when all retries are exhausted the
147
+ * write proceeds unlocked rather than erroring (same posture as lockfile.ts).
148
+ */
149
+ export function acquireConfigLock() {
150
+ const lockPath = getConfigLockPath();
151
+ try {
152
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
153
+ }
154
+ catch {
155
+ // Directory already exists or unwritable — let the write fail naturally.
156
+ }
157
+ for (let attempt = 0; attempt < CONFIG_LOCK_MAX_RETRIES; attempt++) {
158
+ try {
159
+ if (tryAcquireLockSync(lockPath, String(process.pid))) {
160
+ return () => releaseLock(lockPath);
161
+ }
162
+ }
163
+ catch {
164
+ // Non-EEXIST error (permissions, etc.) — bail out and proceed unlocked.
165
+ break;
166
+ }
167
+ if (probeLock(lockPath).state === "stale") {
168
+ releaseLock(lockPath);
169
+ continue; // Reclaimed — retry immediately.
170
+ }
171
+ if (attempt < CONFIG_LOCK_MAX_RETRIES - 1) {
172
+ // Busy spin (synchronous) — config writes are fast.
173
+ const deadline = Date.now() + CONFIG_LOCK_RETRY_DELAY_MS;
174
+ while (Date.now() < deadline) {
175
+ // spin
176
+ }
177
+ }
178
+ }
179
+ // Best-effort: proceed without lock.
180
+ return () => { };
181
+ }
182
+ /**
183
+ * Run `fn` inside the config write lock. Always releases the lock.
184
+ */
185
+ export function withConfigLock(fn) {
186
+ const release = acquireConfigLock();
187
+ try {
188
+ return fn();
189
+ }
190
+ finally {
191
+ release();
192
+ }
193
+ }
194
+ // ── Unified diff helper ──────────────────────────────────────────────────────
195
+ /**
196
+ * Produce a minimal unified diff between `before` and `after` text.
197
+ * Uses LCS-based diff with 2-line context. Returns an empty string when the
198
+ * inputs are identical. `label` is used as the path in the diff header.
199
+ *
200
+ * Designed for config files (typically < 200 lines). O(m*n) in line count.
201
+ */
202
+ export function unifiedDiff(before, after, label) {
203
+ if (before === after)
204
+ return "";
205
+ const a = before.split("\n");
206
+ const b = after.split("\n");
207
+ const eqPairs = lcsLinePairs(a, b);
208
+ const CONTEXT = 2;
209
+ const ops = [];
210
+ let ai = 0;
211
+ let bi = 0;
212
+ let pi = 0;
213
+ while (ai < a.length || bi < b.length) {
214
+ const eq = eqPairs[pi];
215
+ if (eq && eq.ai === ai && eq.bi === bi) {
216
+ ops.push({ type: "eq", line: a[ai], ai, bi });
217
+ ai++;
218
+ bi++;
219
+ pi++;
220
+ }
221
+ else if (ai < a.length && (!eq || ai < eq.ai)) {
222
+ ops.push({ type: "del", line: a[ai], ai, bi });
223
+ ai++;
224
+ }
225
+ else {
226
+ ops.push({ type: "add", line: b[bi], ai, bi });
227
+ bi++;
228
+ }
229
+ }
230
+ // Find changed op indices
231
+ const changed = new Set(ops.map((o, i) => (o.type !== "eq" ? i : -1)).filter((i) => i >= 0));
232
+ if (changed.size === 0)
233
+ return "";
234
+ // Determine which equal lines to include as context
235
+ const include = new Set();
236
+ for (const ci of changed) {
237
+ for (let k = Math.max(0, ci - CONTEXT); k <= Math.min(ops.length - 1, ci + CONTEXT); k++) {
238
+ include.add(k);
239
+ }
240
+ }
241
+ // Collect hunks
242
+ const header = [`--- ${label} (before)`, `+++ ${label} (after)`];
243
+ const out = [];
244
+ let hunkOps = [];
245
+ let prevIncluded = false;
246
+ function flushHunk() {
247
+ if (hunkOps.length === 0)
248
+ return;
249
+ const delStart = hunkOps.find((o) => o.type !== "add")?.ai ?? 0;
250
+ const addStart = hunkOps.find((o) => o.type !== "del")?.bi ?? 0;
251
+ const countA = hunkOps.filter((o) => o.type !== "add").length;
252
+ const countB = hunkOps.filter((o) => o.type !== "del").length;
253
+ out.push(`@@ -${delStart + 1},${countA} +${addStart + 1},${countB} @@`);
254
+ for (const op of hunkOps) {
255
+ const ch = op.type === "eq" ? " " : op.type === "del" ? "-" : "+";
256
+ out.push(`${ch}${op.line}`);
257
+ }
258
+ hunkOps = [];
259
+ }
260
+ for (let k = 0; k < ops.length; k++) {
261
+ if (include.has(k)) {
262
+ hunkOps.push(ops[k]);
263
+ prevIncluded = true;
264
+ }
265
+ else if (prevIncluded) {
266
+ flushHunk();
267
+ prevIncluded = false;
268
+ }
269
+ }
270
+ flushHunk();
271
+ return out.length > 0 ? [...header, ...out].join("\n") : "";
272
+ }
273
+ function lcsLinePairs(a, b) {
274
+ const m = a.length;
275
+ const n = b.length;
276
+ if (m === 0 || n === 0)
277
+ return [];
278
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
279
+ for (let i = 1; i <= m; i++) {
280
+ for (let j = 1; j <= n; j++) {
281
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
282
+ }
283
+ }
284
+ const result = [];
285
+ let i = m;
286
+ let j = n;
287
+ while (i > 0 && j > 0) {
288
+ if (a[i - 1] === b[j - 1]) {
289
+ result.unshift({ ai: i - 1, bi: j - 1 });
290
+ i--;
291
+ j--;
292
+ }
293
+ else if (dp[i - 1][j] > dp[i][j - 1]) {
294
+ i--;
295
+ }
296
+ else {
297
+ j--;
298
+ }
299
+ }
300
+ return result;
301
+ }
302
+ /**
303
+ * Strip JavaScript-style comments from a JSON string (JSONC support).
304
+ * Handles `//` line comments and `/* *​/` block comments while preserving
305
+ * comment-like sequences inside quoted strings.
306
+ */
307
+ export function stripJsonComments(text) {
308
+ let result = "";
309
+ let i = 0;
310
+ let inString = false;
311
+ while (i < text.length) {
312
+ if (inString) {
313
+ if (text[i] === "\\") {
314
+ result += text[i] + (text[i + 1] ?? "");
315
+ i += 2;
316
+ continue;
317
+ }
318
+ if (text[i] === '"') {
319
+ inString = false;
320
+ }
321
+ result += text[i];
322
+ i++;
323
+ continue;
324
+ }
325
+ if (text[i] === '"') {
326
+ inString = true;
327
+ result += text[i];
328
+ i++;
329
+ continue;
330
+ }
331
+ if (text[i] === "/" && text[i + 1] === "/") {
332
+ while (i < text.length && text[i] !== "\n")
333
+ i++;
334
+ continue;
335
+ }
336
+ if (text[i] === "/" && text[i + 1] === "*") {
337
+ i += 2;
338
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
339
+ i++;
340
+ i += 2;
341
+ continue;
342
+ }
343
+ result += text[i];
344
+ i++;
345
+ }
346
+ return result;
347
+ }