akm-cli 0.9.0-beta.54 → 0.9.0-beta.56

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 (103) hide show
  1. package/dist/cli.js +5 -3
  2. package/dist/commands/agent/contribute-cli.js +2 -3
  3. package/dist/commands/env/env-cli.js +187 -202
  4. package/dist/commands/env/secret-cli.js +109 -121
  5. package/dist/commands/feedback-cli.js +152 -155
  6. package/dist/commands/health/advisories.js +151 -0
  7. package/dist/commands/health/improve-metrics.js +754 -0
  8. package/dist/commands/health/llm-usage.js +65 -0
  9. package/dist/commands/health/md-report.js +103 -0
  10. package/dist/commands/health/metrics.js +278 -0
  11. package/dist/commands/health/task-runs.js +135 -0
  12. package/dist/commands/health/types.js +18 -0
  13. package/dist/commands/health/windows.js +196 -0
  14. package/dist/commands/health.js +14 -1624
  15. package/dist/commands/improve/anti-collapse.js +170 -0
  16. package/dist/commands/improve/collapse-detector.js +3 -2
  17. package/dist/commands/improve/consolidate.js +636 -633
  18. package/dist/commands/improve/dedup.js +1 -1
  19. package/dist/commands/improve/distill/content-repair.js +202 -0
  20. package/dist/commands/improve/distill/promote-memory.js +228 -0
  21. package/dist/commands/improve/distill/quality-gate.js +233 -0
  22. package/dist/commands/improve/distill-guards.js +127 -0
  23. package/dist/commands/improve/distill.js +49 -575
  24. package/dist/commands/improve/extract-cli.js +74 -76
  25. package/dist/commands/improve/extract.js +6 -4
  26. package/dist/commands/improve/hot-probation.js +45 -0
  27. package/dist/commands/improve/improve-auto-accept.js +3 -2
  28. package/dist/commands/improve/improve-cli.js +14 -13
  29. package/dist/commands/improve/improve-result-file.js +2 -1
  30. package/dist/commands/improve/improve.js +6 -5
  31. package/dist/commands/improve/loop-stages.js +19 -21
  32. package/dist/commands/improve/preparation.js +4 -2
  33. package/dist/commands/improve/procedural.js +10 -31
  34. package/dist/commands/improve/recombine.js +19 -43
  35. package/dist/commands/improve/reflect.js +1 -1
  36. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  37. package/dist/commands/improve/shared.js +48 -0
  38. package/dist/commands/observability-cli.js +4 -4
  39. package/dist/commands/proposal/drain-policies.js +2 -2
  40. package/dist/commands/proposal/drain.js +1 -1
  41. package/dist/commands/proposal/legacy-import.js +115 -0
  42. package/dist/commands/proposal/proposal-cli.js +3 -3
  43. package/dist/commands/proposal/proposal.js +2 -1
  44. package/dist/commands/proposal/propose.js +1 -1
  45. package/dist/commands/proposal/repository.js +829 -0
  46. package/dist/commands/proposal/validators/proposals.js +5 -920
  47. package/dist/commands/read/remember-cli.js +132 -137
  48. package/dist/commands/read/search-cli.js +1 -1
  49. package/dist/commands/registry-cli.js +76 -87
  50. package/dist/commands/sources/add-cli.js +90 -94
  51. package/dist/commands/sources/history.js +1 -1
  52. package/dist/commands/sources/schema-repair.js +1 -1
  53. package/dist/commands/sources/sources-cli.js +3 -3
  54. package/dist/commands/sources/stash-cli.js +1 -1
  55. package/dist/commands/tasks/tasks-cli.js +1 -2
  56. package/dist/commands/wiki-cli.js +2 -3
  57. package/dist/core/common.js +3 -3
  58. package/dist/core/config/config-schema.js +6 -0
  59. package/dist/core/deep-merge.js +38 -0
  60. package/dist/core/events.js +2 -1
  61. package/dist/core/logs-db.js +8 -13
  62. package/dist/core/paths.js +14 -14
  63. package/dist/core/state-db.js +13 -1140
  64. package/dist/indexer/db/db.js +96 -723
  65. package/dist/indexer/db/entry-mapper.js +41 -0
  66. package/dist/indexer/db/schema.js +516 -0
  67. package/dist/indexer/feedback/utility-policy.js +75 -0
  68. package/dist/indexer/graph/graph-extraction.js +2 -1
  69. package/dist/indexer/index-writer-lock.js +9 -0
  70. package/dist/indexer/indexer.js +78 -23
  71. package/dist/indexer/search/fts-query.js +51 -0
  72. package/dist/integrations/agent/spawn.js +15 -66
  73. package/dist/llm/embedders/cache.js +3 -1
  74. package/dist/output/text/helpers.js +13 -0
  75. package/dist/registry/resolve.js +5 -0
  76. package/dist/scripts/migrate-storage.js +6908 -7447
  77. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +44 -43
  78. package/dist/setup/legacy-config.js +106 -0
  79. package/dist/setup/prompt.js +57 -0
  80. package/dist/setup/providers.js +14 -0
  81. package/dist/setup/semantic-assets.js +124 -0
  82. package/dist/setup/setup.js +24 -1607
  83. package/dist/setup/steps/connection.js +734 -0
  84. package/dist/setup/steps/output.js +31 -0
  85. package/dist/setup/steps/platforms.js +124 -0
  86. package/dist/setup/steps/semantic.js +27 -0
  87. package/dist/setup/steps/sources.js +222 -0
  88. package/dist/setup/steps/stashdir.js +42 -0
  89. package/dist/setup/steps/tasks.js +152 -0
  90. package/dist/storage/repositories/canaries-repository.js +107 -0
  91. package/dist/storage/repositories/consolidation-repository.js +38 -0
  92. package/dist/storage/repositories/embeddings-repository.js +72 -0
  93. package/dist/storage/repositories/events-repository.js +187 -0
  94. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  95. package/dist/storage/repositories/improve-runs-repository.js +130 -0
  96. package/dist/storage/repositories/index-db.js +4 -7
  97. package/dist/storage/repositories/proposals-repository.js +220 -0
  98. package/dist/storage/repositories/recombine-repository.js +213 -0
  99. package/dist/storage/repositories/task-history-repository.js +93 -0
  100. package/dist/storage/sqlite-pragmas.js +3 -3
  101. package/dist/tasks/runner.js +2 -1
  102. package/package.json +1 -1
  103. package/dist/commands/improve/homeostatic.js +0 -497
@@ -3,13 +3,11 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { defineCommand } from "citty";
7
6
  import * as p from "../../cli/clack.js";
8
- import { output, runWithJsonErrors } from "../../cli/shared.js";
7
+ import { defineJsonCommand, output } from "../../cli/shared.js";
9
8
  import { UsageError } from "../../core/errors.js";
10
9
  import { appendEvent } from "../../core/events.js";
11
10
  import { warn } from "../../core/warn.js";
12
- import { getHyphenatedBoolean } from "../../output/context.js";
13
11
  import { akmRemove } from "./installed-stashes.js";
14
12
  import { akmAdd } from "./source-add.js";
15
13
  import { addStash } from "./source-manage.js";
@@ -149,7 +147,7 @@ export async function auditInstalledStashForDangerousKeys(opts) {
149
147
  return { blocked: true, exitCode: 1 };
150
148
  }
151
149
  // ── Command definition ────────────────────────────────────────────────────────
152
- export const addCommand = defineCommand({
150
+ export const addCommand = defineJsonCommand({
153
151
  meta: {
154
152
  name: "add",
155
153
  description: "Add a source (local directory, website, npm package, GitHub repo, git URL, or remote provider)",
@@ -181,48 +179,11 @@ export const addCommand = defineCommand({
181
179
  },
182
180
  },
183
181
  async run({ args }) {
184
- await runWithJsonErrors(async () => {
185
- const ref = args.ref.trim();
186
- const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
187
- const allowDangerousKeys = allowInsecure;
188
- // URL with --provider → stash source (remote or git provider)
189
- if (args.provider) {
190
- if (shouldWarnOnPlainHttp(ref)) {
191
- if (!allowInsecure) {
192
- throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
193
- "Use https:// or pass --allow-insecure if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure` only after confirming the URL is trusted.");
194
- }
195
- warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
196
- }
197
- let parsedOptions;
198
- if (args.options) {
199
- try {
200
- const parsed = JSON.parse(args.options);
201
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
202
- throw new UsageError("--options must be a JSON object");
203
- }
204
- parsedOptions = parsed;
205
- }
206
- catch (err) {
207
- if (err instanceof UsageError)
208
- throw err;
209
- throw new UsageError("--options must be valid JSON");
210
- }
211
- }
212
- const result = addStash({
213
- target: ref,
214
- name: args.name,
215
- providerType: args.provider,
216
- options: parsedOptions,
217
- writable: args.writable,
218
- });
219
- appendEvent({
220
- eventType: "add",
221
- metadata: { target: ref, provider: args.provider, name: args.name ?? null, writable: args.writable === true },
222
- });
223
- output("add", result);
224
- return;
225
- }
182
+ const ref = args.ref.trim();
183
+ const allowInsecure = args["allow-insecure"];
184
+ const allowDangerousKeys = allowInsecure;
185
+ // URL with --provider → stash source (remote or git provider)
186
+ if (args.provider) {
226
187
  if (shouldWarnOnPlainHttp(ref)) {
227
188
  if (!allowInsecure) {
228
189
  throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
@@ -230,64 +191,99 @@ export const addCommand = defineCommand({
230
191
  }
231
192
  warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
232
193
  }
233
- const websiteOptions = buildWebsiteOptions(args);
234
- if (args.type === "wiki") {
235
- const { registerWikiSource } = await import("./source-add.js");
236
- const result = await registerWikiSource({
237
- ref,
238
- name: args.name,
239
- options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
240
- writable: args.writable,
241
- });
242
- appendEvent({
243
- eventType: "add",
244
- metadata: { target: ref, type: "wiki", name: args.name ?? null, writable: args.writable === true },
245
- });
246
- output("add", result);
247
- return;
194
+ let parsedOptions;
195
+ if (args.options) {
196
+ try {
197
+ const parsed = JSON.parse(args.options);
198
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
199
+ throw new UsageError("--options must be a JSON object");
200
+ }
201
+ parsedOptions = parsed;
202
+ }
203
+ catch (err) {
204
+ if (err instanceof UsageError)
205
+ throw err;
206
+ throw new UsageError("--options must be valid JSON");
207
+ }
248
208
  }
249
- const result = await akmAdd({
209
+ const result = addStash({
210
+ target: ref,
211
+ name: args.name,
212
+ providerType: args.provider,
213
+ options: parsedOptions,
214
+ writable: args.writable,
215
+ });
216
+ appendEvent({
217
+ eventType: "add",
218
+ metadata: { target: ref, provider: args.provider, name: args.name ?? null, writable: args.writable === true },
219
+ });
220
+ output("add", result);
221
+ return;
222
+ }
223
+ if (shouldWarnOnPlainHttp(ref)) {
224
+ if (!allowInsecure) {
225
+ throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
226
+ "Use https:// or pass --allow-insecure if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure` only after confirming the URL is trusted.");
227
+ }
228
+ warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
229
+ }
230
+ const websiteOptions = buildWebsiteOptions(args);
231
+ if (args.type === "wiki") {
232
+ const { registerWikiSource } = await import("./source-add.js");
233
+ const result = await registerWikiSource({
250
234
  ref,
251
235
  name: args.name,
252
- overrideType: args.type,
253
236
  options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
254
237
  writable: args.writable,
255
238
  });
256
239
  appendEvent({
257
240
  eventType: "add",
258
- metadata: {
259
- target: ref,
260
- name: args.name ?? null,
261
- overrideType: args.type ?? null,
262
- writable: args.writable === true,
263
- },
241
+ metadata: { target: ref, type: "wiki", name: args.name ?? null, writable: args.writable === true },
264
242
  });
265
- // ── Post-install env key audit ──────────────────────────────────────────
266
- // Resolve the stash root from the install result and scan any env files
267
- // for dangerous env var keys. When findings are present the install is
268
- // gated: TTY → interactive confirmation prompt; non-TTY without
269
- // --allow-insecure → hard failure (exit 1). Pass
270
- // --allow-insecure to skip the prompt non-interactively.
271
- const installedStashRoot = result.installed?.stashRoot ??
272
- (result.sourceAdded && "stashRoot" in result.sourceAdded ? result.sourceAdded.stashRoot : undefined);
273
- if (installedStashRoot) {
274
- // Use the canonical installed id (most reliably resolved by akmRemove) rather
275
- // than the raw user-supplied ref which may not match after URL normalisation.
276
- const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
277
- // The audit RETURNS its decision; we decide `process.exit` here, OUTSIDE
278
- // any catch, so the abort cannot be lost to a swallowed exception (C3).
279
- const decision = await auditInstalledStashForDangerousKeys({
280
- installedStashRoot,
281
- ref,
282
- allowDangerousKeys,
283
- rollbackTarget,
284
- isTTY: process.stdin.isTTY === true,
285
- });
286
- if (decision.blocked) {
287
- process.exit(decision.exitCode);
288
- }
289
- }
290
243
  output("add", result);
244
+ return;
245
+ }
246
+ const result = await akmAdd({
247
+ ref,
248
+ name: args.name,
249
+ overrideType: args.type,
250
+ options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
251
+ writable: args.writable,
252
+ });
253
+ appendEvent({
254
+ eventType: "add",
255
+ metadata: {
256
+ target: ref,
257
+ name: args.name ?? null,
258
+ overrideType: args.type ?? null,
259
+ writable: args.writable === true,
260
+ },
291
261
  });
262
+ // ── Post-install env key audit ──────────────────────────────────────────
263
+ // Resolve the stash root from the install result and scan any env files
264
+ // for dangerous env var keys. When findings are present the install is
265
+ // gated: TTY → interactive confirmation prompt; non-TTY without
266
+ // --allow-insecure → hard failure (exit 1). Pass
267
+ // --allow-insecure to skip the prompt non-interactively.
268
+ const installedStashRoot = result.installed?.stashRoot ??
269
+ (result.sourceAdded && "stashRoot" in result.sourceAdded ? result.sourceAdded.stashRoot : undefined);
270
+ if (installedStashRoot) {
271
+ // Use the canonical installed id (most reliably resolved by akmRemove) rather
272
+ // than the raw user-supplied ref which may not match after URL normalisation.
273
+ const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
274
+ // The audit RETURNS its decision; we decide `process.exit` here, OUTSIDE
275
+ // any catch, so the abort cannot be lost to a swallowed exception (C3).
276
+ const decision = await auditInstalledStashForDangerousKeys({
277
+ installedStashRoot,
278
+ ref,
279
+ allowDangerousKeys,
280
+ rollbackTarget,
281
+ isTTY: process.stdin.isTTY === true,
282
+ });
283
+ if (decision.blocked) {
284
+ process.exit(decision.exitCode);
285
+ }
286
+ }
287
+ output("add", result);
292
288
  },
293
289
  });
@@ -22,7 +22,7 @@ import { readEvents } from "../../core/events.js";
22
22
  import { isoToSqlite, parseSinceToIso } from "../../core/time.js";
23
23
  import { closeDatabase, openExistingDatabase } from "../../indexer/db/db.js";
24
24
  import { getUsageEvents } from "../../indexer/usage/usage-events.js";
25
- import { listProposals } from "../proposal/validators/proposals.js";
25
+ import { listProposals } from "../proposal/repository.js";
26
26
  // Proposal lifecycle event types emitted by the proposal substrate (#225).
27
27
  const PROPOSAL_EVENT_TYPES = new Set(["promoted", "rejected"]);
28
28
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -23,7 +23,7 @@ import { resolveStandardsContext } from "../../core/standards/resolve-standards-
23
23
  import { info } from "../../core/warn.js";
24
24
  import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
25
25
  import { chatCompletion, parseEmbeddedJsonResponse } from "../../llm/client.js";
26
- import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
26
+ import { createProposal, isProposalSkipped } from "../proposal/repository.js";
27
27
  // ── Constants ────────────────────────────────────────────────────────────────
28
28
  /** Minimum gap between schema-repair attempts on the same asset. */
29
29
  const SCHEMA_REPAIR_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -23,7 +23,7 @@ import { loadConfig } from "../../core/config/config.js";
23
23
  import { UsageError } from "../../core/errors.js";
24
24
  import { appendEvent } from "../../core/events.js";
25
25
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
26
- import { getHyphenatedBoolean, parseFlagValue } from "../../output/context.js";
26
+ import { parseFlagValue } from "../../output/context.js";
27
27
  import { resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
28
28
  import { pkgVersion } from "../../version.js";
29
29
  import { akmHistory } from "./history.js";
@@ -125,8 +125,8 @@ export const upgradeCommand = defineJsonCommand({
125
125
  output("upgrade", check);
126
126
  return;
127
127
  }
128
- const skipChecksum = getHyphenatedBoolean(args, "skip-checksum");
129
- const skipPostUpgrade = getHyphenatedBoolean(args, "skip-post-upgrade");
128
+ const skipChecksum = args["skip-checksum"];
129
+ const skipPostUpgrade = args["skip-post-upgrade"];
130
130
  const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade });
131
131
  output("upgrade", result);
132
132
  },
@@ -60,7 +60,7 @@ export const initCommand = defineJsonCommand({
60
60
  const legacyDir = parseFlagValue(process.argv, "--stashDir") ?? parseFlagValue(process.argv, "--stash-dir");
61
61
  const result = await akmInit({
62
62
  dir: args.dir ?? legacyDir,
63
- setDefault: getHyphenatedBoolean(args, "set-default"),
63
+ setDefault: args["set-default"],
64
64
  });
65
65
  output("init", result);
66
66
  },
@@ -15,7 +15,6 @@
15
15
  import { defineCommand } from "citty";
16
16
  import { parsePositiveIntFlag } from "../../cli/parse-args.js";
17
17
  import { defineGroupCommand, defineJsonCommand, output, runWithJsonErrors } from "../../cli/shared.js";
18
- import { getHyphenatedArg } from "../../output/context.js";
19
18
  import { detectServerDefault, registerDefaultTasks } from "./default-tasks.js";
20
19
  import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./tasks.js";
21
20
  const tasksAddCommand = defineJsonCommand({
@@ -51,7 +50,7 @@ const tasksAddCommand = defineJsonCommand({
51
50
  profile: args.profile,
52
51
  params: args.params,
53
52
  name: args.name,
54
- when_to_use: getHyphenatedArg(args, "when-to-use"),
53
+ when_to_use: args["when-to-use"],
55
54
  description: args.description,
56
55
  tags: args.tags
57
56
  ? args.tags
@@ -16,7 +16,6 @@ import { defineGroupCommand, defineJsonCommand, output, runWithJsonErrors } from
16
16
  import { resolveStashDir } from "../core/common.js";
17
17
  import { loadConfig, resolveConfiguredSources } from "../core/config/config.js";
18
18
  import { ConfigError, UsageError } from "../core/errors.js";
19
- import { getHyphenatedArg, getHyphenatedBoolean } from "../output/context.js";
20
19
  import { akmAgentDispatch } from "./agent/agent-dispatch.js";
21
20
  import { readKnowledgeInput } from "./read/knowledge.js";
22
21
  import { buildWebsiteOptions } from "./sources/add-cli.js";
@@ -108,7 +107,7 @@ const wikiRemoveCommand = defineJsonCommand({
108
107
  process.stderr.write("Aborted.\n");
109
108
  return;
110
109
  }
111
- const withSources = getHyphenatedBoolean(args, "with-sources");
110
+ const withSources = args["with-sources"];
112
111
  const { removeWiki } = await import("../wiki/wiki.js");
113
112
  const { akmIndex } = await import("../indexer/indexer.js");
114
113
  const stashDir = resolveStashDir();
@@ -244,7 +243,7 @@ const wikiIngestCommand = defineJsonCommand({
244
243
  if (!profileName) {
245
244
  throw new UsageError("akm wiki ingest requires an agent profile. Pass --profile <name> or set defaults.agent in config.", "MISSING_REQUIRED_ARGUMENT", "Available profiles are listed under profiles.agent in your config. Run `akm config get profiles.agent` to inspect.");
246
245
  }
247
- const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
246
+ const timeoutMs = parsePositiveIntFlag(args["timeout-ms"], "--timeout-ms");
248
247
  const model = getStringArg(args, "model");
249
248
  const { getDefaultLlmConfig } = await import("../core/config/config.js");
250
249
  const dispatchResult = await akmAgentDispatch({
@@ -132,9 +132,9 @@ export function writeFileAtomic(target, content, mode) {
132
132
  *
133
133
  * Throws if no valid stash directory is found.
134
134
  */
135
- export function resolveStashDir(_options) {
135
+ export function resolveStashDir(_options, env = process.env) {
136
136
  // 1. Env var override (for CI, scripts, testing)
137
- const envDir = process.env.AKM_STASH_DIR?.trim();
137
+ const envDir = env.AKM_STASH_DIR?.trim();
138
138
  if (envDir) {
139
139
  return validateStashDir(envDir);
140
140
  }
@@ -143,7 +143,7 @@ export function resolveStashDir(_options) {
143
143
  if (configStashDir)
144
144
  return validateStashDir(configStashDir);
145
145
  // 3. Platform default — use it if it exists
146
- const defaultDir = getDefaultStashDir();
146
+ const defaultDir = getDefaultStashDir(env);
147
147
  if (isValidDirectory(defaultDir)) {
148
148
  return defaultDir;
149
149
  }
@@ -846,6 +846,12 @@ export const AkmConfigShape = {
846
846
  semanticSearchMode: z.enum(["off", "auto"]).default("auto"),
847
847
  embedding: EmbeddingConnectionConfigSchema.optional(),
848
848
  index: IndexConfigSchema.optional(),
849
+ // The `installed[]` shape is OWNED by the registry (`InstalledStashEntry`):
850
+ // its `source` is the 4-value `InstallKind` produced by the registry ref
851
+ // parser, and installed entries never carry the extra passthrough keys. The
852
+ // schema still validates entries at runtime, but its OUTPUT type is pinned to
853
+ // the domain type so config consumers get the registry `InstalledStashEntry`
854
+ // (not a looser schema-local mirror) — the single-source-of-truth boundary.
849
855
  installed: z.array(InstalledStashEntrySchema).optional(),
850
856
  registries: z.array(RegistryConfigEntrySchema).optional(),
851
857
  sources: z.array(SourceConfigEntrySchema).optional(),
@@ -0,0 +1,38 @@
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
+ * Generic recursive object merge, extracted from the setup wizard (it is not
6
+ * setup-specific). Plain objects merge key-by-key; arrays and scalars replace
7
+ * wholesale. Used to apply a partial `--file` config over the existing config
8
+ * without dropping sibling subkeys.
9
+ */
10
+ /** True for non-null, non-array plain objects. */
11
+ export function isPlainObject(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ /**
15
+ * Recursively merge `incoming` into `base`: plain objects merge key-by-key,
16
+ * while arrays and scalars replace wholesale. A partial input therefore only
17
+ * updates the keys it carries and never drops sibling subkeys (e.g. a file
18
+ * containing `{ output: { format: "text" } }` leaves `output.detail` intact).
19
+ *
20
+ * `base` is treated as immutable — a fresh object graph is returned.
21
+ */
22
+ export function deepMergeConfig(base, incoming) {
23
+ if (!isPlainObject(incoming))
24
+ return incoming;
25
+ const baseObj = isPlainObject(base) ? base : {};
26
+ const out = { ...baseObj };
27
+ for (const [key, value] of Object.entries(incoming)) {
28
+ if (value === undefined)
29
+ continue;
30
+ if (isPlainObject(value) && isPlainObject(baseObj[key])) {
31
+ out[key] = deepMergeConfig(baseObj[key], value);
32
+ }
33
+ else {
34
+ out[key] = value;
35
+ }
36
+ }
37
+ return out;
38
+ }
@@ -25,9 +25,10 @@
25
25
  * - `ts` is ISO-8601 (UTC, millisecond precision).
26
26
  */
27
27
  import path from "node:path";
28
+ import { insertEvent, readStateEvents } from "../storage/repositories/events-repository.js";
28
29
  import { rethrowIfTestIsolationError } from "./errors.js";
29
30
  import { getDataDir } from "./paths.js";
30
- import { insertEvent, openStateDatabase, readStateEvents, withStateDb } from "./state-db.js";
31
+ import { openStateDatabase, withStateDb } from "./state-db.js";
31
32
  import { error } from "./warn.js";
32
33
  /**
33
34
  * Legacy events.jsonl path — used only by the migration script
@@ -37,11 +37,9 @@
37
37
  *
38
38
  * @module logs-db
39
39
  */
40
- import fs from "node:fs";
41
40
  import path from "node:path";
42
- import { openDatabase } from "../storage/database.js";
43
41
  import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
44
- import { applyStandardPragmas } from "../storage/sqlite-pragmas.js";
42
+ import { openManagedDatabase } from "../storage/managed-db.js";
45
43
  import { getDataDir } from "./paths.js";
46
44
  // ── Path helper ──────────────────────────────────────────────────────────────
47
45
  /**
@@ -72,16 +70,13 @@ export function getLogsDbPath() {
72
70
  */
73
71
  export function openLogsDatabase(dbPath) {
74
72
  const resolvedPath = dbPath ?? getLogsDbPath();
75
- const dir = path.dirname(resolvedPath);
76
- if (!fs.existsSync(dir)) {
77
- fs.mkdirSync(dir, { recursive: true });
78
- }
79
- const db = openDatabase(resolvedPath);
80
- // PRAGMAs must run before any DDL or DML. foreignKeys:false preserves this
81
- // opener's historical behaviour — logs.db has never enforced foreign keys.
82
- applyStandardPragmas(db, { dataDir: dir, foreignKeys: false });
83
- runMigrations(db);
84
- return db;
73
+ // foreignKeys:false preserves this opener's historical behaviour — logs.db
74
+ // has never enforced foreign keys.
75
+ return openManagedDatabase({
76
+ path: resolvedPath,
77
+ pragmas: { dataDir: path.dirname(resolvedPath), foreignKeys: false },
78
+ init: runMigrations,
79
+ });
85
80
  }
86
81
  // ── Migrations ───────────────────────────────────────────────────────────────
87
82
  /**
@@ -115,8 +115,8 @@ export function getConfigPath() {
115
115
  return path.join(getConfigDir(), "config.json");
116
116
  }
117
117
  // ── Cache directory ──────────────────────────────────────────────────────────
118
- export function getCacheDir() {
119
- const override = process.env.AKM_CACHE_DIR?.trim();
118
+ export function getCacheDir(env = process.env) {
119
+ const override = env.AKM_CACHE_DIR?.trim();
120
120
  if (override)
121
121
  return override;
122
122
  // Explicit XDG/platform overrides win before the transient-stash isolation
@@ -125,13 +125,13 @@ export function getCacheDir() {
125
125
  // as set, so the AKM_STASH_DIR transient rule does not silently move cache
126
126
  // writes away from where they pointed them.
127
127
  if (IS_WINDOWS) {
128
- const localAppData = process.env.LOCALAPPDATA?.trim();
128
+ const localAppData = env.LOCALAPPDATA?.trim();
129
129
  if (localAppData)
130
130
  return path.join(localAppData, "akm");
131
- const userProfile = process.env.USERPROFILE?.trim();
131
+ const userProfile = env.USERPROFILE?.trim();
132
132
  if (userProfile)
133
133
  return path.join(userProfile, "AppData", "Local", "akm");
134
- const appData = process.env.APPDATA?.trim();
134
+ const appData = env.APPDATA?.trim();
135
135
  if (appData) {
136
136
  // Heuristic fallback: APPDATA points to %APPDATA% (Roaming), so
137
137
  // navigate to the sibling "Local" directory. This is typically
@@ -141,7 +141,7 @@ export function getCacheDir() {
141
141
  }
142
142
  }
143
143
  else {
144
- const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
144
+ const xdgCacheHome = env.XDG_CACHE_HOME?.trim();
145
145
  if (xdgCacheHome)
146
146
  return path.join(xdgCacheHome, "akm");
147
147
  }
@@ -150,7 +150,7 @@ export function getCacheDir() {
150
150
  // into `${AKM_STASH_DIR}/.akm/cache` so that config backups, registry-index
151
151
  // cache, and other regenerable artifacts do not pollute the user's host
152
152
  // ~/.cache/akm directory.
153
- const stashOverride = process.env.AKM_STASH_DIR?.trim();
153
+ const stashOverride = env.AKM_STASH_DIR?.trim();
154
154
  if (stashOverride && isTransientStashPath(stashOverride)) {
155
155
  return path.join(stashOverride, ".akm", "cache");
156
156
  }
@@ -158,7 +158,7 @@ export function getCacheDir() {
158
158
  // None of LOCALAPPDATA / USERPROFILE / APPDATA were set above.
159
159
  throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
160
160
  }
161
- const home = process.env.HOME?.trim();
161
+ const home = env.HOME?.trim();
162
162
  if (!home)
163
163
  return path.join("/tmp", "akm-cache");
164
164
  return path.join(home, ".cache", "akm");
@@ -257,17 +257,17 @@ export function getTaskHistoryDir() {
257
257
  return path.join(getCacheDir(), "tasks", "history");
258
258
  }
259
259
  // ── Default stash directory ──────────────────────────────────────────────────
260
- export function getDefaultStashDir() {
261
- const override = process.env.AKM_STASH_DIR?.trim();
260
+ export function getDefaultStashDir(env = process.env) {
261
+ const override = env.AKM_STASH_DIR?.trim();
262
262
  if (override)
263
263
  return override;
264
264
  if (IS_WINDOWS) {
265
- const userProfile = process.env.USERPROFILE?.trim();
265
+ const userProfile = env.USERPROFILE?.trim();
266
266
  if (userProfile)
267
267
  return path.join(userProfile, "Documents", "akm");
268
268
  return path.join("C:\\", "akm");
269
269
  }
270
- const home = process.env.HOME?.trim();
270
+ const home = env.HOME?.trim();
271
271
  if (!home) {
272
272
  throw new ConfigError("Unable to determine default stash directory. Set HOME.", "STASH_DIR_NOT_FOUND");
273
273
  }
@@ -294,7 +294,7 @@ export function getDefaultStashDir() {
294
294
  * is fine even though `~/.local` is refused). This catches fat-finger
295
295
  * `--dir /` or `--dir ~` without preventing legitimate nested use.
296
296
  */
297
- export function assertSafeStashDir(stashDir) {
297
+ export function assertSafeStashDir(stashDir, env = process.env) {
298
298
  const resolved = path.resolve(stashDir);
299
299
  // Filesystem root — POSIX and Windows drive roots.
300
300
  if (resolved === "/" || /^[A-Za-z]:[\\/]?$/.test(resolved)) {
@@ -334,7 +334,7 @@ export function assertSafeStashDir(stashDir) {
334
334
  // under bun test (which isolates HOME to a tempdir while os.homedir()
335
335
  // still returns the real user's home).
336
336
  const candidateHomes = new Set();
337
- const envHome = (process.env.HOME ?? process.env.USERPROFILE)?.trim();
337
+ const envHome = (env.HOME ?? env.USERPROFILE)?.trim();
338
338
  if (envHome)
339
339
  candidateHomes.add(path.resolve(envHome));
340
340
  try {