akm-cli 0.9.8-beta.3 → 0.9.9-beta.1

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 (34) hide show
  1. package/CHANGELOG.md +186 -132
  2. package/dist/commands/health/checks.js +40 -0
  3. package/dist/commands/health.js +57 -31
  4. package/dist/commands/migrate-cli.js +29 -189
  5. package/dist/commands/sources/add-cli.js +7 -0
  6. package/dist/commands/sources/installed-stashes.js +36 -8
  7. package/dist/commands/sources/self-update.js +104 -62
  8. package/dist/commands/sources/source-add.js +6 -5
  9. package/dist/commands/sources/sources-cli.js +7 -18
  10. package/dist/commands/tasks/tasks-cli.js +4 -3
  11. package/dist/commands/tasks/tasks.js +13 -6
  12. package/dist/core/adapter/adapter-ids.js +35 -0
  13. package/dist/core/adapter/adapters/index.js +29 -0
  14. package/dist/core/adapter/detect-adapter.js +91 -3
  15. package/dist/core/config/config.js +1 -1
  16. package/dist/core/config/schema/sources-bundles.js +23 -0
  17. package/dist/core/extra-params.js +1 -1
  18. package/dist/core/state/migrations.js +2 -4
  19. package/dist/core/state-db.js +31 -8
  20. package/dist/indexer/indexer.js +64 -1
  21. package/dist/scripts/akm-migrate-node.js +86534 -20434
  22. package/dist/scripts/akm-migrate.js +86415 -20280
  23. package/dist/tasks/backends/cron.js +21 -6
  24. package/dist/tasks/resolve-akm-bin.js +1 -1
  25. package/docs/README.md +1 -0
  26. package/docs/integration/bundling-akm.md +276 -0
  27. package/docs/migration/v0.9.0-troubleshooting.md +10 -14
  28. package/docs/migration/v0.9.1-to-v0.9.2.md +12 -16
  29. package/docs/reference/cli.md +59 -31
  30. package/docs/reference/tasks.md +4 -10
  31. package/package.json +2 -1
  32. package/dist/commands/migrate/config-extra-params.js +0 -61
  33. package/dist/commands/migrate/dead-residue.js +0 -113
  34. package/dist/commands/migrate/stale-txn.js +0 -49
@@ -2,207 +2,50 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
5
- import { resolveStashDir } from "../core/common.js";
6
- import { resetConfigCache } from "../core/config/config.js";
7
- import { ConfigError } from "../core/errors.js";
8
- import { getConfigPath } from "../core/paths.js";
9
- import { applyConfigExtraParamsLift, findConfigExtraParamsLift } from "./migrate/config-extra-params.js";
10
- import { findDeadResidueEntries, removeDeadResidue } from "./migrate/dead-residue.js";
11
- import { findStaleTxnEntries, recoverStaleTxns } from "./migrate/stale-txn.js";
12
5
  import { runMigrationTool } from "./migration-tool.js";
13
- async function callMigrateTool(args, runTool) {
6
+ /**
7
+ * `akm migrate` is a thin wrapper over the standalone `akm-migrate`
8
+ * executable, which owns every migration step and every historical shape
9
+ * (`scripts/akm-migrate/`). This module only spawns it, re-emits its one JSON
10
+ * plan through the normal output pipeline so `--format` applies, and mirrors
11
+ * its exit code. `akm upgrade` calls the same executable after an install.
12
+ * Passed the runner as a parameter so a test can hand it a stand-in.
13
+ */
14
+ export async function runMigrateSubcommand(command, args, runTool = runMigrationTool) {
14
15
  const result = await runTool(args);
15
16
  if (result.stderr)
16
17
  process.stderr.write(result.stderr);
17
- const resultLine = result.stdout.trim();
18
- if (!resultLine)
19
- return { status: result.status };
18
+ const line = result.stdout.trim();
19
+ let plan;
20
20
  try {
21
- return { status: result.status, plan: JSON.parse(resultLine) };
21
+ plan = line ? JSON.parse(line) : undefined;
22
22
  }
23
23
  catch {
24
- console.log(resultLine);
25
- return { status: result.status };
24
+ plan = undefined;
26
25
  }
27
- }
28
- function worstStatus(left, right) {
29
- if (left === "blocked" || right === "blocked")
30
- return "blocked";
31
- if (left === "ready" || right === "ready")
32
- return "ready";
33
- return "current";
34
- }
35
- /**
36
- * Resolve one generation's contribution to the combined status — fail
37
- * CLOSED, never open (code-review finding: this tool advertises itself as
38
- * "blocked-not-guessed").
39
- *
40
- * A generation that exited SUCCESS with no plan on stdout legitimately means
41
- * "nothing to report" and defaults to `"current"`. A generation that exited
42
- * NON-SUCCESS (by the caller's own guard, this can only be `EXIT_CODES.
43
- * GENERAL` — the "blocked" code) with a parsed `plan.status` reports that
44
- * status verbatim, same as before.
45
- *
46
- * The gap this closes: NON-SUCCESS with NO parseable plan at all —
47
- * `runMigrationTool` coerces a `spawnSync` `status` of `null` (the child was
48
- * killed by a signal — OOM, a timeout, a manual kill — never scheduled to
49
- * exit) to `1`, indistinguishable from the migrator's own legitimate
50
- * "blocked" exit code, and truncated/malformed stdout hits the same
51
- * `JSON.parse` catch in `callMigrateTool`. Previously `?? "current"` silently
52
- * read a crashed generation as "nothing to migrate"; this reports it as
53
- * `"blocked"` with an explanatory blocker instead, so the combined exit code
54
- * (`EXIT_CODES.GENERAL` below) actually reflects that the generation's real
55
- * state is unknown, rather than reporting success at exit 0.
56
- */
57
- export function resolveGenerationStatus(call, label) {
58
- const planStatus = call.plan?.status;
59
- if (planStatus !== undefined)
60
- return { status: planStatus };
61
- if (call.status !== EXIT_CODES.SUCCESS) {
62
- return {
63
- status: "blocked",
64
- error: `${label}: the child process exited without printing a plan (exit status ${call.status}) — its real migration state is unknown.`,
65
- };
66
- }
67
- return { status: "current" };
68
- }
69
- /**
70
- * Run BOTH migration generations — task-v2-to-v3, then task-v3-to-task-
71
- * source-v4 — and print one combined plan (spec
72
- * docs/plans/specs/p4-deletions-closeout.md §3.2.5, rows B-31/B-32).
73
- *
74
- * Each generation is its OWN subprocess call into the standalone migrator
75
- * (`scripts/akm-migrate.ts`'s `status`/`apply` and `task-v4-status`/
76
- * `task-v4-apply` verbs, UNCHANGED — row B-33), so each keeps its own
77
- * `withConfigLock` + `O_EXCL` backup root + prevalidate + TOCTOU recheck +
78
- * atomic replace + reverse rollback + convergence check, and the two are
79
- * NEVER interleaved. The two calls are unconditional and independent of
80
- * each other's outcome: a blocked (or otherwise incomplete) generation-1
81
- * result does not stop generation 2 from running against whatever is
82
- * already task source v4 — exactly `akm-migrate status`/`task-v4-status`
83
- * (or `apply`/`task-v4-apply`) run back to back by hand. Only a genuine
84
- * hard failure (a status neither SUCCESS nor the "blocked" GENERAL code —
85
- * a config error, a crash) aborts the second call, since generation 1 never
86
- * got to look at a stable tree in that case.
87
- */
88
- export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runTool = runMigrationTool) {
89
- // Superseded pre-0.9.0 .akm layouts are a migration concern like any other:
90
- // status names them, apply removes them. (First shipped as a bolted-on
91
- // `health --clean-dead-residue` flag; folded here where it belongs.) The
92
- // legacy extraParams -> first-class-field config lift (#852) is the same
93
- // shape: status names it, apply persists it once instead of the old
94
- // permanent silent lift on every config load.
95
- // No configured bundle means there is no stash to scan — an empty domain,
96
- // not an error — so migrate still works before `akm bundle create`. Any
97
- // OTHER ConfigError propagates.
98
- const configPath = getConfigPath();
99
- const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
100
- // The config lift runs BEFORE anything that loads config. A config still
101
- // carrying legacy extraParams keys fails `loadConfig` closed, and the error
102
- // it fails with names `akm migrate apply` as the remedy -- but both
103
- // `resolveStashDir` below and the task migrator itself load config, so that
104
- // remedy could never reach the lift that fixes it. Applying it first is what
105
- // makes the advice true. `resetConfigCache` so every load below sees the
106
- // rewritten file rather than the rejected one.
107
- const configExtraParams = applyResidue
108
- ? applyConfigExtraParamsLift(configPath)
109
- : { pending: findConfigExtraParamsLift(configPath) };
110
- if (applyResidue && configExtraParams.applied)
111
- resetConfigCache();
112
- // status and --dry-run cannot rewrite the file, so a pending lift still
113
- // blocks every config load below. Report it as the blocker rather than
114
- // letting the operator hit the same circular error again.
115
- const pendingLift = applyResidue ? undefined : configExtraParams.pending;
116
- if (pendingLift && pendingLift.lifted.length > 0) {
117
- output(command, { status: "blocked", blockers: pendingLift.lifted, configExtraParams });
118
- process.exitCode = EXIT_CODES.GENERAL;
119
- return;
120
- }
121
- let stashDir;
122
- try {
123
- stashDir = resolveStashDir();
124
- }
125
- catch (error) {
126
- if (!(error instanceof ConfigError) || error.code !== "STASH_DIR_NOT_FOUND")
127
- throw error;
128
- }
129
- const first = await callMigrateTool(genOneArgs, runTool);
130
- if (first.status !== EXIT_CODES.SUCCESS && first.status !== EXIT_CODES.GENERAL) {
131
- process.exitCode = first.status;
132
- return;
133
- }
134
- const second = await callMigrateTool(genTwoArgs, runTool);
135
- if (second.status !== EXIT_CODES.SUCCESS && second.status !== EXIT_CODES.GENERAL) {
136
- process.exitCode = second.status;
137
- return;
138
- }
139
- if (!first.plan && !second.plan) {
140
- if (first.status !== EXIT_CODES.SUCCESS)
141
- process.exitCode = first.status;
142
- return;
143
- }
144
- const combined = combineMigrationPlans(first, second);
145
- // The stash-scoped sections need a bundle to scan; no configured bundle is
146
- // an empty domain, not an error, so migrate still works before
147
- // `akm bundle create`. The config lift is config-scoped and always runs.
148
- const stashSections = stashDir === undefined
149
- ? {}
150
- : {
151
- deadResidue: applyResidue
152
- ? { removed: removeDeadResidue(stashDir) }
153
- : { pending: findDeadResidueEntries(stashDir) },
154
- staleTxns: applyResidue
155
- ? { recovered: await recoverStaleTxns(stashDir) }
156
- : { pending: findStaleTxnEntries(stashDir) },
157
- };
158
- output(command, { ...combined, ...stashSections, configExtraParams });
159
- if (combined.status === "blocked")
160
- process.exitCode = EXIT_CODES.GENERAL;
161
- }
162
- /**
163
- * Merge both generations' plans into the one combined envelope the command
164
- * prints. Deliberately PURE — every rule the combined plan encodes (the
165
- * {@link worstStatus} rollup, the fail-closed
166
- * {@link resolveGenerationStatus} contribution, and the blockers merge, which
167
- * orders generation 1's own blockers after its resolution error and before
168
- * generation 2's) is decided here from two plain values, so it is provable
169
- * without a subprocess, a CLI dispatch, or an output-mode singleton. The
170
- * caller keeps the only two effectful decisions: whether generation 2 runs at
171
- * all, and the process exit code.
172
- */
173
- export function combineMigrationPlans(first, second) {
174
- const firstResolved = resolveGenerationStatus(first, "task-v2-to-v3");
175
- const secondResolved = resolveGenerationStatus(second, "task-v3-to-task-source-v4");
176
- return {
177
- schemaVersion: 1,
178
- status: worstStatus(firstResolved.status, secondResolved.status),
179
- blockers: [
180
- ...(firstResolved.error ? [firstResolved.error] : []),
181
- ...(first.plan?.blockers ?? []),
182
- ...(secondResolved.error ? [secondResolved.error] : []),
183
- ...(second.plan?.blockers ?? []),
184
- ],
185
- taskV3Migration: first.plan?.taskV3Migration,
186
- taskV4Migration: second.plan?.taskV4Migration,
187
- ...(first.plan?.backupPath !== undefined ? { backupPath: first.plan.backupPath } : {}),
188
- ...(first.plan?.applied !== undefined ? { applied: first.plan.applied } : {}),
189
- ...(second.plan?.backupPath !== undefined ? { taskV4BackupPath: second.plan.backupPath } : {}),
190
- ...(second.plan?.applied !== undefined ? { taskV4Applied: second.plan.applied } : {}),
191
- };
26
+ if (plan)
27
+ output(command, plan);
28
+ else if (line)
29
+ console.log(line);
30
+ if (result.status !== EXIT_CODES.SUCCESS)
31
+ process.exitCode = result.status;
192
32
  }
193
33
  export const migrateCommand = defineGroupCommand({
194
- meta: { name: "migrate", description: "Inspect or apply task-v2 and task-v3 sources to task source v4" },
34
+ meta: {
35
+ name: "migrate",
36
+ description: "Inspect or apply pending migrations: legacy config, state.db, and task-v2/v3 sources to v4",
37
+ },
195
38
  subCommands: {
196
39
  status: defineJsonCommand({
197
- meta: { name: "status", description: "Read-only task-v2 and task-v3 migration check" },
40
+ meta: { name: "status", description: "Read-only check of every pending migration" },
198
41
  run() {
199
- return runMigrateSubcommand("migrate-status", ["status"], ["task-v4-status"]);
42
+ return runMigrateSubcommand("migrate-status", ["status"]);
200
43
  },
201
44
  }),
202
45
  apply: defineJsonCommand({
203
46
  meta: {
204
47
  name: "apply",
205
- description: "Back up and atomically convert task-v2 and task-v3 files to task source v4",
48
+ description: "Back up and apply every pending migration (`akm upgrade` runs this after an install)",
206
49
  },
207
50
  args: {
208
51
  "dry-run": {
@@ -212,13 +55,10 @@ export const migrateCommand = defineGroupCommand({
212
55
  },
213
56
  },
214
57
  run({ args }) {
215
- const dryRunFlag = args.dryRun ? ["--dry-run"] : [];
216
- return runMigrateSubcommand("migrate-apply", ["apply", ...dryRunFlag], ["task-v4-apply", ...dryRunFlag]);
58
+ return runMigrateSubcommand("migrate-apply", args.dryRun ? ["apply", "--dry-run"] : ["apply"]);
217
59
  },
218
60
  }),
219
61
  },
220
- // No `defaultRun`: bare `akm migrate` is a usage error (exit 2). This group
221
- // already threw its own hand-rolled UsageError; it now shares the canonical
222
- // one from `defineGroupCommand` so the message and hint match every other
223
- // group — owner ruling 12.
62
+ // No `defaultRun`: bare `akm migrate` is a usage error (exit 2), the
63
+ // canonical bare-group behavior shared with every other group.
224
64
  });
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { getStringArg, parsePositiveIntFlag } from "../../cli/parse-args.js";
5
5
  import { defineJsonCommand, output } from "../../cli/shared.js";
6
+ import { VALID_ADAPTER_IDS } from "../../core/adapter/adapter-ids.js";
6
7
  import { UsageError } from "../../core/errors.js";
7
8
  import { appendEvent } from "../../core/events.js";
8
9
  import { warn } from "../../core/warn.js";
@@ -75,6 +76,11 @@ export const addCommand = defineJsonCommand({
75
76
  provider: { type: "string", description: "Provider type (e.g. website, npm). Required for URL sources." },
76
77
  options: { type: "string", description: 'Provider options as JSON (e.g. \'{"apiKey":"key"}\').' },
77
78
  name: { type: "string", description: "Human-friendly name for the source" },
79
+ adapter: {
80
+ type: "string",
81
+ description: "Override the auto-detected component adapter for a local directory (#909). One of: " +
82
+ `${VALID_ADAPTER_IDS.join(", ")}.`,
83
+ },
78
84
  writable: {
79
85
  type: "boolean",
80
86
  description: "Mark a git bundle as writable so changes can be pushed back",
@@ -145,6 +151,7 @@ export const addCommand = defineJsonCommand({
145
151
  name: args.name,
146
152
  options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
147
153
  writable: args.writable,
154
+ adapter: args.adapter,
148
155
  });
149
156
  appendEvent({
150
157
  eventType: "add",
@@ -16,6 +16,7 @@
16
16
  import { createHash } from "node:crypto";
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
+ import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
19
20
  import { isWithin, resolveStashDir } from "../../core/common.js";
20
21
  import { acquireConfigReadFence, bundleComponentConfig, getSources, loadConfig } from "../../core/config/config.js";
21
22
  import { AkmError, ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
@@ -150,13 +151,40 @@ function describeBundleSource(entry) {
150
151
  }
151
152
  return { kind: "npm", locator: entry.npm ?? "" };
152
153
  }
153
- function describeComponents(entry) {
154
- return Object.entries(entry.components ?? {}).map(([name, component]) => ({
155
- name,
156
- ...(component.root !== undefined ? { root: component.root } : {}),
157
- ...(component.adapter !== undefined ? { adapter: component.adapter } : {}),
158
- ...(component.writable !== undefined ? { writable: component.writable } : {}),
159
- }));
154
+ /**
155
+ * Per-component `{ adapter, detected }` disclosure (#908/#909): `adapter` is
156
+ * the EFFECTIVE adapter (explicit config, or auto-detected via the same
157
+ * ordered probe `akm index` uses) and `detected` is `true` exactly when no
158
+ * explicit `adapter` was configured. Before this, an auto-detected adapter
159
+ * was invisible on `akm bundle list` a mixed-layout bundle silently
160
+ * shadowed by a narrower adapter (#908) gave no sign a choice had even been
161
+ * made. `bundleRoot` is the bundle's resolved content root (lock `localRoot`
162
+ * or its plain `path`), or `undefined` when neither is known yet (an
163
+ * unresolved registry/website source) — detection is skipped in that case
164
+ * rather than probing a path that may not exist.
165
+ */
166
+ function describeComponents(entry, bundleRoot) {
167
+ const configuredComponents = entry.components ?? {};
168
+ const names = Object.keys(configuredComponents);
169
+ if (names.length === 0) {
170
+ // No explicit component at all — the implicit single component every
171
+ // bundle gets (spec §1.2 rule 5; `deriveInstallations`/`componentForSource`
172
+ // apply the same "main"-shaped default at index time).
173
+ const adapter = bundleRoot !== undefined ? detectAdapterId(bundleRoot) : "akm";
174
+ return [{ name: "main", adapter, detected: true }];
175
+ }
176
+ return names.map((name) => {
177
+ const component = configuredComponents[name];
178
+ const componentRoot = bundleRoot !== undefined ? path.resolve(bundleRoot, component.root ?? ".") : undefined;
179
+ const adapter = component.adapter ?? (componentRoot !== undefined ? detectAdapterId(componentRoot) : "akm");
180
+ return {
181
+ name,
182
+ ...(component.root !== undefined ? { root: component.root } : {}),
183
+ adapter,
184
+ detected: component.adapter === undefined,
185
+ ...(component.writable !== undefined ? { writable: component.writable } : {}),
186
+ };
187
+ });
160
188
  }
161
189
  function describeLock(entry) {
162
190
  if (!entry)
@@ -241,7 +269,7 @@ export async function akmListSources(input) {
241
269
  ...(lock?.resolvedVersion !== undefined ? { version: lock.resolvedVersion } : {}),
242
270
  writable: componentWritable ?? bundle.writable ?? kind === "filesystem",
243
271
  ...(configured.registryId !== undefined ? { registryId: configured.registryId } : {}),
244
- components: describeComponents(configured),
272
+ components: describeComponents(configured, root || undefined),
245
273
  lock: describeLock(lock),
246
274
  itemCount: bundleCounts.itemCount,
247
275
  byType: bundleCounts.byType,
@@ -7,10 +7,11 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { fetchWithRetry, IS_WINDOWS, ResponseTooLargeError, readBodyWithByteCap, readChunkWithDeadline, } from "../../core/common.js";
9
9
  import { ConfigError } from "../../core/errors.js";
10
- import { upgradeHistoricalStateDatabase } from "../../core/state-db.js";
11
10
  import { warn } from "../../core/warn.js";
12
11
  import { githubHeaders } from "../../integrations/github.js";
13
12
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
13
+ import { resolveNpmGlobalRoot } from "../../tasks/resolve-akm-bin.js";
14
+ import { runMigrationTool } from "../migration-tool.js";
14
15
  const REPO = "itlackey/akm";
15
16
  const DEFAULT_PACKAGE_NAME = "akm-cli";
16
17
  const NODE_MODULES_SEGMENT = "/node_modules/";
@@ -91,8 +92,37 @@ export function getInstallSignals() {
91
92
  bunMain: mainPath,
92
93
  importMetaDir: getDirname(import.meta.url),
93
94
  hasAkmVersion: typeof AKM_VERSION !== "undefined",
95
+ npmGlobalRoot: resolveNpmGlobalRootForThisProcess(),
94
96
  };
95
97
  }
98
+ function resolveNpmGlobalRootForThisProcess() {
99
+ const nodePath = process.env.AKM_LAUNCHER_NODE?.trim() || (process.versions.bun ? undefined : process.execPath);
100
+ if (!nodePath)
101
+ return undefined;
102
+ try {
103
+ return resolveNpmGlobalRoot(nodePath, process.env);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ function isUnderDirectory(dir, root) {
110
+ const real = (value) => {
111
+ try {
112
+ return fs.realpathSync(value);
113
+ }
114
+ catch {
115
+ return path.resolve(value);
116
+ }
117
+ };
118
+ const relative = path.relative(real(root), real(dir));
119
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
120
+ }
121
+ /** The package that depends on this akm: the directory holding the `node_modules` it lives in. */
122
+ function packageLocalRoot(importMetaDir) {
123
+ const index = normalizePathSeparators(importMetaDir).lastIndexOf(NODE_MODULES_SEGMENT);
124
+ return index < 0 ? importMetaDir : importMetaDir.slice(0, index);
125
+ }
96
126
  // AKM_VERSION ambient type is declared in globals.d.ts
97
127
  export function detectInstallMethod(signals) {
98
128
  const s = signals ?? getInstallSignals();
@@ -104,6 +134,14 @@ export function detectInstallMethod(signals) {
104
134
  if (PNPM_GLOBAL_INSTALL_PATTERN.test(normalizedImportMetaDir)) {
105
135
  return "pnpm";
106
136
  }
137
+ // A node_modules install outside the npm global root is a DEPENDENCY of
138
+ // some other package (an image's tools dir, a plugin's node_modules):
139
+ // it moves when that package does, and an `npm install -g` here would
140
+ // "succeed" while the parent kept executing its own copy. Only a proven
141
+ // global root can make that call; without one this stays "npm".
142
+ if (s.npmGlobalRoot && s.importMetaDir && !isUnderDirectory(s.importMetaDir, s.npmGlobalRoot)) {
143
+ return "package-local";
144
+ }
107
145
  return "npm";
108
146
  }
109
147
  // Bun-compiled binaries: mainPath points to a virtual /$bunfs/
@@ -172,7 +210,23 @@ export async function performUpgrade(check, opts, dependencies) {
172
210
  const { currentVersion, latestVersion, installMethod } = check;
173
211
  const force = opts?.force === true;
174
212
  const skipPostUpgrade = opts?.skipPostUpgrade === true;
175
- // All install methods can short-circuit here unless the user explicitly forces an upgrade.
213
+ const runTool = dependencies?.runMigrationTool ?? runMigrationTool;
214
+ // Every `akm upgrade` ends by running `akm-migrate apply`, install or no
215
+ // install: the migrator on disk after the install step is the one whose
216
+ // migrations the installed akm needs, and an image that ships akm has
217
+ // nothing to install and nobody to run a migration by hand (#895). The two
218
+ // no-install cases return here.
219
+ if (installMethod === "package-local") {
220
+ const parent = packageLocalRoot(getInstallSignals().importMetaDir ?? "");
221
+ return {
222
+ currentVersion,
223
+ newVersion: latestVersion,
224
+ upgraded: false,
225
+ installMethod,
226
+ message: `akm runs as a dependency of the package at ${parent}; upgrade that package to move akm.`,
227
+ migration: await runMigrationStep(runTool),
228
+ };
229
+ }
176
230
  if (!check.updateAvailable && !force) {
177
231
  return {
178
232
  currentVersion,
@@ -180,6 +234,7 @@ export async function performUpgrade(check, opts, dependencies) {
180
234
  upgraded: false,
181
235
  installMethod,
182
236
  message: `akm v${currentVersion} is already the latest version`,
237
+ migration: await runMigrationStep(runTool),
183
238
  };
184
239
  }
185
240
  const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
@@ -190,7 +245,7 @@ export async function performUpgrade(check, opts, dependencies) {
190
245
  latestVersion,
191
246
  installMethod,
192
247
  skipPostUpgrade,
193
- upgradeState: dependencies?.upgradeHistoricalStateDatabase ?? upgradeHistoricalStateDatabase,
248
+ runTool,
194
249
  });
195
250
  }
196
251
  if (installMethod === "unknown") {
@@ -200,6 +255,7 @@ export async function performUpgrade(check, opts, dependencies) {
200
255
  upgraded: false,
201
256
  installMethod,
202
257
  message: `Unable to detect install method. Upgrade manually from https://github.com/${REPO}/releases`,
258
+ migration: await runMigrationStep(runTool),
203
259
  };
204
260
  }
205
261
  // Binary install
@@ -309,6 +365,8 @@ export async function performUpgrade(check, opts, dependencies) {
309
365
  }
310
366
  // The replacement completed; the temporary rollback copy is no longer needed.
311
367
  removeFileBestEffort(backupPath);
368
+ // The new binary is at execPath now, so this re-execs the NEW migrator.
369
+ const migration = await runMigrationStep(runTool);
312
370
  return {
313
371
  currentVersion,
314
372
  newVersion: latestVersion,
@@ -316,32 +374,46 @@ export async function performUpgrade(check, opts, dependencies) {
316
374
  installMethod,
317
375
  binaryPath: execPath,
318
376
  checksumVerified,
319
- postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }, dependencies?.upgradeHistoricalStateDatabase ?? upgradeHistoricalStateDatabase),
377
+ migration,
378
+ postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }),
320
379
  };
321
380
  }
322
381
  /**
323
- * Rebuild the derived index after a successful upgrade.
382
+ * `akm-migrate apply`, spawned so it is whichever migrator is on disk NOW:
383
+ * after a successful install, the new one. Its JSON plan becomes the
384
+ * response's `migration`. A migrator that could not run or print a plan
385
+ * reports `failed` with its error text instead of throwing, so the install
386
+ * outcome the caller is about to report is never lost behind it.
324
387
  */
325
- function runPostUpgradeTasks(akmBin, opts, upgradeState) {
326
- let stateUpgrade;
388
+ async function runMigrationStep(runTool) {
389
+ let result;
327
390
  try {
328
- stateUpgrade = upgradeState();
391
+ result = await runTool(["apply"]);
329
392
  }
330
393
  catch (error) {
331
- const detail = error instanceof Error ? error.message : String(error);
332
- return {
333
- ok: false,
334
- skipped: opts.skip,
335
- message: `Upgrade completed, but the state schema was not prepared (${detail}). ` +
336
- "Preserve state.db and run `akm upgrade --state-only` before other AKM commands (the binary is already current).",
337
- };
394
+ return { status: "failed", error: error instanceof Error ? error.message : String(error) };
395
+ }
396
+ const line = result.stdout.trim();
397
+ try {
398
+ const plan = JSON.parse(line);
399
+ if (plan.status === "current" || plan.status === "ready" || plan.status === "blocked") {
400
+ return plan;
401
+ }
338
402
  }
339
- const stateNote = stateUpgrade.safetyCopyPath ? ` Historical state safety copy: ${stateUpgrade.safetyCopyPath}.` : "";
403
+ catch {
404
+ // Not a plan; reported below with whatever the migrator did say.
405
+ }
406
+ return { status: "failed", error: result.stderr.trim() || line || `akm-migrate exited ${result.status}` };
407
+ }
408
+ /**
409
+ * Rebuild the derived index after a successful upgrade.
410
+ */
411
+ function runPostUpgradeTasks(akmBin, opts) {
340
412
  if (opts.skip) {
341
413
  return {
342
414
  ok: true,
343
415
  skipped: true,
344
- message: `Upgrade completed.${stateNote} Skipped the index rebuild. Run \`akm index\` manually to rebuild the index.`,
416
+ message: "Upgrade completed. Skipped the index rebuild. Run `akm index` manually to rebuild the index.",
345
417
  };
346
418
  }
347
419
  try {
@@ -354,7 +426,7 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
354
426
  return {
355
427
  ok: false,
356
428
  skipped: false,
357
- message: `Upgrade completed.${stateNote} The index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
429
+ message: `Upgrade completed. The index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
358
430
  };
359
431
  }
360
432
  if (result.status !== 0) {
@@ -363,14 +435,14 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
363
435
  ok: false,
364
436
  skipped: false,
365
437
  exitCode: result.status,
366
- message: `Upgrade completed.${stateNote} Post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
438
+ message: `Upgrade completed. Post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
367
439
  };
368
440
  }
369
441
  return {
370
442
  ok: true,
371
443
  skipped: false,
372
444
  exitCode: 0,
373
- message: `Upgrade completed and the index was rebuilt against the new binary.${stateNote}`,
445
+ message: "Upgrade completed and the index was rebuilt against the new binary.",
374
446
  };
375
447
  }
376
448
  catch (err) {
@@ -378,7 +450,7 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
378
450
  return {
379
451
  ok: false,
380
452
  skipped: false,
381
- message: `Upgrade completed.${stateNote} The index rebuild failed: ${detail}. Run \`akm index\` manually.`,
453
+ message: `Upgrade completed. The index rebuild failed: ${detail}. Run \`akm index\` manually.`,
382
454
  };
383
455
  }
384
456
  }
@@ -387,8 +459,8 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
387
459
  * version verification → post-upgrade tasks.
388
460
  * Extracted whole so performUpgrade stays under its fn-size baseline.
389
461
  */
390
- function runPackageManagerUpgrade(input) {
391
- const { packageManagerCommand, currentVersion, latestVersion, installMethod, skipPostUpgrade, upgradeState } = input;
462
+ async function runPackageManagerUpgrade(input) {
463
+ const { packageManagerCommand, currentVersion, latestVersion, installMethod, skipPostUpgrade, runTool } = input;
392
464
  if (!latestVersion) {
393
465
  throw new Error("Unable to determine latest version from GitHub releases. Check https://github.com/itlackey/akm/releases");
394
466
  }
@@ -402,7 +474,12 @@ function runPackageManagerUpgrade(input) {
402
474
  }
403
475
  if (result.status !== 0) {
404
476
  const details = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
405
- throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}`);
477
+ // The install could not change what runs, so the migrator on disk is
478
+ // still the right one: run it, then say so, or an operator reading the
479
+ // EACCES will assume the migration is stuck behind it (#895).
480
+ const migration = await runMigrationStep(runTool);
481
+ throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}\n` +
482
+ `Pending migrations ran anyway (status: ${migration.status}).`);
406
483
  }
407
484
  // The package manager exiting 0 does not prove it delivered
408
485
  // `latestVersion`: a lagging `@latest` dist-tag (partial publish,
@@ -420,6 +497,7 @@ function runPackageManagerUpgrade(input) {
420
497
  `v${installedVersion} (expected v${latestVersion}). The ${installMethod} registry's @latest tag ` +
421
498
  `may be lagging the GitHub release — try again shortly, or install the exact version: ` +
422
499
  `${packageManagerCommand.displayCommand.replace(/@latest\b/, `@${latestVersion}`)}`,
500
+ migration: await runMigrationStep(runTool),
423
501
  };
424
502
  }
425
503
  return {
@@ -430,7 +508,8 @@ function runPackageManagerUpgrade(input) {
430
508
  message: installedVersion === latestVersion
431
509
  ? `akm upgraded via ${installMethod} (verified: akm --version reports v${installedVersion})`
432
510
  : `akm upgraded via ${installMethod} (installed version could not be verified)`,
433
- postUpgrade: runPostUpgradeTasks("akm", { skip: skipPostUpgrade }, upgradeState),
511
+ migration: await runMigrationStep(runTool),
512
+ postUpgrade: runPostUpgradeTasks("akm", { skip: skipPostUpgrade }),
434
513
  };
435
514
  }
436
515
  /**
@@ -520,40 +599,3 @@ export function getPackageManagerUpgradeCommand(installMethod, packageName = get
520
599
  }
521
600
  return undefined;
522
601
  }
523
- /**
524
- * Apply pending historical destructive state.db migrations WITHOUT installing a
525
- * new akm — the body of `akm upgrade --state-only`.
526
- *
527
- * Migrations flagged `historical-destructive` are refused during an ordinary
528
- * managed open: they need a verified sibling safety copy taken under the
529
- * migration writer lock, and that is deliberate, so an unattended `akm index`
530
- * can never quietly drop operator state.
531
- *
532
- * The bug this fixes is not the guard but its reachability (#895). The only
533
- * code path that set `allowHistoricalDestructiveStateUpgrade` ran as a
534
- * POST-INSTALL step of a real upgrade, so it sat behind an npm install. Where
535
- * akm is installed globally by an image and the runtime user is unprivileged,
536
- * that install fails EACCES and throws long before the migration is reached —
537
- * leaving the documented remedy impossible to run and `akm index --full`
538
- * permanently blocked. Nothing about the migration itself needs the network,
539
- * root, or a new binary; it is local, offline, and already verified.
540
- *
541
- * The safety copy is NOT skipped here. This changes only who may ask for the
542
- * migration, never what it does.
543
- */
544
- export function upgradeStateOnly(currentVersion, dependencies) {
545
- const upgradeState = dependencies?.upgradeHistoricalStateDatabase ?? upgradeHistoricalStateDatabase;
546
- const result = upgradeState();
547
- return {
548
- currentVersion,
549
- newVersion: currentVersion,
550
- upgraded: false,
551
- installMethod: detectInstallMethod(),
552
- message: result.upgraded
553
- ? `Applied pending state.db migrations. Safety copy: ${result.safetyCopyPath}`
554
- : "state.db is already current; no migration was needed",
555
- stateUpgrade: result.safetyCopyPath
556
- ? { applied: result.upgraded, safetyCopyPath: result.safetyCopyPath }
557
- : { applied: result.upgraded },
558
- };
559
- }