akm-cli 0.9.8 → 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 +57 -0
  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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.9.9-beta.1] - 2026-09-02
8
+
9
+ ### Changed
10
+
11
+ - **`akm-migrate` is the one migration tool, and `akm upgrade` runs it**
12
+ (#895 follow-up, #901). The standalone `akm-migrate` executable now runs
13
+ every migration step in one plan — the legacy config `extraParams` lift,
14
+ pending `state.db` migrations (historical-destructive ones included, with
15
+ the verified safety copy), task v2 → v3, task v3 → task source v4, and the
16
+ residue sweeps — as `akm-migrate status` / `apply [--dry-run]`; its
17
+ per-generation verbs (`task-v4-status`, `task-v4-apply`) are gone.
18
+ `akm migrate status|apply` is now a thin wrapper over that executable, and
19
+ every historical shape (`scripts/akm-migrate/`) lives outside the CLI
20
+ proper. `akm upgrade` runs `akm-migrate apply` **after** its install step —
21
+ the migrator that shipped with whatever is now installed — on every run,
22
+ install or no install, and reports the plan under `migration`; a blocked or
23
+ failed migration exits 1. A package-manager install that fails (EACCES on a
24
+ root-owned global directory) still runs the migrator and says so. An akm
25
+ installed as a dependency of another package is now detected
26
+ (`installMethod: "package-local"`) and never reinstalled — an
27
+ `npm install -g` there "succeeded" while the parent kept executing its own
28
+ copy — but its migrations still run. `akm upgrade --state-only` and the
29
+ `stateUpgrade` response field are removed; `akm migrate apply` is the
30
+ offline path. An ordinary managed open still refuses a historical-
31
+ destructive migration, and its message names these two commands.
32
+ A container that ships akm can put `akm upgrade` (or `akm migrate apply`)
33
+ in its entrypoint: on a current installation it is a no-op.
34
+
35
+ ### Fixed
36
+
37
+ - **`akm task sync` reports failed sources under `failures` on both the live
38
+ and `--dry-run` shapes** (#906). Live sync used to report them under
39
+ `failed` while `--dry-run` already used `failures`. No alias; both paths
40
+ now agree.
41
+ - **`akm task sync` no longer misdiagnoses a supercronic-managed container as
42
+ missing the `crontab` binary** (#910). `crontab -l` exiting non-zero with
43
+ no output, or with stderr saying "no crontab", is cron's own way of saying
44
+ "empty crontab" (BSD's `no crontab for <user>`, or a PATH shim like
45
+ OpenPalm's before any spool exists). Only a spawn that cannot find the
46
+ binary (ENOENT) is reported as missing; any other failure is reported as
47
+ what it said.
48
+ - **`akm task sync --rebind` no longer warns on every run of an image-baked
49
+ install** (#868 residue). The warning about binding scheduled tasks to a
50
+ mutable, unproven binary fires only when a rebind actually changes an
51
+ entry's bound invocation.
52
+ - **A mixed-layout bundle no longer auto-detects as a narrow tool-dir adapter
53
+ and silently drops the rest** (#908). A root that a tool-dir-shaped adapter
54
+ (`agent-skills`, `claude`, `opencode`) claims but that also carries ordinary
55
+ akm content detects as `akm`, the superset. `akm bundle list` now reports
56
+ each component's effective `adapter` and whether it was `detected`, and
57
+ indexing under an explicitly narrow adapter warns once with the count of
58
+ files and directories it skipped.
59
+ - **An unrecognised `components.*.adapter` is rejected** (#909) with
60
+ `INVALID_CONFIG_FILE` listing the accepted names instead of silently
61
+ falling back to `akm`. `akm bundle add --adapter <name>` overrides
62
+ auto-detection for a local directory; its `--help` lists the registry.
63
+
7
64
  ## [0.9.8] - 2026-09-02
8
65
 
9
66
  A cleanup and stabilization release: deletion of machinery that policed the
@@ -4,6 +4,7 @@
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { loadConfig } from "../../core/config/config.js";
6
6
  import { ConfigError } from "../../core/errors.js";
7
+ import { listPendingStateMigrations } from "../../core/state-db.js";
7
8
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
8
9
  import { resolveEngine } from "../../integrations/agent/engine-resolution.js";
9
10
  import { executionEngineDefinitionsFromConfig } from "../../integrations/agent/execution-definitions.js";
@@ -441,6 +442,40 @@ export function runSelectedModelAliasesProbe(deps = {}) {
441
442
  evidence: { checked, missing },
442
443
  };
443
444
  }
445
+ /**
446
+ * Hard check: state.db's migration ledger has no pending entries.
447
+ *
448
+ * Read-only via `listPendingStateMigrations` (a preflight open, never a
449
+ * managed one), so running this check is always safe — even when the
450
+ * corresponding managed open would refuse outright because a pending
451
+ * migration is historical-destructive (see `beforeMigrationLocked` in
452
+ * `src/core/state/migrations.ts`). This is what lets `akm health` report that
453
+ * refusal as an ordinary `fail` check instead of crashing the whole command —
454
+ * and what replaces a bundler grepping akm's refusal error text
455
+ * to detect the same case.
456
+ */
457
+ export function runPendingStateMigrationsCheck(stateDbPath, deps = {}) {
458
+ const pending = (deps.listPendingStateMigrations ?? listPendingStateMigrations)(stateDbPath);
459
+ if (pending.length === 0) {
460
+ return {
461
+ name: "state-db-migrations",
462
+ kind: "deterministic",
463
+ status: "pass",
464
+ confidence: "high",
465
+ message: "state.db has no pending migrations.",
466
+ evidence: { path: stateDbPath, pending: [] },
467
+ };
468
+ }
469
+ const range = pending.length > 1 ? `${pending[0]} … ${pending[pending.length - 1]}` : pending[0];
470
+ return {
471
+ name: "state-db-migrations",
472
+ kind: "deterministic",
473
+ status: "fail",
474
+ confidence: "high",
475
+ message: `${pending.length} pending state.db migration(s) (${range}); run \`akm migrate apply\`.`,
476
+ evidence: { path: stateDbPath, pending },
477
+ };
478
+ }
444
479
  /**
445
480
  * The ordered health-check registry. ORDER IS LOAD-BEARING: `akmHealth`
446
481
  * iterates this array and appends to hardChecks/advisories in sequence, so the
@@ -477,6 +512,11 @@ export const HEALTH_CHECKS = [
477
512
  evidence: { path: ctx.stateDbPath, durationMs: ctx.probe.durationMs },
478
513
  }),
479
514
  },
515
+ {
516
+ name: "state-db-migrations",
517
+ channel: "hard",
518
+ run: (ctx) => runPendingStateMigrationsCheck(ctx.stateDbPath),
519
+ },
480
520
  {
481
521
  name: "task-history-read",
482
522
  channel: "hard",
@@ -10,14 +10,14 @@ import { readEvents } from "../core/events.js";
10
10
  import { openLogsDatabase } from "../core/logs-db.js";
11
11
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
12
12
  import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
13
- import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
13
+ import { listExistingTableNames, listPendingStateMigrations, openStateDatabase } from "../core/state-db.js";
14
14
  import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
15
15
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
16
16
  import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
17
17
  import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
18
18
  import { pkgVersion } from "../version.js";
19
19
  import { collectImproveAdvisories } from "./health/advisories.js";
20
- import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
20
+ import { HEALTH_CHECKS, runHealthEngineProbes, runPendingStateMigrationsCheck, } from "./health/checks.js";
21
21
  import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
22
22
  import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
23
23
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
@@ -414,37 +414,32 @@ function resolveWindowComparePhase(options, db, stateDbPath, now, logsDb) {
414
414
  return { windowResults, deltas };
415
415
  }
416
416
  /**
417
- * The health report for a state.db this process cannot read (#791).
417
+ * The health report for a state.db a managed open cannot reach at all: either
418
+ * the file is not readable (#791), or — the same shape, a different cause —
419
+ * it holds a pending migration the managed open refuses to apply without
420
+ * deliberate consent (`akm upgrade` / `akm migrate apply` are the
421
+ * only two callers allowed to admit a historical-destructive migration; see
422
+ * `beforeMigrationLocked` in `src/core/state/migrations.ts`).
418
423
  *
419
- * `akm health` is what an operator runs when other commands are misbehaving, so
420
- * it must survive the permission problem long enough to NAME it. Previously it
421
- * threw `ConfigError` (exit 78) on the state.db open, which meant the one
422
- * command able to explain a data-directory permission fault died before
423
- * reaching any of its advisories.
424
+ * `akm health` is what an operator (or a bundler's boot check) runs when
425
+ * something else is misbehaving, so it must survive either problem long
426
+ * enough to NAME it. Previously an unreadable file was already handled this
427
+ * way, but a pending migration was not: the managed open's refusal escaped as
428
+ * a thrown `ConfigError` (exit 78) and crashed the whole command before any
429
+ * check — including this one — could report anything.
424
430
  *
425
- * Reported as a hard-channel `fail` — the run genuinely could not assess the
426
- * install with the path, errno, mode/owner and running uid in the message. It
427
- * exits non-zero either way; the difference is that the operator is now told
428
- * WHY instead of being handed a bare "unable to open database file".
431
+ * Reported as a single hard-channel `fail` check — the run genuinely could
432
+ * not open state.db, so every check that depends on it is skipped rather than
433
+ * attempted and it still exits non-zero, just through health's normal
434
+ * `fail` path instead of a thrown config-error exit.
429
435
  */
430
- function unreadableStateDbReport(detail, options) {
436
+ function degradedStateDbReport(hardCheck, options) {
431
437
  return {
432
438
  schemaVersion: 3,
433
439
  ok: false,
434
440
  status: "fail",
435
441
  since: parseHealthSince(options.since),
436
- hardChecks: [
437
- {
438
- name: "state-db-readable",
439
- kind: "deterministic",
440
- status: "fail",
441
- confidence: "high",
442
- message: `state.db exists but is not readable: ${detail}. Every other health check is skipped because ` +
443
- "none of them can read it. Check the owner and mode of the data directory, or point " +
444
- "AKM_DATA_DIR / XDG_DATA_HOME at a location this user owns.",
445
- evidence: { detail },
446
- },
447
- ],
442
+ hardChecks: [hardCheck],
448
443
  advisories: [],
449
444
  metrics: {
450
445
  taskFailRate: 0,
@@ -459,6 +454,18 @@ function unreadableStateDbReport(detail, options) {
459
454
  improve: summarizeImproveCompleted([]),
460
455
  };
461
456
  }
457
+ function unreadableStateDbCheck(detail) {
458
+ return {
459
+ name: "state-db-readable",
460
+ kind: "deterministic",
461
+ status: "fail",
462
+ confidence: "high",
463
+ message: `state.db exists but is not readable: ${detail}. Every other health check is skipped because ` +
464
+ "none of them can read it. Check the owner and mode of the data directory, or point " +
465
+ "AKM_DATA_DIR / XDG_DATA_HOME at a location this user owns.",
466
+ evidence: { detail },
467
+ };
468
+ }
462
469
  export function akmHealth(options = {}) {
463
470
  validateAkmHealthOptions(options);
464
471
  const now = options.now ?? (() => Date.now());
@@ -466,11 +473,13 @@ export function akmHealth(options = {}) {
466
473
  const stateDbPath = options.stateDbPath ?? getStateDbPathInDataDir();
467
474
  const hardChecks = [];
468
475
  const advisories = [];
469
- // #791: an UNREADABLE state.db is the one failure `akm health` most needs to
470
- // be able to report, because it is the command an operator runs to find out
471
- // why everything else is behaving oddly. Dying here with exit 78 meant health
472
- // could not diagnose that state at all not even the checks that never touch
473
- // state.db got to run. Report it as a finding instead.
476
+ // #791: an UNREADABLE state.db, or one with a pending migration the
477
+ // managed open refuses to apply, are the two failures `akm health` most
478
+ // needs to be able to report, because this is the command an operator (or a
479
+ // bundler's boot check) runs to find out why everything else is behaving
480
+ // oddly. Dying here meant health could not diagnose that state at all — not
481
+ // even the checks that never touch state.db got to run. Report it as a
482
+ // finding instead.
474
483
  let db;
475
484
  try {
476
485
  db = openStateDatabase(stateDbPath);
@@ -478,7 +487,24 @@ export function akmHealth(options = {}) {
478
487
  catch (error) {
479
488
  const { access, code } = classifyPathAccess(stateDbPath);
480
489
  if (access === "inaccessible") {
481
- return unreadableStateDbReport(describeInaccessiblePath(stateDbPath, code), options);
490
+ return degradedStateDbReport(unreadableStateDbCheck(describeInaccessiblePath(stateDbPath, code)), options);
491
+ }
492
+ // The managed open's refusal of a pending historical-destructive
493
+ // migration is a plain `Error`, not a distinguishable error class — so
494
+ // confirm the cause via the read-only preflight (`listPendingStateMigrations`,
495
+ // which never applies anything) rather than pattern-matching the message.
496
+ // A ledger too broken to enumerate at all throws here too; in that case
497
+ // fall through to the generic config-error report below, since it is a
498
+ // genuinely different, rarer failure this check cannot explain.
499
+ let pendingMigrationsCheck;
500
+ try {
501
+ pendingMigrationsCheck = runPendingStateMigrationsCheck(stateDbPath, { listPendingStateMigrations });
502
+ }
503
+ catch {
504
+ pendingMigrationsCheck = undefined;
505
+ }
506
+ if (pendingMigrationsCheck?.status === "fail") {
507
+ return degradedStateDbReport(pendingMigrationsCheck, options);
482
508
  }
483
509
  throw new ConfigError(`Unable to open state.db: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
484
510
  }
@@ -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,