@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
|
@@ -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 =
|
|
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(
|
|
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_([
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|