@canonical/pragma-cli 0.36.0 → 0.38.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 +3 -2
- package/bundled-skills/adoption-a1-styles/SKILL.md +24 -0
- package/bundled-skills/adoption-a2-components/SKILL.md +22 -6
- package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +1397 -327
- package/bundled-skills/anatomy-author/SKILL.md +840 -137
- package/bundled-skills/design-auditor/SKILL.md +133 -9
- package/bundled-skills/implement-component/SKILL.md +192 -23
- package/bundled-skills/specify-component/SKILL.md +124 -12
- package/bundled-skills/specify-pattern/SKILL.md +47 -8
- package/dist/package.json +23 -20
- package/dist/pragma.conf.js +963 -40
- package/dist/pragma.conf.js.map +1 -1
- package/dist/src/capabilities/capabilities/catalog.js +14 -5
- package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
- package/dist/src/capabilities/capabilities/hints.js +33 -5
- package/dist/src/capabilities/capabilities/hints.js.map +1 -1
- package/dist/src/capabilities/config/show.render.js +30 -2
- package/dist/src/capabilities/config/show.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/doctor/checks/checkNodeVersion.js +11 -6
- package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/harnessInventory.js +1 -1
- package/dist/src/capabilities/doctor/checks/harnessInventory.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/doctor/checks/targetHealth.js +104 -14
- package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
- package/dist/src/capabilities/graph/connect.render.js +101 -0
- package/dist/src/capabilities/graph/connect.render.js.map +1 -0
- package/dist/src/capabilities/graph/connect.types.js +21 -0
- package/dist/src/capabilities/graph/connect.types.js.map +1 -0
- package/dist/src/capabilities/graph/connect.verb.js +147 -0
- package/dist/src/capabilities/graph/connect.verb.js.map +1 -0
- package/dist/src/capabilities/graph/index.js +5 -3
- package/dist/src/capabilities/graph/index.js.map +1 -1
- package/dist/src/capabilities/graph/queryText.js +206 -0
- package/dist/src/capabilities/graph/queryText.js.map +1 -0
- package/dist/src/capabilities/graph/relationIndex.js +302 -0
- package/dist/src/capabilities/graph/relationIndex.js.map +1 -0
- package/dist/src/capabilities/graph/runConnect.js +456 -0
- package/dist/src/capabilities/graph/runConnect.js.map +1 -0
- package/dist/src/capabilities/graph/runQuery.js +21 -4
- package/dist/src/capabilities/graph/runQuery.js.map +1 -1
- package/dist/src/capabilities/setup/buildPlan.js +6 -2
- package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
- package/dist/src/capabilities/setup/operations/index.js +7 -2
- 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 +72 -14
- package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupLsp.js +372 -62
- package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupMcp.js +150 -5
- 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/operations/writability.js +114 -0
- package/dist/src/capabilities/setup/operations/writability.js.map +1 -0
- package/dist/src/capabilities/setup/plan.js.map +1 -1
- package/dist/src/capabilities/setup/setup.verb.js +80 -31
- package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
- package/dist/src/capabilities/setup/targets.js +124 -42
- 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/error/renderError.js +3 -2
- package/dist/src/kernel/error/renderError.js.map +1 -1
- package/dist/src/kernel/error/validOptions.js +41 -0
- package/dist/src/kernel/error/validOptions.js.map +1 -0
- package/dist/src/kernel/packs/compile.js +90 -6
- package/dist/src/kernel/packs/compile.js.map +1 -1
- package/dist/src/kernel/packs/cursor.js +135 -0
- package/dist/src/kernel/packs/cursor.js.map +1 -0
- package/dist/src/kernel/packs/graphql/buildLookupDocument.js +9 -2
- package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -1
- package/dist/src/kernel/packs/paging.js +108 -0
- package/dist/src/kernel/packs/paging.js.map +1 -0
- package/dist/src/kernel/packs/renderPack.js +154 -5
- package/dist/src/kernel/packs/renderPack.js.map +1 -1
- package/dist/src/kernel/packs/resolveEntity.js +118 -19
- package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
- package/dist/src/kernel/packs/runBodies.js +156 -18
- package/dist/src/kernel/packs/runBodies.js.map +1 -1
- package/dist/src/kernel/packs/schema.js +160 -12
- package/dist/src/kernel/packs/schema.js.map +1 -1
- package/dist/src/kernel/packs/sparql/authorQuery.js +227 -0
- package/dist/src/kernel/packs/sparql/authorQuery.js.map +1 -0
- package/dist/src/kernel/packs/sparql/buildListQuery.js +251 -0
- package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -0
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js +125 -22
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
- package/dist/src/kernel/packs/sparql/filterValues.js +145 -0
- package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -0
- package/dist/src/kernel/packs/sparql/index.js +24 -14
- package/dist/src/kernel/packs/sparql/index.js.map +1 -1
- package/dist/src/kernel/packs/sparql/runSelect.js +10 -4
- package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
- package/dist/src/kernel/packs/sparql/searchTerm.js +25 -0
- package/dist/src/kernel/packs/sparql/searchTerm.js.map +1 -0
- package/dist/src/kernel/packs/storyRules.js +151 -0
- package/dist/src/kernel/packs/storyRules.js.map +1 -0
- package/dist/src/kernel/packs/tierScope.js +310 -0
- package/dist/src/kernel/packs/tierScope.js.map +1 -0
- package/dist/src/kernel/packs/types.js +80 -0
- package/dist/src/kernel/packs/types.js.map +1 -1
- package/dist/src/kernel/project/cli/buildProgram.js +10 -1
- package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
- package/dist/src/kernel/project/cli/dispatch.js +104 -3
- 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/project/cli/suggestNames.js +31 -7
- package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
- package/dist/src/kernel/project/mcp/registerVerb.js +5 -1
- package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
- package/dist/src/kernel/render/renderers.js +34 -2
- package/dist/src/kernel/render/renderers.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 +17 -10
- 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/readEntity.js +19 -4
- package/dist/src/kernel/runtime/readEntity.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 +38 -5
- 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 +23 -20
- package/pragma.conf.ts +983 -40
- package/src/capabilities/block.parity.test.ts +207 -27
- package/src/capabilities/block.shipped.exec.test.ts +156 -14
- package/src/capabilities/block.tierRank.exec.test.ts +46 -9
- package/src/capabilities/capabilities/catalog.ts +14 -5
- package/src/capabilities/capabilities/hints.ts +40 -5
- package/src/capabilities/config/collectConfigShow.test.ts +1 -0
- package/src/capabilities/config/set.test.ts +24 -25
- package/src/capabilities/config/show.render.test.ts +9 -4
- package/src/capabilities/config/show.render.ts +30 -2
- 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/distribution.parity.test.ts +12 -11
- package/src/capabilities/distribution.test.ts +77 -7
- package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
- package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
- package/src/capabilities/doctor/checks/harnessInventory.test.ts +13 -6
- package/src/capabilities/doctor/checks/harnessInventory.ts +8 -4
- 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 +114 -13
- package/src/capabilities/doctor/doctor.test.ts +327 -36
- package/src/capabilities/emptyRecovery.test.ts +84 -28
- package/src/capabilities/graph/connect.render.ts +141 -0
- package/src/capabilities/graph/connect.shipped.exec.test.ts +370 -0
- package/src/capabilities/graph/connect.test.ts +361 -0
- package/src/capabilities/graph/connect.types.ts +136 -0
- package/src/capabilities/graph/connect.verb.ts +165 -0
- package/src/capabilities/graph/index.ts +5 -3
- package/src/capabilities/graph/query.shipped.exec.test.ts +66 -0
- package/src/capabilities/graph/query.test.ts +17 -0
- package/src/capabilities/graph/queryText.test.ts +121 -0
- package/src/capabilities/graph/queryText.ts +240 -0
- package/src/capabilities/graph/relationIndex.ts +372 -0
- package/src/capabilities/graph/runConnect.ts +577 -0
- package/src/capabilities/graph/runQuery.ts +21 -6
- package/src/capabilities/lazy.test.ts +32 -2
- package/src/capabilities/listBudget.shipped.exec.test.ts +239 -0
- package/src/capabilities/listLookup.shipped.exec.test.ts +387 -0
- package/src/capabilities/listQuery.shipped.exec.test.ts +400 -0
- package/src/capabilities/resources/resources.test.ts +38 -2
- package/src/capabilities/setup/buildPlan.ts +7 -1
- package/src/capabilities/setup/operations/index.ts +14 -0
- 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 +93 -15
- package/src/capabilities/setup/operations/setupLsp.ts +544 -85
- package/src/capabilities/setup/operations/setupMcp.ts +212 -4
- package/src/capabilities/setup/operations/setupSkills.ts +7 -2
- package/src/capabilities/setup/operations/writability.test.ts +151 -0
- package/src/capabilities/setup/operations/writability.ts +135 -0
- package/src/capabilities/setup/plan.ts +11 -0
- package/src/capabilities/setup/setup.test.ts +992 -16
- package/src/capabilities/setup/setup.verb.ts +84 -36
- package/src/capabilities/setup/targets.ts +152 -48
- package/src/capabilities/setup/types.ts +41 -5
- package/src/capabilities/shared/index.ts +1 -0
- package/src/capabilities/sources/sources.test.ts +55 -0
- package/src/capabilities/surface.test.ts +59 -11
- package/src/capabilities/tier.parity.test.ts +2 -2
- package/src/capabilities/token.parity.test.ts +784 -0
- package/src/capabilities/tokenGraph.cli.test.ts +279 -0
- package/src/capabilities/tokenGraph.shipped.exec.test.ts +653 -0
- package/src/capabilities/variable.parity.test.ts +448 -0
- package/src/constants.ts +8 -0
- package/src/kernel/completion/shellDrive.test.ts +32 -30
- package/src/kernel/config/defaults.test.ts +21 -2
- package/src/kernel/config/readConfig.test.ts +1 -1
- package/src/kernel/error/renderError.ts +3 -2
- package/src/kernel/error/validOptions.test.ts +57 -0
- package/src/kernel/error/validOptions.ts +45 -0
- package/src/kernel/packs/collect.test.ts +2 -0
- package/src/kernel/packs/compile.test.ts +188 -15
- package/src/kernel/packs/compile.ts +109 -14
- package/src/kernel/packs/cursor.ts +135 -0
- package/src/kernel/packs/filteredEmpty.test.ts +213 -0
- package/src/kernel/packs/graphql/buildLookupDocument.ts +16 -9
- package/src/kernel/packs/graphqlSource.test.ts +152 -0
- package/src/kernel/packs/listNotice.test.ts +156 -0
- package/src/kernel/packs/paging.ts +110 -0
- package/src/kernel/packs/renderPack.ts +186 -13
- package/src/kernel/packs/resolveEntity.test.ts +62 -3
- package/src/kernel/packs/resolveEntity.ts +186 -26
- package/src/kernel/packs/runBodies.ts +196 -26
- package/src/kernel/packs/schema.ts +180 -13
- package/src/kernel/packs/sparql/authorQuery.test.ts +179 -0
- package/src/kernel/packs/sparql/authorQuery.ts +257 -0
- package/src/kernel/packs/sparql/buildListQuery.test.ts +344 -0
- package/src/kernel/packs/sparql/buildListQuery.ts +344 -0
- package/src/kernel/packs/sparql/buildLookupQuery.test.ts +138 -0
- package/src/kernel/packs/sparql/buildLookupQuery.ts +140 -20
- package/src/kernel/packs/sparql/filterValues.test.ts +86 -0
- package/src/kernel/packs/sparql/filterValues.ts +182 -0
- package/src/kernel/packs/sparql/index.ts +30 -14
- package/src/kernel/packs/sparql/runSelect.test.ts +63 -0
- package/src/kernel/packs/sparql/runSelect.ts +42 -2
- package/src/kernel/packs/sparql/searchTerm.ts +29 -0
- package/src/kernel/packs/storyRules.ts +180 -0
- package/src/kernel/packs/tierScope.test.ts +487 -0
- package/src/kernel/packs/tierScope.ts +409 -0
- package/src/kernel/packs/toolDescription.test.ts +13 -10
- package/src/kernel/packs/types.ts +311 -9
- package/src/kernel/project/cli/buildProgram.ts +17 -1
- package/src/kernel/project/cli/dispatch.test.ts +87 -0
- package/src/kernel/project/cli/dispatch.ts +114 -4
- package/src/kernel/project/cli/rootHelp.ts +8 -5
- package/src/kernel/project/cli/suggest.test.ts +35 -0
- package/src/kernel/project/cli/suggestNames.ts +34 -7
- package/src/kernel/project/mcp/registerVerb.ts +5 -1
- package/src/kernel/render/contracts.ts +2 -0
- package/src/kernel/render/prefixes.test.ts +2 -0
- package/src/kernel/render/renderers.test.ts +43 -0
- package/src/kernel/render/renderers.ts +34 -1
- 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 +17 -13
- package/src/kernel/runtime/loadSession.ts +3 -1
- package/src/kernel/runtime/probe.ts +1 -1
- package/src/kernel/runtime/readEntity.ts +19 -4
- 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 +39 -5
- package/src/kernel/runtime/types.ts +30 -0
- package/src/kernel/spec/emitReference.ts +1 -1
- package/src/kernel/spec/surfaceConformance.test.ts +2 -2
- package/src/kernel/spec/types.ts +17 -0
- package/src/testing/PARITY_GAPS.ts +4 -4
- package/src/testing/behavioral/agentSession.mcp.test.ts +43 -39
- package/src/testing/behavioral/completion.test.ts +7 -6
- package/src/testing/behavioral/disclosure.test.ts +39 -37
- package/src/testing/behavioral/errorMatrix.mcp.test.ts +82 -64
- package/src/testing/behavioral/errorMatrix.storeless.test.ts +11 -10
- package/src/testing/behavioral/journeys.cli.test.ts +71 -32
- package/src/testing/behavioral/journeys.defaultPack.test.ts +6 -4
- package/src/testing/behavioral/journeys.firstInstall.test.ts +70 -5
- package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +4 -3
- package/src/testing/behavioral/journeys.packageStories.test.ts +2 -1
- package/src/testing/behavioral/mcpSurface.test.ts +28 -25
- package/src/testing/behavioral/parity.test.ts +25 -23
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +20 -0
- package/src/testing/eval/cases/effectful.ts +7 -1
- package/src/testing/eval/cases/readNouns.ts +100 -11
- package/src/testing/fixtures/blockGraph.ts +89 -5
- package/src/testing/fixtures/connectGraph.ts +159 -0
- package/src/testing/fixtures/graph/canonical.ts +128 -32
- package/src/testing/helpers/fsProbe.ts +88 -0
- package/src/testing/helpers/runCli.test.ts +44 -0
- package/src/testing/helpers/runCli.ts +27 -3
- package/src/testing/perf/budgets.test.ts +3 -1
- package/src/testing/perf/budgets.ts +138 -54
- package/dist/src/kernel/packs/sparql/applyFilters.js +0 -167
- package/dist/src/kernel/packs/sparql/applyFilters.js.map +0 -1
- package/dist/src/kernel/packs/sparql/applySearch.js +0 -25
- package/dist/src/kernel/packs/sparql/applySearch.js.map +0 -1
- package/src/kernel/packs/sparql/applyFilters.ts +0 -203
- package/src/kernel/packs/sparql/applySearch.ts +0 -33
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* routing, and MCP plan-first with the sub-verbs absent from the catalog.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
16
17
|
import {
|
|
18
|
+
chmodSync,
|
|
17
19
|
existsSync,
|
|
18
20
|
lstatSync,
|
|
19
21
|
mkdirSync,
|
|
@@ -26,7 +28,7 @@ import {
|
|
|
26
28
|
writeFileSync,
|
|
27
29
|
} from "node:fs";
|
|
28
30
|
import { tmpdir } from "node:os";
|
|
29
|
-
import { join, relative, sep } from "node:path";
|
|
31
|
+
import { basename, join, relative, sep } from "node:path";
|
|
30
32
|
import { execute } from "@canonical/summon-core";
|
|
31
33
|
import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
|
|
32
34
|
import { runTask, runUndo } from "@canonical/task/node";
|
|
@@ -38,11 +40,25 @@ import { executeVerb } from "../../kernel/project/cli/dispatch.js";
|
|
|
38
40
|
import { bootRuntime } from "../../kernel/runtime/boot.js";
|
|
39
41
|
import type { GlobalFlags } from "../../kernel/runtime/types.js";
|
|
40
42
|
import type { VerbSpec } from "../../kernel/spec/types.js";
|
|
43
|
+
import { storeProbe } from "../../testing/helpers/fsProbe.js";
|
|
41
44
|
import { projectCli } from "../../testing/helpers/projectCli.js";
|
|
42
45
|
import { projectMcp } from "../../testing/helpers/projectMcp.js";
|
|
43
46
|
import { capabilities } from "../index.js";
|
|
47
|
+
import { resolveRoots } from "./buildPlan.js";
|
|
44
48
|
import { detectCompletions } from "./operations/setupCompletions.js";
|
|
45
49
|
import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
|
|
50
|
+
import {
|
|
51
|
+
composeLsp,
|
|
52
|
+
describeEditorSource,
|
|
53
|
+
detectLsp,
|
|
54
|
+
firstLspBlock,
|
|
55
|
+
lspBlockReason,
|
|
56
|
+
lspUninstallRemedy,
|
|
57
|
+
lspVsixPath,
|
|
58
|
+
ownedLspEditors,
|
|
59
|
+
selectedEditors,
|
|
60
|
+
} from "./operations/setupLsp.js";
|
|
61
|
+
import { composeMcp, detectMcp, firstMcpBlock } from "./operations/setupMcp.js";
|
|
46
62
|
import {
|
|
47
63
|
composeSkills,
|
|
48
64
|
composeSkillsRemoval,
|
|
@@ -51,9 +67,12 @@ import {
|
|
|
51
67
|
skillsSkipReason,
|
|
52
68
|
skillsSkipRemedy,
|
|
53
69
|
} from "./operations/setupSkills.js";
|
|
70
|
+
import type { Roots } from "./plan.js";
|
|
54
71
|
import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
|
|
55
72
|
import { setupModule } from "./setup.verb.js";
|
|
56
73
|
import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
|
|
74
|
+
import { findTarget, type TargetDefinition } from "./targets.js";
|
|
75
|
+
import type { Scope } from "./types.js";
|
|
57
76
|
|
|
58
77
|
const FLAGS: GlobalFlags = {
|
|
59
78
|
llm: false,
|
|
@@ -90,6 +109,17 @@ const DETECTED = detectShell();
|
|
|
90
109
|
const SHELL = DETECTED.kind === "detected" ? DETECTED.shell : null;
|
|
91
110
|
const withShell = it.skipIf(SHELL === null);
|
|
92
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The cases that arrange a real read-only directory with `chmod 0o500`.
|
|
114
|
+
*
|
|
115
|
+
* Root ignores the mode bits — `access(W_OK)` succeeds on a `0o500` directory
|
|
116
|
+
* it owns — so `probeWritable` finds nothing to block and the case asserts the
|
|
117
|
+
* opposite of what it means. GitHub-hosted runners are non-root and run these;
|
|
118
|
+
* a devcontainer or a bare `docker run` of the suite is root and stands down,
|
|
119
|
+
* which is honest rather than red.
|
|
120
|
+
*/
|
|
121
|
+
const notRoot = it.skipIf(process.getuid?.() === 0);
|
|
122
|
+
|
|
93
123
|
/**
|
|
94
124
|
* A PATH directory carrying a `pragma` stub. The completions target refuses to
|
|
95
125
|
* install a script whose `pragma __complete` cannot be found — an inert script
|
|
@@ -101,17 +131,62 @@ const stubPath = (): string => {
|
|
|
101
131
|
return dir;
|
|
102
132
|
};
|
|
103
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The machine's real PATH, captured before the suite isolates it. A stub
|
|
136
|
+
* editor script that has to run a real utility (`rm`) restores this INSIDE
|
|
137
|
+
* itself — hardcoding `/bin:/usr/bin` breaks on hosts that keep coreutils
|
|
138
|
+
* elsewhere, and the isolated PATH the tests run under has no `rm`.
|
|
139
|
+
*/
|
|
140
|
+
const HOST_PATH = process.env.PATH ?? "";
|
|
141
|
+
|
|
142
|
+
/** Run a host binary under the real PATH, whatever a case has set. */
|
|
143
|
+
const onHost = (
|
|
144
|
+
command: string,
|
|
145
|
+
args: readonly string[],
|
|
146
|
+
): ReturnType<typeof spawnSync<string>> =>
|
|
147
|
+
spawnSync(command, [...args], {
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
env: { ...process.env, PATH: HOST_PATH },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The case that checks the Nix remedy against the real Nix parser. Nix is not
|
|
154
|
+
* a dependency of this repository, so where the binary is absent the case
|
|
155
|
+
* stands down and the structural assertions beside it carry the claim.
|
|
156
|
+
*/
|
|
157
|
+
const withNix = it.skipIf(
|
|
158
|
+
onHost("nix-instantiate", ["--version"]).error !== undefined,
|
|
159
|
+
);
|
|
160
|
+
|
|
104
161
|
let prevHome: string | undefined;
|
|
105
162
|
let prevPath: string | undefined;
|
|
163
|
+
let prevXdg: string | undefined;
|
|
164
|
+
let prevAppData: string | undefined;
|
|
106
165
|
beforeEach(() => {
|
|
107
166
|
prevHome = process.env.HOME;
|
|
108
167
|
prevPath = process.env.PATH;
|
|
168
|
+
prevXdg = process.env.XDG_CONFIG_HOME;
|
|
169
|
+
prevAppData = process.env.APPDATA;
|
|
109
170
|
process.env.HOME = tmp("pragma-setup-home-");
|
|
110
171
|
process.env.PATH = stubPath();
|
|
172
|
+
// PER-TEST, not per-file. The VS Code family's per-user `mcp.json` lives
|
|
173
|
+
// under `$XDG_CONFIG_HOME/<product>/User`, so a global `setup mcp` in one
|
|
174
|
+
// test CREATES that directory — and the file-level XDG isolation
|
|
175
|
+
// (`setupXdgIsolation.ts`) is shared by every test in this file, so the next
|
|
176
|
+
// one detected the editor by the directory its predecessor had just made.
|
|
177
|
+
// Which showed up as a wrong `lsp` row three describes away.
|
|
178
|
+
process.env.XDG_CONFIG_HOME = tmp("pragma-setup-xdg-");
|
|
179
|
+
// The win32 arm of the same directory. Unset on this host, but a developer
|
|
180
|
+
// running the suite on Windows would otherwise detect their real editor.
|
|
181
|
+
delete process.env.APPDATA;
|
|
111
182
|
});
|
|
112
183
|
afterEach(() => {
|
|
113
184
|
process.env.HOME = prevHome;
|
|
114
185
|
process.env.PATH = prevPath;
|
|
186
|
+
if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
187
|
+
else process.env.XDG_CONFIG_HOME = prevXdg;
|
|
188
|
+
if (prevAppData === undefined) delete process.env.APPDATA;
|
|
189
|
+
else process.env.APPDATA = prevAppData;
|
|
115
190
|
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
|
|
116
191
|
roots.length = 0;
|
|
117
192
|
});
|
|
@@ -507,9 +582,7 @@ describe("setup mcp — the per-file question, asked directly", () => {
|
|
|
507
582
|
// the only gate, and the choices carry the detected state per file.
|
|
508
583
|
expect(targets?.when?.({})).toBe(true);
|
|
509
584
|
expect(
|
|
510
|
-
|
|
511
|
-
/— (add|update|unchanged)$/.test(c.label),
|
|
512
|
-
),
|
|
585
|
+
targets?.choices?.every((c) => /— (add|update|unchanged)$/.test(c.label)),
|
|
513
586
|
).toBe(true);
|
|
514
587
|
});
|
|
515
588
|
|
|
@@ -665,8 +738,11 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
|
665
738
|
_tag: "Exec";
|
|
666
739
|
command: string;
|
|
667
740
|
})[];
|
|
668
|
-
// The fetch still runs once; the sideload runs for `codium` alone
|
|
669
|
-
|
|
741
|
+
// The fetch still runs once; the sideload runs for `codium` alone — and
|
|
742
|
+
// it spawns the CLI FILE detection resolved, not the bare name, so an
|
|
743
|
+
// editor found inside a macOS app bundle (nothing on PATH) works too.
|
|
744
|
+
expect(execs.map((e) => basename(e.command))).toEqual(["bun", "codium"]);
|
|
745
|
+
expect(execs[1].command).toBe(join(stubDir, "codium"));
|
|
670
746
|
});
|
|
671
747
|
|
|
672
748
|
it("composes nothing at all when every editor is deselected", async () => {
|
|
@@ -751,7 +827,7 @@ describe("setup lsp — the removal contract the other four rows implement", ()
|
|
|
751
827
|
command: string;
|
|
752
828
|
args: string[];
|
|
753
829
|
})[];
|
|
754
|
-
expect(execs.map((e) => e.command)).toEqual(["code"]);
|
|
830
|
+
expect(execs.map((e) => basename(e.command))).toEqual(["code"]);
|
|
755
831
|
expect(execs[0].args).toEqual([
|
|
756
832
|
"--uninstall-extension",
|
|
757
833
|
"canonical.terrazzo-lsp-extension",
|
|
@@ -815,7 +891,7 @@ describe("setup lsp — the removal contract the other four rows implement", ()
|
|
|
815
891
|
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
816
892
|
expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
|
|
817
893
|
expect(lspUninstallRemedy(detected)).toBe(
|
|
818
|
-
"codium --uninstall-extension canonical.terrazzo-lsp-extension
|
|
894
|
+
`${join(stubDir, "codium")} --uninstall-extension canonical.terrazzo-lsp-extension`,
|
|
819
895
|
);
|
|
820
896
|
});
|
|
821
897
|
|
|
@@ -829,18 +905,22 @@ describe("setup lsp — the removal contract the other four rows implement", ()
|
|
|
829
905
|
);
|
|
830
906
|
const row = plan.rows.find((r) => r.target === "lsp");
|
|
831
907
|
expect(row?.action).toBe("skip");
|
|
832
|
-
expect(row?.reason).toContain("no VS Code-family editor
|
|
908
|
+
expect(row?.reason).toContain("no VS Code-family editor found");
|
|
833
909
|
});
|
|
834
910
|
|
|
835
911
|
it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
|
|
836
912
|
// The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
|
|
837
913
|
// marker-writing script — never a real editor: an undo test must not be
|
|
838
|
-
// able to mutate the machine running it.
|
|
914
|
+
// able to mutate the machine running it. It DOES delete the seeded
|
|
915
|
+
// extension entry (inside this test's temp HOME), because the removal now
|
|
916
|
+
// asserts its own goal against the extensions directory: a stub that
|
|
917
|
+
// exits 0 leaving the entry behind is the LYING-editor case, pinned as a
|
|
918
|
+
// failure below.
|
|
839
919
|
const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
|
|
840
920
|
const dir = stubPath();
|
|
841
921
|
writeFileSync(
|
|
842
922
|
join(dir, "code"),
|
|
843
|
-
`#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
|
|
923
|
+
`#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
|
|
844
924
|
{ mode: 0o755 },
|
|
845
925
|
);
|
|
846
926
|
process.env.PATH = dir;
|
|
@@ -872,6 +952,205 @@ describe("setup lsp — the removal contract the other four rows implement", ()
|
|
|
872
952
|
expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
|
|
873
953
|
expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
|
|
874
954
|
});
|
|
955
|
+
|
|
956
|
+
/** Drive `setup lsp --undo` capturing stderr (where the row lines land). */
|
|
957
|
+
const undoLspCapturing = async (): Promise<{
|
|
958
|
+
chunks: string[];
|
|
959
|
+
outcome?: { stdout?: string; stderr?: string; exitCode: number };
|
|
960
|
+
thrown?: unknown;
|
|
961
|
+
}> => {
|
|
962
|
+
const chunks: string[] = [];
|
|
963
|
+
const spy = vi
|
|
964
|
+
.spyOn(process.stderr, "write")
|
|
965
|
+
.mockImplementation((chunk: unknown): boolean => {
|
|
966
|
+
chunks.push(String(chunk));
|
|
967
|
+
return true;
|
|
968
|
+
});
|
|
969
|
+
try {
|
|
970
|
+
const outcome = await executeVerb(
|
|
971
|
+
verbOf("lsp"),
|
|
972
|
+
{},
|
|
973
|
+
UNDO,
|
|
974
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
975
|
+
);
|
|
976
|
+
return { chunks, outcome };
|
|
977
|
+
} catch (thrown) {
|
|
978
|
+
return { chunks, thrown };
|
|
979
|
+
} finally {
|
|
980
|
+
spy.mockRestore();
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
it("HEADLINE: an uninstall that exits 0 leaving the extension on disk reports FAILED, never `✓ removed`", async () => {
|
|
985
|
+
// The observed field defect: VS Code-family CLIs can exit 0 while
|
|
986
|
+
// deferring or skipping the deletion. The reversal's success criterion
|
|
987
|
+
// was that exit code; detection's criterion is the extensions DIRECTORY.
|
|
988
|
+
// The two disagreed permanently and the run printed `✓ … removed` over an
|
|
989
|
+
// extension that was still installed. The removal now asserts its own
|
|
990
|
+
// goal in-task — probe the owned entries after the uninstall — and the
|
|
991
|
+
// run reports the survival as a failure with a manual remedy.
|
|
992
|
+
const dir = stubPath();
|
|
993
|
+
writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
994
|
+
process.env.PATH = dir;
|
|
995
|
+
seedExtension(".vscode", "1.2.3");
|
|
996
|
+
const survivor = join(
|
|
997
|
+
process.env.HOME ?? "",
|
|
998
|
+
".vscode",
|
|
999
|
+
"extensions",
|
|
1000
|
+
"canonical.terrazzo-lsp-extension-1.2.3",
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const { chunks, outcome, thrown } = await undoLspCapturing();
|
|
1004
|
+
const stderrText = chunks.join("");
|
|
1005
|
+
|
|
1006
|
+
// The world: the artifact survived, and the run must not claim otherwise.
|
|
1007
|
+
expect(existsSync(survivor)).toBe(true);
|
|
1008
|
+
expect(stderrText).not.toContain("— removed");
|
|
1009
|
+
expect(outcome).toBeUndefined();
|
|
1010
|
+
expect(thrown).toBeDefined();
|
|
1011
|
+
const err = asPragmaError(thrown);
|
|
1012
|
+
expect(err.code).toBe("UNSUPPORTED");
|
|
1013
|
+
expect(err.message).toContain("lsp");
|
|
1014
|
+
expect(err.message).toContain("still present after the undo ran");
|
|
1015
|
+
// The remedy names the surviving path as DATA for a manual deletion —
|
|
1016
|
+
// not the CLI command that has just demonstrably done nothing while
|
|
1017
|
+
// reporting success, and not a synthesized shell line (the entry names
|
|
1018
|
+
// come off the filesystem, so no quoting is trustworthy, and one command
|
|
1019
|
+
// cannot be right for every supported platform).
|
|
1020
|
+
expect(err.recovery?.message ?? "").toContain(survivor);
|
|
1021
|
+
expect(err.recovery?.message ?? "").not.toContain("rm -rf");
|
|
1022
|
+
expect(err.recovery?.message ?? "").not.toContain("--uninstall-extension");
|
|
1023
|
+
// The truthful row is reported (stderr), naming the survival.
|
|
1024
|
+
expect(stderrText).toContain("still present after the undo ran");
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("a DANGLING matching symlink still counts as a survivor — the probe uses detection's criterion", async () => {
|
|
1028
|
+
// Detection calls an entry present when `readdirSync` lists it. A probe
|
|
1029
|
+
// built on default `exists` (`fs.access`) FOLLOWS symlinks, so a dangling
|
|
1030
|
+
// matching symlink would be "installed" to detection and "absent" to the
|
|
1031
|
+
// probe: an editor that exits 0 without removing it would report
|
|
1032
|
+
// `undone`, and the next detection would find it again — the exact
|
|
1033
|
+
// "reports success it did not achieve" this fix exists to close, rebuilt
|
|
1034
|
+
// inside the fix. The probe therefore asks about the entry itself
|
|
1035
|
+
// (lstat semantics), the same question the directory listing answers.
|
|
1036
|
+
const dir = stubPath();
|
|
1037
|
+
writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
1038
|
+
process.env.PATH = dir;
|
|
1039
|
+
const extensionsDir = join(process.env.HOME ?? "", ".vscode", "extensions");
|
|
1040
|
+
mkdirSync(extensionsDir, { recursive: true });
|
|
1041
|
+
const entry = join(extensionsDir, "canonical.terrazzo-lsp-extension-1.2.3");
|
|
1042
|
+
symlinkSync(join(extensionsDir, "no-such-target"), entry);
|
|
1043
|
+
|
|
1044
|
+
const { chunks, outcome, thrown } = await undoLspCapturing();
|
|
1045
|
+
|
|
1046
|
+
expect(lstatSync(entry).isSymbolicLink()).toBe(true); // still there
|
|
1047
|
+
expect(chunks.join("")).not.toContain("— removed");
|
|
1048
|
+
expect(outcome).toBeUndefined();
|
|
1049
|
+
const err = asPragmaError(thrown);
|
|
1050
|
+
expect(err.message).toContain("still present after the undo ran");
|
|
1051
|
+
expect(err.recovery?.message ?? "").toContain(entry);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("a truthful uninstall reports its `removed` row from the executed reversal", async () => {
|
|
1055
|
+
// The positive half of the headline: when the editor really deletes the
|
|
1056
|
+
// entry, the in-task postcondition passes, the reversal's outcome comes
|
|
1057
|
+
// back `undone`, and the row reports `removed` — same sentence as before
|
|
1058
|
+
// the fix, now derived from what ran instead of from the plan.
|
|
1059
|
+
const dir = stubPath();
|
|
1060
|
+
writeFileSync(
|
|
1061
|
+
join(dir, "code"),
|
|
1062
|
+
`#!/bin/sh\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
|
|
1063
|
+
{ mode: 0o755 },
|
|
1064
|
+
);
|
|
1065
|
+
process.env.PATH = dir;
|
|
1066
|
+
seedExtension(".vscode", "1.2.3");
|
|
1067
|
+
|
|
1068
|
+
const { chunks, outcome, thrown } = await undoLspCapturing();
|
|
1069
|
+
expect(thrown).toBeUndefined();
|
|
1070
|
+
expect(outcome?.exitCode).toBe(0);
|
|
1071
|
+
expect(outcome?.stdout).toBe("Undid 1 step(s).\n");
|
|
1072
|
+
expect(chunks.join("")).toContain("— removed");
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("the `✓ … removed` rows cannot be produced by the collection walk alone", async () => {
|
|
1076
|
+
// The collection walk mocks every exec to exit 0 and every prompt to its
|
|
1077
|
+
// default — under the old code that was enough to print the whole green
|
|
1078
|
+
// recap before one real process had spawned. With an uninstall that
|
|
1079
|
+
// GENUINELY fails (exit 1), the executed reversal fails; if any `removed`
|
|
1080
|
+
// row still appeared on stderr, it could only have come from the mocked
|
|
1081
|
+
// walk.
|
|
1082
|
+
const dir = stubPath();
|
|
1083
|
+
writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
|
1084
|
+
process.env.PATH = dir;
|
|
1085
|
+
seedExtension(".vscode", "1.2.3");
|
|
1086
|
+
|
|
1087
|
+
const { chunks, outcome, thrown } = await undoLspCapturing();
|
|
1088
|
+
expect(chunks.join("")).not.toContain("— removed");
|
|
1089
|
+
expect(outcome).toBeUndefined();
|
|
1090
|
+
expect(thrown).toBeDefined();
|
|
1091
|
+
const err = asPragmaError(thrown);
|
|
1092
|
+
expect(err.message).toContain("--uninstall-extension");
|
|
1093
|
+
expect(err.message).toContain("exited with code 1");
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it("appliedUndo grades rows from the outcomes — one model, both directions", async () => {
|
|
1097
|
+
// The projection unit: the sink the FORWARD run fills is fed by the undo
|
|
1098
|
+
// interpreter's outcomes instead, and the same `outcomeFor` reads it
|
|
1099
|
+
// back. A failed outcome keyed to the row fails the row (carrying the
|
|
1100
|
+
// reversal's own remedy); an actionable row that no reversal reported
|
|
1101
|
+
// back for cannot claim `removed`; a run with clean outcomes reports
|
|
1102
|
+
// `removed`.
|
|
1103
|
+
seedExtension(".vscode", "1.2.3");
|
|
1104
|
+
const run = await buildSetupRun(
|
|
1105
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1106
|
+
"lsp",
|
|
1107
|
+
"global",
|
|
1108
|
+
true,
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
const clean = run.appliedUndo([{ key: "global:lsp", status: "undone" }]);
|
|
1112
|
+
expect(clean.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
|
|
1113
|
+
status: "removed",
|
|
1114
|
+
note: "removed",
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const failed = run.appliedUndo([
|
|
1118
|
+
{
|
|
1119
|
+
key: "global:lsp",
|
|
1120
|
+
status: "failed",
|
|
1121
|
+
error: { code: "UNSUPPORTED", message: "still there" },
|
|
1122
|
+
},
|
|
1123
|
+
]);
|
|
1124
|
+
expect(failed.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
|
|
1125
|
+
status: "failed",
|
|
1126
|
+
note: "still there",
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// No reversal reported back at all for an actionable row: never `removed`.
|
|
1130
|
+
const silent = run.appliedUndo([]);
|
|
1131
|
+
expect(silent.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
|
|
1132
|
+
status: "failed",
|
|
1133
|
+
note: "no reversal was executed for this target",
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("a removal row with nothing owned reports `kept`, not a green noop", async () => {
|
|
1138
|
+
// The `kept` status was declared in the vocabulary and counted by
|
|
1139
|
+
// `planTally` but no producer ever emitted it. On a removal, a `none`
|
|
1140
|
+
// row IS the kept case: the run owned nothing here and deliberately left
|
|
1141
|
+
// the machine's state standing — standing aside (○), not work done (✓).
|
|
1142
|
+
const run = await buildSetupRun(
|
|
1143
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1144
|
+
"lsp",
|
|
1145
|
+
"global",
|
|
1146
|
+
true,
|
|
1147
|
+
);
|
|
1148
|
+
expect(run.plan.rows.find((r) => r.target === "lsp")?.action).toBe("none");
|
|
1149
|
+
const kept = run.appliedUndo([]);
|
|
1150
|
+
expect(kept.rows.find((r) => r.target === "lsp")?.outcome).toEqual({
|
|
1151
|
+
status: "kept",
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
875
1154
|
});
|
|
876
1155
|
|
|
877
1156
|
describe("setup (run-all wizard) — scope threading", () => {
|
|
@@ -1296,6 +1575,40 @@ describe("setup skills", () => {
|
|
|
1296
1575
|
expect(() => lstatSync(linkPath)).toThrow();
|
|
1297
1576
|
});
|
|
1298
1577
|
|
|
1578
|
+
it("the removal plan's children are the FOLDERS the links live in, deduped", async () => {
|
|
1579
|
+
// `dirs` mapped `a.linkPath` — one path per LINK — so the `new Set(…)`
|
|
1580
|
+
// deduped nothing and the removal preview printed an N-path wall where
|
|
1581
|
+
// the forward plan says `N skills → M folders`. The folders are the
|
|
1582
|
+
// links' parent directories.
|
|
1583
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1584
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1585
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1586
|
+
for (const name of ["s1", "s2"]) {
|
|
1587
|
+
const skillDir = join(cwd, ".pragma", "skills", name);
|
|
1588
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1589
|
+
writeFileSync(
|
|
1590
|
+
join(skillDir, "SKILL.md"),
|
|
1591
|
+
`---\nname: ${name}\ndescription: A test skill.\n---\n`,
|
|
1592
|
+
);
|
|
1593
|
+
symlinkSync(skillDir, join(linkDir, name));
|
|
1594
|
+
}
|
|
1595
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1596
|
+
expect(ownedSkillLinks(detected)).toHaveLength(2);
|
|
1597
|
+
|
|
1598
|
+
const { findTarget } = await import("./targets.js");
|
|
1599
|
+
const draft = findTarget("skills")?.removalPlan(
|
|
1600
|
+
detected as never,
|
|
1601
|
+
"project",
|
|
1602
|
+
{ global: process.env.HOME ?? "", project: cwd },
|
|
1603
|
+
);
|
|
1604
|
+
expect(draft?.action).toBe("remove");
|
|
1605
|
+
expect(draft?.detail).toBe("2 links");
|
|
1606
|
+
// TWO links, ONE folder: the children name the folder once, not each link.
|
|
1607
|
+
expect(draft?.children?.map((c) => c.label)).toEqual([
|
|
1608
|
+
`.${sep}${join(".agents", "skills")}`,
|
|
1609
|
+
]);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1299
1612
|
it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
|
|
1300
1613
|
// `readlink` reports the raw target, which may be relative — resolving it
|
|
1301
1614
|
// against the link's own directory is what makes the containment test
|
|
@@ -1579,8 +1892,10 @@ describe("setup lsp — prerequisites (bun absent / no editor CLI)", () => {
|
|
|
1579
1892
|
command: string;
|
|
1580
1893
|
args: string[];
|
|
1581
1894
|
})[];
|
|
1582
|
-
// One fetch (bun add, durable staging) + one sideload per PENDING editor
|
|
1583
|
-
|
|
1895
|
+
// One fetch (bun add, durable staging) + one sideload per PENDING editor,
|
|
1896
|
+
// spawned as the resolved CLI file rather than the bare name.
|
|
1897
|
+
expect(execs.map((e) => basename(e.command))).toEqual(["bun", "codium"]);
|
|
1898
|
+
expect(execs[1].command).toBe(join(stubDir, "codium"));
|
|
1584
1899
|
expect(execs[0].args).toEqual([
|
|
1585
1900
|
"add",
|
|
1586
1901
|
"@canonical/terrazzo-lsp-extension@latest",
|
|
@@ -1721,7 +2036,11 @@ describe("setup lsp — prerequisites (bun absent / no editor CLI)", () => {
|
|
|
1721
2036
|
);
|
|
1722
2037
|
expect(outcome.exitCode).toBe(0);
|
|
1723
2038
|
expect(outcome.stdout).toContain("skipped");
|
|
1724
|
-
|
|
2039
|
+
// The reason names every CLI AND every place it was looked for: "not on
|
|
2040
|
+
// PATH" became a misleading half-answer once the app-bundle and
|
|
2041
|
+
// user-directory probes existed.
|
|
2042
|
+
expect(outcome.stdout).toContain("no VS Code-family editor found");
|
|
2043
|
+
expect(outcome.stdout).toContain("under /Applications");
|
|
1725
2044
|
// The remedy names what would count, and states plainly that nothing is
|
|
1726
2045
|
// possible here yet rather than offering a command this machine lacks.
|
|
1727
2046
|
expect(outcome.stdout).toContain(
|
|
@@ -1887,7 +2206,7 @@ describe("setup — idempotent detection of already-present config", () => {
|
|
|
1887
2206
|
// never a silent omission, and never a failure.
|
|
1888
2207
|
const row = plan.rows.find((r) => r.target === "lsp");
|
|
1889
2208
|
expect(row?.action).toBe("skip");
|
|
1890
|
-
expect(row?.reason).toContain("no VS Code-family editor
|
|
2209
|
+
expect(row?.reason).toContain("no VS Code-family editor found");
|
|
1891
2210
|
});
|
|
1892
2211
|
});
|
|
1893
2212
|
|
|
@@ -2058,7 +2377,7 @@ describe("setup — a converged machine is reported, never asked", () => {
|
|
|
2058
2377
|
expect(outcome.stdout).not.toContain("Setup plan");
|
|
2059
2378
|
// It says what was detected and what happened to it — doctor's house shape.
|
|
2060
2379
|
expect(outcome.stdout).toContain("skipped");
|
|
2061
|
-
expect(outcome.stdout).toContain("no VS Code-family editor
|
|
2380
|
+
expect(outcome.stdout).toContain("no VS Code-family editor found");
|
|
2062
2381
|
});
|
|
2063
2382
|
|
|
2064
2383
|
it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
|
|
@@ -2294,3 +2613,660 @@ describe("setup — mixed-noun wiring & MCP surface", () => {
|
|
|
2294
2613
|
expect(Array.isArray((plan.data as { plan: unknown }).plan)).toBe(true);
|
|
2295
2614
|
});
|
|
2296
2615
|
});
|
|
2616
|
+
|
|
2617
|
+
/**
|
|
2618
|
+
* The VS Code family's per-user `mcp.json` — the location that turned `--local`
|
|
2619
|
+
* from a workaround back into a choice.
|
|
2620
|
+
*
|
|
2621
|
+
* `--local` used to be the ONLY way to get a pragma entry into VS Code, because
|
|
2622
|
+
* the registry row declared no global location at all. These cases pin both
|
|
2623
|
+
* bands and the `both` selection between them, on the real filesystem.
|
|
2624
|
+
*/
|
|
2625
|
+
describe("setup mcp — the VS Code family's per-user mcp.json", () => {
|
|
2626
|
+
let prevPath: string | undefined;
|
|
2627
|
+
beforeEach(() => {
|
|
2628
|
+
prevPath = process.env.PATH;
|
|
2629
|
+
process.env.PATH = stubPath();
|
|
2630
|
+
});
|
|
2631
|
+
afterEach(() => {
|
|
2632
|
+
process.env.PATH = prevPath;
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2635
|
+
/** The per-user file for one product, under the isolated XDG config home. */
|
|
2636
|
+
const userMcp = (product: string): string =>
|
|
2637
|
+
join(process.env.XDG_CONFIG_HOME as string, product, "User", "mcp.json");
|
|
2638
|
+
|
|
2639
|
+
/**
|
|
2640
|
+
* The user-level signal that earns VS Code its global band: the per-user
|
|
2641
|
+
* directory a real install creates on first launch. A committed `.vscode/`
|
|
2642
|
+
* cannot stand in for it — see the pair of cases at the end of this block.
|
|
2643
|
+
*/
|
|
2644
|
+
const installVscode = (product = "Code"): void => {
|
|
2645
|
+
mkdirSync(join(process.env.XDG_CONFIG_HOME as string, product, "User"), {
|
|
2646
|
+
recursive: true,
|
|
2647
|
+
});
|
|
2648
|
+
};
|
|
2649
|
+
|
|
2650
|
+
it("--global reaches VS Code at $XDG_CONFIG_HOME/Code/User/mcp.json, under `servers`", async () => {
|
|
2651
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2652
|
+
mkdirSync(join(cwd, ".vscode"), { recursive: true });
|
|
2653
|
+
installVscode();
|
|
2654
|
+
const outcome = await executeVerb(
|
|
2655
|
+
verbOf("mcp"),
|
|
2656
|
+
{ global: true },
|
|
2657
|
+
YES,
|
|
2658
|
+
bootRuntime(FLAGS, cwd),
|
|
2659
|
+
);
|
|
2660
|
+
expect(outcome.exitCode).toBe(0);
|
|
2661
|
+
const config = JSON.parse(readFileSync(userMcp("Code"), "utf-8"));
|
|
2662
|
+
// VS Code's own key, and a GLOBAL entry, so no `cwd` pins the machine
|
|
2663
|
+
// registration to whatever directory the run happened from.
|
|
2664
|
+
expect(config.servers?.pragma?.command).toBe("pragma");
|
|
2665
|
+
expect(config.servers?.pragma?.args).toEqual(["mcp", "serve"]);
|
|
2666
|
+
expect(config.servers?.pragma?.cwd).toBeUndefined();
|
|
2667
|
+
// The project file is untouched by a global run.
|
|
2668
|
+
expect(existsSync(join(cwd, ".vscode", "mcp.json"))).toBe(false);
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
it("--local still writes .vscode/mcp.json — the semantics did not change", async () => {
|
|
2672
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2673
|
+
mkdirSync(join(cwd, ".vscode"), { recursive: true });
|
|
2674
|
+
await executeVerb(
|
|
2675
|
+
verbOf("mcp"),
|
|
2676
|
+
{ local: true },
|
|
2677
|
+
YES,
|
|
2678
|
+
bootRuntime(FLAGS, cwd),
|
|
2679
|
+
);
|
|
2680
|
+
const config = JSON.parse(
|
|
2681
|
+
readFileSync(join(cwd, ".vscode", "mcp.json"), "utf-8"),
|
|
2682
|
+
);
|
|
2683
|
+
expect(config.servers?.pragma?.cwd).toBe(cwd);
|
|
2684
|
+
expect(existsSync(userMcp("Code"))).toBe(false);
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
it("--scope both covers BOTH of VS Code's files, each with its own entry shape", async () => {
|
|
2688
|
+
// `--scope both` is two rows, not one merged band: the project row writes
|
|
2689
|
+
// `.vscode/mcp.json` with the `cwd` a per-repo registration pins, and the
|
|
2690
|
+
// global row writes the per-user file without one. (Inside the harnesses
|
|
2691
|
+
// layer, a `both` SELECTION resolves a dual-scope harness to its project
|
|
2692
|
+
// file alone — `configTargets.test.ts` pins that; this is the CLI's own
|
|
2693
|
+
// two-scope run, which is what the flag means here.)
|
|
2694
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2695
|
+
mkdirSync(join(cwd, ".vscode"), { recursive: true });
|
|
2696
|
+
installVscode();
|
|
2697
|
+
await executeVerb(
|
|
2698
|
+
verbOf("mcp"),
|
|
2699
|
+
{ scope: "both" },
|
|
2700
|
+
YES,
|
|
2701
|
+
bootRuntime(FLAGS, cwd),
|
|
2702
|
+
);
|
|
2703
|
+
const project = JSON.parse(
|
|
2704
|
+
readFileSync(join(cwd, ".vscode", "mcp.json"), "utf-8"),
|
|
2705
|
+
);
|
|
2706
|
+
const user = JSON.parse(readFileSync(userMcp("Code"), "utf-8"));
|
|
2707
|
+
expect(project.servers?.pragma?.cwd).toBe(cwd);
|
|
2708
|
+
expect(user.servers?.pragma?.cwd).toBeUndefined();
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
it("VSCodium's per-user file is its OWN, detected by its own directory", async () => {
|
|
2712
|
+
// Per the owner, 2026-09-16: VSCodium is an MCP client row. Its user
|
|
2713
|
+
// directory is the signal, and `VSCodium/User/mcp.json` is the file — a
|
|
2714
|
+
// bare `.vscode` project dir must never stand in for it.
|
|
2715
|
+
installVscode("VSCodium");
|
|
2716
|
+
const outcome = await executeVerb(
|
|
2717
|
+
verbOf("mcp"),
|
|
2718
|
+
{ global: true },
|
|
2719
|
+
YES,
|
|
2720
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2721
|
+
);
|
|
2722
|
+
expect(outcome.exitCode).toBe(0);
|
|
2723
|
+
const config = JSON.parse(readFileSync(userMcp("VSCodium"), "utf-8"));
|
|
2724
|
+
expect(config.servers?.pragma?.command).toBe("pragma");
|
|
2725
|
+
// VS Code itself was not detected, so no `Code/User` file was invented.
|
|
2726
|
+
expect(existsSync(userMcp("Code"))).toBe(false);
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* The global write has to be EARNED by a signal about the MACHINE. A
|
|
2731
|
+
* committed `.vscode/` travels with the repository, so on its own it would
|
|
2732
|
+
* have created a per-user VS Code config for every contributor who clones —
|
|
2733
|
+
* VS Code installed or not.
|
|
2734
|
+
*/
|
|
2735
|
+
it("a committed .vscode/ and an empty home writes NO per-user file", async () => {
|
|
2736
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2737
|
+
mkdirSync(join(cwd, ".vscode"), { recursive: true });
|
|
2738
|
+
const outcome = await executeVerb(
|
|
2739
|
+
verbOf("mcp"),
|
|
2740
|
+
{ global: true },
|
|
2741
|
+
YES,
|
|
2742
|
+
bootRuntime(FLAGS, cwd),
|
|
2743
|
+
);
|
|
2744
|
+
expect(outcome.exitCode).toBe(0);
|
|
2745
|
+
expect(existsSync(userMcp("Code"))).toBe(false);
|
|
2746
|
+
// The row says there is nothing here, rather than inventing a location.
|
|
2747
|
+
expect(outcome.stdout).toContain("no AI harness on this machine");
|
|
2748
|
+
});
|
|
2749
|
+
|
|
2750
|
+
it("a home with Code/User earns it, `.vscode/` or not", async () => {
|
|
2751
|
+
installVscode();
|
|
2752
|
+
const outcome = await executeVerb(
|
|
2753
|
+
verbOf("mcp"),
|
|
2754
|
+
{ global: true },
|
|
2755
|
+
YES,
|
|
2756
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2757
|
+
);
|
|
2758
|
+
expect(outcome.exitCode).toBe(0);
|
|
2759
|
+
const config = JSON.parse(readFileSync(userMcp("Code"), "utf-8"));
|
|
2760
|
+
expect(config.servers?.pragma?.command).toBe("pragma");
|
|
2761
|
+
});
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* A location this command must not write — Nix-managed, or read-only.
|
|
2766
|
+
*
|
|
2767
|
+
* Both used to be a `failed` row carrying a raw fs message and no next step,
|
|
2768
|
+
* on a machine configured exactly as its owner intends. The probe runs BEFORE
|
|
2769
|
+
* anything is composed, so both are named skips whose remedy is the one action
|
|
2770
|
+
* that works there.
|
|
2771
|
+
*
|
|
2772
|
+
* The Nix cases drive the `FsProbe` seam: no CI host has a `/nix/store`, and
|
|
2773
|
+
* the point of the block is that the decision is made by the DIRECTORY, so a
|
|
2774
|
+
* fixture that says "this path resolves into the store" is the whole input.
|
|
2775
|
+
*/
|
|
2776
|
+
describe("setup — a Nix-managed or read-only location is a named skip", () => {
|
|
2777
|
+
let prevPath: string | undefined;
|
|
2778
|
+
beforeEach(() => {
|
|
2779
|
+
prevPath = process.env.PATH;
|
|
2780
|
+
process.env.PATH = stubPath();
|
|
2781
|
+
});
|
|
2782
|
+
afterEach(() => {
|
|
2783
|
+
process.env.PATH = prevPath;
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
/** One row's forward draft, straight off the target table. */
|
|
2787
|
+
const draftOf = <D>(
|
|
2788
|
+
id: "mcp" | "lsp",
|
|
2789
|
+
detection: D,
|
|
2790
|
+
scope: Scope,
|
|
2791
|
+
roots: Roots,
|
|
2792
|
+
) =>
|
|
2793
|
+
(findTarget(id) as unknown as TargetDefinition<D>).plan(
|
|
2794
|
+
detection,
|
|
2795
|
+
scope,
|
|
2796
|
+
roots,
|
|
2797
|
+
);
|
|
2798
|
+
|
|
2799
|
+
it("mcp: a store-managed config file is a row SKIP whose remedy is the entry itself", async () => {
|
|
2800
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2801
|
+
mkdirSync(join(cwd, ".vscode"), { recursive: true });
|
|
2802
|
+
const xdg = process.env.XDG_CONFIG_HOME as string;
|
|
2803
|
+
// The user-level signal that earns the global band (a committed `.vscode/`
|
|
2804
|
+
// does not), so there IS a per-user file for the probe to block.
|
|
2805
|
+
mkdirSync(join(xdg, "Code", "User"), { recursive: true });
|
|
2806
|
+
const rt = bootRuntime(FLAGS, cwd);
|
|
2807
|
+
const detection = await detectMcp(rt, "global", storeProbe(xdg));
|
|
2808
|
+
const draft = draftOf("mcp", detection, "global", await resolveRoots(rt));
|
|
2809
|
+
|
|
2810
|
+
expect(draft.action).toBe("skip");
|
|
2811
|
+
expect(draft.reason).toContain("managed by Nix");
|
|
2812
|
+
expect(draft.reason).toContain("/nix/store/");
|
|
2813
|
+
// The remedy is the EXACT entry a write would have emitted, through the
|
|
2814
|
+
// group's own serializer — so what the user pastes into their config
|
|
2815
|
+
// classifies as `registered` next time, not as drift to "repair" forever.
|
|
2816
|
+
expect(draft.remedy).toContain('"servers"');
|
|
2817
|
+
expect(draft.remedy).toContain('"pragma"');
|
|
2818
|
+
expect(draft.remedy).toContain('"mcp","serve"');
|
|
2819
|
+
// One line, because the renderer prints one dim line.
|
|
2820
|
+
expect(draft.remedy).not.toContain("\n");
|
|
2821
|
+
// The child says so too, rather than offering an `add` that cannot happen.
|
|
2822
|
+
expect(draft.children?.map((c) => c.action)).toEqual(["skip"]);
|
|
2823
|
+
|
|
2824
|
+
// And nothing is composed: the file is never opened.
|
|
2825
|
+
const { effects } = dryRun(composeMcp(detection, detection.groups));
|
|
2826
|
+
expect(effects).toEqual([]);
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
it("mcp: a blocked TOML config gets its entry as TOML, not as JSON", async () => {
|
|
2830
|
+
// Codex keeps `config.toml`. A JSON body pasted into it is not an entry,
|
|
2831
|
+
// it is a syntax error — and this row's whole job is to say what to write.
|
|
2832
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2833
|
+
mkdirSync(join(cwd, ".codex"), { recursive: true });
|
|
2834
|
+
const rt = bootRuntime(FLAGS, cwd);
|
|
2835
|
+
const detection = await detectMcp(rt, "project", storeProbe(cwd));
|
|
2836
|
+
const draft = draftOf("mcp", detection, "project", await resolveRoots(rt));
|
|
2837
|
+
|
|
2838
|
+
expect(draft.action).toBe("skip");
|
|
2839
|
+
// TOML's dotted-key inline table: the same entry the writer's
|
|
2840
|
+
// `[mcp_servers.pragma]` table holds, in the one-line form a remedy can
|
|
2841
|
+
// print — and through the same value formatter, so the quoting matches.
|
|
2842
|
+
expect(draft.remedy).toContain("mcp_servers.pragma = {");
|
|
2843
|
+
expect(draft.remedy).toContain('command = "pragma"');
|
|
2844
|
+
expect(draft.remedy).toContain('args = ["mcp", "serve"]');
|
|
2845
|
+
expect(draft.remedy).not.toContain('{"mcp_servers"');
|
|
2846
|
+
expect(draft.remedy).not.toContain("\n");
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
it("mcp: reports no block for a scope whose files are all writable", async () => {
|
|
2850
|
+
// The negative: with nothing blocked there is no reason and no remedy to
|
|
2851
|
+
// print, so the row falls through to its ordinary answer.
|
|
2852
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2853
|
+
mkdirSync(join(cwd, ".codex"), { recursive: true });
|
|
2854
|
+
const rt = bootRuntime(FLAGS, cwd);
|
|
2855
|
+
const detection = await detectMcp(rt, "project");
|
|
2856
|
+
expect(firstMcpBlock(detection, await resolveRoots(rt))).toBeUndefined();
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
notRoot(
|
|
2860
|
+
"mcp: the blocked row exits 0 through the real verb, with nothing written",
|
|
2861
|
+
async () => {
|
|
2862
|
+
// A read-only directory, for real — the block is a property of the
|
|
2863
|
+
// filesystem, so at least one case has to meet one.
|
|
2864
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2865
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2866
|
+
const home = process.env.HOME as string;
|
|
2867
|
+
const cursorDir = join(home, ".cursor");
|
|
2868
|
+
mkdirSync(cursorDir, { recursive: true });
|
|
2869
|
+
chmodSync(cursorDir, 0o500);
|
|
2870
|
+
try {
|
|
2871
|
+
const outcome = await executeVerb(
|
|
2872
|
+
verbOf("mcp"),
|
|
2873
|
+
{ global: true },
|
|
2874
|
+
YES,
|
|
2875
|
+
bootRuntime(FLAGS, cwd),
|
|
2876
|
+
);
|
|
2877
|
+
expect(outcome.exitCode).toBe(0);
|
|
2878
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2879
|
+
expect(outcome.stdout).toContain("is not writable");
|
|
2880
|
+
expect(existsSync(join(cursorDir, "mcp.json"))).toBe(false);
|
|
2881
|
+
} finally {
|
|
2882
|
+
chmodSync(cursorDir, 0o700);
|
|
2883
|
+
}
|
|
2884
|
+
},
|
|
2885
|
+
);
|
|
2886
|
+
|
|
2887
|
+
it("lsp: a store-managed extensions folder is a row SKIP naming the VSIX and the snippet", async () => {
|
|
2888
|
+
const stubDir = stubPath();
|
|
2889
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
2890
|
+
process.env.PATH = stubDir;
|
|
2891
|
+
const home = process.env.HOME as string;
|
|
2892
|
+
const extensions = join(home, ".vscode-oss", "extensions");
|
|
2893
|
+
mkdirSync(extensions, { recursive: true });
|
|
2894
|
+
const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
|
|
2895
|
+
const detection = await detectLsp(
|
|
2896
|
+
rt.cwd,
|
|
2897
|
+
undefined,
|
|
2898
|
+
storeProbe(extensions),
|
|
2899
|
+
);
|
|
2900
|
+
const draft = draftOf("lsp", detection, "global", await resolveRoots(rt));
|
|
2901
|
+
|
|
2902
|
+
expect(draft.action).toBe("skip");
|
|
2903
|
+
expect(draft.reason).toContain("managed by Nix");
|
|
2904
|
+
// There is no marketplace listing, so the remedy cannot name an id: it
|
|
2905
|
+
// gives a home-manager user both halves — where the file comes from, and
|
|
2906
|
+
// the attribute that installs it.
|
|
2907
|
+
expect(draft.remedy).toContain("buildVscodeExtension");
|
|
2908
|
+
expect(draft.remedy).toContain(lspVsixPath(detection));
|
|
2909
|
+
expect(draft.remedy).not.toContain("\n");
|
|
2910
|
+
// The five attributes `buildVscodeExtension` has no defaults for, and the
|
|
2911
|
+
// quoted path: an unquoted Nix path literal cannot carry the `@` in
|
|
2912
|
+
// `@canonical/…` (it is a parse error), so `src` goes through
|
|
2913
|
+
// `builtins.path`. The `--parse` case below is the proof.
|
|
2914
|
+
for (const attr of [
|
|
2915
|
+
'name = "terrazzo-lsp-extension"',
|
|
2916
|
+
"version = ",
|
|
2917
|
+
'vscodeExtPublisher = "canonical"',
|
|
2918
|
+
'vscodeExtName = "terrazzo-lsp-extension"',
|
|
2919
|
+
'vscodeExtUniqueId = "canonical.terrazzo-lsp-extension"',
|
|
2920
|
+
"src = builtins.path { path = ",
|
|
2921
|
+
]) {
|
|
2922
|
+
expect(draft.remedy).toContain(attr);
|
|
2923
|
+
}
|
|
2924
|
+
// And it says which one the user must fill in.
|
|
2925
|
+
expect(draft.remedy).toContain("version from the fetched package.json");
|
|
2926
|
+
// Nothing is composed, and the reversal owns nothing either.
|
|
2927
|
+
expect(dryRun(composeLsp(detection)).effects).toEqual([]);
|
|
2928
|
+
expect(ownedLspEditors(detection)).toEqual([]);
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
withNix("lsp: the Nix remedy's expression really parses as Nix", async () => {
|
|
2932
|
+
// The remedy is a line a user pastes into their own config, so "it looks
|
|
2933
|
+
// like Nix" is not the bar. The sketch this replaced was a parse error
|
|
2934
|
+
// twice over (an unquoted path with an `@` in it, and no `name`), and a
|
|
2935
|
+
// remedy that cannot be used is worse than none. `nix-instantiate
|
|
2936
|
+
// --parse` is the parser itself; where it is not installed the case
|
|
2937
|
+
// stands down and the structural assertions above stand.
|
|
2938
|
+
const stubDir = stubPath();
|
|
2939
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
2940
|
+
process.env.PATH = stubDir;
|
|
2941
|
+
const extensions = join(
|
|
2942
|
+
process.env.HOME as string,
|
|
2943
|
+
".vscode-oss",
|
|
2944
|
+
"extensions",
|
|
2945
|
+
);
|
|
2946
|
+
mkdirSync(extensions, { recursive: true });
|
|
2947
|
+
const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
|
|
2948
|
+
const detection = await detectLsp(
|
|
2949
|
+
rt.cwd,
|
|
2950
|
+
undefined,
|
|
2951
|
+
storeProbe(extensions),
|
|
2952
|
+
);
|
|
2953
|
+
const remedy = draftOf("lsp", detection, "global", await resolveRoots(rt))
|
|
2954
|
+
.remedy as string;
|
|
2955
|
+
// The remedy is prose plus the expression; the expression starts at the
|
|
2956
|
+
// attribute path the user assigns.
|
|
2957
|
+
const expr = remedy.slice(remedy.indexOf("programs.vscode"));
|
|
2958
|
+
// Bound as a function argument so `--parse` is all that is exercised:
|
|
2959
|
+
// `pkgs` is the caller's, and nothing here should be evaluated.
|
|
2960
|
+
const file = join(tmp("pragma-setup-nix-"), "snippet.nix");
|
|
2961
|
+
writeFileSync(file, `{ pkgs }: { ${expr} }\n`);
|
|
2962
|
+
const result = onHost("nix-instantiate", ["--parse", file]);
|
|
2963
|
+
expect(result.stderr).toBe("");
|
|
2964
|
+
expect(result.status).toBe(0);
|
|
2965
|
+
});
|
|
2966
|
+
|
|
2967
|
+
notRoot(
|
|
2968
|
+
"lsp: a read-only extensions folder is a skip the WIZARD omits the editor from",
|
|
2969
|
+
async () => {
|
|
2970
|
+
// End to end over the real filesystem, and the reason the child prompt
|
|
2971
|
+
// filters skips: summon's choice shape has no disabled state, so an
|
|
2972
|
+
// offered editor is one the user can select into doing nothing.
|
|
2973
|
+
const stubDir = stubPath();
|
|
2974
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
2975
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
2976
|
+
process.env.PATH = stubDir;
|
|
2977
|
+
const home = process.env.HOME as string;
|
|
2978
|
+
const blocked = join(home, ".vscode-oss", "extensions");
|
|
2979
|
+
mkdirSync(blocked, { recursive: true });
|
|
2980
|
+
chmodSync(blocked, 0o500);
|
|
2981
|
+
try {
|
|
2982
|
+
const { plan, generator } = await buildSetupRun(
|
|
2983
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2984
|
+
"lsp",
|
|
2985
|
+
"global",
|
|
2986
|
+
);
|
|
2987
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
2988
|
+
// VS Code is installable, so the ROW is still an install — only the
|
|
2989
|
+
// one blocked editor is a skip, carrying its own reason.
|
|
2990
|
+
expect(row?.action).toBe("install");
|
|
2991
|
+
const codium = row?.children?.find((c) => c.key === "codium");
|
|
2992
|
+
expect(codium?.action).toBe("skip");
|
|
2993
|
+
expect(codium?.reason).toContain("not writable");
|
|
2994
|
+
const editors = generator.prompts.find(
|
|
2995
|
+
(pr) => pr.name === "lspEditors",
|
|
2996
|
+
);
|
|
2997
|
+
expect(editors?.choices?.map((c) => c.value)).toEqual(["code"]);
|
|
2998
|
+
expect(editors?.default).toEqual(["code"]);
|
|
2999
|
+
} finally {
|
|
3000
|
+
chmodSync(blocked, 0o700);
|
|
3001
|
+
}
|
|
3002
|
+
},
|
|
3003
|
+
);
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
/**
|
|
3007
|
+
* HOW each editor was found — the provenance both surfaces print.
|
|
3008
|
+
*
|
|
3009
|
+
* A machine that reported "no editor found" while having the editor is the
|
|
3010
|
+
* defect these three probes exist for, and "which one found it" is the fact
|
|
3011
|
+
* that tells a user whose run did nothing which of the two happened.
|
|
3012
|
+
*/
|
|
3013
|
+
describe("setup lsp — how each editor was found", () => {
|
|
3014
|
+
it("names `via PATH` and the CLI file, on the child row", async () => {
|
|
3015
|
+
const prevPath = process.env.PATH;
|
|
3016
|
+
const stubDir = stubPath();
|
|
3017
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
3018
|
+
process.env.PATH = stubDir;
|
|
3019
|
+
try {
|
|
3020
|
+
const { plan } = await buildSetupRun(
|
|
3021
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
3022
|
+
"lsp",
|
|
3023
|
+
"global",
|
|
3024
|
+
);
|
|
3025
|
+
const child = plan.rows
|
|
3026
|
+
.find((r) => r.target === "lsp")
|
|
3027
|
+
?.children?.find((c) => c.key === "codium");
|
|
3028
|
+
expect(child?.label).toContain("via PATH");
|
|
3029
|
+
expect(child?.label).toContain(join(stubDir, "codium"));
|
|
3030
|
+
} finally {
|
|
3031
|
+
process.env.PATH = prevPath;
|
|
3032
|
+
}
|
|
3033
|
+
});
|
|
3034
|
+
|
|
3035
|
+
it("finds a macOS install inside its app bundle, and sideloads THAT path", async () => {
|
|
3036
|
+
// The colleague's machine: VS Code installed, nothing on PATH, because the
|
|
3037
|
+
// palette's "Install 'code' command in PATH" is opt-in. The darwin arm
|
|
3038
|
+
// cannot run on this host, so the platform is injected — which is exactly
|
|
3039
|
+
// what the seam is for.
|
|
3040
|
+
const home = tmp("pragma-darwin-home-");
|
|
3041
|
+
const bundleBin = join(
|
|
3042
|
+
home,
|
|
3043
|
+
"Applications",
|
|
3044
|
+
"Visual Studio Code.app",
|
|
3045
|
+
"Contents",
|
|
3046
|
+
"Resources",
|
|
3047
|
+
"app",
|
|
3048
|
+
"bin",
|
|
3049
|
+
);
|
|
3050
|
+
mkdirSync(bundleBin, { recursive: true });
|
|
3051
|
+
writeFileSync(join(bundleBin, "code"), "");
|
|
3052
|
+
const detection = await detectLsp(tmp("pragma-setup-proj-"), {
|
|
3053
|
+
platform: "darwin",
|
|
3054
|
+
env: {},
|
|
3055
|
+
home,
|
|
3056
|
+
isWsl: false,
|
|
3057
|
+
});
|
|
3058
|
+
expect(detection.editors.map((e) => [e.editor.id, e.foundBy])).toEqual([
|
|
3059
|
+
["vscode", "bundle"],
|
|
3060
|
+
]);
|
|
3061
|
+
expect(detection.editors[0]?.block).toBeUndefined();
|
|
3062
|
+
expect(detection.state).toBe("absent");
|
|
3063
|
+
expect(
|
|
3064
|
+
describeEditorSource(detection.editors[0], {
|
|
3065
|
+
global: home,
|
|
3066
|
+
project: "/p",
|
|
3067
|
+
}),
|
|
3068
|
+
).toBe(
|
|
3069
|
+
"via app bundle (~/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code)",
|
|
3070
|
+
);
|
|
3071
|
+
|
|
3072
|
+
const execs = dryRun(composeLsp(detection)).effects.filter(
|
|
3073
|
+
(e) => e._tag === "Exec",
|
|
3074
|
+
) as (Effect & { _tag: "Exec"; command: string })[];
|
|
3075
|
+
// The bundle CLI is on no PATH, so the bare name would spawn nothing.
|
|
3076
|
+
expect(execs.map((e) => e.command)).toEqual([
|
|
3077
|
+
"bun",
|
|
3078
|
+
join(bundleBin, "code"),
|
|
3079
|
+
]);
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
it("an editor found ONLY by its user directory is a skip naming the palette command", async () => {
|
|
3083
|
+
// A fresh install: no CLI anywhere, no extensions folder, just the
|
|
3084
|
+
// configuration directory the editor made on first launch. Nothing can be
|
|
3085
|
+
// installed through a directory, and the remedy is the one command that
|
|
3086
|
+
// changes that.
|
|
3087
|
+
const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
|
|
3088
|
+
mkdirSync(join(process.env.XDG_CONFIG_HOME as string, "Code", "User"), {
|
|
3089
|
+
recursive: true,
|
|
3090
|
+
});
|
|
3091
|
+
const detection = await detectLsp(rt.cwd);
|
|
3092
|
+
expect(detection.editors.map((e) => [e.editor.id, e.foundBy])).toEqual([
|
|
3093
|
+
["vscode", "user-dir"],
|
|
3094
|
+
]);
|
|
3095
|
+
// No BLOCK: nothing is wrong with the folder. The provenance itself is why
|
|
3096
|
+
// nothing can be installed — there is no CLI to run.
|
|
3097
|
+
expect(detection.editors[0]?.block).toBeUndefined();
|
|
3098
|
+
expect(
|
|
3099
|
+
describeEditorSource(detection.editors[0], await resolveRoots(rt)),
|
|
3100
|
+
).toBe("via user directory");
|
|
3101
|
+
|
|
3102
|
+
const { plan } = await buildSetupRun(rt, "lsp", "global");
|
|
3103
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
3104
|
+
expect(row?.action).toBe("skip");
|
|
3105
|
+
expect(row?.reason).toContain("no command-line launcher");
|
|
3106
|
+
expect(row?.children?.[0]?.action).toBe("skip");
|
|
3107
|
+
// The ROW headline names the editor; the child's own reason does not,
|
|
3108
|
+
// because its label already has — the name once per line, not three times.
|
|
3109
|
+
expect(row?.reason).toBe(
|
|
3110
|
+
"VS Code: no command-line launcher — found only its user directory",
|
|
3111
|
+
);
|
|
3112
|
+
expect(row?.children?.[0]?.reason).toBe(
|
|
3113
|
+
"no command-line launcher — found only its user directory",
|
|
3114
|
+
);
|
|
3115
|
+
expect(row?.children?.[0]?.label).toContain("VS Code");
|
|
3116
|
+
// And the row is not offered: there is nothing to select into doing.
|
|
3117
|
+
expect(row?.selected).toBe(false);
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
it("the palette remedy names the editor's own CLI, and nothing composes", async () => {
|
|
3121
|
+
const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
|
|
3122
|
+
mkdirSync(join(process.env.XDG_CONFIG_HOME as string, "VSCodium", "User"), {
|
|
3123
|
+
recursive: true,
|
|
3124
|
+
});
|
|
3125
|
+
const detection = await detectLsp(rt.cwd);
|
|
3126
|
+
const remedy = firstLspBlock(detection)?.remedy;
|
|
3127
|
+
expect(remedy).toContain("Install 'codium' command in PATH");
|
|
3128
|
+
expect(remedy).toContain("setup lsp");
|
|
3129
|
+
expect(remedy).not.toContain("\n");
|
|
3130
|
+
expect(dryRun(composeLsp(detection)).effects).toEqual([]);
|
|
3131
|
+
expect(selectedEditors(detection)).toEqual([]);
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
it("reports no block for a machine whose editors are all actionable", async () => {
|
|
3135
|
+
// The negative of every case above: a plain PATH hit is not blocked, so
|
|
3136
|
+
// there is no headline and no remedy to print — and no uninstall command
|
|
3137
|
+
// either, since nothing here carries a copy.
|
|
3138
|
+
const prevPath = process.env.PATH;
|
|
3139
|
+
const stubDir = stubPath();
|
|
3140
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
3141
|
+
process.env.PATH = stubDir;
|
|
3142
|
+
try {
|
|
3143
|
+
const detection = await detectLsp(tmp("pragma-setup-proj-"));
|
|
3144
|
+
expect(firstLspBlock(detection)).toBeUndefined();
|
|
3145
|
+
expect(lspBlockReason(detection.editors[0])).toBeUndefined();
|
|
3146
|
+
expect(lspUninstallRemedy(detection)).toBeUndefined();
|
|
3147
|
+
} finally {
|
|
3148
|
+
process.env.PATH = prevPath;
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
});
|
|
3152
|
+
|
|
3153
|
+
/**
|
|
3154
|
+
* The lsp row's remaining shapes: nothing to do, nothing to undo, and the
|
|
3155
|
+
* read-only block's own remedy.
|
|
3156
|
+
*/
|
|
3157
|
+
describe("setup lsp — the row's quiet answers", () => {
|
|
3158
|
+
let prevPath: string | undefined;
|
|
3159
|
+
let stubDir = "";
|
|
3160
|
+
beforeEach(() => {
|
|
3161
|
+
prevPath = process.env.PATH;
|
|
3162
|
+
stubDir = stubPath();
|
|
3163
|
+
process.env.PATH = stubDir;
|
|
3164
|
+
});
|
|
3165
|
+
afterEach(() => {
|
|
3166
|
+
process.env.PATH = prevPath;
|
|
3167
|
+
});
|
|
3168
|
+
|
|
3169
|
+
/** Seed a versioned extension copy under HOME's `dir`. */
|
|
3170
|
+
const seed = (dir: string, version = "1.2.3"): void => {
|
|
3171
|
+
mkdirSync(
|
|
3172
|
+
join(
|
|
3173
|
+
process.env.HOME as string,
|
|
3174
|
+
dir,
|
|
3175
|
+
"extensions",
|
|
3176
|
+
`canonical.terrazzo-lsp-extension-${version}`,
|
|
3177
|
+
),
|
|
3178
|
+
{ recursive: true },
|
|
3179
|
+
);
|
|
3180
|
+
};
|
|
3181
|
+
|
|
3182
|
+
it("plans `no change` when every detected editor already carries it", async () => {
|
|
3183
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
3184
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
3185
|
+
seed(".vscode");
|
|
3186
|
+
seed(".vscode-oss");
|
|
3187
|
+
const { plan } = await buildSetupRun(
|
|
3188
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
3189
|
+
"lsp",
|
|
3190
|
+
"global",
|
|
3191
|
+
);
|
|
3192
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
3193
|
+
expect(row?.action).toBe("none");
|
|
3194
|
+
expect(row?.detail).toBe("Terrazzo extension in VS Code, VSCodium");
|
|
3195
|
+
expect(row?.children?.every((c) => c.action === "unchanged")).toBe(true);
|
|
3196
|
+
});
|
|
3197
|
+
|
|
3198
|
+
notRoot(
|
|
3199
|
+
"a read-only folder with nothing else installable is a row skip with the chmod remedy",
|
|
3200
|
+
async () => {
|
|
3201
|
+
// One editor, and its extensions folder refuses W_OK — so there is nothing
|
|
3202
|
+
// installable at all and the row's whole answer is the block. The remedy
|
|
3203
|
+
// is the one action that settles it, and it does NOT name the VSIX: the
|
|
3204
|
+
// fetch never ran, so the file it would name is not there.
|
|
3205
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
3206
|
+
const extensions = join(
|
|
3207
|
+
process.env.HOME as string,
|
|
3208
|
+
".vscode-oss",
|
|
3209
|
+
"extensions",
|
|
3210
|
+
);
|
|
3211
|
+
mkdirSync(extensions, { recursive: true });
|
|
3212
|
+
chmodSync(extensions, 0o500);
|
|
3213
|
+
try {
|
|
3214
|
+
const run = await buildSetupRun(
|
|
3215
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
3216
|
+
"lsp",
|
|
3217
|
+
"global",
|
|
3218
|
+
);
|
|
3219
|
+
// The remedy rides on the row's OUTCOME — the one dim line the recap
|
|
3220
|
+
// prints beneath a skip — so the assertion reads it where a user sees it.
|
|
3221
|
+
const row = run.applied({}).rows.find((r) => r.target === "lsp");
|
|
3222
|
+
expect(row?.action).toBe("skip");
|
|
3223
|
+
expect(row?.reason).toContain("not writable");
|
|
3224
|
+
expect(row?.outcome?.status).toBe("skipped");
|
|
3225
|
+
const remedy = row?.outcome?.remedy as string;
|
|
3226
|
+
expect(remedy).toBe(
|
|
3227
|
+
`make the extensions folder writable, then run \`${BIN_NAME} setup lsp\` again`,
|
|
3228
|
+
);
|
|
3229
|
+
// Never the VSIX. `composeLsp` fetches nothing when no editor is
|
|
3230
|
+
// installable, so a `--install-extension <vsix>` line would name a file
|
|
3231
|
+
// that is not on this machine.
|
|
3232
|
+
expect(remedy).not.toContain("--install-extension");
|
|
3233
|
+
expect(remedy).not.toContain("terrazzo-lsp.vsix");
|
|
3234
|
+
} finally {
|
|
3235
|
+
chmodSync(extensions, 0o700);
|
|
3236
|
+
}
|
|
3237
|
+
},
|
|
3238
|
+
);
|
|
3239
|
+
|
|
3240
|
+
notRoot(
|
|
3241
|
+
"a removal over a blocked editor is the same skip, not a reversal that cannot run",
|
|
3242
|
+
async () => {
|
|
3243
|
+
// The copy IS there, and the uninstall would have to write into a folder
|
|
3244
|
+
// pragma must not touch — so the row says so rather than composing a
|
|
3245
|
+
// reversal that would fail.
|
|
3246
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
3247
|
+
seed(".vscode-oss");
|
|
3248
|
+
const extensions = join(
|
|
3249
|
+
process.env.HOME as string,
|
|
3250
|
+
".vscode-oss",
|
|
3251
|
+
"extensions",
|
|
3252
|
+
);
|
|
3253
|
+
chmodSync(extensions, 0o500);
|
|
3254
|
+
try {
|
|
3255
|
+
const run = await buildSetupRun(
|
|
3256
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
3257
|
+
"lsp",
|
|
3258
|
+
"global",
|
|
3259
|
+
true,
|
|
3260
|
+
);
|
|
3261
|
+
const row = run.applied({}).rows.find((r) => r.target === "lsp");
|
|
3262
|
+
expect(row?.action).toBe("skip");
|
|
3263
|
+
expect(row?.reason).toContain("not writable");
|
|
3264
|
+
expect(row?.outcome?.remedy).toContain(
|
|
3265
|
+
"make the extensions folder writable",
|
|
3266
|
+
);
|
|
3267
|
+
} finally {
|
|
3268
|
+
chmodSync(extensions, 0o700);
|
|
3269
|
+
}
|
|
3270
|
+
},
|
|
3271
|
+
);
|
|
3272
|
+
});
|