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.
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
@@ -7,10 +7,11 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { fetchWithRetry, IS_WINDOWS, ResponseTooLargeError, readBodyWithByteCap, readChunkWithDeadline, } from "../../core/common.js";
9
9
  import { ConfigError } from "../../core/errors.js";
10
- import { upgradeHistoricalStateDatabase } from "../../core/state-db.js";
11
10
  import { warn } from "../../core/warn.js";
12
11
  import { githubHeaders } from "../../integrations/github.js";
13
12
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
13
+ import { resolveNpmGlobalRoot } from "../../tasks/resolve-akm-bin.js";
14
+ import { runMigrationTool } from "../migration-tool.js";
14
15
  const REPO = "itlackey/akm";
15
16
  const DEFAULT_PACKAGE_NAME = "akm-cli";
16
17
  const NODE_MODULES_SEGMENT = "/node_modules/";
@@ -91,8 +92,37 @@ export function getInstallSignals() {
91
92
  bunMain: mainPath,
92
93
  importMetaDir: getDirname(import.meta.url),
93
94
  hasAkmVersion: typeof AKM_VERSION !== "undefined",
95
+ npmGlobalRoot: resolveNpmGlobalRootForThisProcess(),
94
96
  };
95
97
  }
98
+ function resolveNpmGlobalRootForThisProcess() {
99
+ const nodePath = process.env.AKM_LAUNCHER_NODE?.trim() || (process.versions.bun ? undefined : process.execPath);
100
+ if (!nodePath)
101
+ return undefined;
102
+ try {
103
+ return resolveNpmGlobalRoot(nodePath, process.env);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ function isUnderDirectory(dir, root) {
110
+ const real = (value) => {
111
+ try {
112
+ return fs.realpathSync(value);
113
+ }
114
+ catch {
115
+ return path.resolve(value);
116
+ }
117
+ };
118
+ const relative = path.relative(real(root), real(dir));
119
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
120
+ }
121
+ /** The package that depends on this akm: the directory holding the `node_modules` it lives in. */
122
+ function packageLocalRoot(importMetaDir) {
123
+ const index = normalizePathSeparators(importMetaDir).lastIndexOf(NODE_MODULES_SEGMENT);
124
+ return index < 0 ? importMetaDir : importMetaDir.slice(0, index);
125
+ }
96
126
  // AKM_VERSION ambient type is declared in globals.d.ts
97
127
  export function detectInstallMethod(signals) {
98
128
  const s = signals ?? getInstallSignals();
@@ -104,6 +134,14 @@ export function detectInstallMethod(signals) {
104
134
  if (PNPM_GLOBAL_INSTALL_PATTERN.test(normalizedImportMetaDir)) {
105
135
  return "pnpm";
106
136
  }
137
+ // A node_modules install outside the npm global root is a DEPENDENCY of
138
+ // some other package (an image's tools dir, a plugin's node_modules):
139
+ // it moves when that package does, and an `npm install -g` here would
140
+ // "succeed" while the parent kept executing its own copy. Only a proven
141
+ // global root can make that call; without one this stays "npm".
142
+ if (s.npmGlobalRoot && s.importMetaDir && !isUnderDirectory(s.importMetaDir, s.npmGlobalRoot)) {
143
+ return "package-local";
144
+ }
107
145
  return "npm";
108
146
  }
109
147
  // Bun-compiled binaries: mainPath points to a virtual /$bunfs/
@@ -172,7 +210,23 @@ export async function performUpgrade(check, opts, dependencies) {
172
210
  const { currentVersion, latestVersion, installMethod } = check;
173
211
  const force = opts?.force === true;
174
212
  const skipPostUpgrade = opts?.skipPostUpgrade === true;
175
- // All install methods can short-circuit here unless the user explicitly forces an upgrade.
213
+ const runTool = dependencies?.runMigrationTool ?? runMigrationTool;
214
+ // Every `akm upgrade` ends by running `akm-migrate apply`, install or no
215
+ // install: the migrator on disk after the install step is the one whose
216
+ // migrations the installed akm needs, and an image that ships akm has
217
+ // nothing to install and nobody to run a migration by hand (#895). The two
218
+ // no-install cases return here.
219
+ if (installMethod === "package-local") {
220
+ const parent = packageLocalRoot(getInstallSignals().importMetaDir ?? "");
221
+ return {
222
+ currentVersion,
223
+ newVersion: latestVersion,
224
+ upgraded: false,
225
+ installMethod,
226
+ message: `akm runs as a dependency of the package at ${parent}; upgrade that package to move akm.`,
227
+ migration: await runMigrationStep(runTool),
228
+ };
229
+ }
176
230
  if (!check.updateAvailable && !force) {
177
231
  return {
178
232
  currentVersion,
@@ -180,6 +234,7 @@ export async function performUpgrade(check, opts, dependencies) {
180
234
  upgraded: false,
181
235
  installMethod,
182
236
  message: `akm v${currentVersion} is already the latest version`,
237
+ migration: await runMigrationStep(runTool),
183
238
  };
184
239
  }
185
240
  const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
@@ -190,7 +245,7 @@ export async function performUpgrade(check, opts, dependencies) {
190
245
  latestVersion,
191
246
  installMethod,
192
247
  skipPostUpgrade,
193
- upgradeState: dependencies?.upgradeHistoricalStateDatabase ?? upgradeHistoricalStateDatabase,
248
+ runTool,
194
249
  });
195
250
  }
196
251
  if (installMethod === "unknown") {
@@ -200,6 +255,7 @@ export async function performUpgrade(check, opts, dependencies) {
200
255
  upgraded: false,
201
256
  installMethod,
202
257
  message: `Unable to detect install method. Upgrade manually from https://github.com/${REPO}/releases`,
258
+ migration: await runMigrationStep(runTool),
203
259
  };
204
260
  }
205
261
  // Binary install
@@ -309,6 +365,8 @@ export async function performUpgrade(check, opts, dependencies) {
309
365
  }
310
366
  // The replacement completed; the temporary rollback copy is no longer needed.
311
367
  removeFileBestEffort(backupPath);
368
+ // The new binary is at execPath now, so this re-execs the NEW migrator.
369
+ const migration = await runMigrationStep(runTool);
312
370
  return {
313
371
  currentVersion,
314
372
  newVersion: latestVersion,
@@ -316,32 +374,46 @@ export async function performUpgrade(check, opts, dependencies) {
316
374
  installMethod,
317
375
  binaryPath: execPath,
318
376
  checksumVerified,
319
- postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }, dependencies?.upgradeHistoricalStateDatabase ?? upgradeHistoricalStateDatabase),
377
+ migration,
378
+ postUpgrade: runPostUpgradeTasks(execPath, { skip: skipPostUpgrade }),
320
379
  };
321
380
  }
322
381
  /**
323
- * Rebuild the derived index after a successful upgrade.
382
+ * `akm-migrate apply`, spawned so it is whichever migrator is on disk NOW:
383
+ * after a successful install, the new one. Its JSON plan becomes the
384
+ * response's `migration`. A migrator that could not run or print a plan
385
+ * reports `failed` with its error text instead of throwing, so the install
386
+ * outcome the caller is about to report is never lost behind it.
324
387
  */
325
- function runPostUpgradeTasks(akmBin, opts, upgradeState) {
326
- let stateUpgrade;
388
+ async function runMigrationStep(runTool) {
389
+ let result;
327
390
  try {
328
- stateUpgrade = upgradeState();
391
+ result = await runTool(["apply"]);
329
392
  }
330
393
  catch (error) {
331
- const detail = error instanceof Error ? error.message : String(error);
332
- return {
333
- ok: false,
334
- skipped: opts.skip,
335
- message: `Upgrade completed, but the state schema was not prepared (${detail}). ` +
336
- "Preserve state.db and run `akm upgrade --state-only` before other AKM commands (the binary is already current).",
337
- };
394
+ return { status: "failed", error: error instanceof Error ? error.message : String(error) };
395
+ }
396
+ const line = result.stdout.trim();
397
+ try {
398
+ const plan = JSON.parse(line);
399
+ if (plan.status === "current" || plan.status === "ready" || plan.status === "blocked") {
400
+ return plan;
401
+ }
338
402
  }
339
- const stateNote = stateUpgrade.safetyCopyPath ? ` Historical state safety copy: ${stateUpgrade.safetyCopyPath}.` : "";
403
+ catch {
404
+ // Not a plan; reported below with whatever the migrator did say.
405
+ }
406
+ return { status: "failed", error: result.stderr.trim() || line || `akm-migrate exited ${result.status}` };
407
+ }
408
+ /**
409
+ * Rebuild the derived index after a successful upgrade.
410
+ */
411
+ function runPostUpgradeTasks(akmBin, opts) {
340
412
  if (opts.skip) {
341
413
  return {
342
414
  ok: true,
343
415
  skipped: true,
344
- message: `Upgrade completed.${stateNote} Skipped the index rebuild. Run \`akm index\` manually to rebuild the index.`,
416
+ message: "Upgrade completed. Skipped the index rebuild. Run `akm index` manually to rebuild the index.",
345
417
  };
346
418
  }
347
419
  try {
@@ -354,7 +426,7 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
354
426
  return {
355
427
  ok: false,
356
428
  skipped: false,
357
- message: `Upgrade completed.${stateNote} The index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
429
+ message: `Upgrade completed. The index rebuild could not start: ${result.error.message}. Run \`akm index\` manually.`,
358
430
  };
359
431
  }
360
432
  if (result.status !== 0) {
@@ -363,14 +435,14 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
363
435
  ok: false,
364
436
  skipped: false,
365
437
  exitCode: result.status,
366
- message: `Upgrade completed.${stateNote} Post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
438
+ message: `Upgrade completed. Post-upgrade \`akm index\` failed (${detail}). Run \`akm index\` manually.`,
367
439
  };
368
440
  }
369
441
  return {
370
442
  ok: true,
371
443
  skipped: false,
372
444
  exitCode: 0,
373
- message: `Upgrade completed and the index was rebuilt against the new binary.${stateNote}`,
445
+ message: "Upgrade completed and the index was rebuilt against the new binary.",
374
446
  };
375
447
  }
376
448
  catch (err) {
@@ -378,7 +450,7 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
378
450
  return {
379
451
  ok: false,
380
452
  skipped: false,
381
- message: `Upgrade completed.${stateNote} The index rebuild failed: ${detail}. Run \`akm index\` manually.`,
453
+ message: `Upgrade completed. The index rebuild failed: ${detail}. Run \`akm index\` manually.`,
382
454
  };
383
455
  }
384
456
  }
@@ -387,8 +459,8 @@ function runPostUpgradeTasks(akmBin, opts, upgradeState) {
387
459
  * version verification → post-upgrade tasks.
388
460
  * Extracted whole so performUpgrade stays under its fn-size baseline.
389
461
  */
390
- function runPackageManagerUpgrade(input) {
391
- const { packageManagerCommand, currentVersion, latestVersion, installMethod, skipPostUpgrade, upgradeState } = input;
462
+ async function runPackageManagerUpgrade(input) {
463
+ const { packageManagerCommand, currentVersion, latestVersion, installMethod, skipPostUpgrade, runTool } = input;
392
464
  if (!latestVersion) {
393
465
  throw new Error("Unable to determine latest version from GitHub releases. Check https://github.com/itlackey/akm/releases");
394
466
  }
@@ -402,7 +474,12 @@ function runPackageManagerUpgrade(input) {
402
474
  }
403
475
  if (result.status !== 0) {
404
476
  const details = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
405
- throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}`);
477
+ // The install could not change what runs, so the migrator on disk is
478
+ // still the right one: run it, then say so, or an operator reading the
479
+ // EACCES will assume the migration is stuck behind it (#895).
480
+ const migration = await runMigrationStep(runTool);
481
+ throw new Error(`Failed to upgrade akm via ${installMethod}: ${details}\nRun manually: ${packageManagerCommand.displayCommand}\n` +
482
+ `Pending migrations ran anyway (status: ${migration.status}).`);
406
483
  }
407
484
  // The package manager exiting 0 does not prove it delivered
408
485
  // `latestVersion`: a lagging `@latest` dist-tag (partial publish,
@@ -420,6 +497,7 @@ function runPackageManagerUpgrade(input) {
420
497
  `v${installedVersion} (expected v${latestVersion}). The ${installMethod} registry's @latest tag ` +
421
498
  `may be lagging the GitHub release — try again shortly, or install the exact version: ` +
422
499
  `${packageManagerCommand.displayCommand.replace(/@latest\b/, `@${latestVersion}`)}`,
500
+ migration: await runMigrationStep(runTool),
423
501
  };
424
502
  }
425
503
  return {
@@ -430,7 +508,8 @@ function runPackageManagerUpgrade(input) {
430
508
  message: installedVersion === latestVersion
431
509
  ? `akm upgraded via ${installMethod} (verified: akm --version reports v${installedVersion})`
432
510
  : `akm upgraded via ${installMethod} (installed version could not be verified)`,
433
- postUpgrade: runPostUpgradeTasks("akm", { skip: skipPostUpgrade }, upgradeState),
511
+ migration: await runMigrationStep(runTool),
512
+ postUpgrade: runPostUpgradeTasks("akm", { skip: skipPostUpgrade }),
434
513
  };
435
514
  }
436
515
  /**
@@ -520,40 +599,3 @@ export function getPackageManagerUpgradeCommand(installMethod, packageName = get
520
599
  }
521
600
  return undefined;
522
601
  }
523
- /**
524
- * Apply pending historical destructive state.db migrations WITHOUT installing a
525
- * new akm — the body of `akm upgrade --state-only`.
526
- *
527
- * Migrations flagged `historical-destructive` are refused during an ordinary
528
- * managed open: they need a verified sibling safety copy taken under the
529
- * migration writer lock, and that is deliberate, so an unattended `akm index`
530
- * can never quietly drop operator state.
531
- *
532
- * The bug this fixes is not the guard but its reachability (#895). The only
533
- * code path that set `allowHistoricalDestructiveStateUpgrade` ran as a
534
- * POST-INSTALL step of a real upgrade, so it sat behind an npm install. Where
535
- * akm is installed globally by an image and the runtime user is unprivileged,
536
- * that install fails EACCES and throws long before the migration is reached —
537
- * leaving the documented remedy impossible to run and `akm index --full`
538
- * permanently blocked. Nothing about the migration itself needs the network,
539
- * root, or a new binary; it is local, offline, and already verified.
540
- *
541
- * The safety copy is NOT skipped here. This changes only who may ask for the
542
- * migration, never what it does.
543
- */
544
- export function upgradeStateOnly(currentVersion, dependencies) {
545
- const upgradeState = dependencies?.upgradeHistoricalStateDatabase ?? upgradeHistoricalStateDatabase;
546
- const result = upgradeState();
547
- return {
548
- currentVersion,
549
- newVersion: currentVersion,
550
- upgraded: false,
551
- installMethod: detectInstallMethod(),
552
- message: result.upgraded
553
- ? `Applied pending state.db migrations. Safety copy: ${result.safetyCopyPath}`
554
- : "state.db is already current; no migration was needed",
555
- stateUpgrade: result.safetyCopyPath
556
- ? { applied: result.upgraded, safetyCopyPath: result.safetyCopyPath }
557
- : { applied: result.upgraded },
558
- };
559
- }
@@ -31,7 +31,7 @@ export async function akmAdd(input) {
31
31
  try {
32
32
  const parsed = parseRegistryRef(ref);
33
33
  if (parsed.source === "local") {
34
- return addLocalSource(ref, parsed.sourcePath, stashDir, input.name);
34
+ return addLocalSource(ref, parsed.sourcePath, stashDir, input.name, input.adapter);
35
35
  }
36
36
  }
37
37
  catch {
@@ -40,22 +40,23 @@ export async function akmAdd(input) {
40
40
  return addRegistryStash(ref, stashDir, input.writable);
41
41
  }
42
42
  /** Add a local directory as a filesystem bundle. */
43
- async function addLocalSource(ref, sourcePath, stashDir, explicitName) {
43
+ async function addLocalSource(ref, sourcePath, stashDir, explicitName, explicitAdapter) {
44
44
  const stashRoot = detectStashRoot(sourcePath);
45
45
  const resolvedPath = path.resolve(stashRoot);
46
+ const adapter = explicitAdapter ?? detectAdapterId(resolvedPath);
46
47
  let bundleKey = explicitName ?? toReadableId(resolvedPath);
47
48
  mutateConfig((config) => {
48
49
  const existing = bundleKeyForPath(config, resolvedPath);
49
50
  if (existing) {
50
51
  bundleKey = existing;
51
52
  const current = config.bundles?.[existing];
52
- if (current?.components)
53
+ if (current?.components && explicitAdapter === undefined)
53
54
  return config;
54
55
  const bundles = { ...(config.bundles ?? {}) };
55
56
  bundles[existing] = {
56
57
  ...current,
57
58
  path: resolvedPath,
58
- components: { main: { root: ".", adapter: detectAdapterId(resolvedPath) } },
59
+ components: { main: { root: ".", adapter } },
59
60
  };
60
61
  return { ...config, bundles };
61
62
  }
@@ -63,7 +64,7 @@ async function addLocalSource(ref, sourcePath, stashDir, explicitName) {
63
64
  bundleKey = nextBundleKey(bundles, explicitName, resolvedPath);
64
65
  bundles[bundleKey] = {
65
66
  path: resolvedPath,
66
- components: { main: { root: ".", adapter: detectAdapterId(resolvedPath) } },
67
+ components: { main: { root: ".", adapter } },
67
68
  };
68
69
  return { ...config, bundles };
69
70
  });
@@ -27,13 +27,13 @@
27
27
  */
28
28
  import { defineCommand } from "citty";
29
29
  import { getParsedInvocation } from "../../cli/invocation.js";
30
- import { defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
30
+ import { defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
31
31
  import { loadConfig } from "../../core/config/config.js";
32
32
  import { UsageError } from "../../core/errors.js";
33
33
  import { appendEvent } from "../../core/events.js";
34
34
  import { resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
35
35
  import { pkgVersion } from "../../version.js";
36
- import { checkForUpdate, performUpgrade, upgradeStateOnly } from "./self-update.js";
36
+ import { checkForUpdate, performUpgrade } from "./self-update.js";
37
37
  import { akmClone } from "./source-clone.js";
38
38
  export const upgradeCommand = defineJsonCommand({
39
39
  meta: { name: "upgrade", description: "Upgrade akm to the latest release" },
@@ -45,24 +45,8 @@ export const upgradeCommand = defineJsonCommand({
45
45
  description: "Skip the post-upgrade index rebuild",
46
46
  default: false,
47
47
  },
48
- "state-only": {
49
- type: "boolean",
50
- description: "Apply pending state.db migrations without installing a new akm",
51
- default: false,
52
- },
53
48
  },
54
49
  async run({ args }) {
55
- // Applying a historical destructive state migration used to be reachable
56
- // ONLY as a post-install step of a real upgrade, so an install akm cannot
57
- // rewrite -- a global npm install owned by root, an image that ships the
58
- // CLI -- had no route to it at all: the npm step fails EACCES and throws
59
- // long before the migration runs (#895). The migration is a local,
60
- // offline, already-verified operation; it does not need the network or a
61
- // new binary, and coupling it to one was the bug.
62
- if (args["state-only"]) {
63
- output("upgrade", upgradeStateOnly(pkgVersion));
64
- return;
65
- }
66
50
  const check = await checkForUpdate(pkgVersion);
67
51
  if (args.check) {
68
52
  output("upgrade", check);
@@ -71,6 +55,11 @@ export const upgradeCommand = defineJsonCommand({
71
55
  const skipPostUpgrade = args["skip-post-upgrade"];
72
56
  const result = await performUpgrade(check, { force: args.force, skipPostUpgrade });
73
57
  output("upgrade", result);
58
+ // The install may have succeeded, but an upgrade whose migration is
59
+ // blocked or could not run is not done: exit like `akm migrate apply` does.
60
+ if (result.migration?.status === "blocked" || result.migration?.status === "failed") {
61
+ process.exitCode = EXIT_CODES.GENERAL;
62
+ }
74
63
  },
75
64
  });
76
65
  // `sync` body, standalone so the git-commit/push logic stays in one place.
@@ -349,10 +349,11 @@ const tasksSyncCommand = defineJsonCommand({
349
349
  const result = await akmTasksSync({}, args.bundle, { rebind });
350
350
  output("task-sync", result);
351
351
  // #867: sync degrades — sources that failed to parse/prepare are
352
- // excluded from reconciliation and reported in `result.failed` rather
352
+ // excluded from reconciliation and reported in `result.failures` rather
353
353
  // than poisoning the whole sync, but their presence must still fail
354
- // the command's exit code so the breakage stays visible.
355
- if (result.failed.length > 0)
354
+ // the command's exit code so the breakage stays visible. (#906: this key
355
+ // matches the `--dry-run` preview's `failures` field — no separate name.)
356
+ if (result.failures.length > 0)
356
357
  process.exitCode = EXIT_CODES.GENERAL;
357
358
  },
358
359
  });
@@ -391,7 +391,7 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
391
391
  const expectedSignature = sched.expectedSignature?.bind(sched);
392
392
  const needsRuntime = preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined);
393
393
  const prepared = needsRuntime
394
- ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, options.rebind === true, "reconcile native scheduler bindings", warnings)
394
+ ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, options.rebind === true, "reconcile native scheduler bindings", warnings, allEntries.map((entry) => entry.binding))
395
395
  : undefined;
396
396
  const plan = finalizeSchedulerSyncPlan({
397
397
  ...common,
@@ -416,7 +416,7 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
416
416
  unchanged: [...plan.unchanged],
417
417
  skipped: [],
418
418
  backend: sched.name,
419
- failed: plan.failures.map((failure) => ({ ...failure })),
419
+ failures: plan.failures.map((failure) => ({ ...failure })),
420
420
  ...(warnings.length > 0 ? { warnings } : {}),
421
421
  };
422
422
  }
@@ -841,16 +841,16 @@ async function prepareTaskAddSchedulerTransaction(input) {
841
841
  publishOperationIndex: removals.length,
842
842
  });
843
843
  }
844
- function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warnings) {
844
+ function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warnings, installedBindings = []) {
845
845
  if (deps.backend && !deps.schedulerRuntime)
846
846
  return base ? { options: base } : {};
847
847
  if (deps.schedulerRuntime) {
848
848
  const runtime = deps.schedulerRuntime();
849
- warnIneligibleRebind(runtime, explicitRebind, warnings);
849
+ warnIneligibleRebind(runtime, explicitRebind, warnings, installedBindings);
850
850
  return { options: { ...base, binding: runtime.binding, contextPath: runtime.contextPath } };
851
851
  }
852
852
  const invocation = resolveAndValidateSchedulerInvocation(explicitRebind, operation);
853
- warnIneligibleRebind(invocation, explicitRebind, warnings);
853
+ warnIneligibleRebind(invocation, explicitRebind, warnings, installedBindings);
854
854
  const descriptor = schedulerContextDescriptor();
855
855
  const contextPath = schedulerContextPath(descriptor);
856
856
  return {
@@ -870,9 +870,16 @@ function resolveAndValidateSchedulerInvocation(explicitRebind, operation) {
870
870
  }
871
871
  return { binding: invocation.argv, contextPath: "", eligible: invocation.eligible, kind: invocation.kind };
872
872
  }
873
- function warnIneligibleRebind(runtime, explicitRebind, warnings) {
873
+ function warnIneligibleRebind(runtime, explicitRebind, warnings, installedBindings) {
874
874
  if (!explicitRebind || runtime.eligible !== false || warnings.length > 0)
875
875
  return;
876
+ // #868 residue: a `--rebind` that binds every currently-installed
877
+ // entry to the SAME invocation it already carries changes nothing — this
878
+ // is the steady state of an image-baked install re-running `task sync
879
+ // --rebind` on a timer. Only warn when the rebind actually moves an entry
880
+ // to a different invocation.
881
+ if (installedBindings.length > 0 && installedBindings.every((bound) => sameArgv(bound, runtime.binding)))
882
+ return;
876
883
  warnings.push(`--rebind bound scheduled tasks to an ineligible ${runtime.kind ?? "unknown"} invocation (${runtime.binding.join(" ")}); scheduled runs will invoke a mutable, unproven binary. Install akm via \`npm install --global akm-cli\` or a standalone release, then re-run \`akm task sync --rebind\`.`);
877
884
  }
878
885
  function groupInstalledBindings(entries, invocation) {
@@ -0,0 +1,35 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Dependency-free adapter-id table (#909) — mirrors
6
+ * `src/integrations/harnesses/ids.ts`'s split from its own heavy barrel.
7
+ *
8
+ * `core/config/schema/sources-bundles.ts` needs one small, DATA-shaped fact
9
+ * about the adapter registry: the canonical ordered id list, to validate
10
+ * `components.*.adapter` and to reject a typo instead of silently falling
11
+ * back to `akm` (#909). Importing `./registry.ts` (`BUILTIN_ADAPTERS`) for
12
+ * that would pull in all 11 concrete adapters and, transitively, the indexer
13
+ * modules they delegate to (`indexer/passes/metadata`, `core/asset/*`, …) —
14
+ * weight a config-schema module has no reason to carry just to validate one
15
+ * enum. This table is the canonical, dependency-free MIRROR of the id list;
16
+ * `./adapters/index.ts`'s `BUILTIN_ADAPTERS` construction asserts its ids
17
+ * match this table (order included) at module-load time, so the two can
18
+ * never silently drift without a loud failure.
19
+ */
20
+ /** Canonical, ordered list of valid adapter ids (matches `BUILTIN_ADAPTERS` order). */
21
+ export const ADAPTER_ID_TABLE = [
22
+ "website-snapshot",
23
+ "agent-skills",
24
+ "claude",
25
+ "opencode",
26
+ "dotenv",
27
+ "akm-workflow",
28
+ "akm-task",
29
+ "llm-wiki",
30
+ "akm",
31
+ "okf",
32
+ "generic-files",
33
+ ];
34
+ /** The dependency-free counterpart of `./registry.ts`'s `getAdapters().map(a => a.id)`. */
35
+ export const VALID_ADAPTER_IDS = ADAPTER_ID_TABLE;
@@ -1,6 +1,21 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
+ /**
5
+ * Built-in `BundleAdapter` barrel + the static, frozen `BUILTIN_ADAPTERS` list
6
+ * (normative §12.6) — akm 0.9.0 chunk-2 (WI-A) + the format-family work item
7
+ * (#46).
8
+ *
9
+ * `BUILTIN_ADAPTERS` is the ordered built-in adapter set the registry
10
+ * (`../registry`) exposes via `getAdapters()` / `adapterForId()`. It is a
11
+ * plain frozen array populated at MODULE LOAD — there is no mutable
12
+ * registration step and no load-order dependency, so no production call site
13
+ * (`installations.ts#detectAdapterId`, `provider-utils.ts#detectStashRoot`)
14
+ * depends on anyone first calling a registration function (normative §12.6 —
15
+ * "static frozen `BUILTIN_ADAPTERS` map"). The earlier mutable
16
+ * `registerAdapter` singleton that this replaced is retired.
17
+ */
18
+ import { ADAPTER_ID_TABLE } from "../adapter-ids.js";
4
19
  import { agentSkillsAdapter } from "./agent-skills-adapter.js";
5
20
  import { akmAdapter } from "./akm-adapter.js";
6
21
  import { akmTaskAdapter } from "./akm-task-adapter.js";
@@ -69,3 +84,17 @@ export const BUILTIN_ADAPTERS = Object.freeze([
69
84
  // Explicit-config fallback (never auto-selected) — last.
70
85
  genericFilesAdapter,
71
86
  ]);
87
+ // Construction-time drift guard (#909): ../adapter-ids.ts's ADAPTER_ID_TABLE
88
+ // is config's dependency-free mirror of this list, kept out of core/config's
89
+ // import graph so config doesn't have to import this (heavier) barrel. Assert
90
+ // they match so the mirror can never silently drift — a new/reordered/renamed
91
+ // adapter that forgets to update adapter-ids.ts fails loudly here instead of
92
+ // quietly breaking config's `components.*.adapter` validation.
93
+ {
94
+ const actualIds = BUILTIN_ADAPTERS.map((a) => a.id);
95
+ const expectedIds = [...ADAPTER_ID_TABLE];
96
+ if (actualIds.length !== expectedIds.length || actualIds.some((id, i) => id !== expectedIds[i])) {
97
+ throw new Error(`adapter-ids.ts ADAPTER_ID_TABLE (${expectedIds.join(", ")}) does not match ` +
98
+ `BUILTIN_ADAPTERS (${actualIds.join(", ")}) — update adapter-ids.ts to match.`);
99
+ }
100
+ }
@@ -1,13 +1,101 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
- import { getAdapters } from "./registry.js";
5
- /** Select the first built-in adapter whose ordered root probe claims `root`. */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { adapterForId, getAdapters } from "./registry.js";
7
+ /**
8
+ * Tool-dir-shaped adapters (#908): their install-time probe recognizes only a
9
+ * BOUNDED slice of the root — `claude`/`opencode`'s own `commands`/`agents`/
10
+ * `skills` tool dirs, or `agent-skills`' own `<name>/SKILL.md` packages. A
11
+ * bundle that ALSO carries ordinary akm content (`knowledge/`, `workflows/`,
12
+ * a stray `content/` folder of Markdown, …) alongside one of these layouts had
13
+ * that content SILENTLY dropped: the narrow adapter won the ordered probe and
14
+ * indexed only its own three-dir slice, with no warning that anything else
15
+ * was there (issue #908 — 73 documents disappeared from one real bundle).
16
+ *
17
+ * `okf` / `llm-wiki` / `dotenv` / `website-snapshot` / `akm-workflow` /
18
+ * `akm-task` are deliberately NOT in this set: each carries its own tight,
19
+ * disjoint marker (a root index document, `schema.md`+`pages/`, an env/secrets-only
20
+ * layout, `manifest.json`, a workflow/task-shaped top-level file) that is not
21
+ * at risk of firing merely because a FEW directory names happen to overlap
22
+ * with akm's own stash subdirs — narrowing the fix to the three families the
23
+ * issue is actually about keeps this from touching adapters it was never
24
+ * about.
25
+ */
26
+ const SHADOWABLE_ADAPTER_IDS = new Set(["agent-skills", "claude", "opencode"]);
27
+ /**
28
+ * True when `root` — already claimed by `winnerId` (one of
29
+ * {@link SHADOWABLE_ADAPTER_IDS}) — ALSO carries a top-level directory the
30
+ * `akm` adapter's own probe recognizes as its workspace shape (spec §1.2) and
31
+ * that `winnerId` does not own (its `directoryList()`, or — for `agent-skills`,
32
+ * which owns no fixed directory names — a root-level `<name>/SKILL.md`
33
+ * package) but which holds at least one real file. Cheap by design: only
34
+ * shallow `readdirSync` calls, one level into each candidate top-level dir —
35
+ * no recursive walk, no file content read, no git spawn — so this never adds
36
+ * meaningful cost to the install-time probe it augments.
37
+ */
38
+ function hasExtraAkmContent(root, winnerId) {
39
+ const akm = adapterForId("akm");
40
+ if (akm?.looksLikeRoot?.(root) !== true)
41
+ return false;
42
+ const winner = adapterForId(winnerId);
43
+ const stubComponent = { id: "detect", adapter: winnerId, root, writable: false };
44
+ const ownedDirs = new Set(winner?.directoryList?.(stubComponent) ?? []);
45
+ let entries;
46
+ try {
47
+ entries = fs.readdirSync(root, { withFileTypes: true });
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ for (const entry of entries) {
53
+ if (!entry.isDirectory() || entry.name.startsWith("."))
54
+ continue;
55
+ if (ownedDirs.has(entry.name))
56
+ continue;
57
+ if (winnerId === "agent-skills") {
58
+ // A root-level `<name>/SKILL.md` package IS agent-skills' own recognized
59
+ // surface even though it has no fixed directoryList — not "extra" content.
60
+ try {
61
+ if (fs.statSync(path.join(root, entry.name, "SKILL.md")).isFile())
62
+ continue;
63
+ }
64
+ catch {
65
+ // Not a skill package — fall through to the candidate-file check below.
66
+ }
67
+ }
68
+ let children;
69
+ try {
70
+ children = fs.readdirSync(path.join(root, entry.name), { withFileTypes: true });
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (children.some((child) => child.isFile() && !child.name.startsWith(".")))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Select the first built-in adapter whose ordered root probe claims `root`.
82
+ *
83
+ * A mixed-layout bundle (#908) that both a tool-dir-shaped adapter AND the
84
+ * `akm` adapter would claim detects as `akm` — the superset, which still
85
+ * indexes the narrower layout's own files correctly (`agent-skills`'
86
+ * `<name>/SKILL.md` packages, `claude`/`opencode`'s `commands`/`agents`/
87
+ * `skills`) while also picking up whatever else is in the bundle. A bundle
88
+ * that carries ONLY the narrow layout (no extra content) is unaffected.
89
+ */
6
90
  export function detectAdapterId(root, fallback = "akm") {
7
91
  for (const adapter of getAdapters()) {
8
92
  try {
9
- if (adapter.looksLikeRoot?.(root) === true)
93
+ if (adapter.looksLikeRoot?.(root) === true) {
94
+ if (SHADOWABLE_ADAPTER_IDS.has(adapter.id) && hasExtraAkmContent(root, adapter.id)) {
95
+ return "akm";
96
+ }
10
97
  return adapter.id;
98
+ }
11
99
  }
12
100
  catch {
13
101
  // An unreadable or racing probe does not claim the bundle.