@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.
- package/dist/package.json +22 -19
- package/dist/pragma.conf.js +35 -5
- package/dist/pragma.conf.js.map +1 -1
- package/dist/src/capabilities/capabilities/hints.js +1 -1
- package/dist/src/capabilities/capabilities/hints.js.map +1 -1
- package/dist/src/capabilities/create/create.verb.js +2 -1
- package/dist/src/capabilities/create/create.verb.js.map +1 -1
- package/dist/src/capabilities/create/createSurface.generated.js +23 -1
- package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
- package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
- package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
- package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
- package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupGenerator.js +64 -13
- package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupLsp.js +59 -13
- package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupMcp.js +4 -1
- package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
- package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
- package/dist/src/capabilities/setup/setup.verb.js +79 -30
- package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
- package/dist/src/capabilities/setup/targets.js +12 -6
- package/dist/src/capabilities/setup/targets.js.map +1 -1
- package/dist/src/capabilities/shared/index.js +1 -1
- package/dist/src/capabilities/shared/index.js.map +1 -1
- package/dist/src/constants.js +7 -1
- package/dist/src/constants.js.map +1 -1
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js +11 -7
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
- package/dist/src/kernel/project/cli/dispatch.js +23 -1
- package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
- package/dist/src/kernel/project/cli/rootHelp.js +8 -5
- package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
- package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
- package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/read.js +14 -9
- package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
- package/dist/src/kernel/runtime/loadSession.js +3 -1
- package/dist/src/kernel/runtime/loadSession.js.map +1 -1
- package/dist/src/kernel/runtime/probe.js +1 -1
- package/dist/src/kernel/runtime/probe.js.map +1 -1
- package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
- package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
- package/dist/src/kernel/runtime/refs/resolve.js +9 -3
- package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
- package/package.json +22 -19
- package/pragma.conf.ts +36 -5
- package/src/capabilities/capabilities/hints.ts +1 -1
- package/src/capabilities/config/collectConfigShow.test.ts +1 -0
- package/src/capabilities/create/byteEquality.test.ts +28 -1
- package/src/capabilities/create/create.test.ts +1 -0
- package/src/capabilities/create/create.verb.ts +2 -1
- package/src/capabilities/create/createGrammar.test.ts +4 -1
- package/src/capabilities/create/createSurface.generated.ts +23 -1
- package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
- package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
- package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
- package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
- package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
- package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
- package/src/capabilities/lazy.test.ts +26 -2
- package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
- package/src/capabilities/resources/resources.test.ts +38 -2
- package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
- package/src/capabilities/setup/operations/setupConfig.ts +7 -2
- package/src/capabilities/setup/operations/setupGenerator.ts +83 -14
- package/src/capabilities/setup/operations/setupLsp.ts +95 -22
- package/src/capabilities/setup/operations/setupMcp.ts +7 -1
- package/src/capabilities/setup/operations/setupSkills.ts +7 -2
- package/src/capabilities/setup/setup.test.ts +247 -2
- package/src/capabilities/setup/setup.verb.ts +83 -35
- package/src/capabilities/setup/targets.ts +18 -8
- package/src/capabilities/shared/index.ts +1 -0
- package/src/capabilities/surface.test.ts +12 -4
- package/src/constants.ts +8 -0
- package/src/kernel/config/defaults.test.ts +17 -2
- package/src/kernel/config/readConfig.test.ts +1 -1
- package/src/kernel/packs/sparql/buildLookupQuery.ts +11 -7
- package/src/kernel/project/cli/dispatch.ts +23 -1
- package/src/kernel/project/cli/rootHelp.ts +8 -5
- package/src/kernel/render/prefixes.test.ts +2 -0
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
- package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
- package/src/kernel/runtime/graphpack/embedded.ts +14 -6
- package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
- package/src/kernel/runtime/graphpack/read.ts +14 -12
- package/src/kernel/runtime/loadSession.ts +3 -1
- package/src/kernel/runtime/probe.ts +1 -1
- package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
- package/src/kernel/runtime/refs/parseRef.ts +25 -1
- package/src/kernel/runtime/refs/resolve.ts +9 -3
- package/src/kernel/runtime/types.ts +17 -0
- package/src/testing/helpers/runCli.test.ts +44 -0
- 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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
419
|
+
`\`${BIN_NAME} setup ${row.target}${undo ? " --undo" : ""}\``,
|
|
362
420
|
)
|
|
363
|
-
.join("
|
|
364
|
-
|
|
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
|
-
/**
|
|
116
|
-
|
|
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
|
-
|
|
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. */
|
|
@@ -192,10 +192,18 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
|
|
|
192
192
|
{
|
|
193
193
|
v: "application",
|
|
194
194
|
args: ["[app-path]"],
|
|
195
|
-
//
|
|
196
|
-
// reachable explicit form (only `--no-`
|
|
197
|
-
// guard rejected), so the projection no longer carries
|
|
198
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* `
|
|
129
|
-
* answered ENTITY_NOT_FOUND with
|
|
130
|
-
* addressable population was the ~13%
|
|
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
|