akm-cli 0.9.2 → 0.9.4

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 (46) hide show
  1. package/CHANGELOG.md +110 -1
  2. package/README.md +1 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/commands/health/improve-metrics.js +17 -0
  9. package/dist/commands/health/windows.js +2 -2
  10. package/dist/commands/health.js +2 -2
  11. package/dist/commands/improve/preparation.js +8 -1
  12. package/dist/commands/lint/index.js +3 -7
  13. package/dist/commands/tasks/tasks-cli.js +28 -3
  14. package/dist/commands/tasks/tasks.js +29 -1
  15. package/dist/core/adapter/adapters/akm-adapter.js +21 -14
  16. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  17. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  18. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  19. package/dist/core/adapter/recognize-match.js +1 -20
  20. package/dist/core/asset/asset-placement.js +21 -2
  21. package/dist/core/common.js +21 -1
  22. package/dist/core/config/config.js +19 -3
  23. package/dist/core/extra-params.js +115 -1
  24. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  25. package/dist/indexer/search/db-search.js +6 -0
  26. package/dist/indexer/walk/matchers.js +0 -22
  27. package/dist/output/shapes/helpers.js +19 -1
  28. package/dist/output/shapes/passthrough.js +1 -0
  29. package/dist/output/text/command-format.js +4 -0
  30. package/dist/registry/pinned-request-helper.js +2 -2
  31. package/dist/registry/pinned-transport.js +6 -6
  32. package/dist/scripts/akm-migrate-node.js +12698 -12530
  33. package/dist/scripts/akm-migrate.js +12698 -12530
  34. package/dist/storage/repositories/proposals-repository.js +33 -1
  35. package/dist/storage/repositories/task-history-repository.js +22 -10
  36. package/dist/tasks/run/run-native-task.js +28 -1
  37. package/dist/tasks/run/task-history.js +23 -3
  38. package/dist/tasks/scheduler-sync-preview.js +43 -0
  39. package/dist/tasks/scheduler-sync.js +1 -0
  40. package/dist/tasks/source/bounded-document.js +1 -1
  41. package/dist/tasks/source/parse-task-source.js +77 -11
  42. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  43. package/dist/tasks/source/task-to-v3.js +500 -0
  44. package/dist/tasks/source/task-to-v4.js +467 -0
  45. package/docs/reference/cli.md +8 -3
  46. package/package.json +4 -4
@@ -23,8 +23,24 @@ function changesToStored(changes) {
23
23
  /**
24
24
  * Reconstruct `Proposal.changes` from `metadata_json.changes` + the `content`
25
25
  * column.
26
+ *
27
+ * Read-time compatibility shim (#858/#859): proposals created before this
28
+ * field existed have no `changes` key in `metadata_json` at all (~89% of the
29
+ * archived accepted/rejected rows on real installs) and that history cannot
30
+ * be reconstructed from `content` alone. Treat a completely absent `changes`
31
+ * key as a known legacy gap — return an empty change list rather than
32
+ * throwing — so these rows still round-trip as real proposals (and count
33
+ * toward accepted/rejected totals) instead of being dropped or crashing
34
+ * every reader. A `changes` value that *is* present but malformed (wrong
35
+ * type, invalid entries) is still corruption and throws, same as before; the
36
+ * write path (`proposalToRowValues`) still refuses to persist an empty or
37
+ * missing change list for new proposals, so this leniency only ever applies
38
+ * to pre-existing rows.
26
39
  */
27
40
  function storedToChanges(stored, content) {
41
+ if (stored === undefined) {
42
+ return [];
43
+ }
28
44
  if (!Array.isArray(stored) || stored.length === 0) {
29
45
  throw new Error("Proposal metadata is missing changes.");
30
46
  }
@@ -307,7 +323,23 @@ export function listStateProposals(db, options = {}) {
307
323
  content, frontmatter_json, metadata_json
308
324
  FROM proposals ${where} ORDER BY created_at ASC, rowid ASC`)
309
325
  .all(...params);
310
- return rows.map(proposalRowToProposal);
326
+ // Per-row skip-and-warn (#858/#859): a single row that fails to parse
327
+ // (invalid ref/status/metadata shape — genuine corruption, distinct from
328
+ // the tolerated legacy-missing-`changes` case in `storedToChanges` above)
329
+ // must not abort the entire list. Every caller of this function reads a
330
+ // multi-row archive; one bad row hiding the rest behind a thrown error is
331
+ // strictly worse than surfacing the well-formed rows plus a warning.
332
+ const proposals = [];
333
+ for (const row of rows) {
334
+ try {
335
+ proposals.push(proposalRowToProposal(row));
336
+ }
337
+ catch (error) {
338
+ const message = error instanceof Error ? error.message : String(error);
339
+ console.warn(`[akm] Skipping unparseable proposal row (id=${row.id}, ref=${row.ref}): ${message}`);
340
+ }
341
+ }
342
+ return proposals;
311
343
  }
312
344
  /**
313
345
  * Look up a single proposal by id, optionally scoped to one stash root.
@@ -24,7 +24,23 @@ function validateDetail(value) {
24
24
  metadataError("detail.exitCode must be a number or null");
25
25
  }
26
26
  }
27
- /** Decode the current task-history metadata shape. */
27
+ /**
28
+ * Decode the task-history metadata shape.
29
+ *
30
+ * Read-time compatibility shim (mirrors proposals-repository's
31
+ * `storedToChanges`): `metadataVersion` was added in a later release. 58% of
32
+ * a real install's `task_history` rows (8,596/14,801) predate the field
33
+ * entirely, and 88 more carry additive fields (`profile`, `repairReason`)
34
+ * written by prior releases. An absent `metadataVersion` is a LEGACY row, not
35
+ * corruption — decode what's present (defaulting the absent `detail` key to
36
+ * `null`) instead of throwing. Unknown keys are ignored rather than
37
+ * hard-rejected, so the next additive field never regresses this again;
38
+ * neither `profile` nor `repairReason` is read anywhere downstream, so they
39
+ * are dropped harmlessly rather than round-tripped. A `metadataVersion` that
40
+ * IS present but not `2` is a genuinely unknown/future shape and still
41
+ * rejected, as is a non-number `durationMs` or a `detail` that fails
42
+ * {@link validateDetail} — real corruption, not version skew.
43
+ */
28
44
  export function decodeTaskHistoryMetadata(input) {
29
45
  let parsed = input;
30
46
  if (typeof input === "string") {
@@ -37,27 +53,23 @@ export function decodeTaskHistoryMetadata(input) {
37
53
  }
38
54
  if (!isRecord(parsed))
39
55
  metadataError("root must be an object");
40
- if (parsed.metadataVersion !== 2)
56
+ if (parsed.metadataVersion !== undefined && parsed.metadataVersion !== 2) {
41
57
  metadataError(`unsupported metadataVersion: ${String(parsed.metadataVersion)}`);
42
- const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine", "targetVocab"]);
43
- const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
44
- if (unknown.length > 0)
45
- metadataError(`unknown fields: ${unknown.sort().join(", ")}`);
58
+ }
46
59
  if (typeof parsed.durationMs !== "number")
47
60
  metadataError("durationMs must be a number");
48
- if (!("detail" in parsed))
49
- metadataError("detail is required");
61
+ const detail = "detail" in parsed ? parsed.detail : null;
50
62
  if (parsed.engine !== undefined && parsed.engine !== null && typeof parsed.engine !== "string") {
51
63
  metadataError("engine must be a string or null");
52
64
  }
53
65
  if (parsed.targetVocab !== undefined && parsed.targetVocab !== 2) {
54
66
  metadataError("targetVocab must be 2 when present");
55
67
  }
56
- validateDetail(parsed.detail);
68
+ validateDetail(detail);
57
69
  return {
58
70
  metadataVersion: 2,
59
71
  durationMs: parsed.durationMs,
60
- detail: parsed.detail ?? null,
72
+ detail: detail ?? null,
61
73
  ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
62
74
  ...(parsed.targetVocab === 2 ? { targetVocab: 2 } : {}),
63
75
  };
@@ -64,13 +64,40 @@ export function shellCommand(task, platform = process.platform, env = process.en
64
64
  return [task.shell, "-c", command];
65
65
  case "pwsh":
66
66
  case "powershell":
67
- return [shellExecutable(task.shell, platform, env), "-NoProfile", "-NonInteractive", "-Command", command];
67
+ return [
68
+ shellExecutable(task.shell, platform, env),
69
+ "-NoProfile",
70
+ "-NonInteractive",
71
+ "-Command",
72
+ withExitCodePropagation(command),
73
+ ];
68
74
  case "cmd":
69
75
  return [shellExecutable("cmd", platform, env), "/d", "/s", "/c", command];
70
76
  default:
71
77
  return assertNever(task.shell, "shellCommand");
72
78
  }
73
79
  }
80
+ /**
81
+ * `-Command` (documented identically for powershell.exe 5.1 and pwsh 7+, in
82
+ * about_PowerShell_exe / about_Pwsh) already derives its own process exit
83
+ * code from `$?`, so a genuinely failing last statement already yields a
84
+ * nonzero exit and status "failed" — but any native exit code outside {0, 1}
85
+ * is collapsed to 1, discarding the real value that task history reports.
86
+ *
87
+ * Appending a bare `exit $LASTEXITCODE` to recover it is unsafe on its own:
88
+ * `$LASTEXITCODE` stays `$null` for a command that never runs a native
89
+ * executable (a pure PowerShell/cmdlet command), and `exit $null` resolves
90
+ * to exit code 0 — turning a failed cmdlet into a false "completed".
91
+ *
92
+ * Reading `$?` first, before anything else can run, reproduces -Command's
93
+ * own completed/failed determination exactly (immune to that regression and
94
+ * to a `$LASTEXITCODE` left stale by an earlier native call in the same
95
+ * command), then upgrades to the precise native exit code only when the
96
+ * failing last statement actually set one.
97
+ */
98
+ function withExitCodePropagation(command) {
99
+ return `${command}; if ($?) { exit 0 } elseif ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE } else { exit 1 }`;
100
+ }
74
101
  /**
75
102
  * Bind an unambiguous leading bare `akm` (including the task-v2 migrator's
76
103
  * quoted form) to this installation. Explicit paths and arbitrary shell
@@ -79,14 +79,34 @@ export function readTaskHistory(options = {}) {
79
79
  // discarded and `akm task history --id X --limit 20` always returned one
80
80
  // run. The CLI documents --limit as "Maximum rows to return"; honour it.
81
81
  if (options.limit !== undefined && options.limit > 0) {
82
- return getTaskHistoryRuns(db, options.id, options.limit).map(taskHistoryRowToResult);
82
+ return decodeTaskHistoryRows(getTaskHistoryRuns(db, options.id, options.limit));
83
83
  }
84
84
  const row = getTaskHistory(db, options.id);
85
- return row ? [taskHistoryRowToResult(row)] : [];
85
+ return row ? decodeTaskHistoryRows([row]) : [];
86
86
  }
87
- return queryTaskHistory(db, options.limit !== undefined && options.limit > 0 ? { limit: options.limit } : {}).map(taskHistoryRowToResult);
87
+ return decodeTaskHistoryRows(queryTaskHistory(db, options.limit !== undefined && options.limit > 0 ? { limit: options.limit } : {}));
88
88
  });
89
89
  }
90
+ /**
91
+ * Per-row skip-and-warn (mirrors `listStateProposals` in
92
+ * proposals-repository.ts): a single genuinely-corrupt `metadata_json` row
93
+ * must not abort the entire history read. `decodeTaskHistoryMetadata` (via
94
+ * `taskHistoryRowToResult`) already tolerates legacy/additive shapes; only
95
+ * real corruption reaches this catch.
96
+ */
97
+ function decodeTaskHistoryRows(rows) {
98
+ const results = [];
99
+ for (const row of rows) {
100
+ try {
101
+ results.push(taskHistoryRowToResult(row));
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ console.warn(`[akm] Skipping unparseable task_history row (task_id=${row.task_id}, started_at=${row.started_at}): ${message}`);
106
+ }
107
+ }
108
+ return results;
109
+ }
90
110
  /**
91
111
  * Convert a `TaskHistoryRow` from state.db back to a `TaskRunResult` shape
92
112
  * that callers of `readTaskHistory()` expect.
@@ -0,0 +1,43 @@
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
+ * Project a set of scheduler sync operations into a preview report. Pure:
6
+ * no I/O, no backend calls. Accepts a bare operation array (not the whole
7
+ * `SchedulerSyncPlan`) so a future `task prune` can build a preview from
8
+ * its own remove-only operation set without needing a full sync plan.
9
+ */
10
+ export function renderSchedulerPlanPreview(backend, operations, unchanged = []) {
11
+ const adds = [];
12
+ const updates = [];
13
+ const removes = [];
14
+ for (const operation of operations) {
15
+ if (operation.kind === "remove") {
16
+ removes.push({
17
+ id: operation.id,
18
+ kind: "remove",
19
+ nativeId: operation.nativeId,
20
+ ...(operation.ownerBundlePath !== undefined ? { ownerBundlePath: operation.ownerBundlePath } : {}),
21
+ });
22
+ }
23
+ else if (operation.kind === "install") {
24
+ adds.push({ id: operation.binding.id, kind: "install" });
25
+ }
26
+ else {
27
+ updates.push({ id: operation.binding.id, kind: "update" });
28
+ }
29
+ }
30
+ return Object.freeze({
31
+ backend,
32
+ dryRun: true,
33
+ adds: Object.freeze(adds),
34
+ updates: Object.freeze(updates),
35
+ removes: Object.freeze(removes),
36
+ unchanged: Object.freeze([...unchanged]),
37
+ hasRemovals: removes.length > 0,
38
+ });
39
+ }
40
+ /** Convenience wrapper for the common case: previewing a whole {@link SchedulerSyncPlan}. */
41
+ export function renderSchedulerSyncPlanPreview(backend, plan) {
42
+ return renderSchedulerPlanPreview(backend, plan.operations, plan.unchanged);
43
+ }
@@ -135,6 +135,7 @@ export function finalizeSchedulerSyncPlan(input, prepared) {
135
135
  invocation: current.invocation,
136
136
  fingerprint: priorFingerprint,
137
137
  }),
138
+ ...(current.ownerBundlePath !== undefined ? { ownerBundlePath: current.ownerBundlePath } : {}),
138
139
  }));
139
140
  }
140
141
  return Object.freeze({
@@ -392,7 +392,7 @@ export function assertBoundedTaskYamlDocument(document, options) {
392
392
  * P4 (docs/plans/specs/p4-deletions-closeout.md §3.2) deleted task v3
393
393
  * acceptance from `src` entirely — `parseTaskV3Yaml` and its `"task v3
394
394
  * source"` label no longer exist here (the grammar survives only in the
395
- * vendored, frozen `scripts/akm-migrate/migrate/task-source-v3-frozen.ts`
395
+ * vendored, frozen `src/tasks/source/task-source-v3-frozen.ts`
396
396
  * copy, which does not call this function). The two live `src` callers
397
397
  * today: `parseTaskSourceV4`'s standalone YAML-string entry
398
398
  * (`task-source-v4.ts:790`) passes `sourceLabel: "task source v4"`; the
@@ -7,14 +7,17 @@
7
7
  *
8
8
  * Runs the bounded YAML front end ONCE (`readBoundedTaskSourceYaml`), reads
9
9
  * `root.version`, and either dispatches into `parseTaskSourceV4Document` or
10
- * fails closed — no second parse, no re-serialization, no synthetic document
11
- * (the P1b §4.3 invariant this phase carries forward).
10
+ * routes through the in-memory read shim below — no second parse of the v4
11
+ * grammar itself, no re-serialization back to disk, no synthetic document
12
+ * (the P1b §4.3 invariant this phase carries forward: the shim adds a pure
13
+ * bytes-in/bytes-out detour, never a disk write).
12
14
  *
13
15
  * The terminal routing table:
14
16
  *
15
17
  * | root `version` | outcome |
16
18
  * |------------------------|-----------------------------------------------------------------|
17
19
  * | `4` | `parseTaskSourceV4Document` — the new grammar (row B-13) |
20
+ * | `2` or `3` | in-memory read shim (below): the SAME pure planners `akm migrate apply` uses (`./task-to-v3.ts`, `./task-to-v4.ts`) convert the bytes already in hand to v4 in memory; the result is parsed and returned with a one-line stderr deprecation warning. If the deterministic conversion itself fails (an unmigratable shape), falls back to `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming the migrator (B-14/B-15) — the shim removes friction for the deterministic case, it never hides a real problem |
18
21
  * | any other number | `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming the migrator (B-14/B-15) |
19
22
  * | absent / not a number | `parseTaskSourceV4Document` — its own `TASK_SOURCE_INVALID` "version is required and must be 4" / "must be exactly 4" wording (row B-16) |
20
23
  *
@@ -24,17 +27,28 @@
24
27
  * message that would send the user to the migrator for a document that was
25
28
  * never task v2 or v3 in the first place.
26
29
  *
27
- * task v2 and task v3 sources are no longer read by `src` at all — the only
28
- * surviving reader is the frozen, vendored copy in
29
- * `scripts/akm-migrate/migrate/task-source-v3-frozen.ts`, reachable only
30
- * through `akm migrate apply` / `akm-migrate`. The front end's own
31
- * pre-version failures (source not a string, source too large, YAML
32
- * parse/warning/expansion) render with the label `task source` (row B-17,
33
- * closing the "task v3 source" label wart P2a's §3.4 recorded).
30
+ * task v2 and task v3 sources are no longer read as their own standing
31
+ * grammar anywhere else in `src` — the only readers of that grammar are the
32
+ * pure, byte-producing planners (`./task-to-v3.ts`, `./task-to-v4.ts`, and
33
+ * the frozen v3 reader `./task-source-v3-frozen.ts`), reached either through
34
+ * this shim (bytes in, bytes out, never touches disk) or through
35
+ * `akm migrate apply` / `akm-migrate` (`scripts/akm-migrate`, which
36
+ * additionally rewrites the file on disk once the user asks for that).
37
+ * Policy: a deterministic byte transform is the tool's job, not the user's —
38
+ * upgrading past a schema bump must not silently break a scheduled task, so
39
+ * v2/v3 files keep reading successfully at the cost of a one-line
40
+ * deprecation warning, and `akm migrate apply` remains available to rewrite
41
+ * the file and silence it. The front end's own pre-version failures (source
42
+ * not a string, source too large, YAML parse/warning/expansion) render with
43
+ * the label `task source` (row B-17, closing the "task v3 source" label
44
+ * wart P2a's §3.4 recorded).
34
45
  */
35
46
  import { UsageError } from "../../core/errors.js";
47
+ import { warn } from "../../core/warn.js";
36
48
  import { readBoundedTaskSourceYaml } from "./bounded-document.js";
37
- import { parseTaskSourceV4Document, TASK_SOURCE_V4_VERSION } from "./task-source-v4.js";
49
+ import { parseTaskSourceV4, parseTaskSourceV4Document, TASK_SOURCE_V4_VERSION, } from "./task-source-v4.js";
50
+ import { planTaskToV3File } from "./task-to-v3.js";
51
+ import { planTaskToV4File } from "./task-to-v4.js";
38
52
  /** Read the root `version` field without over-accepting non-number values (e.g. the string `"4"`). */
39
53
  export function peekTaskSourceVersion(root) {
40
54
  if (root === null || typeof root !== "object" || Array.isArray(root))
@@ -43,12 +57,64 @@ export function peekTaskSourceVersion(root) {
43
57
  return typeof value === "number" ? value : undefined;
44
58
  }
45
59
  const TASK_MIGRATE_HINT = "Run `akm migrate apply --dry-run` to preview the task-v3 to task-source-v4 conversion, then run `akm migrate apply`.";
60
+ function unsupportedVersionError(filePath, version) {
61
+ return new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
62
+ }
63
+ /**
64
+ * Plan the SAME bytes already in hand through the pure v3->v4 (and, for v2,
65
+ * chained v2->v3->v4) migration planner(s) — never touches disk, never
66
+ * writes the file, never re-reads it from disk. Returns the produced v4
67
+ * YAML text, or `undefined` when the deterministic conversion cannot
68
+ * proceed (an unmigratable v2/v3 shape) — the caller falls back to the same
69
+ * hard error this gate threw before the shim existed.
70
+ */
71
+ function planInMemoryV4Bytes(version, yaml, filePath, workspaceRoot) {
72
+ const bytes = Buffer.from(yaml, "utf8");
73
+ // `writable`/`onDiskWritable` gate the DISK apply path's "don't touch a
74
+ // read-only file" check inside the planners; this shim never writes
75
+ // anything to disk, so that check does not apply here and must not block
76
+ // an otherwise-legal read of a task file that happens to be read-only.
77
+ const baseInput = {
78
+ filePath,
79
+ bytes,
80
+ mode: 0o644,
81
+ writable: true,
82
+ onDiskWritable: true,
83
+ ...(workspaceRoot ? { containmentRoot: workspaceRoot } : {}),
84
+ };
85
+ let v3Bytes;
86
+ if (version === 3) {
87
+ v3Bytes = bytes;
88
+ }
89
+ else {
90
+ const v3Outcome = planTaskToV3File(baseInput);
91
+ if (v3Outcome.status !== "changed")
92
+ return undefined;
93
+ v3Bytes = v3Outcome.after;
94
+ }
95
+ const v4Outcome = planTaskToV4File({ ...baseInput, bytes: v3Bytes });
96
+ if (v4Outcome.status !== "changed")
97
+ return undefined;
98
+ return v4Outcome.after.toString("utf8");
99
+ }
46
100
  /** Parse task source YAML, routing per the terminal table above. */
47
101
  export function parseTaskSource(input) {
48
102
  const { root, lineAt } = readBoundedTaskSourceYaml(input, { sourceLabel: "task source" });
49
103
  const version = peekTaskSourceVersion(root);
50
104
  if (version !== undefined && version !== TASK_SOURCE_V4_VERSION) {
51
- throw new UsageError(`TASK_SCHEMA_VERSION_UNSUPPORTED: Task at ${input.filePath} uses task schema version ${version}, which this release does not accept.`, "TASK_SCHEMA_VERSION_UNSUPPORTED", TASK_MIGRATE_HINT);
105
+ if (version === 2 || version === 3) {
106
+ const v4Yaml = planInMemoryV4Bytes(version, input.yaml, input.filePath, input.workspaceRoot);
107
+ if (v4Yaml !== undefined) {
108
+ const v4 = parseTaskSourceV4({
109
+ yaml: v4Yaml,
110
+ filePath: input.filePath,
111
+ ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
112
+ });
113
+ warn(`akm: task ${input.filePath} uses schema v${version} — auto-read as v4; run \`akm migrate apply\` to rewrite it and silence this`);
114
+ return Object.freeze({ version: 4, v4 });
115
+ }
116
+ }
117
+ throw unsupportedVersionError(input.filePath, version);
52
118
  }
53
119
  const documentOptions = {
54
120
  filePath: input.filePath,