akm-cli 0.9.8 → 0.9.9
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.
- package/CHANGELOG.md +57 -0
- package/dist/commands/health/checks.js +40 -0
- package/dist/commands/health.js +57 -31
- package/dist/commands/migrate-cli.js +29 -189
- package/dist/commands/sources/add-cli.js +7 -0
- package/dist/commands/sources/installed-stashes.js +36 -8
- package/dist/commands/sources/self-update.js +104 -62
- package/dist/commands/sources/source-add.js +6 -5
- package/dist/commands/sources/sources-cli.js +7 -18
- package/dist/commands/tasks/tasks-cli.js +4 -3
- package/dist/commands/tasks/tasks.js +13 -6
- package/dist/core/adapter/adapter-ids.js +35 -0
- package/dist/core/adapter/adapters/index.js +29 -0
- package/dist/core/adapter/detect-adapter.js +91 -3
- package/dist/core/config/config.js +1 -1
- package/dist/core/config/schema/sources-bundles.js +23 -0
- package/dist/core/extra-params.js +1 -1
- package/dist/core/state/migrations.js +2 -4
- package/dist/core/state-db.js +31 -8
- package/dist/indexer/indexer.js +64 -1
- package/dist/scripts/akm-migrate-node.js +86534 -20434
- package/dist/scripts/akm-migrate.js +86415 -20280
- package/dist/tasks/backends/cron.js +21 -6
- package/dist/tasks/resolve-akm-bin.js +1 -1
- package/docs/README.md +1 -0
- package/docs/integration/bundling-akm.md +276 -0
- package/docs/migration/v0.9.0-troubleshooting.md +10 -14
- package/docs/migration/v0.9.1-to-v0.9.2.md +12 -16
- package/docs/reference/cli.md +59 -31
- package/docs/reference/tasks.md +4 -10
- package/package.json +2 -1
- package/dist/commands/migrate/config-extra-params.js +0 -61
- package/dist/commands/migrate/dead-residue.js +0 -113
- 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] - 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",
|
package/dist/commands/health.js
CHANGED
|
@@ -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
|
|
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
|
|
420
|
-
* it must survive
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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
|
|
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
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
return { status: result.status };
|
|
18
|
+
const line = result.stdout.trim();
|
|
19
|
+
let plan;
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
plan = line ? JSON.parse(line) : undefined;
|
|
22
22
|
}
|
|
23
23
|
catch {
|
|
24
|
-
|
|
25
|
-
return { status: result.status };
|
|
24
|
+
plan = undefined;
|
|
26
25
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
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: {
|
|
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
|
|
40
|
+
meta: { name: "status", description: "Read-only check of every pending migration" },
|
|
198
41
|
run() {
|
|
199
|
-
return runMigrateSubcommand("migrate-status", ["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
|
|
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
|
-
|
|
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)
|
|
221
|
-
//
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,
|