@canonical/pragma-cli 0.35.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/README.md +2 -2
- package/bundled-skills/add-standard/SKILL.md +338 -0
- package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
- package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
- package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
- package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
- package/bundled-skills/anatomy-author/SKILL.md +1226 -0
- package/bundled-skills/design-auditor/SKILL.md +455 -0
- package/bundled-skills/implement-component/SKILL.md +333 -0
- package/bundled-skills/specify-component/SKILL.md +331 -0
- package/bundled-skills/specify-pattern/SKILL.md +292 -0
- package/dist/package.json +24 -20
- package/dist/pragma.conf.js +266 -34
- package/dist/pragma.conf.js.map +1 -1
- package/dist/src/bin.js +1 -1
- package/dist/src/bin.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/colophon/colophon.render.js +1 -1
- package/dist/src/capabilities/colophon/colophon.render.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/create/mount.js +4 -3
- package/dist/src/capabilities/create/mount.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/checkPackageRefs.js +60 -4
- package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
- package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
- package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/index.js +6 -6
- 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/doctor/checks/targetHealth.js +194 -50
- package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
- package/dist/src/capabilities/doctor/doctor.render.js +43 -64
- package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
- package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
- package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
- package/dist/src/capabilities/doctor/runChecks.js +14 -11
- package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
- package/dist/src/capabilities/graph/query.render.js +2 -2
- package/dist/src/capabilities/graph/query.render.js.map +1 -1
- package/dist/src/capabilities/info/collectInfo.js +8 -2
- package/dist/src/capabilities/info/collectInfo.js.map +1 -1
- package/dist/src/capabilities/info/info.render.js +6 -2
- package/dist/src/capabilities/info/info.render.js.map +1 -1
- package/dist/src/capabilities/prompt/prompt.render.js +2 -2
- package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
- package/dist/src/capabilities/setup/buildPlan.js +43 -39
- package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
- package/dist/src/capabilities/setup/mode.js +2 -2
- package/dist/src/capabilities/setup/operations/index.js +4 -4
- package/dist/src/capabilities/setup/operations/index.js.map +1 -1
- 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 +156 -58
- package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
- package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
- package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
- package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
- package/dist/src/capabilities/setup/plan.js.map +1 -1
- package/dist/src/capabilities/setup/plan.render.js +144 -66
- package/dist/src/capabilities/setup/plan.render.js.map +1 -1
- package/dist/src/capabilities/setup/setup.render.js +1 -1
- package/dist/src/capabilities/setup/setup.verb.js +165 -63
- package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
- package/dist/src/capabilities/setup/targets.js +122 -51
- package/dist/src/capabilities/setup/targets.js.map +1 -1
- package/dist/src/capabilities/shared/index.js +7 -6
- package/dist/src/capabilities/shared/index.js.map +1 -1
- package/dist/src/capabilities/shared/packageManager.js +300 -54
- package/dist/src/capabilities/shared/packageManager.js.map +1 -1
- package/dist/src/capabilities/skill/discover.js +97 -11
- package/dist/src/capabilities/skill/discover.js.map +1 -1
- package/dist/src/capabilities/skill/render.js +2 -2
- package/dist/src/capabilities/skill/render.js.map +1 -1
- package/dist/src/capabilities/sources/installSkills.js +145 -3
- package/dist/src/capabilities/sources/installSkills.js.map +1 -1
- package/dist/src/capabilities/sources/runUpdate.js +68 -2
- package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
- package/dist/src/capabilities/sources/update.verb.js +3 -3
- package/dist/src/capabilities/sources/update.verb.js.map +1 -1
- package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
- package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
- package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
- package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
- package/dist/src/constants.js +13 -2
- package/dist/src/constants.js.map +1 -1
- package/dist/src/kernel/index.js +11 -2
- package/dist/src/kernel/index.js.map +1 -1
- package/dist/src/kernel/interactivity.js +68 -0
- package/dist/src/kernel/interactivity.js.map +1 -0
- package/dist/src/kernel/packs/renderPack.js +3 -3
- package/dist/src/kernel/packs/renderPack.js.map +1 -1
- package/dist/src/kernel/packs/resolveEntity.js +63 -12
- package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
- package/dist/src/kernel/packs/runBodies.js +57 -4
- package/dist/src/kernel/packs/runBodies.js.map +1 -1
- package/dist/src/kernel/packs/schema.js +54 -2
- package/dist/src/kernel/packs/schema.js.map +1 -1
- package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
- package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
- package/dist/src/kernel/packs/types.js.map +1 -1
- package/dist/src/kernel/project/cli/buildProgram.js +1 -1
- package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
- package/dist/src/kernel/project/cli/dispatch.js +46 -24
- package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
- package/dist/src/kernel/project/cli/globalFlags.js +4 -1
- package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
- package/dist/src/kernel/project/cli/helpFormat.js +1 -2
- package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
- package/dist/src/kernel/project/cli/rootHelp.js +42 -9
- package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
- package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
- package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
- package/dist/src/kernel/render/renderers.js +1 -1
- package/dist/src/kernel/render/style.js +29 -9
- package/dist/src/kernel/render/style.js.map +1 -1
- package/dist/src/kernel/render/vocabulary.js +174 -0
- package/dist/src/kernel/render/vocabulary.js.map +1 -0
- 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/dist/src/kernel/spec/emitReference.js +1 -1
- package/dist/src/kernel/spec/emitReference.js.map +1 -1
- package/package.json +24 -20
- package/pragma.conf.ts +272 -34
- package/src/bin.ts +8 -1
- package/src/capabilities/block.parity.test.ts +25 -11
- package/src/capabilities/block.shipped.exec.test.ts +282 -0
- package/src/capabilities/block.tierRank.exec.test.ts +300 -0
- package/src/capabilities/capabilities/hints.ts +1 -1
- package/src/capabilities/colophon/colophon.render.ts +1 -2
- package/src/capabilities/colophon/colophon.test.ts +2 -2
- 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 +5 -2
- package/src/capabilities/create/createSurface.generated.ts +23 -1
- package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
- package/src/capabilities/create/mount.ts +3 -3
- package/src/capabilities/distribution.parity.test.ts +4 -0
- package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
- package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
- package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
- package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
- package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
- package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
- package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
- package/src/capabilities/doctor/checks/index.ts +6 -6
- 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/doctor/checks/targetHealth.ts +229 -59
- package/src/capabilities/doctor/doctor.render.test.ts +108 -32
- package/src/capabilities/doctor/doctor.render.ts +56 -80
- package/src/capabilities/doctor/doctor.test.ts +247 -35
- package/src/capabilities/doctor/doctor.verb.ts +7 -2
- package/src/capabilities/doctor/runChecks.ts +14 -11
- package/src/capabilities/doctor/types.ts +15 -24
- package/src/capabilities/emptyRecovery.test.ts +10 -2
- package/src/capabilities/graph/query.render.ts +2 -2
- package/src/capabilities/info/collectInfo.ts +8 -1
- package/src/capabilities/info/info.render.ts +6 -2
- package/src/capabilities/info/info.test.ts +37 -1
- package/src/capabilities/info/types.ts +10 -2
- package/src/capabilities/lazy.test.ts +26 -2
- package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
- package/src/capabilities/prompt/prompt.render.ts +2 -2
- package/src/capabilities/renderVocabularySync.test.ts +82 -0
- package/src/capabilities/resources/resources.test.ts +38 -2
- package/src/capabilities/scopeTypeSync.test.ts +29 -15
- package/src/capabilities/setup/buildPlan.ts +45 -41
- package/src/capabilities/setup/index.ts +1 -1
- package/src/capabilities/setup/mode.ts +2 -2
- package/src/capabilities/setup/operations/index.ts +6 -1
- 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 +227 -84
- package/src/capabilities/setup/operations/setupLsp.ts +203 -38
- package/src/capabilities/setup/operations/setupMcp.ts +139 -62
- package/src/capabilities/setup/operations/setupSkills.ts +399 -60
- package/src/capabilities/setup/plan.render.ts +171 -76
- package/src/capabilities/setup/plan.ts +17 -12
- package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
- package/src/capabilities/setup/setup.render.test.ts +50 -36
- package/src/capabilities/setup/setup.render.ts +1 -1
- package/src/capabilities/setup/setup.test.ts +1126 -50
- package/src/capabilities/setup/setup.verb.ts +174 -68
- package/src/capabilities/setup/targets.ts +138 -60
- package/src/capabilities/setup/types.ts +10 -18
- package/src/capabilities/shared/index.ts +18 -6
- package/src/capabilities/shared/packageManager.test.ts +421 -6
- package/src/capabilities/shared/packageManager.ts +394 -59
- package/src/capabilities/skill/bundledSkills.test.ts +347 -0
- package/src/capabilities/skill/discover.ts +100 -11
- package/src/capabilities/skill/render.ts +2 -2
- package/src/capabilities/skill/skill.test.ts +17 -4
- package/src/capabilities/sources/installSkills.ts +181 -3
- package/src/capabilities/sources/runUpdate.ts +78 -2
- package/src/capabilities/sources/sources.test.ts +228 -0
- package/src/capabilities/sources/update.verb.ts +3 -3
- package/src/capabilities/surface.test.ts +14 -6
- package/src/capabilities/upgrade/runUpgrade.ts +45 -6
- package/src/capabilities/upgrade/types.ts +10 -2
- package/src/capabilities/upgrade/upgrade.render.ts +9 -2
- package/src/capabilities/upgrade/upgrade.test.ts +72 -0
- package/src/constants.ts +14 -1
- package/src/identity.test.ts +18 -5
- package/src/kernel/completion/entitySource.test.ts +15 -2
- package/src/kernel/completion/safety.test.ts +16 -9
- package/src/kernel/config/defaults.test.ts +19 -3
- package/src/kernel/config/readConfig.test.ts +1 -1
- package/src/kernel/index.ts +11 -2
- package/src/kernel/interactivity.test.ts +76 -0
- package/src/kernel/interactivity.ts +69 -0
- package/src/kernel/packs/compile.test.ts +92 -0
- package/src/kernel/packs/renderPack.ts +3 -3
- package/src/kernel/packs/resolveEntity.test.ts +75 -14
- package/src/kernel/packs/resolveEntity.ts +88 -24
- package/src/kernel/packs/runBodies.ts +84 -19
- package/src/kernel/packs/schema.ts +60 -4
- package/src/kernel/packs/sparql/applyFilters.ts +125 -4
- package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
- package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
- package/src/kernel/packs/toolDescription.test.ts +35 -1
- package/src/kernel/packs/types.ts +125 -2
- package/src/kernel/project/cli/buildProgram.test.ts +73 -2
- package/src/kernel/project/cli/buildProgram.ts +8 -1
- package/src/kernel/project/cli/dispatch.test.ts +5 -4
- package/src/kernel/project/cli/dispatch.ts +48 -25
- package/src/kernel/project/cli/globalFlags.ts +4 -1
- package/src/kernel/project/cli/helpFormat.ts +1 -2
- package/src/kernel/project/cli/rootHelp.ts +44 -8
- package/src/kernel/project/mcp/registerVerb.ts +20 -1
- package/src/kernel/render/prefixes.test.ts +2 -0
- package/src/kernel/render/renderers.ts +1 -1
- package/src/kernel/render/style.ts +32 -9
- package/src/kernel/render/vocabulary.ts +276 -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/kernel/spec/emitReference.ts +1 -1
- package/src/kernel/spec/types.ts +35 -6
- package/src/testing/PARITY_GAPS.ts +9 -3
- package/src/testing/behavioral/docExamples.test.ts +4 -2
- package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
- package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
- package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
- package/src/testing/eval/cases/readNouns.ts +245 -9
- package/src/testing/fixtures/blockGraph.ts +28 -13
- package/src/testing/fixtures/graph/canonical.ts +95 -12
- package/src/testing/helpers/runCli.test.ts +44 -0
- package/src/testing/helpers/runCli.ts +27 -3
- package/dist/src/capabilities/shared/bands.js +0 -16
- package/dist/src/capabilities/shared/bands.js.map +0 -1
- package/src/capabilities/shared/bands.ts +0 -16
|
@@ -26,9 +26,9 @@ import {
|
|
|
26
26
|
writeFileSync,
|
|
27
27
|
} from "node:fs";
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
|
-
import { join, relative } from "node:path";
|
|
29
|
+
import { join, relative, sep } from "node:path";
|
|
30
30
|
import { execute } from "@canonical/summon-core";
|
|
31
|
-
import { dryRun, type Effect, type Task } from "@canonical/task";
|
|
31
|
+
import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
|
|
32
32
|
import { runTask, runUndo } from "@canonical/task/node";
|
|
33
33
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
34
34
|
import { BIN_NAME } from "../../constants.js";
|
|
@@ -42,13 +42,16 @@ import { projectCli } from "../../testing/helpers/projectCli.js";
|
|
|
42
42
|
import { projectMcp } from "../../testing/helpers/projectMcp.js";
|
|
43
43
|
import { capabilities } from "../index.js";
|
|
44
44
|
import { detectCompletions } from "./operations/setupCompletions.js";
|
|
45
|
-
import { buildSetupRun } from "./operations/setupGenerator.js";
|
|
45
|
+
import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
|
|
46
46
|
import {
|
|
47
47
|
composeSkills,
|
|
48
48
|
composeSkillsRemoval,
|
|
49
49
|
detectSkills,
|
|
50
50
|
ownedSkillLinks,
|
|
51
|
+
skillsSkipReason,
|
|
52
|
+
skillsSkipRemedy,
|
|
51
53
|
} from "./operations/setupSkills.js";
|
|
54
|
+
import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
|
|
52
55
|
import { setupModule } from "./setup.verb.js";
|
|
53
56
|
import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
|
|
54
57
|
|
|
@@ -98,6 +101,14 @@ const stubPath = (): string => {
|
|
|
98
101
|
return dir;
|
|
99
102
|
};
|
|
100
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
|
+
|
|
101
112
|
let prevHome: string | undefined;
|
|
102
113
|
let prevPath: string | undefined;
|
|
103
114
|
beforeEach(() => {
|
|
@@ -204,7 +215,7 @@ describe("setup completions", () => {
|
|
|
204
215
|
// The dry-run renders the PLAN through the verb's formatPlan seam, not the
|
|
205
216
|
// kernel's raw effect dump: one row, the shell it detected, and the script
|
|
206
217
|
// path rendered against the header's roots instead of repeated absolutely.
|
|
207
|
-
expect(outcome.stdout).toContain("Setup plan — global
|
|
218
|
+
expect(outcome.stdout).toContain("Setup plan — global");
|
|
208
219
|
expect(outcome.stdout).toMatch(
|
|
209
220
|
new RegExp(`completions\\s+install\\s+${SHELL} →`),
|
|
210
221
|
);
|
|
@@ -249,7 +260,7 @@ describe("setup mcp — recap gate", () => {
|
|
|
249
260
|
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // makes Cursor detected
|
|
250
261
|
const configPath = join(cwd, ".cursor", "mcp.json");
|
|
251
262
|
|
|
252
|
-
// `--local`: the project
|
|
263
|
+
// `--local`: the project scope is opt-in now, so a bare `setup mcp` would
|
|
253
264
|
// write the HOME config and leave this repository untouched.
|
|
254
265
|
const outcome = await executeVerb(
|
|
255
266
|
verbOf("mcp"),
|
|
@@ -405,7 +416,7 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
405
416
|
// A per-user server must not be pinned to whatever directory `setup mcp
|
|
406
417
|
// --global` happened to run from (a registration made from ~/Downloads
|
|
407
418
|
// used to serve ~/Downloads forever, and re-running from repo B flipped
|
|
408
|
-
// the machine
|
|
419
|
+
// the machine scope to repo B).
|
|
409
420
|
const home = process.env.HOME ?? "";
|
|
410
421
|
const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
|
|
411
422
|
const register = async (cwd: string): Promise<string> => {
|
|
@@ -461,9 +472,9 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
461
472
|
expect(
|
|
462
473
|
JSON.parse(readFileSync(configPath, "utf-8")).mcpServers.pragma,
|
|
463
474
|
).toEqual({ command: "pragma", args: ["mcp", "serve"] });
|
|
464
|
-
// ...and it stays `
|
|
475
|
+
// ...and it stays `registered` afterwards (no churn on every run).
|
|
465
476
|
const after = await detectMcp(bootRuntime(FLAGS, cwd), "global");
|
|
466
|
-
expect(mcpGroupState(after, configPath)).toBe("
|
|
477
|
+
expect(mcpGroupState(after, configPath)).toBe("registered");
|
|
467
478
|
});
|
|
468
479
|
|
|
469
480
|
it("a PROJECT entry still records the project root as cwd", async () => {
|
|
@@ -482,25 +493,32 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
482
493
|
});
|
|
483
494
|
});
|
|
484
495
|
|
|
485
|
-
describe("setup mcp —
|
|
486
|
-
it("
|
|
496
|
+
describe("setup mcp — the per-file question, asked directly", () => {
|
|
497
|
+
it("asks the per-file multiselect with NO `customize` meta-question in front", async () => {
|
|
498
|
+
// `answers.customize` was read in exactly one place in the repo — this
|
|
499
|
+
// prompt's `when` — and by nothing else: not `generate`, not `applied`, not
|
|
500
|
+
// any plan, recap, renderer or JSON projection. Answering no just meant the
|
|
501
|
+
// child prompt went unasked, its key was absent, and the compose bodies
|
|
502
|
+
// read that as "all" — so everything detected was configured anyway.
|
|
487
503
|
const cwd = tmp("pragma-setup-proj-");
|
|
488
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
504
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
505
|
+
mkdirSync(join(cwd, ".gemini"), { recursive: true }); // 2 children ⇒ narrowable
|
|
489
506
|
const { generator } = await buildSetupRun(
|
|
490
507
|
bootRuntime(FLAGS, cwd),
|
|
491
508
|
"mcp",
|
|
492
|
-
"
|
|
509
|
+
"project",
|
|
493
510
|
);
|
|
494
|
-
|
|
511
|
+
expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
|
|
495
512
|
const targets = generator.prompts.find((p) => p.name === "mcpTargets");
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
513
|
+
expect(targets).toBeDefined();
|
|
514
|
+
// It is asked whenever its row is in the run — the row's own selection is
|
|
515
|
+
// the only gate, and the choices carry the detected state per file.
|
|
516
|
+
expect(targets?.when?.({})).toBe(true);
|
|
517
|
+
expect(
|
|
518
|
+
(targets?.choices as { label: string }[]).every((c) =>
|
|
519
|
+
/— (add|update|unchanged)$/.test(c.label),
|
|
520
|
+
),
|
|
521
|
+
).toBe(true);
|
|
504
522
|
});
|
|
505
523
|
|
|
506
524
|
it("recaps the files the run KEPT, not the ones it offered", async () => {
|
|
@@ -517,7 +535,6 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
|
|
|
517
535
|
|
|
518
536
|
const applied = run.applied({
|
|
519
537
|
targets: ["project:mcp"],
|
|
520
|
-
customize: true,
|
|
521
538
|
mcpTargets: [kept],
|
|
522
539
|
});
|
|
523
540
|
expect(applied.rows.find((r) => r.target === "mcp")?.outcome?.note).toBe(
|
|
@@ -526,6 +543,67 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
|
|
|
526
543
|
});
|
|
527
544
|
});
|
|
528
545
|
|
|
546
|
+
describe("setup — live row events (the wizard's step feed)", () => {
|
|
547
|
+
it("brackets each row with start/done, in the recap's own sentence", async () => {
|
|
548
|
+
// The wizard's live progress renders the PLAN's rows, not the effect
|
|
549
|
+
// transcript — one `start` as a row's body is entered, one `done` carrying
|
|
550
|
+
// the same `target detail — note` sentence the recap will print, so the
|
|
551
|
+
// watcher and the recap reader see one dialect.
|
|
552
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
553
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
554
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
555
|
+
const events: RowEvent[] = [];
|
|
556
|
+
run.setRowListener((event) => events.push(event));
|
|
557
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
558
|
+
expect(events.map((event) => event.status)).toEqual(["start", "done"]);
|
|
559
|
+
expect(events[0]?.key).toBe("project:mcp");
|
|
560
|
+
// The start label is the row without an outcome; the done label appends
|
|
561
|
+
// the SAME note `applied()` puts on the recap row.
|
|
562
|
+
const applied = run.applied({});
|
|
563
|
+
const note = applied.rows.find((r) => r.target === "mcp")?.outcome?.note;
|
|
564
|
+
expect(note).toBe("1 added");
|
|
565
|
+
expect(events[1]?.label).toBe(`${events[0]?.label} — ${note}`);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("no listener registered costs nothing — the run is unchanged", async () => {
|
|
569
|
+
// `--yes`, `--dry-run` and MCP never register one; the bracket emits into
|
|
570
|
+
// the void and the composed effects are identical.
|
|
571
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
572
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
573
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
574
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
575
|
+
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("a failing row reports `failed` with its cause, and its siblings still bracket", async () => {
|
|
579
|
+
// Same arrangement as the S1-1 isolation test: an editor on PATH with no
|
|
580
|
+
// `bun`, so the LSP row's compose fails. The event stream must name the
|
|
581
|
+
// failure — the wizard paints that row ✗ live — while the run continues.
|
|
582
|
+
const prevPath = process.env.PATH;
|
|
583
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
584
|
+
process.env.XDG_DATA_HOME = tmp("pragma-rowevents-data-");
|
|
585
|
+
const dir = stubPath();
|
|
586
|
+
writeFileSync(join(dir, "code"), "");
|
|
587
|
+
process.env.PATH = dir;
|
|
588
|
+
try {
|
|
589
|
+
const run = await buildSetupRun(
|
|
590
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
591
|
+
"lsp",
|
|
592
|
+
"global",
|
|
593
|
+
);
|
|
594
|
+
const events: RowEvent[] = [];
|
|
595
|
+
run.setRowListener((event) => events.push(event));
|
|
596
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
597
|
+
expect(events.map((event) => event.status)).toEqual(["start", "failed"]);
|
|
598
|
+
expect(events[1]?.label).toContain("bun");
|
|
599
|
+
} finally {
|
|
600
|
+
process.env.PATH = prevPath;
|
|
601
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
602
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
529
607
|
describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
530
608
|
let prevPath: string | undefined;
|
|
531
609
|
let stubDir = "";
|
|
@@ -573,20 +651,18 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
|
573
651
|
expect(children?.find((c) => c.key === "codium")?.action).toBe("add");
|
|
574
652
|
});
|
|
575
653
|
|
|
576
|
-
it("
|
|
654
|
+
it("asks the per-editor multiselect directly, with both forks pre-selected", async () => {
|
|
577
655
|
const { generator } = await buildSetupRun(
|
|
578
656
|
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
579
657
|
"lsp",
|
|
580
658
|
"global",
|
|
581
659
|
);
|
|
582
|
-
|
|
660
|
+
expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
|
|
583
661
|
const editors = generator.prompts.find((p) => p.name === "lspEditors");
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
// every fork without an extra question.
|
|
662
|
+
// Both pending editors are pre-selected, so pressing enter installs into
|
|
663
|
+
// every fork — the same default the meta-question used to stand for.
|
|
587
664
|
expect(editors?.default).toEqual(["code", "codium"]);
|
|
588
|
-
expect(editors?.when?.({
|
|
589
|
-
expect(editors?.when?.({ customize: true })).toBe(true);
|
|
665
|
+
expect(editors?.when?.({})).toBe(true);
|
|
590
666
|
});
|
|
591
667
|
|
|
592
668
|
it("sideloads only into the editors the user kept", async () => {
|
|
@@ -608,6 +684,405 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
|
608
684
|
const { effects } = dryRun(composeLsp(detected, []));
|
|
609
685
|
expect(effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
610
686
|
});
|
|
687
|
+
|
|
688
|
+
it("the removal is `none` when no editor carries the extension", async () => {
|
|
689
|
+
// Both forks are on PATH, neither has a copy — there is nothing this
|
|
690
|
+
// command owns, so a removal has honestly nothing to do.
|
|
691
|
+
const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
|
|
692
|
+
"./operations/setupLsp.js"
|
|
693
|
+
);
|
|
694
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
695
|
+
expect(ownedLspEditors(detected)).toEqual([]);
|
|
696
|
+
expect(collectUndos(composeLspRemoval(detected))).toEqual([]);
|
|
697
|
+
|
|
698
|
+
const { plan } = await buildSetupRun(
|
|
699
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
700
|
+
"lsp",
|
|
701
|
+
"global",
|
|
702
|
+
true,
|
|
703
|
+
);
|
|
704
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
705
|
+
expect(row?.action).toBe("none");
|
|
706
|
+
expect(row?.detail).toBe("no editor carries the extension");
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe("setup lsp — the removal contract the other four rows implement", () => {
|
|
711
|
+
let prevPath: string | undefined;
|
|
712
|
+
let stubDir = "";
|
|
713
|
+
beforeEach(() => {
|
|
714
|
+
prevPath = process.env.PATH;
|
|
715
|
+
stubDir = stubPath();
|
|
716
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
717
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
718
|
+
process.env.PATH = stubDir;
|
|
719
|
+
});
|
|
720
|
+
afterEach(() => {
|
|
721
|
+
process.env.PATH = prevPath;
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
/** Seed a versioned extension copy in an editor's extensions dir. */
|
|
725
|
+
const seedExtension = (dir: string, version: string): void => {
|
|
726
|
+
mkdirSync(
|
|
727
|
+
join(
|
|
728
|
+
process.env.HOME ?? "",
|
|
729
|
+
dir,
|
|
730
|
+
"extensions",
|
|
731
|
+
`canonical.terrazzo-lsp-extension-${version}`,
|
|
732
|
+
),
|
|
733
|
+
{ recursive: true },
|
|
734
|
+
);
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
it("carries one `--uninstall-extension` exec per OWNED editor as its undo", async () => {
|
|
738
|
+
// The row hardcoded a skip behind a docblock claiming "an `exec` carries no
|
|
739
|
+
// reversal" — factually wrong: `Exec` has an `undo` slot, and the sibling
|
|
740
|
+
// `composeMcpRemoval` already uses this exact carrier shape.
|
|
741
|
+
seedExtension(".vscode", "1.2.3");
|
|
742
|
+
const { detectLsp, composeLspRemoval } = await import(
|
|
743
|
+
"./operations/setupLsp.js"
|
|
744
|
+
);
|
|
745
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
746
|
+
|
|
747
|
+
// The FORWARD side must be walkable with every effect mocked: it is a
|
|
748
|
+
// `mkdir` of the editor's own extensions dir, which reads nothing.
|
|
749
|
+
const forward = dryRun(composeLspRemoval(detected));
|
|
750
|
+
expect(forward.effects.map((e) => e._tag)).toEqual(["MakeDir"]);
|
|
751
|
+
expect(forward.effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
752
|
+
|
|
753
|
+
const undos = collectUndos(composeLspRemoval(detected));
|
|
754
|
+
expect(undos).toHaveLength(1);
|
|
755
|
+
const execs = undos
|
|
756
|
+
.flatMap((u) => dryRun(u).effects)
|
|
757
|
+
.filter((e) => e._tag === "Exec") as (Effect & {
|
|
758
|
+
_tag: "Exec";
|
|
759
|
+
command: string;
|
|
760
|
+
args: string[];
|
|
761
|
+
})[];
|
|
762
|
+
expect(execs.map((e) => e.command)).toEqual(["code"]);
|
|
763
|
+
expect(execs[0].args).toEqual([
|
|
764
|
+
"--uninstall-extension",
|
|
765
|
+
"canonical.terrazzo-lsp-extension",
|
|
766
|
+
]);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("REGRESSION: a pre-0.8.3 copy still plans `install` forward but `remove` back", async () => {
|
|
770
|
+
// The conflation that would have caused a silent wrong-skip.
|
|
771
|
+
// `extensionInstalled` was version-gated, so an editor carrying a dead
|
|
772
|
+
// pre-0.8.3 copy — one this command itself installed — reported
|
|
773
|
+
// `installed: false`, and a removal keyed on it would have walked past the
|
|
774
|
+
// dead extension while reporting success.
|
|
775
|
+
seedExtension(".vscode", "0.8.1");
|
|
776
|
+
const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
|
|
777
|
+
"./operations/setupLsp.js"
|
|
778
|
+
);
|
|
779
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
780
|
+
const code = detected.editors.find((e) => e.editor.cli === "code");
|
|
781
|
+
// Present, but not current — the two facts stay separate.
|
|
782
|
+
expect(code?.present).toBe(true);
|
|
783
|
+
expect(code?.installed).toBe(false);
|
|
784
|
+
|
|
785
|
+
// Forward is UNCHANGED by this fix: the dead copy is still "not installed".
|
|
786
|
+
const forward = await buildSetupRun(
|
|
787
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
788
|
+
"lsp",
|
|
789
|
+
"global",
|
|
790
|
+
);
|
|
791
|
+
expect(forward.plan.rows.find((r) => r.target === "lsp")?.action).toBe(
|
|
792
|
+
"install",
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
// Removal keys on ownership, so the dead copy IS removed.
|
|
796
|
+
expect(ownedLspEditors(detected).map((e) => e.editor.cli)).toEqual([
|
|
797
|
+
"code",
|
|
798
|
+
]);
|
|
799
|
+
const { plan } = await buildSetupRun(
|
|
800
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
801
|
+
"lsp",
|
|
802
|
+
"global",
|
|
803
|
+
true,
|
|
804
|
+
);
|
|
805
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
806
|
+
expect(row?.action).toBe("remove");
|
|
807
|
+
expect(row?.children?.map((c) => c.key)).toEqual(["code"]);
|
|
808
|
+
expect(
|
|
809
|
+
collectUndos(composeLspRemoval(detected))
|
|
810
|
+
.flatMap((u) => dryRun(u).effects)
|
|
811
|
+
.filter((e) => e._tag === "Exec"),
|
|
812
|
+
).toHaveLength(1);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("the remedy names an OWNING editor, not the first one on PATH", async () => {
|
|
816
|
+
// With Cursor (or here: `code`) installed and the extension only in
|
|
817
|
+
// VSCodium, the printed remedy named the wrong editor — a command that
|
|
818
|
+
// would report success having uninstalled nothing.
|
|
819
|
+
seedExtension(".vscode-oss", "1.2.3"); // codium's extensions dir
|
|
820
|
+
const { detectLsp, lspUninstallRemedy } = await import(
|
|
821
|
+
"./operations/setupLsp.js"
|
|
822
|
+
);
|
|
823
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
824
|
+
expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
|
|
825
|
+
expect(lspUninstallRemedy(detected)).toBe(
|
|
826
|
+
"codium --uninstall-extension canonical.terrazzo-lsp-extension",
|
|
827
|
+
);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("still SKIPS with the named reason when no editor CLI is on PATH", async () => {
|
|
831
|
+
process.env.PATH = stubPath(); // no editor CLI at all
|
|
832
|
+
const { plan } = await buildSetupRun(
|
|
833
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
834
|
+
"lsp",
|
|
835
|
+
"global",
|
|
836
|
+
true,
|
|
837
|
+
);
|
|
838
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
839
|
+
expect(row?.action).toBe("skip");
|
|
840
|
+
expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
|
|
844
|
+
// The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
|
|
845
|
+
// marker-writing script — never a real editor: an undo test must not be
|
|
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.
|
|
851
|
+
const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
|
|
852
|
+
const dir = stubPath();
|
|
853
|
+
writeFileSync(
|
|
854
|
+
join(dir, "code"),
|
|
855
|
+
`#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
|
|
856
|
+
{ mode: 0o755 },
|
|
857
|
+
);
|
|
858
|
+
process.env.PATH = dir;
|
|
859
|
+
seedExtension(".vscode", "1.2.3");
|
|
860
|
+
|
|
861
|
+
const outcome = await executeVerb(
|
|
862
|
+
verbOf("lsp"),
|
|
863
|
+
{},
|
|
864
|
+
UNDO,
|
|
865
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
866
|
+
);
|
|
867
|
+
expect(outcome.exitCode).toBe(0);
|
|
868
|
+
expect(outcome.stdout).toBe("Undid 1 step(s).\n");
|
|
869
|
+
expect(readFileSync(marker, "utf-8")).toBe(
|
|
870
|
+
"--uninstall-extension\ncanonical.terrazzo-lsp-extension\n",
|
|
871
|
+
);
|
|
872
|
+
// NON-GOAL guard: the staging dir is a cache, not this removal's business.
|
|
873
|
+
// Nothing here deletes it, and nothing here touches the VSIX.
|
|
874
|
+
const { detectLsp, composeLspRemoval } = await import(
|
|
875
|
+
"./operations/setupLsp.js"
|
|
876
|
+
);
|
|
877
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
878
|
+
const all = [
|
|
879
|
+
...dryRun(composeLspRemoval(detected)).effects,
|
|
880
|
+
...collectUndos(composeLspRemoval(detected)).flatMap(
|
|
881
|
+
(u) => dryRun(u).effects,
|
|
882
|
+
),
|
|
883
|
+
];
|
|
884
|
+
expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
|
|
885
|
+
expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
|
|
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
|
+
});
|
|
611
1086
|
});
|
|
612
1087
|
|
|
613
1088
|
describe("setup (run-all wizard) — scope threading", () => {
|
|
@@ -630,7 +1105,7 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
630
1105
|
writeFileSync(join(editorPath, "code"), "");
|
|
631
1106
|
};
|
|
632
1107
|
|
|
633
|
-
/** Seed a discoverable skill so the project-
|
|
1108
|
+
/** Seed a discoverable skill so the project-scope skills step is offerable. */
|
|
634
1109
|
const seedSkill = (cwd: string): void => {
|
|
635
1110
|
const dir = join(cwd, ".pragma", "skills", "s");
|
|
636
1111
|
mkdirSync(dir, { recursive: true });
|
|
@@ -640,10 +1115,10 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
640
1115
|
);
|
|
641
1116
|
};
|
|
642
1117
|
|
|
643
|
-
it("--local omits the global-
|
|
1118
|
+
it("--local omits the global-scope completions + lsp steps, keeping project steps", async () => {
|
|
644
1119
|
const cwd = tmp("pragma-setup-proj-");
|
|
645
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-
|
|
646
|
-
seedSkill(cwd); // project-
|
|
1120
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-scope MCP target
|
|
1121
|
+
seedSkill(cwd); // project-scope skills step
|
|
647
1122
|
const outcome = await executeVerb(
|
|
648
1123
|
setupSelfVerb,
|
|
649
1124
|
{ local: true },
|
|
@@ -652,17 +1127,19 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
652
1127
|
);
|
|
653
1128
|
expect(outcome.exitCode).toBe(0);
|
|
654
1129
|
const plan = outcome.stdout ?? "";
|
|
655
|
-
// A
|
|
656
|
-
// rows saying why they are not in this run, so nothing is silently absent
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1130
|
+
// A scope FILTERS the run-all: the out-of-scope targets are still named, as
|
|
1131
|
+
// rows saying why they are not in this run, so nothing is silently absent —
|
|
1132
|
+
// and the reason names the flag that WOULD reach them, so the row is not a
|
|
1133
|
+
// dead end either.
|
|
1134
|
+
expect(plan).toContain("completions nothing to do");
|
|
1135
|
+
expect(plan).toContain("this one is global only — run it with `--global`");
|
|
1136
|
+
// The per-project steps remain: MCP into .cursor, skills into .agents/skills.
|
|
660
1137
|
expect(plan).toContain("mcp.json");
|
|
661
1138
|
expect(plan).toContain(".agents/skills");
|
|
662
1139
|
});
|
|
663
1140
|
|
|
664
1141
|
withShell(
|
|
665
|
-
"--global omits the project-
|
|
1142
|
+
"--global omits the project-scope skills step, keeping global steps",
|
|
666
1143
|
async () => {
|
|
667
1144
|
const cwd = tmp("pragma-setup-proj-");
|
|
668
1145
|
seedSkill(cwd); // WOULD be offered under the default `both`
|
|
@@ -675,13 +1152,20 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
675
1152
|
);
|
|
676
1153
|
expect(outcome.exitCode).toBe(0);
|
|
677
1154
|
const plan = outcome.stdout ?? "";
|
|
678
|
-
// The global-
|
|
1155
|
+
// The global-scope steps are present under --global. Paths render against
|
|
679
1156
|
// the header's roots, so the row shows `~/.zfunc/_pragma`, not an absolute
|
|
680
1157
|
// prefix repeated on every line.
|
|
681
1158
|
expect(plan).toMatch(new RegExp(`completions\\s+install\\s+${SHELL} →`));
|
|
682
1159
|
expect(plan).toContain("lsp");
|
|
683
|
-
// The project-
|
|
684
|
-
|
|
1160
|
+
// The project-scope skills step is gone (the bug: it used to run under
|
|
1161
|
+
// --global). `shortenPath` renders a project path as `./…` and a global
|
|
1162
|
+
// one as `~/…`, and the two scopes' cross-client directories differ only
|
|
1163
|
+
// by that marker — so the covenant is asserted on the PROJECT spelling.
|
|
1164
|
+
// A bare "not .agents/skills" no longer separates them: the global scope
|
|
1165
|
+
// legitimately links its own `~/.agents/skills` now that the bundled
|
|
1166
|
+
// snapshot gives it skills on a machine that has run nothing.
|
|
1167
|
+
expect(plan).not.toContain(`.${sep}.agents${sep}skills`);
|
|
1168
|
+
expect(plan).toContain(`~${sep}.agents${sep}skills`);
|
|
685
1169
|
},
|
|
686
1170
|
);
|
|
687
1171
|
});
|
|
@@ -701,7 +1185,101 @@ describe("setup skills", () => {
|
|
|
701
1185
|
);
|
|
702
1186
|
expect(outcome.exitCode).toBe(0);
|
|
703
1187
|
expect(outcome.stdout).toContain("skills");
|
|
704
|
-
expect(outcome.stdout).toContain("
|
|
1188
|
+
expect(outcome.stdout).toContain("this project holds no skills");
|
|
1189
|
+
// The skip is no longer a dead end: it names the action that settles it on
|
|
1190
|
+
// THIS machine. The reason does NOT invert — the row still skips.
|
|
1191
|
+
expect(outcome.stdout).toContain("SKILL.md, then run this again");
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("an EXISTING but empty project skills root is not reported as absent", async () => {
|
|
1195
|
+
// `available` is false for an absent root and for an empty one alike, so
|
|
1196
|
+
// the shared reason line said "is absent" about a directory the user is
|
|
1197
|
+
// looking at. `rootExists` is the flag that already separates those two
|
|
1198
|
+
// states everywhere else in the module; the wording now honours it.
|
|
1199
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1200
|
+
mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
|
|
1201
|
+
|
|
1202
|
+
const { plan } = await buildSetupRun(
|
|
1203
|
+
bootRuntime(FLAGS, cwd),
|
|
1204
|
+
"skills",
|
|
1205
|
+
"project",
|
|
1206
|
+
);
|
|
1207
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1208
|
+
expect(row?.action).toBe("skip"); // still nothing to do...
|
|
1209
|
+
expect(row?.reason).toContain("is empty"); // ...for the honest reason
|
|
1210
|
+
expect(row?.reason).not.toContain("does not exist");
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("a genuinely MISSING project skills root still reports absent", async () => {
|
|
1214
|
+
// The other half of the distinction: the empty-root wording must not have
|
|
1215
|
+
// been bought by making every skip vague.
|
|
1216
|
+
const { plan } = await buildSetupRun(
|
|
1217
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1218
|
+
"skills",
|
|
1219
|
+
"project",
|
|
1220
|
+
);
|
|
1221
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1222
|
+
expect(row?.action).toBe("skip");
|
|
1223
|
+
expect(row?.reason).toContain("does not exist");
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("the GLOBAL scope OFFERS skills on a machine that has run nothing", async () => {
|
|
1227
|
+
// THE BEHAVIOUR CHANGE, asserted at the scope that used to be empty. The
|
|
1228
|
+
// global scope's only source was the installed root, so an empty
|
|
1229
|
+
// `XDG_DATA_HOME` — a fresh install — planned `skip` and told the user to
|
|
1230
|
+
// go and run `sources update` first. The bundled snapshot is the scope's
|
|
1231
|
+
// second root, so the row is actionable with no network and no prior
|
|
1232
|
+
// command. The empty-root WORDING this replaces is still asserted, on the
|
|
1233
|
+
// pure reason/remedy pair, in the cell below.
|
|
1234
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
1235
|
+
process.env.XDG_DATA_HOME = tmp("pragma-skills-data-");
|
|
1236
|
+
try {
|
|
1237
|
+
const { plan } = await buildSetupRun(
|
|
1238
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1239
|
+
"skills",
|
|
1240
|
+
"global",
|
|
1241
|
+
);
|
|
1242
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1243
|
+
expect(row?.action).toBe("link");
|
|
1244
|
+
expect(row?.detail).toMatch(/\d+ skills? →/);
|
|
1245
|
+
|
|
1246
|
+
// End to end, inside the same jail (an ambient data root that happened to
|
|
1247
|
+
// hold a skill would stop this exercising the fresh-machine condition).
|
|
1248
|
+
const outcome = await executeVerb(
|
|
1249
|
+
verbOf("skills"),
|
|
1250
|
+
{},
|
|
1251
|
+
YES,
|
|
1252
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1253
|
+
);
|
|
1254
|
+
expect(outcome.exitCode).toBe(0);
|
|
1255
|
+
expect(outcome.stdout).not.toContain("no skills installed");
|
|
1256
|
+
} finally {
|
|
1257
|
+
// `process.env.X = undefined` STORES THE STRING "undefined"; it does not
|
|
1258
|
+
// unset. The variable is always set here (`setupXdgIsolation.ts` gives
|
|
1259
|
+
// every test file its own XDG root), so this restores rather than
|
|
1260
|
+
// deletes in practice — but a test must not leave a poisoned path behind
|
|
1261
|
+
// for the files that run after it if that ever stops being true.
|
|
1262
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
1263
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
it("the GLOBAL skip still names `sources update` when it does happen", async () => {
|
|
1268
|
+
// The remedy the cell above used to assert end-to-end. That state is no
|
|
1269
|
+
// longer reachable with a snapshot in the package — it takes a build whose
|
|
1270
|
+
// `bundled-skills/` is missing AND an empty installed root — so it is
|
|
1271
|
+
// asserted where it actually lives: on the pure pair that both the setup row
|
|
1272
|
+
// and the doctor row read, which is what stops the two surfaces wording one
|
|
1273
|
+
// finding differently. A skip with no next step is the defect.
|
|
1274
|
+
expect(
|
|
1275
|
+
skillsSkipReason("~/.local/share/pragma/skills", "global", true),
|
|
1276
|
+
).toBe(
|
|
1277
|
+
"nothing to link — no skills installed yet; they arrive with the packs " +
|
|
1278
|
+
`\`${BIN_NAME} sources update\` builds`,
|
|
1279
|
+
);
|
|
1280
|
+
expect(
|
|
1281
|
+
skillsSkipRemedy("~/.local/share/pragma/skills", "global"),
|
|
1282
|
+
).toContain(`${BIN_NAME} sources update`);
|
|
705
1283
|
});
|
|
706
1284
|
|
|
707
1285
|
it("detects a created action and composes a symlink carrying an undo", async () => {
|
|
@@ -727,6 +1305,77 @@ describe("setup skills", () => {
|
|
|
727
1305
|
expect(existsSync(join(cwd, ".agents", "skills", "my-skill"))).toBe(false);
|
|
728
1306
|
});
|
|
729
1307
|
|
|
1308
|
+
it("NEVER deletes a user's own symlink that collides with a shipped skill (REGRESSION)", async () => {
|
|
1309
|
+
// Bundled skills ship on every install, so a folder-name collision with a
|
|
1310
|
+
// user's own link is reachable with NO user action — where previously an
|
|
1311
|
+
// empty skill set meant no action was ever composed for that path.
|
|
1312
|
+
// `composeSkills` composes every non-skipped row, so classifying a
|
|
1313
|
+
// resolving foreign link as `replaced` deletes the user's link.
|
|
1314
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1315
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1316
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1317
|
+
writeFileSync(
|
|
1318
|
+
join(skillDir, "SKILL.md"),
|
|
1319
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
// Somewhere entirely outside every pragma root, and it RESOLVES.
|
|
1323
|
+
const mine = join(cwd, "my-own-work", "my-skill");
|
|
1324
|
+
mkdirSync(mine, { recursive: true });
|
|
1325
|
+
writeFileSync(join(mine, "SKILL.md"), "---\nname: mine\n---\n");
|
|
1326
|
+
|
|
1327
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1328
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1329
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1330
|
+
symlinkSync(mine, linkPath);
|
|
1331
|
+
|
|
1332
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1333
|
+
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
1334
|
+
expect(action?.action).toBe("skipped");
|
|
1335
|
+
expect(action?.owned).toBe(false);
|
|
1336
|
+
|
|
1337
|
+
// Neither the preview nor the real run may touch it.
|
|
1338
|
+
const { effects } = dryRun(composeSkills(detected));
|
|
1339
|
+
expect(
|
|
1340
|
+
effects.filter(
|
|
1341
|
+
(e) =>
|
|
1342
|
+
e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
|
|
1343
|
+
),
|
|
1344
|
+
).toHaveLength(0);
|
|
1345
|
+
|
|
1346
|
+
await runTask(composeSkills(detected));
|
|
1347
|
+
expect(readlinkSync(linkPath)).toBe(mine);
|
|
1348
|
+
expect(existsSync(join(linkPath, "SKILL.md"))).toBe(true);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it("sweeps a link into a REMOVED package directory once its skill is gone (REGRESSION)", async () => {
|
|
1352
|
+
// The upgrade shape under a version-stamped layout (pnpm, npx, volta): the
|
|
1353
|
+
// link points into `…@0.34.0/bundled-skills/<name>`, a directory the
|
|
1354
|
+
// upgrade replaced. Ownership by residence in a CURRENT root cannot see it,
|
|
1355
|
+
// and once the release drops that skill no per-skill action covers the path
|
|
1356
|
+
// either — so the dangling link would survive every reconcile, invisible to
|
|
1357
|
+
// setup and to doctor alike.
|
|
1358
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1359
|
+
const skillDir = join(cwd, ".pragma", "skills", "still-here");
|
|
1360
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1361
|
+
writeFileSync(
|
|
1362
|
+
join(skillDir, "SKILL.md"),
|
|
1363
|
+
"---\nname: still-here\ndescription: A test skill.\n---\n",
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1367
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1368
|
+
// Never created: this is the point — the old package directory is gone.
|
|
1369
|
+
const retired = join(cwd, "pragma-cli@0.34.0", "bundled-skills", "retired");
|
|
1370
|
+
const linkPath = join(linkDir, "retired");
|
|
1371
|
+
symlinkSync(retired, linkPath);
|
|
1372
|
+
|
|
1373
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1374
|
+
const orphan = detected.orphans.find((o) => o.linkPath === linkPath);
|
|
1375
|
+
expect(orphan).toBeDefined();
|
|
1376
|
+
expect(orphan?.owned).toBe(true);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
730
1379
|
it("classifies a DANGLING symlink as replaced and repairs it (S1-2)", async () => {
|
|
731
1380
|
// `existsSync` FOLLOWS a symlink, so a dangling link used to read as
|
|
732
1381
|
// "absent" → plan `created` → the real symlink() crashed EEXIST labeled
|
|
@@ -858,6 +1507,40 @@ describe("setup skills", () => {
|
|
|
858
1507
|
expect(() => lstatSync(linkPath)).toThrow();
|
|
859
1508
|
});
|
|
860
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
|
+
|
|
861
1544
|
it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
|
|
862
1545
|
// `readlink` reports the raw target, which may be relative — resolving it
|
|
863
1546
|
// against the link's own directory is what makes the containment test
|
|
@@ -877,17 +1560,168 @@ describe("setup skills", () => {
|
|
|
877
1560
|
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
878
1561
|
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
879
1562
|
expect(action?.owned).toBe(true);
|
|
1563
|
+
// A relative link that RESOLVES to the skill is already correct. Comparing
|
|
1564
|
+
// the raw `readlink` string to an absolute path classified it `replaced`,
|
|
1565
|
+
// so it churned on every run and doctor called it "points elsewhere".
|
|
1566
|
+
expect(action?.action).toBe("skipped");
|
|
1567
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
880
1568
|
|
|
881
1569
|
await runUndo(composeSkillsRemoval(detected));
|
|
882
1570
|
expect(existsSync(linkPath)).toBe(false);
|
|
883
1571
|
});
|
|
884
1572
|
});
|
|
885
1573
|
|
|
1574
|
+
describe("setup skills — the forward pass is a RECONCILE", () => {
|
|
1575
|
+
/** Seed one project skill and return its source dir. */
|
|
1576
|
+
const seed = (cwd: string, name: string): string => {
|
|
1577
|
+
const dir = join(cwd, ".pragma", "skills", name);
|
|
1578
|
+
mkdirSync(dir, { recursive: true });
|
|
1579
|
+
writeFileSync(
|
|
1580
|
+
join(dir, "SKILL.md"),
|
|
1581
|
+
`---\nname: ${name}\ndescription: A skill.\n---\n`,
|
|
1582
|
+
);
|
|
1583
|
+
return dir;
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
it("removes the link a dropped skill left behind, without --undo", async () => {
|
|
1587
|
+
// The reported bug. `d.orphans` was computed and read ONLY by `--undo`,
|
|
1588
|
+
// which removes every owned link rather than the stale ones — so no command
|
|
1589
|
+
// reconciled, and a skill dropped upstream kept its link forever.
|
|
1590
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1591
|
+
const kept = seed(cwd, "kept");
|
|
1592
|
+
const dropped = seed(cwd, "dropped");
|
|
1593
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1594
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1595
|
+
symlinkSync(kept, join(linkDir, "kept"));
|
|
1596
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1597
|
+
rmSync(dropped, { recursive: true, force: true }); // retired upstream
|
|
1598
|
+
|
|
1599
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1600
|
+
expect(detected.orphans.map((o) => o.skillName)).toEqual(["dropped"]);
|
|
1601
|
+
|
|
1602
|
+
await runTask(composeSkills(detected));
|
|
1603
|
+
// `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
|
|
1604
|
+
expect(() => lstatSync(join(linkDir, "dropped"))).toThrow();
|
|
1605
|
+
expect(readlinkSync(join(linkDir, "kept"))).toBe(kept);
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
it("plans the sweep as an ACTIONABLE row, not a deselected `none`", async () => {
|
|
1609
|
+
// An orphan-only tree planned `none` → `defaultSelected(none)` is false →
|
|
1610
|
+
// the row was never composed, so even the fixed reconcile would not run.
|
|
1611
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1612
|
+
const dropped = seed(cwd, "dropped");
|
|
1613
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1614
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1615
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1616
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1617
|
+
|
|
1618
|
+
const { plan } = await buildSetupRun(
|
|
1619
|
+
bootRuntime(FLAGS, cwd),
|
|
1620
|
+
"skills",
|
|
1621
|
+
"project",
|
|
1622
|
+
);
|
|
1623
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1624
|
+
expect(row?.action).toBe("update");
|
|
1625
|
+
expect(row?.selected).toBe(true);
|
|
1626
|
+
expect(row?.detail).toContain("1 stale link to remove");
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it("the sweep is reversible — undo puts the stale link back", async () => {
|
|
1630
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1631
|
+
const dropped = seed(cwd, "dropped");
|
|
1632
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1633
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1634
|
+
const linkPath = join(linkDir, "dropped");
|
|
1635
|
+
symlinkSync(dropped, linkPath);
|
|
1636
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1637
|
+
|
|
1638
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1639
|
+
const { undoCount } = await runUndo(composeSkills(detected));
|
|
1640
|
+
expect(undoCount).toBeGreaterThan(0);
|
|
1641
|
+
expect(readlinkSync(linkPath)).toBe(dropped);
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it("is idempotent — a second pass composes zero effects", async () => {
|
|
1645
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1646
|
+
const dropped = seed(cwd, "dropped");
|
|
1647
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1648
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1649
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1650
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1651
|
+
|
|
1652
|
+
await runTask(
|
|
1653
|
+
composeSkills(await detectSkills(bootRuntime(FLAGS, cwd), "project")),
|
|
1654
|
+
);
|
|
1655
|
+
const second = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1656
|
+
expect(dryRun(composeSkills(second)).effects).toEqual([]);
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it("SAFETY: an ABSENT source root means no opinion — nothing is swept", async () => {
|
|
1660
|
+
// `available` conflated "the root is empty" with "the root does not exist".
|
|
1661
|
+
// Sweeping on that alone would wipe every pragma link out of the harness
|
|
1662
|
+
// dirs on any machine where XDG_DATA_HOME moved or sources update never ran.
|
|
1663
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1664
|
+
const skillDir = seed(cwd, "gone");
|
|
1665
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1666
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1667
|
+
const linkPath = join(linkDir, "gone");
|
|
1668
|
+
symlinkSync(skillDir, linkPath);
|
|
1669
|
+
// The whole source ROOT disappears, not just one skill.
|
|
1670
|
+
rmSync(join(cwd, ".pragma", "skills"), { recursive: true, force: true });
|
|
1671
|
+
|
|
1672
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1673
|
+
expect(detected.rootExists).toBe(false);
|
|
1674
|
+
expect(detected.available).toBe(false);
|
|
1675
|
+
expect(detected.orphans).toEqual([]);
|
|
1676
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1677
|
+
|
|
1678
|
+
await runTask(composeSkills(detected));
|
|
1679
|
+
expect(readlinkSync(linkPath)).toBe(skillDir);
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
|
|
1683
|
+
// The three shapes the sweep must never touch, in one tree: a hand-placed
|
|
1684
|
+
// directory, a link into someone's own checkout, and a link into a SIBLING
|
|
1685
|
+
// whose name merely EXTENDS the root's (the old string-prefix bug).
|
|
1686
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1687
|
+
const root = join(cwd, ".pragma", "skills");
|
|
1688
|
+
mkdirSync(root, { recursive: true }); // exists, but holds no skill
|
|
1689
|
+
const backup = join(cwd, ".pragma", "skills-backup", "sibling");
|
|
1690
|
+
mkdirSync(backup, { recursive: true });
|
|
1691
|
+
writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
|
|
1692
|
+
const elsewhere = join(cwd, "elsewhere", "mine");
|
|
1693
|
+
mkdirSync(elsewhere, { recursive: true });
|
|
1694
|
+
|
|
1695
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1696
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1697
|
+
const realDir = join(linkDir, "hand-placed");
|
|
1698
|
+
mkdirSync(realDir, { recursive: true });
|
|
1699
|
+
writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
|
|
1700
|
+
symlinkSync(elsewhere, join(linkDir, "mine"));
|
|
1701
|
+
symlinkSync(backup, join(linkDir, "sibling"));
|
|
1702
|
+
|
|
1703
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1704
|
+
expect(detected.rootExists).toBe(true); // the sweep IS entitled to act
|
|
1705
|
+
expect(detected.orphans).toEqual([]); // ...and finds nothing of its own
|
|
1706
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1707
|
+
|
|
1708
|
+
await runTask(composeSkills(detected));
|
|
1709
|
+
expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
|
|
1710
|
+
"hand-placed\n",
|
|
1711
|
+
);
|
|
1712
|
+
expect(readlinkSync(join(linkDir, "mine"))).toBe(elsewhere);
|
|
1713
|
+
expect(readlinkSync(join(linkDir, "sibling"))).toBe(backup);
|
|
1714
|
+
expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
|
|
1715
|
+
"the user's own copy\n",
|
|
1716
|
+
);
|
|
1717
|
+
});
|
|
1718
|
+
});
|
|
1719
|
+
|
|
886
1720
|
describe("setup — detections settle independently", () => {
|
|
887
1721
|
it("gives a rejecting detection its own failed row, leaving the rest planned", async () => {
|
|
888
1722
|
// The targets are independent, and one `Promise.all` made them share a
|
|
889
1723
|
// fate: a single rejecting detection prevented a run being built at all,
|
|
890
|
-
// so NONE of the other targets ran and doctor rendered no
|
|
1724
|
+
// so NONE of the other targets ran and doctor rendered no scoped rows.
|
|
891
1725
|
const { TARGETS } = await import("./targets.js");
|
|
892
1726
|
const lsp = TARGETS.find((t) => t.id === "lsp") as (typeof TARGETS)[number];
|
|
893
1727
|
const spy = vi
|
|
@@ -1364,8 +2198,8 @@ describe("setup (run-all wizard)", () => {
|
|
|
1364
2198
|
let thrown: unknown;
|
|
1365
2199
|
try {
|
|
1366
2200
|
// `--scope both`: the failing target (lsp) is global-only, and the two
|
|
1367
|
-
// that must survive it write the project
|
|
1368
|
-
// both. A bare run-all is the GLOBAL
|
|
2201
|
+
// that must survive it write the project scope, so the run has to cover
|
|
2202
|
+
// both. A bare run-all is the GLOBAL scope now and would not touch this
|
|
1369
2203
|
// repository at all.
|
|
1370
2204
|
await executeVerb(
|
|
1371
2205
|
setupSelfVerb,
|
|
@@ -1392,11 +2226,17 @@ describe("setup (run-all wizard)", () => {
|
|
|
1392
2226
|
});
|
|
1393
2227
|
|
|
1394
2228
|
it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
|
|
1395
|
-
// A run-all in a
|
|
1396
|
-
//
|
|
2229
|
+
// A run-all in a scope with no skills must NOT throw — it just doesn't offer
|
|
2230
|
+
// the skills step. Reaching a clean plan proves the graceful degrade.
|
|
2231
|
+
//
|
|
2232
|
+
// POINTED AT THE PROJECT BAND, which is where an empty skills root is still
|
|
2233
|
+
// an ordinary condition: the global scope now always has the bundled
|
|
2234
|
+
// snapshot behind it, so a run-all there can no longer produce the empty
|
|
2235
|
+
// row this cell exists to survive. The condition is the same one; only the
|
|
2236
|
+
// scope that can still reach it has changed.
|
|
1397
2237
|
const outcome = await executeVerb(
|
|
1398
2238
|
setupSelfVerb,
|
|
1399
|
-
{},
|
|
2239
|
+
{ local: true },
|
|
1400
2240
|
DRY,
|
|
1401
2241
|
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1402
2242
|
);
|
|
@@ -1404,9 +2244,245 @@ describe("setup (run-all wizard)", () => {
|
|
|
1404
2244
|
// The skills row is PRESENT and says why it cannot act, rather than being
|
|
1405
2245
|
// dropped from a plan that then claims completeness.
|
|
1406
2246
|
expect(outcome.stdout).toContain("skills");
|
|
1407
|
-
expect(outcome.stdout).toContain("no skills
|
|
2247
|
+
expect(outcome.stdout).toContain("this project holds no skills");
|
|
1408
2248
|
expect(outcome.stdout).toContain("lsp");
|
|
1409
2249
|
});
|
|
2250
|
+
|
|
2251
|
+
it("the GLOBAL run-all offers the skills row on a fresh machine", async () => {
|
|
2252
|
+
// The other side of the same graceful-degrade question, and the one a fresh
|
|
2253
|
+
// install actually meets: a bare run-all is the global scope, whose skills
|
|
2254
|
+
// row is now offerable from the bundled snapshot rather than skipped with a
|
|
2255
|
+
// remedy the user has to go away and run.
|
|
2256
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
2257
|
+
process.env.XDG_DATA_HOME = tmp("pragma-runall-data-");
|
|
2258
|
+
try {
|
|
2259
|
+
const outcome = await executeVerb(
|
|
2260
|
+
setupSelfVerb,
|
|
2261
|
+
{},
|
|
2262
|
+
DRY,
|
|
2263
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2264
|
+
);
|
|
2265
|
+
expect(outcome.exitCode).toBe(0);
|
|
2266
|
+
expect(outcome.stdout).toContain("skills");
|
|
2267
|
+
expect(outcome.stdout).not.toContain("no skills installed");
|
|
2268
|
+
} finally {
|
|
2269
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
2270
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
describe("setup — a converged machine is reported, never asked", () => {
|
|
2276
|
+
// A converged run composes ZERO effects, and the confirm gate then mounted
|
|
2277
|
+
// Ink to render summon-core's "No operations planned." above "Proceed?".
|
|
2278
|
+
// On a sub-verb, whose prompt list is empty, that contentless gate was the
|
|
2279
|
+
// first and only thing the user ever saw.
|
|
2280
|
+
let prevPath: string | undefined;
|
|
2281
|
+
beforeEach(() => {
|
|
2282
|
+
prevPath = process.env.PATH;
|
|
2283
|
+
process.env.PATH = stubPath(); // no editor CLI ⇒ lsp skips
|
|
2284
|
+
});
|
|
2285
|
+
afterEach(() => {
|
|
2286
|
+
process.env.PATH = prevPath;
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
const NO = { dryRun: false, undo: false, yes: false };
|
|
2290
|
+
|
|
2291
|
+
it("non-interactive + no --yes: the recap, NOT the `run again with --yes` hint", async () => {
|
|
2292
|
+
// The compounding defect: a converged plan printed "Nothing was applied.
|
|
2293
|
+
// Run again with --yes to apply." over a machine where there was nothing
|
|
2294
|
+
// to apply in the first place.
|
|
2295
|
+
const outcome = await executeVerb(
|
|
2296
|
+
verbOf("lsp"),
|
|
2297
|
+
{},
|
|
2298
|
+
NO,
|
|
2299
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2300
|
+
);
|
|
2301
|
+
expect(outcome.exitCode).toBe(0);
|
|
2302
|
+
expect(outcome.stdout).not.toContain("Run again with --yes");
|
|
2303
|
+
expect(outcome.stdout).not.toContain("Setup plan");
|
|
2304
|
+
// It says what was detected and what happened to it — doctor's house shape.
|
|
2305
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2306
|
+
expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
|
|
2310
|
+
// The acceptance case. Faking a terminal on both streams is what makes
|
|
2311
|
+
// `cliIsTTY()` true, which is the ONLY gate between this run and the wizard.
|
|
2312
|
+
const prevIn = process.stdin.isTTY;
|
|
2313
|
+
const prevErr = process.stderr.isTTY;
|
|
2314
|
+
(process.stdin as { isTTY?: boolean }).isTTY = true;
|
|
2315
|
+
(process.stderr as { isTTY?: boolean }).isTTY = true;
|
|
2316
|
+
let outcome: Awaited<ReturnType<typeof executeVerb>>;
|
|
2317
|
+
try {
|
|
2318
|
+
outcome = await executeVerb(
|
|
2319
|
+
verbOf("lsp"),
|
|
2320
|
+
{},
|
|
2321
|
+
NO,
|
|
2322
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2323
|
+
);
|
|
2324
|
+
} finally {
|
|
2325
|
+
(process.stdin as { isTTY?: boolean }).isTTY = prevIn;
|
|
2326
|
+
(process.stderr as { isTTY?: boolean }).isTTY = prevErr;
|
|
2327
|
+
}
|
|
2328
|
+
expect(outcome.exitCode).toBe(0);
|
|
2329
|
+
expect(outcome.stdout).not.toContain("Proceed");
|
|
2330
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2331
|
+
// The same lazy-React probe the PROTECTED guard below uses: had the
|
|
2332
|
+
// contentless gate mounted, Ink would be in the module cache.
|
|
2333
|
+
const isReactPkg = (k: string) =>
|
|
2334
|
+
/[\\/](react|react-dom|ink)@\d/.test(k) ||
|
|
2335
|
+
/[\\/]node_modules[\\/](react|react-dom|ink)[\\/]/.test(k);
|
|
2336
|
+
expect(Object.keys(require.cache ?? {}).filter(isReactPkg)).toEqual([]);
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
it("--dry-run is untouched — it still ends with the dry-run hint", async () => {
|
|
2340
|
+
// The guard sits AFTER the `previewing` computation precisely so a preview
|
|
2341
|
+
// keeps saying it was a preview.
|
|
2342
|
+
const outcome = await executeVerb(
|
|
2343
|
+
verbOf("lsp"),
|
|
2344
|
+
{},
|
|
2345
|
+
DRY,
|
|
2346
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2347
|
+
);
|
|
2348
|
+
expect(outcome.exitCode).toBe(0);
|
|
2349
|
+
expect(outcome.stdout).toContain("Setup plan");
|
|
2350
|
+
expect(outcome.stdout).toContain(DRY_RUN_HINT);
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
it("a PARTIALLY converged plan is NOT short-circuited", async () => {
|
|
2354
|
+
// One actionable row is enough: the run goes through `execute` as before,
|
|
2355
|
+
// so the wizard is still reachable on a machine with something to do.
|
|
2356
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2357
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2358
|
+
const outcome = await executeVerb(
|
|
2359
|
+
verbOf("mcp"),
|
|
2360
|
+
{ local: true },
|
|
2361
|
+
NO,
|
|
2362
|
+
bootRuntime(FLAGS, cwd),
|
|
2363
|
+
);
|
|
2364
|
+
expect(outcome.exitCode).toBe(0);
|
|
2365
|
+
// Non-interactive without --yes ⇒ still the preview, hint and all.
|
|
2366
|
+
expect(outcome.stdout).toContain("Setup plan");
|
|
2367
|
+
expect(outcome.stdout).toContain(PREVIEW_HINT);
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
it("the run-all on a fully converged machine recaps every row at exit 0", async () => {
|
|
2371
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2372
|
+
// First pass configures whatever this machine can hold...
|
|
2373
|
+
await executeVerb(setupSelfVerb, {}, YES, bootRuntime(FLAGS, cwd));
|
|
2374
|
+
// ...the second has nothing left to do.
|
|
2375
|
+
const outcome = await executeVerb(
|
|
2376
|
+
setupSelfVerb,
|
|
2377
|
+
{},
|
|
2378
|
+
NO,
|
|
2379
|
+
bootRuntime(FLAGS, cwd),
|
|
2380
|
+
);
|
|
2381
|
+
expect(outcome.exitCode).toBe(0);
|
|
2382
|
+
expect(outcome.stdout).not.toContain("Run again with --yes");
|
|
2383
|
+
expect(outcome.stdout).toContain("targets configured");
|
|
2384
|
+
});
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
describe("setup — the detection summary", () => {
|
|
2388
|
+
let prevPath: string | undefined;
|
|
2389
|
+
beforeEach(() => {
|
|
2390
|
+
prevPath = process.env.PATH;
|
|
2391
|
+
process.env.PATH = stubPath();
|
|
2392
|
+
});
|
|
2393
|
+
afterEach(() => {
|
|
2394
|
+
process.env.PATH = prevPath;
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
/**
|
|
2398
|
+
* Drive a run, capturing STDERR — which is where the report seam writes, and
|
|
2399
|
+
* the same stream the Ink frame would use. The dispatcher builds its own
|
|
2400
|
+
* runtime for a mutation, so an `rt.report` set on the boot runtime is
|
|
2401
|
+
* replaced; capturing the stream is what actually pins the behaviour.
|
|
2402
|
+
*/
|
|
2403
|
+
const onStderr = async (
|
|
2404
|
+
verb: VerbSpec,
|
|
2405
|
+
params: Record<string, unknown>,
|
|
2406
|
+
flags: GlobalFlags,
|
|
2407
|
+
cwd: string,
|
|
2408
|
+
mutation = YES,
|
|
2409
|
+
): Promise<string[]> => {
|
|
2410
|
+
const chunks: string[] = [];
|
|
2411
|
+
const spy = vi
|
|
2412
|
+
.spyOn(process.stderr, "write")
|
|
2413
|
+
.mockImplementation((chunk: unknown): boolean => {
|
|
2414
|
+
chunks.push(String(chunk));
|
|
2415
|
+
return true;
|
|
2416
|
+
});
|
|
2417
|
+
try {
|
|
2418
|
+
await executeVerb(verb, params, mutation, bootRuntime(flags, cwd));
|
|
2419
|
+
} finally {
|
|
2420
|
+
spy.mockRestore();
|
|
2421
|
+
}
|
|
2422
|
+
return chunks;
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2425
|
+
/** The one write the report seam made for the summary block, if any. */
|
|
2426
|
+
const summaryOf = (chunks: readonly string[]): string =>
|
|
2427
|
+
chunks.find((c) => c.startsWith("Detected —")) ?? "";
|
|
2428
|
+
|
|
2429
|
+
it("names what was detected BEFORE anything is asked, detected rows only", async () => {
|
|
2430
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2431
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2432
|
+
const chunks = await onStderr(setupSelfVerb, { local: true }, FLAGS, cwd);
|
|
2433
|
+
// The report seam writes the block as ONE write, before the generator is
|
|
2434
|
+
// driven — so it is also the first thing on the stream.
|
|
2435
|
+
expect(chunks[0]?.startsWith("Detected —")).toBe(true);
|
|
2436
|
+
const summary = summaryOf(chunks);
|
|
2437
|
+
// FIRST — the Ink frame and this line both go to stderr, so only a line
|
|
2438
|
+
// written before `execute` lands above the frame in scrollback.
|
|
2439
|
+
expect(summary).toContain("Detected —");
|
|
2440
|
+
expect(summary).toContain("mcp");
|
|
2441
|
+
// A skip is "nothing detected here", so it is out of the default view...
|
|
2442
|
+
expect(summary).not.toContain("user-level only");
|
|
2443
|
+
expect(summary).toContain("--verbose lists them");
|
|
2444
|
+
});
|
|
2445
|
+
|
|
2446
|
+
it("--verbose widens it to every row, with no `not detected` footer", async () => {
|
|
2447
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2448
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2449
|
+
const summary = summaryOf(
|
|
2450
|
+
await onStderr(
|
|
2451
|
+
setupSelfVerb,
|
|
2452
|
+
{ local: true },
|
|
2453
|
+
{ ...FLAGS, verbose: true },
|
|
2454
|
+
cwd,
|
|
2455
|
+
),
|
|
2456
|
+
);
|
|
2457
|
+
// ...and back in under `--verbose`, which consumes the EXISTING global flag
|
|
2458
|
+
// rather than adding a signal.
|
|
2459
|
+
expect(summary).toContain("this one is global only");
|
|
2460
|
+
expect(summary).not.toContain("--verbose lists them");
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
it("says nothing on a --dry-run — the plan table IS that run's output", async () => {
|
|
2464
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2465
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2466
|
+
const chunks = await onStderr(
|
|
2467
|
+
setupSelfVerb,
|
|
2468
|
+
{ local: true },
|
|
2469
|
+
FLAGS,
|
|
2470
|
+
cwd,
|
|
2471
|
+
DRY,
|
|
2472
|
+
);
|
|
2473
|
+
expect(summaryOf(chunks)).toBe("");
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
it("is silent over MCP — `rt.report` is a no-op there, so no Ink and no noise", async () => {
|
|
2477
|
+
// The seam is the point: a plain-string report, not an Ink mount, is what
|
|
2478
|
+
// keeps this off the React graph AND out of the MCP data stream.
|
|
2479
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2480
|
+
const mcp = await projectMcp([setupModule], cwd);
|
|
2481
|
+
const result = await mcp.callTool("setup", { confirm: true });
|
|
2482
|
+
await mcp.cleanup();
|
|
2483
|
+
expect(result.ok).toBe(true);
|
|
2484
|
+
expect(JSON.stringify(result.data)).not.toContain("Detected —");
|
|
2485
|
+
});
|
|
1410
2486
|
});
|
|
1411
2487
|
|
|
1412
2488
|
describe("lazy-React discipline (PROTECTED)", () => {
|