akm-cli 0.9.0-rc.2 → 0.9.0-rc.3

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 (88) hide show
  1. package/CHANGELOG.md +34 -8
  2. package/dist/assets/improve-strategies/catchup.json +3 -1
  3. package/dist/assets/improve-strategies/consolidate.json +3 -1
  4. package/dist/assets/improve-strategies/frequent.json +3 -1
  5. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  6. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  8. package/dist/assets/improve-strategies/quick.json +3 -1
  9. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  11. package/dist/assets/improve-strategies/synthesize.json +2 -0
  12. package/dist/assets/tasks/core/backup.yml +2 -2
  13. package/dist/cli/config-migrate.js +554 -26
  14. package/dist/cli.js +3 -1
  15. package/dist/commands/backup-cli.js +6 -4
  16. package/dist/commands/config-cli.js +10 -3
  17. package/dist/commands/feedback-cli.js +24 -7
  18. package/dist/commands/health/checks.js +94 -15
  19. package/dist/commands/health/surfaces.js +2 -1
  20. package/dist/commands/improve/anti-collapse.js +3 -3
  21. package/dist/commands/improve/collapse-detector.js +5 -5
  22. package/dist/commands/improve/consolidate.js +123 -66
  23. package/dist/commands/improve/distill/promote-memory.js +8 -6
  24. package/dist/commands/improve/distill/quality-gate.js +1 -1
  25. package/dist/commands/improve/distill-guards.js +1 -1
  26. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  27. package/dist/commands/improve/distill.js +52 -36
  28. package/dist/commands/improve/eligibility.js +9 -8
  29. package/dist/commands/improve/extract-prompt.js +2 -2
  30. package/dist/commands/improve/extract.js +43 -11
  31. package/dist/commands/improve/improve-auto-accept.js +14 -28
  32. package/dist/commands/improve/improve-cli.js +4 -2
  33. package/dist/commands/improve/improve-strategies.js +2 -1
  34. package/dist/commands/improve/improve.js +49 -8
  35. package/dist/commands/improve/loop-stages.js +11 -6
  36. package/dist/commands/improve/preparation.js +51 -17
  37. package/dist/commands/improve/procedural.js +10 -6
  38. package/dist/commands/improve/recombine.js +31 -7
  39. package/dist/commands/improve/reflect.js +24 -14
  40. package/dist/commands/improve/salience.js +5 -1
  41. package/dist/commands/improve/source-identity.js +43 -0
  42. package/dist/commands/migrate-cli.js +36 -0
  43. package/dist/commands/mv-cli.js +647 -258
  44. package/dist/commands/proposal/drain.js +16 -4
  45. package/dist/commands/proposal/proposal-cli.js +3 -2
  46. package/dist/commands/proposal/proposal.js +16 -55
  47. package/dist/commands/proposal/repository.js +664 -58
  48. package/dist/commands/read/curate.js +4 -2
  49. package/dist/commands/read/knowledge.js +4 -1
  50. package/dist/commands/read/search.js +6 -2
  51. package/dist/commands/read/show.js +8 -7
  52. package/dist/commands/sources/self-update.js +156 -112
  53. package/dist/commands/sources/sources-cli.js +7 -2
  54. package/dist/core/common.js +36 -3
  55. package/dist/core/config/config-io.js +2 -2
  56. package/dist/core/config/config-schema.js +15 -3
  57. package/dist/core/config/config.js +14 -13
  58. package/dist/core/file-lock.js +2 -2
  59. package/dist/core/migration-backup.js +816 -171
  60. package/dist/core/migration-operation.js +44 -0
  61. package/dist/core/state/migrations.js +4 -7
  62. package/dist/core/state-db.js +22 -7
  63. package/dist/core/write-source.js +86 -18
  64. package/dist/indexer/db/db.js +109 -53
  65. package/dist/indexer/index-writer-lock.js +38 -37
  66. package/dist/indexer/index-written-assets.js +73 -56
  67. package/dist/indexer/indexer.js +5 -1
  68. package/dist/indexer/search/search-source.js +2 -2
  69. package/dist/indexer/usage/usage-events.js +8 -2
  70. package/dist/integrations/agent/engine-resolution.js +14 -7
  71. package/dist/scripts/migrate-storage.js +867 -990
  72. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +405 -566
  73. package/dist/setup/detected-engines.js +7 -13
  74. package/dist/setup/engine-config.js +14 -4
  75. package/dist/setup/setup.js +1 -1
  76. package/dist/setup/steps/connection.js +1 -1
  77. package/dist/setup/steps/tasks.js +1 -1
  78. package/dist/sources/providers/git-stash.js +58 -21
  79. package/dist/sources/providers/git.js +1 -1
  80. package/dist/storage/engines/sqlite-migrations.js +138 -3
  81. package/dist/storage/repositories/events-repository.js +24 -0
  82. package/dist/storage/repositories/proposals-repository.js +14 -0
  83. package/dist/tasks/embedded.js +2 -0
  84. package/dist/workflows/db.js +24 -13
  85. package/dist/workflows/validator.js +24 -2
  86. package/docs/migration/release-notes/0.9.0.md +24 -7
  87. package/docs/migration/v0.8-to-v0.9.md +124 -19
  88. package/package.json +1 -1
@@ -14,7 +14,7 @@
14
14
  * `mergeCurateSearchResponses`) by importing them directly.
15
15
  */
16
16
  import fs from "node:fs";
17
- import { parseAssetRef } from "../../core/asset/asset-ref.js";
17
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
18
18
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
19
19
  import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
20
20
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
@@ -78,10 +78,12 @@ function logCurateEvent(query, result, eventSource = "user") {
78
78
  for (const item of result.items) {
79
79
  if (!("ref" in item) || typeof item.ref !== "string")
80
80
  continue;
81
+ const parsed = parseAssetRef(item.ref);
82
+ const itemOrigin = "origin" in item && typeof item.origin === "string" ? item.origin : undefined;
81
83
  insertUsageEvent(db, {
82
84
  event_type: "curate",
83
85
  query,
84
- entry_ref: item.ref,
86
+ entry_ref: refToString({ ...parsed, ...(parsed.origin || !itemOrigin ? {} : { origin: itemOrigin }) }),
85
87
  source: eventSource,
86
88
  });
87
89
  }
@@ -21,7 +21,7 @@ import { loadConfig } from "../../core/config/config.js";
21
21
  import { UsageError } from "../../core/errors.js";
22
22
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
23
23
  import { warn } from "../../core/warn.js";
24
- import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
24
+ import { commitWriteTargetBoundary, formatRefForMessage, recordWriteTargetPath, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
25
25
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
26
26
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
27
27
  import { fetchWebsiteMarkdownSnapshot, shouldAllowPrivateWebsiteUrlForTests } from "../../sources/website-ingest.js";
@@ -528,6 +528,9 @@ export async function writeMarkdownAsset(options) {
528
528
  // Degrade to the same applied:false report the non-writable path uses.
529
529
  try {
530
530
  writeSupersededEdge(item.filePath, result.ref);
531
+ if (path.resolve(item.stashRoot) === path.resolve(source.path)) {
532
+ recordWriteTargetPath(source, item.filePath);
533
+ }
531
534
  }
532
535
  catch (error) {
533
536
  const reason = `demotion failed: ${error instanceof Error ? error.message : String(error)}`;
@@ -11,6 +11,7 @@
11
11
  * a registry search behind `--source registry|both`, and log a usage event.
12
12
  * Provider `search()` methods do not exist.
13
13
  */
14
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
14
15
  import { loadConfig } from "../../core/config/config.js";
15
16
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
16
17
  import { appendEvent } from "../../core/events.js";
@@ -191,8 +192,11 @@ function resolveEntryIds(db, hits) {
191
192
  for (const hit of hits) {
192
193
  try {
193
194
  const entryId = getEntryIdByFilePath(db, hit.path);
194
- if (entryId !== undefined)
195
- results.push({ entryId, ref: hit.ref });
195
+ if (entryId !== undefined) {
196
+ const parsed = parseAssetRef(hit.ref);
197
+ const origin = parsed.origin ?? hit.origin ?? undefined;
198
+ results.push({ entryId, ref: refToString({ ...parsed, ...(origin ? { origin } : {}) }) });
199
+ }
196
200
  }
197
201
  catch {
198
202
  /* skip unresolvable */
@@ -19,14 +19,14 @@
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { findCittyTopLevelCommandIndex } from "../../cli/parse-args.js";
22
- import { parseAssetRef } from "../../core/asset/asset-ref.js";
22
+ import { parseAssetRef, refToString } from "../../core/asset/asset-ref.js";
23
23
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
24
24
  import { META_DIR, parseMetaRef, resolveMetaFilePath } from "../../core/asset/stash-meta.js";
25
25
  import { asNonEmptyString } from "../../core/common.js";
26
26
  import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
27
27
  import { NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
28
28
  import { appendEvent, readEvents } from "../../core/events.js";
29
- import { closeDatabase, computeBodyHash, findEntryIdByRef, openExistingDatabase } from "../../indexer/db/db.js";
29
+ import { closeDatabase, computeBodyHash, findEntryIdByRef, getEntryIdByFilePath, openExistingDatabase, } from "../../indexer/db/db.js";
30
30
  import { hasGraphData } from "../../indexer/db/graph-db.js";
31
31
  import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost.js";
32
32
  import { extractGraphForSingleFile } from "../../indexer/graph/graph-extraction.js";
@@ -160,7 +160,7 @@ export async function akmShowUnified(input) {
160
160
  }
161
161
  // Count prior shows of this ref before logging the current one.
162
162
  const priorShowCount = recentShowCount(ref);
163
- logShowEvent(ref, input.eventSource);
163
+ logShowEvent(ref, input.eventSource, result.path, result.origin);
164
164
  if (priorShowCount >= 2) {
165
165
  // Agent has shown this same asset 3+ times — inject a loop-break hint.
166
166
  result.showLoopWarning = priorShowCount + 1;
@@ -257,11 +257,12 @@ function recentShowCount(ref) {
257
257
  return 0;
258
258
  }
259
259
  }
260
- function logShowEvent(ref, eventSource = "user") {
260
+ function logShowEvent(ref, eventSource = "user", filePath, origin) {
261
261
  // Emit a structured event to events.jsonl so workflow-trace consumers
262
262
  // detect akm show invocations without relying on stdout scraping.
263
263
  const parsed = parseAssetRef(ref);
264
- appendEvent({ eventType: "show", ref, metadata: { type: parsed.type, name: parsed.name } });
264
+ const eventRef = refToString({ ...parsed, ...(parsed.origin || !origin ? {} : { origin }) });
265
+ appendEvent({ eventType: "show", ref: eventRef, metadata: { type: parsed.type, name: parsed.name } });
265
266
  // Detect if this show is a selection from a recent search result.
266
267
  try {
267
268
  // D7: bound the query to the last 60 s so we never scan unbounded history
@@ -295,8 +296,8 @@ function logShowEvent(ref, eventSource = "user") {
295
296
  withIndexDb((db) => {
296
297
  insertUsageEvent(db, {
297
298
  event_type: "show",
298
- entry_ref: ref,
299
- entry_id: findEntryIdByRef(db, ref),
299
+ entry_ref: eventRef,
300
+ entry_id: filePath ? getEntryIdByFilePath(db, filePath) : findEntryIdByRef(db, eventRef),
300
301
  source: eventSource,
301
302
  });
302
303
  }, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
@@ -5,7 +5,7 @@ import * as childProcess from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
- import { fetchWithRetry, IS_WINDOWS } from "../../core/common.js";
8
+ import { fetchWithRetry, IS_WINDOWS, ResponseTooLargeError, readBodyWithByteCap } from "../../core/common.js";
9
9
  import { warn } from "../../core/warn.js";
10
10
  import { githubHeaders } from "../../integrations/github.js";
11
11
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
@@ -14,6 +14,61 @@ const DEFAULT_PACKAGE_NAME = "akm-cli";
14
14
  const NODE_MODULES_SEGMENT = "/node_modules/";
15
15
  const BUN_GLOBAL_INSTALL_PATTERN = /(^|\/)\.bun\/(?:[^/]+\/)+node_modules\//;
16
16
  const PNPM_GLOBAL_INSTALL_PATTERN = /(^|\/)(?:pnpm\/global|\.pnpm-global)(?:\/\d+)?\/node_modules\//;
17
+ const MIGRATION_CONTRACT_VERSION = "0.9.0-rc.0";
18
+ const MAX_BINARY_DOWNLOAD_BYTES = 256 * 1024 * 1024;
19
+ const MAX_CHECKSUM_METADATA_BYTES = 1024 * 1024;
20
+ export function defaultMigrationCommand(preparedConfigPath) {
21
+ const configArgs = preparedConfigPath ? ["--config", preparedConfigPath] : [];
22
+ return {
23
+ preflight: (akmBin) => runRequiredCommand(akmBin, ["migrate", "status"], "Migration preflight"),
24
+ stagedPreflight: (akmBin) => runRequiredCommand(akmBin, ["migrate", "status", ...configArgs], "Staged migration preflight"),
25
+ apply: (akmBin) => runRequiredCommand(akmBin, ["migrate", "apply", ...configArgs], "Migration apply"),
26
+ };
27
+ }
28
+ async function streamResponseToFile(response, destination, maxBytes) {
29
+ const declaredText = response.headers.get("content-length");
30
+ if (declaredText) {
31
+ const declared = Number(declaredText);
32
+ if (Number.isFinite(declared) && declared > maxBytes) {
33
+ await response.body?.cancel().catch(() => undefined);
34
+ throw new ResponseTooLargeError(response.url || "binary download", maxBytes, declared);
35
+ }
36
+ }
37
+ if (!response.body)
38
+ throw new Error("Binary download response did not provide a streaming body.");
39
+ const fd = fs.openSync(destination, "w", 0o600);
40
+ const reader = response.body.getReader();
41
+ const hash = createHash("sha256");
42
+ let byteSize = 0;
43
+ try {
44
+ while (true) {
45
+ const { done, value } = await reader.read();
46
+ if (done)
47
+ break;
48
+ if (!value)
49
+ continue;
50
+ byteSize += value.byteLength;
51
+ if (byteSize > maxBytes) {
52
+ await reader.cancel().catch(() => undefined);
53
+ throw new ResponseTooLargeError(response.url || "binary download", maxBytes, byteSize);
54
+ }
55
+ hash.update(value);
56
+ let written = 0;
57
+ while (written < value.byteLength)
58
+ written += fs.writeSync(fd, value, written, value.byteLength - written);
59
+ }
60
+ fs.fdatasyncSync(fd);
61
+ return { byteSize, sha256: hash.digest("hex") };
62
+ }
63
+ catch (error) {
64
+ removeFileBestEffort(destination);
65
+ throw error;
66
+ }
67
+ finally {
68
+ reader.releaseLock();
69
+ fs.closeSync(fd);
70
+ }
71
+ }
17
72
  /** Read live runtime signals. */
18
73
  export function getInstallSignals() {
19
74
  return {
@@ -72,7 +127,7 @@ export async function checkForUpdate(currentVersion) {
72
127
  if (!response.ok) {
73
128
  throw new Error(`Failed to check for updates: ${response.status} ${response.statusText}`);
74
129
  }
75
- const release = (await response.json());
130
+ const release = JSON.parse(await readBodyWithByteCap(response, MAX_CHECKSUM_METADATA_BYTES));
76
131
  const latestTag = release.tag_name ?? "";
77
132
  const latestVersion = latestTag.replace(/^v/, "");
78
133
  return {
@@ -82,10 +137,11 @@ export async function checkForUpdate(currentVersion) {
82
137
  installMethod,
83
138
  };
84
139
  }
85
- export async function performUpgrade(check, opts) {
140
+ export async function performUpgrade(check, opts, dependencies) {
86
141
  const { currentVersion, latestVersion, installMethod } = check;
87
142
  const force = opts?.force === true;
88
143
  const skipPostUpgrade = opts?.skipPostUpgrade === true;
144
+ const migration = dependencies?.migration ?? defaultMigrationCommand(opts?.migrationConfig);
89
145
  // All install methods can short-circuit here unless the user explicitly forces an upgrade.
90
146
  if (!check.updateAvailable && !force) {
91
147
  return {
@@ -96,11 +152,19 @@ export async function performUpgrade(check, opts) {
96
152
  message: `akm v${currentVersion} is already the latest version`,
97
153
  };
98
154
  }
155
+ if (latestVersion &&
156
+ semverOrder(currentVersion, MIGRATION_CONTRACT_VERSION) < 0 &&
157
+ semverOrder(latestVersion, MIGRATION_CONTRACT_VERSION) >= 0) {
158
+ throw new Error("AKM 0.8 does not implement the migrate command or the --migration-config upgrade contract, so self-update cannot safely cross into 0.9. " +
159
+ "Prepare the 0.9 config and an independent backup, install or stage the 0.9 binary manually, then use the new binary to run: " +
160
+ "akm migrate apply --config <prepared-0.9.json>. See docs/migration/v0.8-to-v0.9.md.");
161
+ }
99
162
  const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
100
163
  if (packageManagerCommand) {
101
164
  if (!latestVersion) {
102
165
  throw new Error("Unable to determine latest version from GitHub releases. Check https://github.com/itlackey/akm/releases");
103
166
  }
167
+ migration.preflight("akm");
104
168
  const result = childProcess.spawnSync(packageManagerCommand.command, packageManagerCommand.args, {
105
169
  encoding: "utf8",
106
170
  env: process.env,
@@ -113,6 +177,7 @@ export async function performUpgrade(check, opts) {
113
177
  const details = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
114
178
  throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}`);
115
179
  }
180
+ migration.apply("akm");
116
181
  return {
117
182
  currentVersion,
118
183
  newVersion: latestVersion,
@@ -139,12 +204,34 @@ export async function performUpgrade(check, opts) {
139
204
  const binaryName = getAkmBinaryName();
140
205
  const binaryUrl = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
141
206
  const checksumsUrl = `https://github.com/${REPO}/releases/download/${tag}/checksums.txt`;
207
+ const execPath = dependencies?.execPath ?? process.execPath;
208
+ const execDir = path.dirname(execPath);
209
+ const execName = path.basename(execPath);
210
+ const stagedPath = IS_WINDOWS
211
+ ? path.join(execDir, `.${execName}.new.${process.pid}`)
212
+ : path.join(execDir, `.${execName}.tmp.${process.pid}`);
213
+ const backupPath = `${execPath}${IS_WINDOWS ? ".old" : ".bak"}`;
142
214
  // Download binary
143
215
  const binaryResponse = await fetchWithRetry(binaryUrl);
144
216
  if (!binaryResponse.ok) {
145
217
  throw new Error(`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}`);
146
218
  }
147
- const binaryData = new Uint8Array(await binaryResponse.arrayBuffer());
219
+ let downloaded;
220
+ try {
221
+ downloaded = await streamResponseToFile(binaryResponse, stagedPath, MAX_BINARY_DOWNLOAD_BYTES);
222
+ if (!IS_WINDOWS)
223
+ fs.chmodSync(stagedPath, 0o755);
224
+ }
225
+ catch (err) {
226
+ removeFileBestEffort(stagedPath);
227
+ const code = err.code;
228
+ if (code === "EACCES" || code === "EPERM") {
229
+ throw new Error(`Permission denied writing to ${execDir}.\n` +
230
+ `${IS_WINDOWS ? "Try running as Administrator." : "Run: sudo akm upgrade"}\n` +
231
+ `Or re-run the install script: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`);
232
+ }
233
+ throw err;
234
+ }
148
235
  // Download and verify checksum (mandatory — upgrade is blocked if checksums cannot be fetched)
149
236
  let checksumVerified = false;
150
237
  const skipChecksum = opts?.skipChecksum === true;
@@ -160,10 +247,10 @@ export async function performUpgrade(check, opts) {
160
247
  }
161
248
  }
162
249
  else {
163
- const checksumsText = await checksumsResponse.text();
250
+ const checksumsText = await readBodyWithByteCap(checksumsResponse, MAX_CHECKSUM_METADATA_BYTES);
164
251
  const expectedHash = parseChecksumForFile(checksumsText, binaryName);
165
252
  if (expectedHash) {
166
- const actualHash = createHash("sha256").update(binaryData).digest("hex");
253
+ const actualHash = downloaded.sha256;
167
254
  if (actualHash !== expectedHash) {
168
255
  throw new Error(`Checksum mismatch for ${binaryName}.\n` + `Expected: ${expectedHash}\n` + `Got: ${actualHash}`);
169
256
  }
@@ -183,6 +270,7 @@ export async function performUpgrade(check, opts) {
183
270
  catch (err) {
184
271
  if (err instanceof Error &&
185
272
  (err.message.includes("Checksum mismatch") || err.message.includes("Checksum verification failed"))) {
273
+ removeFileBestEffort(stagedPath);
186
274
  throw err;
187
275
  }
188
276
  // Network or parse failure
@@ -190,99 +278,46 @@ export async function performUpgrade(check, opts) {
190
278
  warn(`WARNING: Could not fetch or parse checksums: ${err instanceof Error ? err.message : String(err)}. Proceeding because --skip-checksum was provided.`);
191
279
  }
192
280
  else {
281
+ removeFileBestEffort(stagedPath);
193
282
  throw new Error(`Checksum verification failed: ${err instanceof Error ? err.message : String(err)}. ` +
194
283
  `Use --skip-checksum to bypass (not recommended).`);
195
284
  }
196
285
  }
197
- const execPath = process.execPath;
198
- const execDir = path.dirname(execPath);
199
- const execName = path.basename(execPath);
200
- if (IS_WINDOWS) {
201
- // Windows: rename running exe, write new one, clean up old on success
202
- const oldPath = `${execPath}.old`;
203
- try {
204
- fs.renameSync(execPath, oldPath);
205
- }
206
- catch (err) {
207
- const code = err.code;
208
- if (code === "EPERM" || code === "EACCES") {
209
- throw new Error(`Permission denied. Cannot rename ${execPath}.\n` +
210
- `Try running as Administrator, or re-download from https://github.com/${REPO}/releases`);
211
- }
212
- const detail = err instanceof Error ? err.message : String(err);
213
- throw new Error(`Failed to rename ${execPath}: ${detail}`);
214
- }
215
- try {
216
- fs.writeFileSync(execPath, binaryData);
217
- }
218
- catch (err) {
219
- // Restore from old
220
- fs.renameSync(oldPath, execPath);
221
- throw err;
222
- }
223
- // Best-effort cleanup of .old
224
- try {
225
- fs.unlinkSync(oldPath);
226
- }
227
- catch {
228
- // Windows may lock the old exe — it will be cleaned up on next startup or manually
229
- }
286
+ try {
287
+ migration.preflight(execPath);
288
+ migration.stagedPreflight(stagedPath);
230
289
  }
231
- else {
232
- // Unix: write to temp file, chmod +x, atomic rename
233
- const tmpPath = path.join(execDir, `.${execName}.tmp.${process.pid}`);
234
- const bakPath = `${execPath}.bak`;
235
- try {
236
- fs.writeFileSync(tmpPath, binaryData);
237
- fs.chmodSync(tmpPath, 0o755);
238
- }
239
- catch (err) {
240
- // Clean up temp file on failure
241
- try {
242
- fs.unlinkSync(tmpPath);
243
- }
244
- catch {
245
- /* ignore */
246
- }
247
- const code = err.code;
248
- if (code === "EACCES" || code === "EPERM") {
249
- throw new Error(`Permission denied writing to ${execDir}.\n` +
250
- `Run: sudo akm upgrade\n` +
251
- `Or re-run the install script: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`);
252
- }
253
- throw err;
254
- }
255
- // Backup current, then atomic rename
256
- try {
257
- fs.copyFileSync(execPath, bakPath);
258
- fs.renameSync(tmpPath, execPath);
259
- }
260
- catch (err) {
261
- // Restore from backup if rename failed
262
- try {
263
- fs.unlinkSync(tmpPath);
264
- }
265
- catch {
266
- /* ignore */
267
- }
268
- try {
269
- if (fs.existsSync(bakPath) && !fs.existsSync(execPath)) {
270
- fs.renameSync(bakPath, execPath);
271
- }
272
- }
273
- catch {
274
- /* ignore */
275
- }
276
- throw err;
277
- }
278
- // Cleanup backup
279
- try {
280
- fs.unlinkSync(bakPath);
281
- }
282
- catch {
283
- /* ignore */
290
+ catch (err) {
291
+ removeFileBestEffort(stagedPath);
292
+ throw err;
293
+ }
294
+ if (fs.existsSync(backupPath)) {
295
+ removeFileBestEffort(stagedPath);
296
+ throw new Error(`Refusing to overwrite retained previous binary at ${backupPath}.`);
297
+ }
298
+ try {
299
+ if (IS_WINDOWS)
300
+ fs.renameSync(execPath, backupPath);
301
+ else
302
+ fs.copyFileSync(execPath, backupPath);
303
+ fs.renameSync(stagedPath, execPath);
304
+ }
305
+ catch (err) {
306
+ removeFileBestEffort(stagedPath);
307
+ if (!fs.existsSync(execPath) && fs.existsSync(backupPath)) {
308
+ fs.renameSync(backupPath, execPath);
284
309
  }
310
+ throw err;
311
+ }
312
+ try {
313
+ migration.apply(execPath);
314
+ }
315
+ catch (err) {
316
+ const detail = err instanceof Error ? err.message : String(err);
317
+ throw new Error(`Migration apply failed; the new binary remains installed and the previous binary retained at ${backupPath}: ${detail}`);
285
318
  }
319
+ // Keep the previous binary available until migration apply has completed.
320
+ removeFileBestEffort(backupPath);
286
321
  return {
287
322
  currentVersion,
288
323
  newVersion: latestVersion,
@@ -290,32 +325,19 @@ export async function performUpgrade(check, opts) {
290
325
  installMethod,
291
326
  binaryPath: execPath,
292
327
  checksumVerified,
293
- // For binary installs, the new binary now lives at execPath; spawn it
294
- // directly so the post-upgrade work runs against the new code.
295
328
  postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }),
296
329
  };
297
330
  }
298
331
  /**
299
- * Run the post-upgrade tasks against the *new* binary as a child process.
300
- *
301
- * Why a child process: the running akm process still has the old code in
302
- * memory. Calling loadConfig()/akmIndex() in-process would use the old
303
- * implementations and miss any DB_VERSION / config-key changes the new
304
- * release introduces.
305
- *
306
- * The new binary's `akm index` does the work for us:
307
- * 1. loadConfig() runs at startup — auto-migrates legacy `stashes` →
308
- * `sources` if the on-disk config still uses the old key.
309
- * 2. ensureSchema() converges index.db forward via its idempotent baseline
310
- * schema + additive migrations (no destructive rebuild).
311
- * 3. The full reindex repopulates entries + workflow_documents + FTS.
332
+ * Rebuild the derived index after the explicit migration command succeeds.
333
+ * Migration and indexing are intentionally separate lifecycle steps.
312
334
  */
313
335
  function runPostUpgradeTasks(akmBin, opts) {
314
336
  if (opts.skip) {
315
337
  return {
316
338
  ok: true,
317
339
  skipped: true,
318
- message: "Skipped post-upgrade tasks. Run `akm index` manually to migrate config and rebuild the index.",
340
+ message: "Migration completed; skipped the index rebuild. Run `akm index` manually to rebuild the index.",
319
341
  };
320
342
  }
321
343
  try {
@@ -328,7 +350,7 @@ function runPostUpgradeTasks(akmBin, opts) {
328
350
  return {
329
351
  ok: false,
330
352
  skipped: false,
331
- message: `Post-upgrade tasks could not start: ${result.error.message}. Run \`akm index\` manually.`,
353
+ message: `Migration completed, but the index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
332
354
  };
333
355
  }
334
356
  if (result.status !== 0) {
@@ -337,14 +359,14 @@ function runPostUpgradeTasks(akmBin, opts) {
337
359
  ok: false,
338
360
  skipped: false,
339
361
  exitCode: result.status,
340
- message: `Post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
362
+ message: `Migration completed, but post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
341
363
  };
342
364
  }
343
365
  return {
344
366
  ok: true,
345
367
  skipped: false,
346
368
  exitCode: 0,
347
- message: "Config migrated (if needed) and index rebuilt against the new binary.",
369
+ message: "Migration completed and the index was rebuilt against the new binary.",
348
370
  };
349
371
  }
350
372
  catch (err) {
@@ -352,10 +374,32 @@ function runPostUpgradeTasks(akmBin, opts) {
352
374
  return {
353
375
  ok: false,
354
376
  skipped: false,
355
- message: `Post-upgrade tasks failed: ${detail}. Run \`akm index\` manually.`,
377
+ message: `Migration completed, but the index rebuild failed: ${detail}. Run \`akm index\` manually.`,
356
378
  };
357
379
  }
358
380
  }
381
+ function runRequiredCommand(akmBin, args, label) {
382
+ const result = childProcess.spawnSync(akmBin, args, {
383
+ encoding: "utf8",
384
+ env: process.env,
385
+ stdio: "pipe",
386
+ });
387
+ if (result.error) {
388
+ throw new Error(`${label} could not start: ${result.error.message}`);
389
+ }
390
+ if (result.status !== 0) {
391
+ const detail = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
392
+ throw new Error(`${label} failed (${detail}).`);
393
+ }
394
+ }
395
+ function removeFileBestEffort(filePath) {
396
+ try {
397
+ fs.unlinkSync(filePath);
398
+ }
399
+ catch {
400
+ // Cleanup is best-effort; the primary operation determines success.
401
+ }
402
+ }
359
403
  function parseChecksumForFile(checksumsText, filename) {
360
404
  for (const line of checksumsText.split("\n")) {
361
405
  const trimmed = line.trim();
@@ -115,9 +115,13 @@ export const upgradeCommand = defineJsonCommand({
115
115
  },
116
116
  "skip-post-upgrade": {
117
117
  type: "boolean",
118
- description: "Skip the post-upgrade `akm index` rebuild (config auto-migration still runs on next `akm` invocation)",
118
+ description: "Skip the post-upgrade index rebuild (migration preflight and apply still run)",
119
119
  default: false,
120
120
  },
121
+ "migration-config": {
122
+ type: "string",
123
+ description: "For 0.9+ upgrades, pass an operator-prepared config only to the new binary's migration apply",
124
+ },
121
125
  },
122
126
  async run({ args }) {
123
127
  const check = await checkForUpdate(pkgVersion);
@@ -127,7 +131,8 @@ export const upgradeCommand = defineJsonCommand({
127
131
  }
128
132
  const skipChecksum = args["skip-checksum"];
129
133
  const skipPostUpgrade = args["skip-post-upgrade"];
130
- const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade });
134
+ const migrationConfig = args["migration-config"];
135
+ const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade, migrationConfig });
131
136
  output("upgrade", result);
132
137
  },
133
138
  });
@@ -27,6 +27,9 @@ export const ASSET_TYPES = Object.freeze([...getAssetTypes()]);
27
27
  export const ASSET_TYPE_SET = new Set(ASSET_TYPES);
28
28
  // ── Constants ───────────────────────────────────────────────────────────────
29
29
  export const IS_WINDOWS = process.platform === "win32";
30
+ export const MAX_CONFIG_FILE_BYTES = 1024 * 1024;
31
+ export const MAX_LOCAL_METADATA_BYTES = 1024 * 1024;
32
+ export const MAX_LOCK_METADATA_BYTES = 64 * 1024;
30
33
  export function isHttpUrl(value) {
31
34
  return !!value && /^https?:\/\//.test(value);
32
35
  }
@@ -58,6 +61,35 @@ export function isAssetType(type) {
58
61
  return Object.hasOwn(TYPE_DIRS, type);
59
62
  }
60
63
  // ── Utilities ───────────────────────────────────────────────────────────────
64
+ export function readTextFileDescriptorWithLimit(fd, maxBytes, label = "File", displayPath = "(open file)") {
65
+ const stat = fs.fstatSync(fd);
66
+ if (!stat.isFile())
67
+ throw new ConfigError(`${label} is not a regular file: ${displayPath}.`, "INVALID_CONFIG_FILE");
68
+ if (stat.size > maxBytes) {
69
+ throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE");
70
+ }
71
+ const buffer = Buffer.allocUnsafe(maxBytes + 1);
72
+ let total = 0;
73
+ while (total <= maxBytes) {
74
+ const bytesRead = fs.readSync(fd, buffer, total, maxBytes + 1 - total, null);
75
+ if (bytesRead === 0)
76
+ break;
77
+ total += bytesRead;
78
+ }
79
+ if (total > maxBytes) {
80
+ throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE");
81
+ }
82
+ return buffer.subarray(0, total).toString("utf8");
83
+ }
84
+ export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
85
+ const fd = fs.openSync(filePath, "r");
86
+ try {
87
+ return readTextFileDescriptorWithLimit(fd, maxBytes, label, filePath);
88
+ }
89
+ finally {
90
+ fs.closeSync(fd);
91
+ }
92
+ }
61
93
  /**
62
94
  * Write content to a file atomically via a temp file + rename.
63
95
  * Prevents partial-write corruption on crash.
@@ -172,7 +204,7 @@ function isValidDirectory(dir) {
172
204
  function readStashDirFromConfig() {
173
205
  try {
174
206
  const configPath = getConfigPath();
175
- const text = fs.readFileSync(configPath, "utf8");
207
+ const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
176
208
  const raw = JSON.parse(text);
177
209
  if (typeof raw === "object" && raw !== null && typeof raw.stashDir === "string" && raw.stashDir.trim()) {
178
210
  return raw.stashDir.trim();
@@ -370,8 +402,9 @@ export async function readBodyWithByteCap(response, maxBytes = DEFAULT_RESPONSE_
370
402
  // No streaming body available (e.g., some mock environments). Fall
371
403
  // back to text() but still enforce the cap post-hoc.
372
404
  const text = await response.text();
373
- if (text.length > maxBytes)
374
- throw new ResponseTooLargeError(url, maxBytes, text.length);
405
+ const byteLength = Buffer.byteLength(text, "utf8");
406
+ if (byteLength > maxBytes)
407
+ throw new ResponseTooLargeError(url, maxBytes, byteLength);
375
408
  return text;
376
409
  }
377
410
  const reader = body.getReader();
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { sleepSync } from "../../runtime.js";
18
- import { writeFileAtomic } from "../common.js";
18
+ import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, writeFileAtomic } from "../common.js";
19
19
  import { ConfigError } from "../errors.js";
20
20
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
21
21
  import { getCacheDir, getConfigDir } from "../paths.js";
@@ -25,7 +25,7 @@ import { getCacheDir, getConfigDir } from "../paths.js";
25
25
  */
26
26
  export function readConfigText(configPath) {
27
27
  try {
28
- return fs.readFileSync(configPath, "utf8");
28
+ return readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
29
29
  }
30
30
  catch (err) {
31
31
  if (err.code === "ENOENT")