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.
- package/CHANGELOG.md +186 -132
- 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
|
@@ -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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
+
migration,
|
|
378
|
+
postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }),
|
|
320
379
|
};
|
|
321
380
|
}
|
|
322
381
|
/**
|
|
323
|
-
*
|
|
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
|
|
326
|
-
let
|
|
388
|
+
async function runMigrationStep(runTool) {
|
|
389
|
+
let result;
|
|
327
390
|
try {
|
|
328
|
-
|
|
391
|
+
result = await runTool(["apply"]);
|
|
329
392
|
}
|
|
330
393
|
catch (error) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|