@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
@@ -101,6 +101,14 @@ const stubPath = (): string => {
101
101
  return dir;
102
102
  };
103
103
 
104
+ /**
105
+ * The machine's real PATH, captured before the suite isolates it. A stub
106
+ * editor script that has to run a real utility (`rm`) restores this INSIDE
107
+ * itself — hardcoding `/bin:/usr/bin` breaks on hosts that keep coreutils
108
+ * elsewhere, and the isolated PATH the tests run under has no `rm`.
109
+ */
110
+ const HOST_PATH = process.env.PATH ?? "";
111
+
104
112
  let prevHome: string | undefined;
105
113
  let prevPath: string | undefined;
106
114
  beforeEach(() => {
@@ -835,12 +843,16 @@ describe("setup lsp — the removal contract the other four rows implement", ()
835
843
  it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
836
844
  // The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
837
845
  // marker-writing script — never a real editor: an undo test must not be
838
- // able to mutate the machine running it.
846
+ // able to mutate the machine running it. It DOES delete the seeded
847
+ // extension entry (inside this test's temp HOME), because the removal now
848
+ // asserts its own goal against the extensions directory: a stub that
849
+ // exits 0 leaving the entry behind is the LYING-editor case, pinned as a
850
+ // failure below.
839
851
  const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
840
852
  const dir = stubPath();
841
853
  writeFileSync(
842
854
  join(dir, "code"),
843
- `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
855
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
844
856
  { mode: 0o755 },
845
857
  );
846
858
  process.env.PATH = dir;
@@ -872,6 +884,205 @@ describe("setup lsp — the removal contract the other four rows implement", ()
872
884
  expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
873
885
  expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
874
886
  });
887
+
888
+ /** Drive `setup lsp --undo` capturing stderr (where the row lines land). */
889
+ const undoLspCapturing = async (): Promise<{
890
+ chunks: string[];
891
+ outcome?: { stdout?: string; stderr?: string; exitCode: number };
892
+ thrown?: unknown;
893
+ }> => {
894
+ const chunks: string[] = [];
895
+ const spy = vi
896
+ .spyOn(process.stderr, "write")
897
+ .mockImplementation((chunk: unknown): boolean => {
898
+ chunks.push(String(chunk));
899
+ return true;
900
+ });
901
+ try {
902
+ const outcome = await executeVerb(
903
+ verbOf("lsp"),
904
+ {},
905
+ UNDO,
906
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
907
+ );
908
+ return { chunks, outcome };
909
+ } catch (thrown) {
910
+ return { chunks, thrown };
911
+ } finally {
912
+ spy.mockRestore();
913
+ }
914
+ };
915
+
916
+ it("HEADLINE: an uninstall that exits 0 leaving the extension on disk reports FAILED, never `✓ removed`", async () => {
917
+ // The observed field defect: VS Code-family CLIs can exit 0 while
918
+ // deferring or skipping the deletion. The reversal's success criterion
919
+ // was that exit code; detection's criterion is the extensions DIRECTORY.
920
+ // The two disagreed permanently and the run printed `✓ … removed` over an
921
+ // extension that was still installed. The removal now asserts its own
922
+ // goal in-task — probe the owned entries after the uninstall — and the
923
+ // run reports the survival as a failure with a manual remedy.
924
+ const dir = stubPath();
925
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
926
+ process.env.PATH = dir;
927
+ seedExtension(".vscode", "1.2.3");
928
+ const survivor = join(
929
+ process.env.HOME ?? "",
930
+ ".vscode",
931
+ "extensions",
932
+ "canonical.terrazzo-lsp-extension-1.2.3",
933
+ );
934
+
935
+ const { chunks, outcome, thrown } = await undoLspCapturing();
936
+ const stderrText = chunks.join("");
937
+
938
+ // The world: the artifact survived, and the run must not claim otherwise.
939
+ expect(existsSync(survivor)).toBe(true);
940
+ expect(stderrText).not.toContain("— removed");
941
+ expect(outcome).toBeUndefined();
942
+ expect(thrown).toBeDefined();
943
+ const err = asPragmaError(thrown);
944
+ expect(err.code).toBe("UNSUPPORTED");
945
+ expect(err.message).toContain("lsp");
946
+ expect(err.message).toContain("still present after the undo ran");
947
+ // The remedy names the surviving path as DATA for a manual deletion —
948
+ // not the CLI command that has just demonstrably done nothing while
949
+ // reporting success, and not a synthesized shell line (the entry names
950
+ // come off the filesystem, so no quoting is trustworthy, and one command
951
+ // cannot be right for every supported platform).
952
+ expect(err.recovery?.message ?? "").toContain(survivor);
953
+ expect(err.recovery?.message ?? "").not.toContain("rm -rf");
954
+ expect(err.recovery?.message ?? "").not.toContain("--uninstall-extension");
955
+ // The truthful row is reported (stderr), naming the survival.
956
+ expect(stderrText).toContain("still present after the undo ran");
957
+ });
958
+
959
+ it("a DANGLING matching symlink still counts as a survivor — the probe uses detection's criterion", async () => {
960
+ // Detection calls an entry present when `readdirSync` lists it. A probe
961
+ // built on default `exists` (`fs.access`) FOLLOWS symlinks, so a dangling
962
+ // matching symlink would be "installed" to detection and "absent" to the
963
+ // probe: an editor that exits 0 without removing it would report
964
+ // `undone`, and the next detection would find it again — the exact
965
+ // "reports success it did not achieve" this fix exists to close, rebuilt
966
+ // inside the fix. The probe therefore asks about the entry itself
967
+ // (lstat semantics), the same question the directory listing answers.
968
+ const dir = stubPath();
969
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
970
+ process.env.PATH = dir;
971
+ const extensionsDir = join(process.env.HOME ?? "", ".vscode", "extensions");
972
+ mkdirSync(extensionsDir, { recursive: true });
973
+ const entry = join(extensionsDir, "canonical.terrazzo-lsp-extension-1.2.3");
974
+ symlinkSync(join(extensionsDir, "no-such-target"), entry);
975
+
976
+ const { chunks, outcome, thrown } = await undoLspCapturing();
977
+
978
+ expect(lstatSync(entry).isSymbolicLink()).toBe(true); // still there
979
+ expect(chunks.join("")).not.toContain("— removed");
980
+ expect(outcome).toBeUndefined();
981
+ const err = asPragmaError(thrown);
982
+ expect(err.message).toContain("still present after the undo ran");
983
+ expect(err.recovery?.message ?? "").toContain(entry);
984
+ });
985
+
986
+ it("a truthful uninstall reports its `removed` row from the executed reversal", async () => {
987
+ // The positive half of the headline: when the editor really deletes the
988
+ // entry, the in-task postcondition passes, the reversal's outcome comes
989
+ // back `undone`, and the row reports `removed` — same sentence as before
990
+ // the fix, now derived from what ran instead of from the plan.
991
+ const dir = stubPath();
992
+ writeFileSync(
993
+ join(dir, "code"),
994
+ `#!/bin/sh\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
995
+ { mode: 0o755 },
996
+ );
997
+ process.env.PATH = dir;
998
+ seedExtension(".vscode", "1.2.3");
999
+
1000
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1001
+ expect(thrown).toBeUndefined();
1002
+ expect(outcome?.exitCode).toBe(0);
1003
+ expect(outcome?.stdout).toBe("Undid 1 step(s).\n");
1004
+ expect(chunks.join("")).toContain("— removed");
1005
+ });
1006
+
1007
+ it("the `✓ … removed` rows cannot be produced by the collection walk alone", async () => {
1008
+ // The collection walk mocks every exec to exit 0 and every prompt to its
1009
+ // default — under the old code that was enough to print the whole green
1010
+ // recap before one real process had spawned. With an uninstall that
1011
+ // GENUINELY fails (exit 1), the executed reversal fails; if any `removed`
1012
+ // row still appeared on stderr, it could only have come from the mocked
1013
+ // walk.
1014
+ const dir = stubPath();
1015
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
1016
+ process.env.PATH = dir;
1017
+ seedExtension(".vscode", "1.2.3");
1018
+
1019
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1020
+ expect(chunks.join("")).not.toContain("— removed");
1021
+ expect(outcome).toBeUndefined();
1022
+ expect(thrown).toBeDefined();
1023
+ const err = asPragmaError(thrown);
1024
+ expect(err.message).toContain("--uninstall-extension");
1025
+ expect(err.message).toContain("exited with code 1");
1026
+ });
1027
+
1028
+ it("appliedUndo grades rows from the outcomes — one model, both directions", async () => {
1029
+ // The projection unit: the sink the FORWARD run fills is fed by the undo
1030
+ // interpreter's outcomes instead, and the same `outcomeFor` reads it
1031
+ // back. A failed outcome keyed to the row fails the row (carrying the
1032
+ // reversal's own remedy); an actionable row that no reversal reported
1033
+ // back for cannot claim `removed`; a run with clean outcomes reports
1034
+ // `removed`.
1035
+ seedExtension(".vscode", "1.2.3");
1036
+ const run = await buildSetupRun(
1037
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1038
+ "lsp",
1039
+ "global",
1040
+ true,
1041
+ );
1042
+
1043
+ const clean = run.appliedUndo([{ key: "global:lsp", status: "undone" }]);
1044
+ expect(clean.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1045
+ status: "removed",
1046
+ note: "removed",
1047
+ });
1048
+
1049
+ const failed = run.appliedUndo([
1050
+ {
1051
+ key: "global:lsp",
1052
+ status: "failed",
1053
+ error: { code: "UNSUPPORTED", message: "still there" },
1054
+ },
1055
+ ]);
1056
+ expect(failed.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1057
+ status: "failed",
1058
+ note: "still there",
1059
+ });
1060
+
1061
+ // No reversal reported back at all for an actionable row: never `removed`.
1062
+ const silent = run.appliedUndo([]);
1063
+ expect(silent.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1064
+ status: "failed",
1065
+ note: "no reversal was executed for this target",
1066
+ });
1067
+ });
1068
+
1069
+ it("a removal row with nothing owned reports `kept`, not a green noop", async () => {
1070
+ // The `kept` status was declared in the vocabulary and counted by
1071
+ // `planTally` but no producer ever emitted it. On a removal, a `none`
1072
+ // row IS the kept case: the run owned nothing here and deliberately left
1073
+ // the machine's state standing — standing aside (○), not work done (✓).
1074
+ const run = await buildSetupRun(
1075
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1076
+ "lsp",
1077
+ "global",
1078
+ true,
1079
+ );
1080
+ expect(run.plan.rows.find((r) => r.target === "lsp")?.action).toBe("none");
1081
+ const kept = run.appliedUndo([]);
1082
+ expect(kept.rows.find((r) => r.target === "lsp")?.outcome).toEqual({
1083
+ status: "kept",
1084
+ });
1085
+ });
875
1086
  });
876
1087
 
877
1088
  describe("setup (run-all wizard) — scope threading", () => {
@@ -1296,6 +1507,40 @@ describe("setup skills", () => {
1296
1507
  expect(() => lstatSync(linkPath)).toThrow();
1297
1508
  });
1298
1509
 
1510
+ it("the removal plan's children are the FOLDERS the links live in, deduped", async () => {
1511
+ // `dirs` mapped `a.linkPath` — one path per LINK — so the `new Set(…)`
1512
+ // deduped nothing and the removal preview printed an N-path wall where
1513
+ // the forward plan says `N skills → M folders`. The folders are the
1514
+ // links' parent directories.
1515
+ const cwd = tmp("pragma-setup-proj-");
1516
+ const linkDir = join(cwd, ".agents", "skills");
1517
+ mkdirSync(linkDir, { recursive: true });
1518
+ for (const name of ["s1", "s2"]) {
1519
+ const skillDir = join(cwd, ".pragma", "skills", name);
1520
+ mkdirSync(skillDir, { recursive: true });
1521
+ writeFileSync(
1522
+ join(skillDir, "SKILL.md"),
1523
+ `---\nname: ${name}\ndescription: A test skill.\n---\n`,
1524
+ );
1525
+ symlinkSync(skillDir, join(linkDir, name));
1526
+ }
1527
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1528
+ expect(ownedSkillLinks(detected)).toHaveLength(2);
1529
+
1530
+ const { findTarget } = await import("./targets.js");
1531
+ const draft = findTarget("skills")?.removalPlan(
1532
+ detected as never,
1533
+ "project",
1534
+ { global: process.env.HOME ?? "", project: cwd },
1535
+ );
1536
+ expect(draft?.action).toBe("remove");
1537
+ expect(draft?.detail).toBe("2 links");
1538
+ // TWO links, ONE folder: the children name the folder once, not each link.
1539
+ expect(draft?.children?.map((c) => c.label)).toEqual([
1540
+ `.${sep}${join(".agents", "skills")}`,
1541
+ ]);
1542
+ });
1543
+
1299
1544
  it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
1300
1545
  // `readlink` reports the raw target, which may be relative — resolving it
1301
1546
  // against the link's own directory is what makes the containment test
@@ -209,6 +209,31 @@ async function runSetup(
209
209
  // to know which of the two it is.
210
210
  const previewing = interactionMode === "batch-dry-run";
211
211
 
212
+ // The undo truth channel. A real `--undo` interprets this verb's task ONLY
213
+ // as the undo interpreter's mocked collection walk — every exec exits 0,
214
+ // every prompt answers its default, and the OutcomeSink is never written —
215
+ // so nothing the task body could report describes what the collected
216
+ // reversals later did. The body therefore says NOTHING on the undo path
217
+ // (see the guard in the gen body below); the kernel calls this seam after
218
+ // the reversals actually ran, handing over the interpreter's per-undo
219
+ // outcomes, and `appliedUndo` projects them onto the rows through the SAME
220
+ // sink + `applied` path the forward run reports through. One outcome
221
+ // model, both directions.
222
+ if (!previewing && input.undo) {
223
+ rt.undoReport = (outcomes) => {
224
+ const applied = run.appliedUndo(outcomes);
225
+ const width = Math.max(...applied.rows.map((row) => row.target.length));
226
+ for (const row of applied.rows) {
227
+ if (row.outcome === undefined) continue;
228
+ rt.report?.(renderProgressLine(row, width));
229
+ }
230
+ if (planExitFailed(applied)) {
231
+ rt.report?.(renderRecap(applied, "Removed"));
232
+ raiseFailedRows(applied, true);
233
+ }
234
+ };
235
+ }
236
+
212
237
  // A CONVERGED machine has no question to ask. Every row is `none` or `skip`,
213
238
  // so nothing composes an effect — and the wizard's confirm gate then mounted
214
239
  // Ink to render summon-core's "No operations planned." above a "Proceed?"
@@ -313,6 +338,16 @@ async function runSetup(
313
338
  const result = yield* $(task);
314
339
  const applied = run.applied(result.answers);
315
340
 
341
+ // A real `--undo` only ever drives this body under the undo
342
+ // interpreter's COLLECTION walk, with every effect mocked. `applied` is
343
+ // then a projection of the mocked walk (`removed` rows for reversals
344
+ // that have not run yet), so reporting it here printed green checks
345
+ // before anything happened. Say nothing; the kernel hands the executed
346
+ // reversals' outcomes to `rt.undoReport` afterwards, and `appliedUndo`
347
+ // grades the rows from those. (`--undo --dry-run` stays on the shared
348
+ // preview path below, where the same suppressions already apply.)
349
+ if (input.undo && !previewing) return applied;
350
+
316
351
  // Progress: one line per row on stderr for an unattended run (the Ink
317
352
  // session already draws them for the wizard). stdout stays the data stream.
318
353
  //
@@ -333,45 +368,58 @@ async function runSetup(
333
368
  // stdout on a failing run.
334
369
  if (planExitFailed(applied)) {
335
370
  rt.report?.(renderRecap(applied));
336
- const failed = applied.rows.filter(
337
- (row) => row.outcome?.status === "failed",
338
- );
339
-
340
- // ONE failure reports itself. The row already carries the cause it was
341
- // given ("`bun` is not found on your PATH") and a remedy that runs on
342
- // this machine; replacing them with a count would throw away the only
343
- // two sentences that say what is wrong and what to do about it.
344
- const only = failed.length === 1 ? failed[0] : undefined;
345
- if (only?.outcome !== undefined) {
346
- throw new PragmaError({
347
- code: "UNSUPPORTED",
348
- message: `${only.target}: ${only.outcome.note ?? "the step did not complete"}`,
349
- ...(only.outcome.remedy === undefined
350
- ? {}
351
- : { recovery: { message: only.outcome.remedy } }),
352
- });
353
- }
371
+ raiseFailedRows(applied);
372
+ }
373
+ return applied;
374
+ });
375
+ }
354
376
 
355
- // Several failures: name every one with its own cause, then point at the
356
- // recap above. A bare list of target names would make the reader re-run
357
- // each one to find out why it failed.
358
- const named = failed
377
+ /**
378
+ * Raise the run's failure from its rows — shared by the forward run's recap
379
+ * and the post-undo truth report, so the two paths name a shortfall in the
380
+ * same words. `undo` only re-words the multi-failure recovery line: the
381
+ * command that retries a removal is `setup <target> --undo`, not the
382
+ * installer.
383
+ *
384
+ * @param applied - The plan with outcomes filled in (at least one `failed`).
385
+ * @param undo - Whether the failed run was a removal.
386
+ * @throws PragmaError naming the failed row(s), always.
387
+ */
388
+ function raiseFailedRows(applied: SetupPlan, undo = false): never {
389
+ const failed = applied.rows.filter((row) => row.outcome?.status === "failed");
390
+
391
+ // ONE failure reports itself. The row already carries the cause it was
392
+ // given ("`bun` is not found on your PATH") and a remedy that runs on
393
+ // this machine; replacing them with a count would throw away the only
394
+ // two sentences that say what is wrong and what to do about it.
395
+ const only = failed.length === 1 ? failed[0] : undefined;
396
+ if (only?.outcome !== undefined) {
397
+ throw new PragmaError({
398
+ code: "UNSUPPORTED",
399
+ message: `${only.target}: ${only.outcome.note ?? "the step did not complete"}`,
400
+ ...(only.outcome.remedy === undefined
401
+ ? {}
402
+ : { recovery: { message: only.outcome.remedy } }),
403
+ });
404
+ }
405
+
406
+ // Several failures: name every one with its own cause, then point at the
407
+ // recap above. A bare list of target names would make the reader re-run
408
+ // each one to find out why it failed.
409
+ const named = failed
410
+ .map((row) => `${row.target} (${row.outcome?.note ?? "no cause recorded"})`)
411
+ .join("; ");
412
+ throw new PragmaError({
413
+ code: "UNSUPPORTED",
414
+ message: `${failed.length} of ${planTally(applied).accountable} targets did not complete: ${named}.`,
415
+ recovery: {
416
+ message: `The other targets are ${undo ? "removed" : "configured"}. Re-run the ones that did not complete: ${failed
359
417
  .map(
360
418
  (row) =>
361
- `${row.target} (${row.outcome?.note ?? "no cause recorded"})`,
419
+ `\`${BIN_NAME} setup ${row.target}${undo ? " --undo" : ""}\``,
362
420
  )
363
- .join("; ");
364
- throw new PragmaError({
365
- code: "UNSUPPORTED",
366
- message: `${failed.length} of ${planTally(applied).accountable} targets did not complete: ${named}.`,
367
- recovery: {
368
- message: `The other targets are configured. Re-run the ones that did not complete: ${failed
369
- .map((row) => `\`${BIN_NAME} setup ${row.target}\``)
370
- .join(", ")}.`,
371
- },
372
- });
373
- }
374
- return applied;
421
+ .join(", ")}.`,
422
+ },
375
423
  });
376
424
  }
377
425
 
@@ -24,6 +24,7 @@
24
24
  * doctor's), so nothing here lands on the `--help`/`__complete` fast path.
25
25
  */
26
26
 
27
+ import { dirname } from "node:path";
27
28
  import type { Task } from "@canonical/task";
28
29
  import { BIN_NAME } from "../../constants.js";
29
30
  import type { PragmaRuntime } from "../../kernel/runtime/index.js";
@@ -112,8 +113,12 @@ export interface TargetDefinition<D> {
112
113
  * paths); a row without children ignores it.
113
114
  */
114
115
  compose(detection: D, chosen?: readonly string[]): Task<void>;
115
- /** The removal effects: forward re-assertion carrying the reversal as `undo`. */
116
- composeRemoval(detection: D): Task<void>;
116
+ /**
117
+ * The removal effects: forward re-assertion carrying the reversal as
118
+ * `undo`. `undoKey` (the row's identity) is stamped on every reversal so
119
+ * the undo interpreter's outcomes can be read back per row.
120
+ */
121
+ composeRemoval(detection: D, undoKey?: string): Task<void>;
117
122
  }
118
123
 
119
124
  /** A table row with its detection type erased — how every consumer holds one. */
@@ -150,7 +155,7 @@ const configTarget = defineTarget<ConfigDetection>({
150
155
  return { action: "remove", detail: path };
151
156
  },
152
157
  compose: (d) => composeConfigFile(d),
153
- composeRemoval: (d) => composeConfigRemoval(d),
158
+ composeRemoval: (d, undoKey) => composeConfigRemoval(d, undoKey),
154
159
  });
155
160
 
156
161
  const completionsTarget = defineTarget<CompletionsDetection>({
@@ -207,7 +212,7 @@ const completionsTarget = defineTarget<CompletionsDetection>({
207
212
  return { action: "remove", detail: shortenPath(d.path, roots) };
208
213
  },
209
214
  compose: (d) => composeCompletions(d),
210
- composeRemoval: (d) => composeCompletionsRemoval(d),
215
+ composeRemoval: (d, undoKey) => composeCompletionsRemoval(d, undoKey),
211
216
  });
212
217
 
213
218
  const lspTarget = defineTarget<LspDetection>({
@@ -283,7 +288,7 @@ const lspTarget = defineTarget<LspDetection>({
283
288
  };
284
289
  },
285
290
  compose: (d, chosen) => composeLsp(d, chosen),
286
- composeRemoval: (d) => composeLspRemoval(d),
291
+ composeRemoval: (d, undoKey) => composeLspRemoval(d, undoKey),
287
292
  });
288
293
 
289
294
  /**
@@ -356,7 +361,7 @@ const mcpTarget = defineTarget<McpDetection>({
356
361
  },
357
362
  compose: (d, chosen) =>
358
363
  composeMcp(d, chosen ? selectedGroups(d, chosen) : d.groups),
359
- composeRemoval: (d) => composeMcpRemoval(d),
364
+ composeRemoval: (d, undoKey) => composeMcpRemoval(d, undoKey),
360
365
  });
361
366
 
362
367
  const skillsTarget = defineTarget<SkillsDetection>({
@@ -402,7 +407,12 @@ const skillsTarget = defineTarget<SkillsDetection>({
402
407
  if (owned.length === 0) {
403
408
  return { action: "none", detail: "no link to remove" };
404
409
  }
405
- const dirs = [...new Set(owned.map((a) => shortenPath(a.linkPath, roots)))];
410
+ // Dedupe to the FOLDERS the links live in — `linkPath` itself is one path
411
+ // per link, so a Set over it deduped nothing and the removal preview
412
+ // printed an 18-path wall where the forward plan says `… → 2 folders`.
413
+ const dirs = [
414
+ ...new Set(owned.map((a) => shortenPath(dirname(a.linkPath), roots))),
415
+ ];
406
416
  return {
407
417
  action: "remove",
408
418
  detail: `${owned.length} ${owned.length === 1 ? "link" : "links"}`,
@@ -414,7 +424,7 @@ const skillsTarget = defineTarget<SkillsDetection>({
414
424
  };
415
425
  },
416
426
  compose: (d) => composeSkills(d),
417
- composeRemoval: (d) => composeSkillsRemoval(d),
427
+ composeRemoval: (d, undoKey) => composeSkillsRemoval(d, undoKey),
418
428
  });
419
429
 
420
430
  /** THE table, in display order. */
@@ -25,6 +25,7 @@
25
25
  export {
26
26
  assertExecOk,
27
27
  checkExecOk,
28
+ failPragma,
28
29
  guardMissingBinary,
29
30
  } from "./assertExecOk.js";
30
31
  export type {
@@ -192,10 +192,18 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
192
192
  {
193
193
  v: "application",
194
194
  args: ["[app-path]"],
195
- // ssr/router are GONE: always-on facts, not prompts — the pair had no
196
- // reachable explicit form (only `--no-` spellings the generator's own
197
- // guard rejected), so the projection no longer carries them.
198
- flags: ["--no-forms", "--intl", "--relay", "--no-run-install"],
195
+ // router is GONE: an always-on fact, not a prompt — it had no
196
+ // reachable explicit form (only a `--no-` spelling the generator's own
197
+ // guard rejected), so the projection no longer carries it. `rendering`
198
+ // is the SSR axis' replacement (L-CIS-3): a select, so it is spelled as
199
+ // a value and never as a negation.
200
+ flags: [
201
+ "--no-forms",
202
+ "--intl",
203
+ "--rendering",
204
+ "--relay",
205
+ "--no-run-install",
206
+ ],
199
207
  mutates: true,
200
208
  mcp: "create_application",
201
209
  },
package/src/constants.ts CHANGED
@@ -41,6 +41,13 @@ const ISSUES_URL = identity.issuesUrl;
41
41
  /** Semver version string read from package.json. */
42
42
  const VERSION: string = pkg.version;
43
43
 
44
+ /**
45
+ * The Node.js range this distribution supports, read from `engines.node` in
46
+ * package.json so the manifest stays the single statement of the floor. The
47
+ * doctor check quotes it, and `isSupportedNodeVersion` is pinned to it by test.
48
+ */
49
+ const SUPPORTED_NODE_RANGE: string = pkg.engines.node;
50
+
44
51
  /**
45
52
  * The project config filename the walker looks for and every surface that
46
53
  * quotes it — diagnostics, the onboarding note, the generated reference, the
@@ -116,5 +123,6 @@ export {
116
123
  PROGRAM_LOGO,
117
124
  PROJECT_CONFIG_FILENAME,
118
125
  RECOVERY_CLI_PREFIX,
126
+ SUPPORTED_NODE_RANGE,
119
127
  VERSION,
120
128
  };
@@ -44,7 +44,12 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
44
44
  expect(defaults.colophon?.summary?.length).toBeLessThan(400);
45
45
  });
46
46
 
47
- it("ships the four canonical default packs (git+https sources)", () => {
47
+ // Five packs, and ONE carries a subdirectory. That is load-bearing, not
48
+ // decoration: `@canonical/token-ontology` lives inside a monorepo, and a
49
+ // git source names a repository — without `:packages/token-ontology` the
50
+ // clone would succeed against a root that has no `definitions/` or `data/`
51
+ // and the pack would contribute nothing, silently.
52
+ it("ships the five canonical default packs (all git+https)", () => {
48
53
  expect(
49
54
  defaults.packs?.map((pack) =>
50
55
  typeof pack === "string"
@@ -60,6 +65,11 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
60
65
  name: "@canonical/anatomy-dsl",
61
66
  source: "git+https://github.com/canonical/anatomy-dsl.git#main",
62
67
  },
68
+ {
69
+ name: "@canonical/token-ontology",
70
+ source:
71
+ "git+https://github.com/canonical/design-tokens.git#main:packages/token-ontology",
72
+ },
63
73
  {
64
74
  name: "@canonical/code-standards",
65
75
  source:
@@ -79,7 +89,7 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
79
89
  const storyCounts = defaults.packs?.map((pack) =>
80
90
  typeof pack === "string" ? 0 : (pack.stories?.length ?? 0),
81
91
  );
82
- expect(storyCounts).toEqual([5, 0, 1, 1]);
92
+ expect(storyCounts).toEqual([5, 0, 0, 1, 1]);
83
93
  });
84
94
 
85
95
  it("declares no removed field — the validator would refuse to load one", () => {
@@ -98,6 +108,11 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
98
108
  // can expand — `cs:` is here for that reader.
99
109
  expect(defaults.prefixes).toEqual({
100
110
  ds: "https://ds.canonical.com/",
111
+ dt: "https://dt.canonical.com/",
112
+ // Nested under `dt:`, and bound separately because `compactUri` takes the
113
+ // longest match — with only the parent bound, every term of this
114
+ // vocabulary would render as `dt:w3c-tokens/…`.
115
+ "w3c-tokens": "https://dt.canonical.com/w3c-tokens/",
101
116
  cs: "http://pragma.canonical.com/codestandards#",
102
117
  });
103
118
  });
@@ -59,7 +59,7 @@ describe("readConfig — layering + provenance", () => {
59
59
  expect(config.tier).toBe("core");
60
60
  expect(config.channel).toBe("experimental");
61
61
  expect(config.detail).toBe("detailed");
62
- expect(config.packs).toHaveLength(4); // from defaults
62
+ expect(config.packs).toHaveLength(5); // from defaults
63
63
 
64
64
  expect(origins).toMatchObject({
65
65
  tier: "project",
@@ -121,13 +121,17 @@ const DERIVED_NAME = 'REPLACE(REPLACE(STR(?uri), "^.*[#/]", ""), "\\\\.", "/")';
121
121
  *
122
122
  * A story whose `list` SYNTHESIZES a name instead of reading one declares
123
123
  * `nameFallback: "iri"`, and then the triple becomes OPTIONAL with
124
- * {@link DERIVED_NAME} behind it. `standard` is the case that exists: 22 of the
125
- * 156 live code standards carry a `cs:name`, and the code-standards ontology
126
- * says so deliberately — it is "an optional human-readable display title" that
127
- * "never participates in identity". `standard list` published
128
- * `react/component/tsdoc` while `standard lookup react/component/tsdoc`
129
- * answered ENTITY_NOT_FOUND with empty suggestions, because the only
130
- * addressable population was the ~13% carrying an asserted name.
124
+ * {@link DERIVED_NAME} behind it. `standard` is the case that exists. When the
125
+ * defect was measured, 22 of the then-156 live code standards carried a
126
+ * `cs:name`, and the code-standards ontology said so deliberately — "an
127
+ * optional human-readable display title" that "never participates in
128
+ * identity". `standard list` published `react/component/tsdoc` while
129
+ * `standard lookup react/component/tsdoc` answered ENTITY_NOT_FOUND with
130
+ * empty suggestions, because the only addressable population was the ~13%
131
+ * carrying an asserted name. (Upstream has since retired `cs:name` for
132
+ * `rdfs:label`, which every standard carries; the fallback stays because a
133
+ * pack is data, and a dropped label must degrade to the derived name rather
134
+ * than become unaddressable.)
131
135
  *
132
136
  * INFERRING the fallback from the mere PRESENCE of a class constraint is what
133
137
  * this option replaced, and it was over-reach in both directions of the same