@canonical/pragma-cli 0.36.0 → 0.37.0

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 (107) hide show
  1. package/dist/package.json +22 -19
  2. package/dist/pragma.conf.js +35 -5
  3. package/dist/pragma.conf.js.map +1 -1
  4. package/dist/src/capabilities/capabilities/hints.js +1 -1
  5. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  6. package/dist/src/capabilities/create/create.verb.js +2 -1
  7. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  8. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  9. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  10. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  11. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  12. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  13. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  14. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  15. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  16. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  17. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  18. package/dist/src/capabilities/setup/operations/setupGenerator.js +64 -13
  19. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  20. package/dist/src/capabilities/setup/operations/setupLsp.js +59 -13
  21. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  22. package/dist/src/capabilities/setup/operations/setupMcp.js +4 -1
  23. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  24. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  25. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  26. package/dist/src/capabilities/setup/setup.verb.js +79 -30
  27. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  28. package/dist/src/capabilities/setup/targets.js +12 -6
  29. package/dist/src/capabilities/setup/targets.js.map +1 -1
  30. package/dist/src/capabilities/shared/index.js +1 -1
  31. package/dist/src/capabilities/shared/index.js.map +1 -1
  32. package/dist/src/constants.js +7 -1
  33. package/dist/src/constants.js.map +1 -1
  34. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +11 -7
  35. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  36. package/dist/src/kernel/project/cli/dispatch.js +23 -1
  37. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  38. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  39. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  40. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  41. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  42. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  43. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  44. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  45. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  46. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  47. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  48. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  49. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  50. package/dist/src/kernel/runtime/loadSession.js +3 -1
  51. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  52. package/dist/src/kernel/runtime/probe.js +1 -1
  53. package/dist/src/kernel/runtime/probe.js.map +1 -1
  54. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  55. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  56. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  57. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  58. package/package.json +22 -19
  59. package/pragma.conf.ts +36 -5
  60. package/src/capabilities/capabilities/hints.ts +1 -1
  61. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  62. package/src/capabilities/create/byteEquality.test.ts +28 -1
  63. package/src/capabilities/create/create.test.ts +1 -0
  64. package/src/capabilities/create/create.verb.ts +2 -1
  65. package/src/capabilities/create/createGrammar.test.ts +4 -1
  66. package/src/capabilities/create/createSurface.generated.ts +23 -1
  67. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  68. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  69. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  70. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  71. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  72. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  73. package/src/capabilities/lazy.test.ts +26 -2
  74. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  75. package/src/capabilities/resources/resources.test.ts +38 -2
  76. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  77. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  78. package/src/capabilities/setup/operations/setupGenerator.ts +83 -14
  79. package/src/capabilities/setup/operations/setupLsp.ts +95 -22
  80. package/src/capabilities/setup/operations/setupMcp.ts +7 -1
  81. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  82. package/src/capabilities/setup/setup.test.ts +247 -2
  83. package/src/capabilities/setup/setup.verb.ts +83 -35
  84. package/src/capabilities/setup/targets.ts +18 -8
  85. package/src/capabilities/shared/index.ts +1 -0
  86. package/src/capabilities/surface.test.ts +12 -4
  87. package/src/constants.ts +8 -0
  88. package/src/kernel/config/defaults.test.ts +17 -2
  89. package/src/kernel/config/readConfig.test.ts +1 -1
  90. package/src/kernel/packs/sparql/buildLookupQuery.ts +11 -7
  91. package/src/kernel/project/cli/dispatch.ts +23 -1
  92. package/src/kernel/project/cli/rootHelp.ts +8 -5
  93. package/src/kernel/render/prefixes.test.ts +2 -0
  94. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  95. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  96. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  97. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  98. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  99. package/src/kernel/runtime/graphpack/read.ts +14 -12
  100. package/src/kernel/runtime/loadSession.ts +3 -1
  101. package/src/kernel/runtime/probe.ts +1 -1
  102. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  103. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  104. package/src/kernel/runtime/refs/resolve.ts +9 -3
  105. package/src/kernel/runtime/types.ts +17 -0
  106. package/src/testing/helpers/runCli.test.ts +44 -0
  107. package/src/testing/helpers/runCli.ts +27 -3
@@ -61,8 +61,31 @@ const EVERYTHING: McpListable = { sources: [{ as: "entities" }] };
61
61
  * only. Payload SIZE is the property that actually regressed (712 entries /
62
62
  * ~155 KB on every connect), and it is the one an agent pays for twice: once in
63
63
  * transfer, once in the context window it can no longer spend on the task.
64
+ *
65
+ * **Raised from 60,000 to 100,000 on 2026-09-01**, when the
66
+ * `@canonical/token-ontology` pack took the payload to 65,119 bytes. The
67
+ * listing was kept whole rather than trimmed to fit: the per-class counts are
68
+ * how an agent gets the shape of the graph before spending a single lookup,
69
+ * and that is worth more than the bytes.
70
+ *
71
+ * **Cost scales with the number of T-Box terms and their metadata, not with
72
+ * the number of packs.** The `ontology` noun emits one resource per matching
73
+ * T-Box entity (`provider.ts`), so a pack costs what its own vocabulary
74
+ * costs — this one happened to add ~23 KB, which is a fact about this
75
+ * ontology and not a rate. The headroom here does not convert into "one more
76
+ * pack" of any size.
77
+ *
78
+ * It is also the only listing that grows this way: every other noun lists
79
+ * collections with their counts, so those grow with the number of nouns. When
80
+ * this ceiling is next reached, the fix is to scope the T-Box slice or
81
+ * paginate it — not to raise the number again, which would turn a gate into a
82
+ * formality.
83
+ *
84
+ * It is deliberately not removed. The regression it was written for is still
85
+ * worth catching, and a ceiling with headroom catches it while a deleted one
86
+ * catches nothing.
64
87
  */
65
- const LISTING_BUDGET_BYTES = 60_000;
88
+ const LISTING_BUDGET_BYTES = 100_000;
66
89
 
67
90
  describe("resource listing (storeless, over the pack index)", () => {
68
91
  it("degrades to a recovery entry on a missing or legacy index", () => {
@@ -322,7 +345,20 @@ describe("resource surface over the server (embedded pack)", () => {
322
345
  let harness: Awaited<ReturnType<typeof projectMcp>>;
323
346
  beforeAll(async () => {
324
347
  harness = await projectMcp(capabilities);
325
- });
348
+ // Boot the store HERE, not in whichever test happens to read first.
349
+ //
350
+ // `createLazyStore` memoises the boot PROMISE, and vitest's per-test
351
+ // timeout aborts the test without aborting the work — so a cold boot that
352
+ // overruns is charged partly to the test that triggered it and partly to
353
+ // the next one, which merely awaits the same promise. That reads as a
354
+ // sudden per-read regression and is nothing of the kind; it cost a real
355
+ // investigation to find that out.
356
+ //
357
+ // Paying it in the hook attributes it where it belongs, keeps every `it`
358
+ // in this file measuring what it claims to, and bounds it by hookTimeout
359
+ // rather than by a 5 s default that pack growth will keep outgrowing.
360
+ await harness.readResource(BUTTON_URI);
361
+ }, 60_000);
326
362
  afterAll(async () => {
327
363
  await harness.cleanup();
328
364
  });
@@ -204,11 +204,18 @@ export function composeCompletions(d: CompletionsDetection): Task<void> {
204
204
  * machine that never installed one reverses zero steps and says so.
205
205
  *
206
206
  * @param d - The detection gathered up front.
207
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
208
+ * their undo outcomes so the caller can read results back per row.
207
209
  * @returns A Task whose undo deletes the installed script.
208
210
  */
209
- export function composeCompletionsRemoval(d: CompletionsDetection): Task<void> {
211
+ export function composeCompletionsRemoval(
212
+ d: CompletionsDetection,
213
+ undoKey?: string,
214
+ ): Task<void> {
210
215
  if (d.path === null || d.script === null || d.state === "absent") {
211
216
  return sequence_([]);
212
217
  }
213
- return sequence_([writeFile(d.path, d.script, { undo: deleteFile(d.path) })]);
218
+ return sequence_([
219
+ writeFile(d.path, d.script, { undo: deleteFile(d.path), undoKey }),
220
+ ]);
214
221
  }
@@ -110,12 +110,17 @@ export function composeConfigFile(d: ConfigDetection): Task<void> {
110
110
  * delete as its `undo`, which is what the undo interpreter executes.
111
111
  *
112
112
  * @param d - The detection gathered up front.
113
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
114
+ * their undo outcomes so the caller can read results back per row.
113
115
  * @returns A Task whose undo removes a seed-only config, or an empty Task.
114
116
  */
115
- export function composeConfigRemoval(d: ConfigDetection): Task<void> {
117
+ export function composeConfigRemoval(
118
+ d: ConfigDetection,
119
+ undoKey?: string,
120
+ ): Task<void> {
116
121
  if (!d.exists || !d.isSeed) return sequence_([]);
117
122
  return sequence_([
118
- writeFile(d.path, SEED_CONFIG, { undo: deleteFile(d.path) }),
123
+ writeFile(d.path, SEED_CONFIG, { undo: deleteFile(d.path), undoKey }),
119
124
  ]);
120
125
  }
121
126
 
@@ -34,6 +34,7 @@ import {
34
34
  type TaskError,
35
35
  warn,
36
36
  } from "@canonical/task";
37
+ import type { UndoOutcome } from "@canonical/task/node";
37
38
  import { BIN_NAME } from "../../../constants.js";
38
39
  import { PragmaError } from "../../../kernel/error/index.js";
39
40
  import type { PragmaRuntime } from "../../../kernel/runtime/index.js";
@@ -103,6 +104,20 @@ export interface SetupRun {
103
104
  readonly generator: GeneratorDefinition;
104
105
  /** The plan with the wizard's selection and the run's outcomes applied. */
105
106
  applied(answers: Record<string, unknown>): SetupPlan;
107
+ /**
108
+ * The plan graded by the EXECUTED reversals — `--undo`'s own `applied`.
109
+ *
110
+ * A real `--undo` never interprets the composed task for real: the undo
111
+ * interpreter walks it with every effect mocked (to collect the
112
+ * reversals), so the {@link OutcomeSink} the forward run fills stays
113
+ * empty, and only the interpreter's per-undo outcomes — correlated by the
114
+ * `undoKey` each reversal was stamped with in `generate` — say what
115
+ * actually happened. This projection writes those outcomes onto the SAME
116
+ * sink and reads the plan back through the SAME `outcomeFor` the forward
117
+ * run uses: one outcome model, both directions. An actionable row none of
118
+ * whose reversals reported back is a failure, never a quiet `removed`.
119
+ */
120
+ appliedUndo(outcomes: readonly UndoOutcome[]): SetupPlan;
106
121
  /**
107
122
  * Register THE live row listener (the Ink wizard's step adapter). The
108
123
  * composed rows emit through it while they are interpreted — which means
@@ -463,8 +478,15 @@ export async function buildSetupRun(
463
478
  // MCP file paths to every other target's compose.
464
479
  const key = CHILD_ANSWER[row.target];
465
480
  const children = key === undefined ? undefined : readList(answers, key);
481
+ // A removal stamps the row's identity on every reversal it composes:
482
+ // `--undo` executes those reversals in a LATER phase (the undo
483
+ // interpreter's), whose outcomes come back correlated by this key —
484
+ // see `appliedUndo`.
466
485
  const task = removal
467
- ? hit.target.composeRemoval(hit.detection)
486
+ ? hit.target.composeRemoval(
487
+ hit.detection,
488
+ rowKey(row.scope, row.target),
489
+ )
468
490
  : hit.target.compose(hit.detection, children);
469
491
  // The row's progress sentence — the `target detail — note` shape
470
492
  // `renderProgressLine` prints — so the live view and the recap say the
@@ -509,7 +531,17 @@ export async function buildSetupRun(
509
531
  // outcome at all left a converged re-run with a plan-shaped output that
510
532
  // claimed nothing had run — while the row still rendered a bare green ✓.
511
533
  // Convergence is a real result: one quiet line per row, zero writes.
512
- if (row.action === "none") return { status: "noop", note: "unchanged" };
534
+ //
535
+ // On a REMOVAL the same state is `kept`, not `noop`: the run owned
536
+ // nothing here and deliberately left the machine's state standing (the
537
+ // config the user edited, the editor with no extension copy). That is
538
+ // standing aside, which the vocabulary marks inert (○), where `noop`'s
539
+ // green ✓ would claim removal work that never existed.
540
+ if (row.action === "none") {
541
+ return removal
542
+ ? { status: "kept" }
543
+ : { status: "noop", note: "unchanged" };
544
+ }
513
545
  if (!isChosen(row, chosen)) return undefined;
514
546
  const error = sink.get(rowKey(row.scope, row.target));
515
547
  if (error !== undefined) {
@@ -530,25 +562,62 @@ export async function buildSetupRun(
530
562
  };
531
563
  };
532
564
 
565
+ /** The one answers→plan projection both `applied` faces share. */
566
+ const project = (answers: Record<string, unknown>): SetupPlan => {
567
+ const chosen = readList(answers, ROWS_ANSWER);
568
+ return withRows(
569
+ plan,
570
+ plan.rows.map((row): PlanRow => {
571
+ const outcome = outcomeFor(row, chosen, answers);
572
+ return {
573
+ ...row,
574
+ selected: isChosen(row, chosen),
575
+ ...(outcome === undefined ? {} : { outcome }),
576
+ };
577
+ }),
578
+ );
579
+ };
580
+
533
581
  return {
534
582
  plan,
535
583
  generator,
536
584
  setRowListener: (listener) => {
537
585
  rowListener = listener;
538
586
  },
539
- applied: (answers) => {
540
- const chosen = readList(answers, ROWS_ANSWER);
541
- return withRows(
542
- plan,
543
- plan.rows.map((row): PlanRow => {
544
- const outcome = outcomeFor(row, chosen, answers);
545
- return {
546
- ...row,
547
- selected: isChosen(row, chosen),
548
- ...(outcome === undefined ? {} : { outcome }),
549
- };
550
- }),
587
+ applied: project,
588
+ appliedUndo: (outcomes) => {
589
+ // The undo path's sink fill. The forward run's recover frames write the
590
+ // sink WHILE the task is interpreted; a real `--undo` interprets the
591
+ // task only as the mocked collection walk, so the executed reversals'
592
+ // outcomes land here instead — same sink, same reader (`outcomeFor`),
593
+ // other direction. The first failure per row wins: it is the earliest
594
+ // cause, and the row's note should name it, not the knock-on.
595
+ sink.clear();
596
+ for (const outcome of outcomes) {
597
+ if (outcome.status !== "failed" || outcome.key === undefined) continue;
598
+ if (sink.get(outcome.key) !== undefined) continue;
599
+ sink.record(outcome.key, outcome.error);
600
+ }
601
+ // A row that owed reversals and heard back from NONE of them cannot
602
+ // report `removed` — nothing proved anything happened. Structurally
603
+ // every actionable removal row composes at least one keyed reversal,
604
+ // so this is a guard against a future target breaking that invariant,
605
+ // not a path any current target takes.
606
+ const reported = new Set(
607
+ outcomes.flatMap((o) => (o.key === undefined ? [] : [o.key])),
551
608
  );
609
+ for (const row of plan.rows) {
610
+ const id = rowKey(row.scope, row.target);
611
+ if (!isActionable(row.action) || !row.selected) continue;
612
+ if (reported.has(id) || sink.get(id) !== undefined) continue;
613
+ sink.record(id, {
614
+ code: "UNSUPPORTED",
615
+ message: "no reversal was executed for this target",
616
+ });
617
+ }
618
+ // No answers: `--undo` asks nothing, so every row falls back to its
619
+ // own default selection — exactly the set `generate` composed from.
620
+ return project({});
552
621
  },
553
622
  };
554
623
  }
@@ -36,13 +36,21 @@ import type { EditorCliDefinition, PlatformEnv } from "@canonical/harnesses";
36
36
  import {
37
37
  type ExecResult,
38
38
  exec,
39
+ exists,
39
40
  flatMap,
40
41
  mkdir,
42
+ pure,
43
+ sequence,
41
44
  sequence_,
42
45
  type Task,
43
46
  } from "@canonical/task";
44
47
  import { BIN_NAME } from "../../../constants.js";
45
- import { checkExecOk, guardMissingBinary } from "../../shared/index.js";
48
+ import { PragmaError } from "../../../kernel/error/index.js";
49
+ import {
50
+ checkExecOk,
51
+ failPragma,
52
+ guardMissingBinary,
53
+ } from "../../shared/index.js";
46
54
  import type { LspState } from "../types.js";
47
55
 
48
56
  /** The full extension id, as the editor's extensions dir spells it. */
@@ -76,6 +84,15 @@ export interface DetectedEditor {
76
84
  * {@link composeLspRemoval}); when `present` is true, this is that path.
77
85
  */
78
86
  readonly extensionsDir: string;
87
+ /**
88
+ * The matching entry NAMES under {@link extensionsDir} — the artifacts
89
+ * `present` is true about. The removal's postcondition probes exactly
90
+ * these after the uninstall (see {@link composeLspRemoval}): the editor
91
+ * CLI's exit code and the directory listing are DIFFERENT success
92
+ * criteria, and only the directory is the one detection (and doctor)
93
+ * believe.
94
+ */
95
+ readonly presentEntries: readonly string[];
79
96
  }
80
97
 
81
98
  /**
@@ -187,12 +204,13 @@ const compareVersions = (left: number[], right: number[]): number => {
187
204
  const extensionState = (
188
205
  editor: EditorCliDefinition,
189
206
  platform: PlatformEnv,
190
- ): { present: boolean; current: boolean } => {
207
+ ): { present: boolean; current: boolean; entries: string[] } => {
191
208
  let entries: string[];
192
209
  try {
193
210
  entries = readdirSync(editor.extensionsDir(platform));
194
211
  } catch {
195
- return { present: false, current: false }; // No dir — nothing installed.
212
+ // No dir — nothing installed.
213
+ return { present: false, current: false, entries: [] };
196
214
  }
197
215
  const prefix = `${EXTENSION_ID}-`;
198
216
  const matching = entries.filter((entry) =>
@@ -203,7 +221,7 @@ const extensionState = (
203
221
  if (version === undefined) return false; // Unreadable: cannot vouch for it.
204
222
  return compareVersions(version, MINIMUM) >= 0;
205
223
  });
206
- return { present: matching.length > 0, current };
224
+ return { present: matching.length > 0, current, entries: matching };
207
225
  };
208
226
 
209
227
  /**
@@ -221,12 +239,13 @@ export async function detectLsp(_cwd: string): Promise<LspDetection> {
221
239
  const editors: DetectedEditor[] = editorClis
222
240
  .filter((editor) => cliOnPath(executableCandidates(editor.cli, platform)))
223
241
  .map((editor) => {
224
- const { present, current } = extensionState(editor, platform);
242
+ const { present, current, entries } = extensionState(editor, platform);
225
243
  return {
226
244
  editor,
227
245
  present,
228
246
  installed: current,
229
247
  extensionsDir: editor.extensionsDir(platform),
248
+ presentEntries: entries,
230
249
  };
231
250
  });
232
251
  const state: LspState =
@@ -377,7 +396,8 @@ export const ownedLspEditors = (d: LspDetection): readonly DetectedEditor[] =>
377
396
 
378
397
  /**
379
398
  * Compose the removal: one `<editor cli> --uninstall-extension <id>` per owned
380
- * editor, carried as the `undo` of a forward no-op.
399
+ * editor, carried as the `undo` of a forward no-op — and each uninstall
400
+ * asserts its own GOAL, not just its exit code.
381
401
  *
382
402
  * The docblock this replaces claimed "an `exec` carries no reversal". That was
383
403
  * factually wrong about the effect model — `Exec` has an `undo` slot and `exec`
@@ -391,37 +411,90 @@ export const ownedLspEditors = (d: LspDetection): readonly DetectedEditor[] =>
391
411
  * `present` proves already exists — reads nothing and is a genuine no-op. Phase
392
412
  * two then runs the uninstall against the real editor.
393
413
  *
414
+ * THE POSTCONDITION is the removal's real success criterion. The exec's exit
415
+ * code and the extensions DIRECTORY are two different judges — detection
416
+ * deliberately reads the directory (probing via the CLI can launch an
417
+ * editor), and VS Code-family CLIs are known to exit 0 while deferring or
418
+ * skipping the deletion — so after `checkExecOk` the undo probes the exact
419
+ * entries detection said this command owns, and FAILS (with the manual
420
+ * deletion as the remedy — never the command that just claimed success) when
421
+ * any survived. An in-task failure here is safe precisely because
422
+ * `runCollectedUndos` executes each undo isolated: it used to abort every
423
+ * reversal still pending, which is why no removal could afford to assert its
424
+ * own goal.
425
+ *
394
426
  * @param d - The detection gathered up front.
395
- * @returns A Task whose undo uninstalls the extension from each owned editor.
427
+ * @param undoKey - Correlation key stamped on each reversal, echoed on its
428
+ * `UndoOutcome` so the caller can read the outcomes back per row.
429
+ * @returns A Task whose undo uninstalls the extension from each owned editor
430
+ * and verifies the copy is gone.
396
431
  */
397
- export function composeLspRemoval(d: LspDetection): Task<void> {
432
+ export function composeLspRemoval(
433
+ d: LspDetection,
434
+ undoKey?: string,
435
+ ): Task<void> {
398
436
  const owned = ownedLspEditors(d);
399
437
  // Nothing on this machine carries a copy — there is no honest work to model,
400
438
  // and the plan row says so. Same shape as the other rows' empty removals.
401
439
  if (owned.length === 0) return sequence_([]);
402
440
  return sequence_(
403
- owned.map(({ editor, extensionsDir }) => {
441
+ owned.map(({ editor, extensionsDir, presentEntries }) => {
404
442
  const command = `${editor.cli} --uninstall-extension ${EXTENSION_ID}`;
443
+ const ownedPaths = presentEntries.map((entry) =>
444
+ join(extensionsDir, entry),
445
+ );
446
+ const uninstall = flatMap(
447
+ exec(
448
+ editor.cli,
449
+ ["--uninstall-extension", EXTENSION_ID],
450
+ extensionsDir,
451
+ ),
452
+ (result) =>
453
+ checkExecOk(command, result as ExecResult, {
454
+ message:
455
+ `${editor.name} refused the uninstall (its output is above). ` +
456
+ `Remove it by hand with \`${command}\`.`,
457
+ }),
458
+ );
459
+ // The probe must apply DETECTION'S presence criterion, or the check can
460
+ // quietly stop checking: `extensionState` calls an entry present when
461
+ // `readdirSync` lists it, and a default `exists` (`fs.access`) follows
462
+ // symlinks — so a dangling matching symlink would be "installed" to
463
+ // detection and "absent" here, and an editor that exits 0 without
464
+ // removing it would report `undone` while the next detection finds it
465
+ // again. `followSymlinks: false` is `lstat` semantics: the entry
466
+ // itself, exactly what the directory listing answers.
467
+ const verified = flatMap(uninstall, () =>
468
+ flatMap(
469
+ sequence(ownedPaths.map((p) => exists(p, { followSymlinks: false }))),
470
+ (stillThere) => {
471
+ const survivors = ownedPaths.filter((_, i) => stillThere[i]);
472
+ if (survivors.length === 0) return pure(undefined);
473
+ // The remedy presents the surviving paths as DATA, never as a
474
+ // synthesized shell line: the names come from `readdirSync`, so
475
+ // no quoting is trustworthy, and one command cannot be right for
476
+ // every platform the editor registry supports.
477
+ return failPragma(
478
+ new PragmaError({
479
+ code: "UNSUPPORTED",
480
+ message: `\`${command}\` exited 0, but the extension is still present after the undo ran.`,
481
+ recovery: {
482
+ message: `Delete ${survivors.length === 1 ? "this entry" : "these entries"} from ${editor.name}'s extensions folder by hand, then restart it: ${survivors.join(" · ")}`,
483
+ },
484
+ }),
485
+ );
486
+ },
487
+ ),
488
+ );
405
489
  return mkdir(extensionsDir, true, {
406
490
  undo: guardMissingBinary(
407
491
  editor.cli,
408
492
  {
409
493
  message: `The \`${editor.cli}\` CLI disappeared from PATH mid-run — restore it, then run \`${BIN_NAME} setup lsp --undo\` again.`,
410
494
  },
411
- flatMap(
412
- exec(
413
- editor.cli,
414
- ["--uninstall-extension", EXTENSION_ID],
415
- extensionsDir,
416
- ),
417
- (result) =>
418
- checkExecOk(command, result as ExecResult, {
419
- message:
420
- `${editor.name} refused the uninstall (its output is above). ` +
421
- `Remove it by hand with \`${command}\`.`,
422
- }),
423
- ),
495
+ verified,
424
496
  ),
497
+ undoKey,
425
498
  });
426
499
  }),
427
500
  );
@@ -356,9 +356,14 @@ export const ownedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
356
356
  * same file is never touched.
357
357
  *
358
358
  * @param d - The detection gathered up front.
359
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
360
+ * their undo outcomes so the caller can read results back per row.
359
361
  * @returns A Task whose undo removes the pragma entry from each owned file.
360
362
  */
361
- export function composeMcpRemoval(d: McpDetection): Task<void> {
363
+ export function composeMcpRemoval(
364
+ d: McpDetection,
365
+ undoKey?: string,
366
+ ): Task<void> {
362
367
  return sequence_(
363
368
  ownedMcpGroups(d).map((group) =>
364
369
  mkdir(dirname(group.path), true, {
@@ -367,6 +372,7 @@ export function composeMcpRemoval(d: McpDetection): Task<void> {
367
372
  d.removeMcpConfigFrom(write, MCP_SERVER_NAME),
368
373
  ),
369
374
  ),
375
+ undoKey,
370
376
  }),
371
377
  ),
372
378
  );
@@ -754,14 +754,19 @@ export const ownedSkillLinks = (
754
754
  * this command owns, never from what a fresh forward plan would create.
755
755
  *
756
756
  * @param d - The detection gathered up front.
757
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
758
+ * their undo outcomes so the caller can read results back per row.
757
759
  * @returns A Task whose undo removes every owned link.
758
760
  */
759
- export function composeSkillsRemoval(d: SkillsDetection): Task<void> {
761
+ export function composeSkillsRemoval(
762
+ d: SkillsDetection,
763
+ undoKey?: string,
764
+ ): Task<void> {
760
765
  const owned = ownedSkillLinks(d);
761
766
  if (owned.length === 0) return sequence_([]);
762
767
  return sequence_(
763
768
  owned.map((a) =>
764
- symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath) }),
769
+ symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath), undoKey }),
765
770
  ),
766
771
  );
767
772
  }