@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
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
* write itself stays idempotent (a re-merge of the identical entry is
|
|
18
18
|
* byte-for-byte), so a re-run is a no-op the recap reports as "unchanged" — the
|
|
19
19
|
* same state-awareness `setup skills` has always had.
|
|
20
|
+
*
|
|
21
|
+
* And it asks, for each group, whether the file could be WRITTEN at all —
|
|
22
|
+
* `probeWritable`, before a single write is composed. A NixOS or home-manager
|
|
23
|
+
* user whose config directory is managed produced a `failed` row carrying a
|
|
24
|
+
* raw `EACCES` and no next step, on a machine configured exactly as its owner
|
|
25
|
+
* intends; that is now a named skip whose remedy is the entry itself, printed
|
|
26
|
+
* through the same serializer the write would have used, for the user to put
|
|
27
|
+
* in the config that owns the directory.
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
import { dirname } from "node:path";
|
|
@@ -29,7 +37,9 @@ import type {
|
|
|
29
37
|
import { mkdir, sequence_, type Task } from "@canonical/task";
|
|
30
38
|
import { MCP_SERVER_NAME } from "../../../constants.js";
|
|
31
39
|
import type { PragmaRuntime } from "../../../kernel/runtime/index.js";
|
|
32
|
-
import type
|
|
40
|
+
import { type Roots, shortenPath } from "../plan.js";
|
|
41
|
+
import type { McpTargetState, Scope, WriteBlock } from "../types.js";
|
|
42
|
+
import { type FsProbe, probeWritable } from "./writability.js";
|
|
33
43
|
|
|
34
44
|
/** The `writeMcpConfigTargets` builder, captured from the dynamic harness import. */
|
|
35
45
|
type WriteMcpConfigTargets =
|
|
@@ -39,6 +49,10 @@ type WriteMcpConfigTargets =
|
|
|
39
49
|
type RemoveMcpConfigFrom =
|
|
40
50
|
typeof import("@canonical/harnesses").removeMcpConfigFrom;
|
|
41
51
|
|
|
52
|
+
/** The one-line TOML entry serializer, captured from the same import. */
|
|
53
|
+
type SerializeTomlInlineEntry =
|
|
54
|
+
typeof import("@canonical/harnesses").serializeTomlInlineEntry;
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
57
|
* The detected MCP state: the per-file target groups (already scoped to the
|
|
44
58
|
* requested `--scope`), a by-path map of each group's prior
|
|
@@ -61,10 +75,26 @@ export interface McpDetection {
|
|
|
61
75
|
readonly stateByPath: ReadonlyMap<string, McpTargetState>;
|
|
62
76
|
/** Each write's own state, keyed by {@link mcpWriteKey} — see the docblock. */
|
|
63
77
|
readonly stateByWrite: ReadonlyMap<string, McpTargetState>;
|
|
78
|
+
/**
|
|
79
|
+
* The groups whose file cannot be written, by group path — a Nix-managed or
|
|
80
|
+
* read-only location, probed up front.
|
|
81
|
+
*
|
|
82
|
+
* Keyed by the group's path rather than per write, because writability is a
|
|
83
|
+
* property of the FILE: two harnesses sharing `.vscode/mcp.json` are blocked
|
|
84
|
+
* or not together, whatever keys they own inside it.
|
|
85
|
+
*/
|
|
86
|
+
readonly blockedByPath: ReadonlyMap<string, WriteBlock>;
|
|
64
87
|
readonly cwd: string;
|
|
65
88
|
readonly platform: PlatformEnv;
|
|
66
89
|
readonly writeMcpConfigTargets: WriteMcpConfigTargets;
|
|
67
90
|
readonly removeMcpConfigFrom: RemoveMcpConfigFrom;
|
|
91
|
+
/**
|
|
92
|
+
* The TOML entry serializer a blocked Codex row prints its remedy with —
|
|
93
|
+
* carried here for the same reason the writer is: this module imports only
|
|
94
|
+
* TYPES from `@canonical/harnesses`, so the package stays off the fast path,
|
|
95
|
+
* and the pure message builders need the function synchronously.
|
|
96
|
+
*/
|
|
97
|
+
readonly serializeTomlInlineEntry: SerializeTomlInlineEntry;
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
/**
|
|
@@ -188,12 +218,14 @@ const mcpWriteKey = (write: ConfigTarget): string =>
|
|
|
188
218
|
*
|
|
189
219
|
* @param rt - The per-invocation runtime.
|
|
190
220
|
* @param scope - The scope being planned (global or project).
|
|
221
|
+
* @param probe - The writability filesystem seam; defaults to the real one.
|
|
191
222
|
* @returns The scope's target groups, their prior states, + the config writer.
|
|
192
223
|
* @note Impure — reads the filesystem via `detectHarnesses` + `readMcpConfigFrom`.
|
|
193
224
|
*/
|
|
194
225
|
export async function detectMcp(
|
|
195
226
|
rt: PragmaRuntime,
|
|
196
227
|
scope: Scope,
|
|
228
|
+
probe?: FsProbe,
|
|
197
229
|
): Promise<McpDetection> {
|
|
198
230
|
const cwd = rt.cwd;
|
|
199
231
|
const [
|
|
@@ -204,6 +236,7 @@ export async function detectMcp(
|
|
|
204
236
|
readMcpConfigFrom,
|
|
205
237
|
readPlatformEnv,
|
|
206
238
|
removeMcpConfigFrom,
|
|
239
|
+
serializeTomlInlineEntry,
|
|
207
240
|
writeMcpConfigTargets,
|
|
208
241
|
},
|
|
209
242
|
{ runTask },
|
|
@@ -224,6 +257,14 @@ export async function detectMcp(
|
|
|
224
257
|
// than measured separately.
|
|
225
258
|
const stateByPath = new Map<string, McpTargetState>();
|
|
226
259
|
const stateByWrite = new Map<string, McpTargetState>();
|
|
260
|
+
// Writability, per file, before anything is composed. Cheap (a stat walk and
|
|
261
|
+
// one `access`), and it is what turns an unavoidable failure into a row that
|
|
262
|
+
// says what to do instead.
|
|
263
|
+
const blockedByPath = new Map<string, WriteBlock>();
|
|
264
|
+
for (const group of groups) {
|
|
265
|
+
const block = probeWritable(group.path, probe);
|
|
266
|
+
if (block !== undefined) blockedByPath.set(group.path, block);
|
|
267
|
+
}
|
|
227
268
|
await Promise.all(
|
|
228
269
|
groups.map(async (group) => {
|
|
229
270
|
const want = pragmaMcpEntry(cwd, group.scope);
|
|
@@ -252,10 +293,12 @@ export async function detectMcp(
|
|
|
252
293
|
groups,
|
|
253
294
|
stateByPath,
|
|
254
295
|
stateByWrite,
|
|
296
|
+
blockedByPath,
|
|
255
297
|
cwd,
|
|
256
298
|
platform,
|
|
257
299
|
writeMcpConfigTargets,
|
|
258
300
|
removeMcpConfigFrom,
|
|
301
|
+
serializeTomlInlineEntry,
|
|
259
302
|
};
|
|
260
303
|
}
|
|
261
304
|
|
|
@@ -283,6 +326,155 @@ export function mcpWriteState(
|
|
|
283
326
|
return d.stateByWrite.get(mcpWriteKey(write)) ?? "absent";
|
|
284
327
|
}
|
|
285
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The block on one group's file, or `undefined` when it can be written.
|
|
331
|
+
*
|
|
332
|
+
* @param d - The detection gathered up front.
|
|
333
|
+
* @param path - The group's config file path.
|
|
334
|
+
* @returns The {@link WriteBlock}, or undefined.
|
|
335
|
+
*/
|
|
336
|
+
export const mcpGroupBlock = (
|
|
337
|
+
d: McpDetection,
|
|
338
|
+
path: string,
|
|
339
|
+
): WriteBlock | undefined => d.blockedByPath.get(path);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Why one BLOCKED group's file cannot be written — one line, as the renderer
|
|
343
|
+
* prints one.
|
|
344
|
+
*
|
|
345
|
+
* @param d - The detection gathered up front.
|
|
346
|
+
* @param group - A group whose file is blocked.
|
|
347
|
+
* @param roots - The two roots the path renders relative to.
|
|
348
|
+
* @returns The reason.
|
|
349
|
+
*/
|
|
350
|
+
function describeMcpBlock(
|
|
351
|
+
block: WriteBlock,
|
|
352
|
+
path: string,
|
|
353
|
+
roots: Roots,
|
|
354
|
+
): string {
|
|
355
|
+
const where = shortenPath(path, roots);
|
|
356
|
+
return block.kind === "nix-store"
|
|
357
|
+
? `${where} is managed by Nix (it resolves to ${block.resolved})`
|
|
358
|
+
: `${where} is not writable (${block.path})`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Why one group's file cannot be written, or `undefined` when it can be.
|
|
363
|
+
*
|
|
364
|
+
* @param d - The detection gathered up front.
|
|
365
|
+
* @param group - The group whose file may be blocked.
|
|
366
|
+
* @param roots - The two roots the path renders relative to.
|
|
367
|
+
* @returns The reason, or `undefined` when the file can be written.
|
|
368
|
+
*/
|
|
369
|
+
export function mcpBlockReason(
|
|
370
|
+
d: McpDetection,
|
|
371
|
+
group: TargetGroup,
|
|
372
|
+
roots: Roots,
|
|
373
|
+
): string | undefined {
|
|
374
|
+
const block = mcpGroupBlock(d, group.path);
|
|
375
|
+
return block === undefined
|
|
376
|
+
? undefined
|
|
377
|
+
: describeMcpBlock(block, group.path, roots);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The remedy beneath a blocked group: the EXACT entry a write would have
|
|
382
|
+
* emitted, and nothing else.
|
|
383
|
+
*
|
|
384
|
+
* It is serialized through the group's own `serializeEntry` — the same
|
|
385
|
+
* function the writer uses — so the entry a user copies into their
|
|
386
|
+
* home-manager or NixOS config is byte-for-byte what `setup mcp` would have
|
|
387
|
+
* written, and the next detection classifies it `registered` rather than
|
|
388
|
+
* `drifted`. A hand-written sample in this string would be a second
|
|
389
|
+
* serializer to keep in sync, and the failure mode is a config the tool then
|
|
390
|
+
* offers to "repair" forever.
|
|
391
|
+
*
|
|
392
|
+
* It is written in the file's OWN FORMAT. A JSON body pasted into Codex's
|
|
393
|
+
* `config.toml` is not an entry, it is a syntax error, and the row that
|
|
394
|
+
* printed it was the one row whose whole job was to say what to write. The
|
|
395
|
+
* TOML spelling is the dotted-key inline table — the same entry the writer's
|
|
396
|
+
* `[mcp_servers.pragma]` table holds, in the one-line form a remedy can print
|
|
397
|
+
* (the renderer gives it one dim line).
|
|
398
|
+
*
|
|
399
|
+
* Every write in the group is included, keyed by its own `mcpKey`: a shared
|
|
400
|
+
* `.vscode/mcp.json` holds two independent entries, and half the answer is
|
|
401
|
+
* not an answer.
|
|
402
|
+
*
|
|
403
|
+
* The line does NOT restate the path or the cause. The reason line directly
|
|
404
|
+
* above it has said both ({@link mcpBlockReason}), and a remedy that repeats
|
|
405
|
+
* them is the duplication the LSP remedies beside it dropped: a remedy states
|
|
406
|
+
* the action.
|
|
407
|
+
*
|
|
408
|
+
* @param d - The detection gathered up front.
|
|
409
|
+
* @param group - The group whose file is blocked.
|
|
410
|
+
* @param roots - The two roots the path renders relative to.
|
|
411
|
+
* @returns The remedy line.
|
|
412
|
+
*/
|
|
413
|
+
function blockedMcpRemedy(
|
|
414
|
+
d: McpDetection,
|
|
415
|
+
group: TargetGroup,
|
|
416
|
+
roots: Roots,
|
|
417
|
+
): string {
|
|
418
|
+
const want = pragmaMcpEntry(d.cwd, group.scope);
|
|
419
|
+
const where = shortenPath(group.path, roots);
|
|
420
|
+
const toml = group.writes[0]?.configFormat === "toml";
|
|
421
|
+
const body = toml
|
|
422
|
+
? group.writes
|
|
423
|
+
.map((write) =>
|
|
424
|
+
d.serializeTomlInlineEntry(
|
|
425
|
+
write.mcpKey,
|
|
426
|
+
MCP_SERVER_NAME,
|
|
427
|
+
write.serializeEntry(want),
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
.join(" ")
|
|
431
|
+
: JSON.stringify(
|
|
432
|
+
Object.fromEntries(
|
|
433
|
+
group.writes.map((write) => [
|
|
434
|
+
write.mcpKey,
|
|
435
|
+
{ [MCP_SERVER_NAME]: write.serializeEntry(want) },
|
|
436
|
+
]),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
return `put this entry in the config that owns ${where}: ${body}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The groups whose file cannot be written, in `d.groups` order. */
|
|
443
|
+
export const blockedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
|
|
444
|
+
d.groups.filter((group) => d.blockedByPath.has(group.path));
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The FIRST group whose file cannot be written, with its reason and its remedy
|
|
448
|
+
* already derived — or `undefined` when every file in the scope is writable.
|
|
449
|
+
*
|
|
450
|
+
* It hands back the derived strings rather than the group, for the reason
|
|
451
|
+
* `firstLspBlock` spells out: the blockedness is what makes the two strings
|
|
452
|
+
* exist, so a caller that took the group and asked for them separately had to
|
|
453
|
+
* assert that both were there, on both surfaces.
|
|
454
|
+
*
|
|
455
|
+
* @param d - The detection gathered up front.
|
|
456
|
+
* @param roots - The two roots the path renders relative to.
|
|
457
|
+
* @returns The first block's reason and remedy, or `undefined`.
|
|
458
|
+
*/
|
|
459
|
+
export const firstMcpBlock = (
|
|
460
|
+
d: McpDetection,
|
|
461
|
+
roots: Roots,
|
|
462
|
+
): { readonly reason: string; readonly remedy: string } | undefined => {
|
|
463
|
+
// A walk rather than `blockedMcpGroups(d).at(0)`: the group and its block
|
|
464
|
+
// come out together, so nothing has to assert afterwards that the block a
|
|
465
|
+
// filter just proved is there really is.
|
|
466
|
+
for (const group of d.groups) {
|
|
467
|
+
const block = mcpGroupBlock(d, group.path);
|
|
468
|
+
if (block !== undefined) {
|
|
469
|
+
return {
|
|
470
|
+
reason: describeMcpBlock(block, group.path, roots),
|
|
471
|
+
remedy: blockedMcpRemedy(d, group, roots),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return undefined;
|
|
476
|
+
};
|
|
477
|
+
|
|
286
478
|
/** The target groups the user selected (by path), or all when none recorded. */
|
|
287
479
|
export function selectedGroups(
|
|
288
480
|
d: McpDetection,
|
|
@@ -314,7 +506,11 @@ export function composeMcp(
|
|
|
314
506
|
// file (VS Code + Cline) a single read-modify-write — dry-run safe. The
|
|
315
507
|
// written entry is SCOPE-shaped per group (a global entry omits `cwd`).
|
|
316
508
|
const pending = groups.filter(
|
|
317
|
-
(group) =>
|
|
509
|
+
(group) =>
|
|
510
|
+
mcpGroupState(d, group.path) !== "registered" &&
|
|
511
|
+
// A blocked file is never written. The write is what USED to discover the
|
|
512
|
+
// block, and it discovered it as a raw fs error on a `failed` row.
|
|
513
|
+
!d.blockedByPath.has(group.path),
|
|
318
514
|
);
|
|
319
515
|
return sequence_(
|
|
320
516
|
pending.map((group) =>
|
|
@@ -334,7 +530,13 @@ export function composeMcp(
|
|
|
334
530
|
* foreign server in a file that does is never touched.
|
|
335
531
|
*/
|
|
336
532
|
export const ownedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
|
|
337
|
-
d.groups.filter(
|
|
533
|
+
d.groups.filter(
|
|
534
|
+
(group) =>
|
|
535
|
+
mcpGroupState(d, group.path) !== "absent" &&
|
|
536
|
+
// A blocked file cannot be written, so it cannot be un-written either.
|
|
537
|
+
// The row says so rather than composing a reversal that would fail.
|
|
538
|
+
!d.blockedByPath.has(group.path),
|
|
539
|
+
);
|
|
338
540
|
|
|
339
541
|
/**
|
|
340
542
|
* Compose the removal of the pragma entry from every owned file.
|
|
@@ -356,9 +558,14 @@ export const ownedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
|
|
|
356
558
|
* same file is never touched.
|
|
357
559
|
*
|
|
358
560
|
* @param d - The detection gathered up front.
|
|
561
|
+
* @param undoKey - Correlation key stamped on the reversal(s), echoed on
|
|
562
|
+
* their undo outcomes so the caller can read results back per row.
|
|
359
563
|
* @returns A Task whose undo removes the pragma entry from each owned file.
|
|
360
564
|
*/
|
|
361
|
-
export function composeMcpRemoval(
|
|
565
|
+
export function composeMcpRemoval(
|
|
566
|
+
d: McpDetection,
|
|
567
|
+
undoKey?: string,
|
|
568
|
+
): Task<void> {
|
|
362
569
|
return sequence_(
|
|
363
570
|
ownedMcpGroups(d).map((group) =>
|
|
364
571
|
mkdir(dirname(group.path), true, {
|
|
@@ -367,6 +574,7 @@ export function composeMcpRemoval(d: McpDetection): Task<void> {
|
|
|
367
574
|
d.removeMcpConfigFrom(write, MCP_SERVER_NAME),
|
|
368
575
|
),
|
|
369
576
|
),
|
|
577
|
+
undoKey,
|
|
370
578
|
}),
|
|
371
579
|
),
|
|
372
580
|
);
|
|
@@ -754,14 +754,19 @@ export const ownedSkillLinks = (
|
|
|
754
754
|
* this command owns, never from what a fresh forward plan would create.
|
|
755
755
|
*
|
|
756
756
|
* @param d - The detection gathered up front.
|
|
757
|
+
* @param undoKey - Correlation key stamped on the reversal(s), echoed on
|
|
758
|
+
* their undo outcomes so the caller can read results back per row.
|
|
757
759
|
* @returns A Task whose undo removes every owned link.
|
|
758
760
|
*/
|
|
759
|
-
export function composeSkillsRemoval(
|
|
761
|
+
export function composeSkillsRemoval(
|
|
762
|
+
d: SkillsDetection,
|
|
763
|
+
undoKey?: string,
|
|
764
|
+
): Task<void> {
|
|
760
765
|
const owned = ownedSkillLinks(d);
|
|
761
766
|
if (owned.length === 0) return sequence_([]);
|
|
762
767
|
return sequence_(
|
|
763
768
|
owned.map((a) =>
|
|
764
|
-
symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath) }),
|
|
769
|
+
symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath), undoKey }),
|
|
765
770
|
),
|
|
766
771
|
);
|
|
767
772
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `probeWritable` — the pure half, driven entirely over its `FsProbe` seam.
|
|
3
|
+
*
|
|
4
|
+
* Every branch here decides whether a row REPORTS a named skip or attempts a
|
|
5
|
+
* write that fails with a raw fs message, and two of them cannot be arranged
|
|
6
|
+
* for real: a test process running as root can write to a `0o500` directory,
|
|
7
|
+
* and no CI host has a `/nix/store`. So the filesystem is three injected
|
|
8
|
+
* functions and each rule is asserted against a fixture that spells out the
|
|
9
|
+
* machine it stands for.
|
|
10
|
+
*
|
|
11
|
+
* The fixture factory is stateful (it records the walk), so it lives in
|
|
12
|
+
* `testing/helpers/fsProbe.ts` and is shared with the setup and doctor suites
|
|
13
|
+
* rather than copied into each.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { fakeFsProbe as probe } from "../../../testing/helpers/fsProbe.js";
|
|
21
|
+
import type { WriteBlock } from "../types.js";
|
|
22
|
+
import { probeWritable } from "./writability.js";
|
|
23
|
+
|
|
24
|
+
describe("probeWritable", () => {
|
|
25
|
+
it("probes the TARGET ITSELF when it is present, not only its directory", () => {
|
|
26
|
+
// home-manager symlinks `mcp.json` straight into the store and leaves
|
|
27
|
+
// `User/` writable. Stopping at the parent would call the file writable
|
|
28
|
+
// and then fail on the write.
|
|
29
|
+
const fs = probe({
|
|
30
|
+
present: ["/home/u/.config/Code/User/mcp.json"],
|
|
31
|
+
realpath: {
|
|
32
|
+
"/home/u/.config/Code/User/mcp.json": "/nix/store/abc-vscode/mcp.json",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(probeWritable("/home/u/.config/Code/User/mcp.json", fs)).toEqual({
|
|
36
|
+
kind: "nix-store",
|
|
37
|
+
resolved: "/nix/store/abc-vscode/mcp.json",
|
|
38
|
+
} satisfies WriteBlock);
|
|
39
|
+
expect(fs.seen).toEqual(["/home/u/.config/Code/User/mcp.json"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("passes a present, writable target", () => {
|
|
43
|
+
const fs = probe({ present: ["/home/u/.vscode/extensions"] });
|
|
44
|
+
expect(probeWritable("/home/u/.vscode/extensions", fs)).toBeUndefined();
|
|
45
|
+
expect(fs.accessed).toEqual(["/home/u/.vscode/extensions"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("passes an ABSENT target whose directory is writable", () => {
|
|
49
|
+
// The ordinary first run: the extensions dir does not exist yet, and it is
|
|
50
|
+
// writable exactly when the directory that would hold it is.
|
|
51
|
+
const fs = probe({ present: ["/home/u"] });
|
|
52
|
+
expect(probeWritable("/home/u/.vscode/extensions", fs)).toBeUndefined();
|
|
53
|
+
expect(fs.accessed).toEqual(["/home/u"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("walks up through several missing levels to the nearest existing node", () => {
|
|
57
|
+
const fs = probe({ present: ["/home/u"] });
|
|
58
|
+
expect(
|
|
59
|
+
probeWritable("/home/u/.config/Code/User/mcp.json", fs),
|
|
60
|
+
).toBeUndefined();
|
|
61
|
+
expect(fs.seen).toEqual([
|
|
62
|
+
"/home/u/.config/Code/User/mcp.json",
|
|
63
|
+
"/home/u/.config/Code/User",
|
|
64
|
+
"/home/u/.config/Code",
|
|
65
|
+
"/home/u/.config",
|
|
66
|
+
"/home/u",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("follows the symlink: the STORE path is the answer, not the link's own name", () => {
|
|
71
|
+
// `~/.vscode/extensions` → `/nix/store/…`: nothing about the link's name
|
|
72
|
+
// says it is managed, and the raw fs error the write produced said nothing
|
|
73
|
+
// about Nix either.
|
|
74
|
+
const fs = probe({
|
|
75
|
+
present: ["/home/u/.vscode/extensions"],
|
|
76
|
+
realpath: {
|
|
77
|
+
"/home/u/.vscode/extensions": "/nix/store/xyz-vscode-with-extensions",
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(probeWritable("/home/u/.vscode/extensions", fs)).toEqual({
|
|
81
|
+
kind: "nix-store",
|
|
82
|
+
resolved: "/nix/store/xyz-vscode-with-extensions",
|
|
83
|
+
} satisfies WriteBlock);
|
|
84
|
+
// W_OK is never consulted — see the next case for why that matters.
|
|
85
|
+
expect(fs.accessed).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("the store wins over W_OK: a store path NEVER reports `read-only`", () => {
|
|
89
|
+
// The store is read-only, so `access(W_OK)` fails there too. Reporting
|
|
90
|
+
// `read-only` would print a permissions remedy at a Nix user — advice that
|
|
91
|
+
// cannot work, and that the next `nixos-rebuild` would undo anyway.
|
|
92
|
+
const fs = probe({
|
|
93
|
+
present: ["/home/u/.vscode/extensions"],
|
|
94
|
+
realpath: { "/home/u/.vscode/extensions": "/nix/store/abc/extensions" },
|
|
95
|
+
denied: ["/nix/store/abc/extensions"],
|
|
96
|
+
});
|
|
97
|
+
expect(probeWritable("/home/u/.vscode/extensions", fs)?.kind).toBe(
|
|
98
|
+
"nix-store",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reports read-only with the RESOLVED path a remedy has to name", () => {
|
|
103
|
+
const fs = probe({
|
|
104
|
+
present: ["/mnt/ro/home/.vscode/extensions"],
|
|
105
|
+
realpath: {
|
|
106
|
+
"/mnt/ro/home/.vscode/extensions": "/mnt/ro/real/extensions",
|
|
107
|
+
},
|
|
108
|
+
denied: ["/mnt/ro/real/extensions"],
|
|
109
|
+
});
|
|
110
|
+
expect(probeWritable("/mnt/ro/home/.vscode/extensions", fs)).toEqual({
|
|
111
|
+
kind: "read-only",
|
|
112
|
+
path: "/mnt/ro/real/extensions",
|
|
113
|
+
} satisfies WriteBlock);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reports read-only for an absent target under a read-only directory", () => {
|
|
117
|
+
const fs = probe({ present: ["/mnt/ro"], denied: ["/mnt/ro"] });
|
|
118
|
+
expect(probeWritable("/mnt/ro/Code/User/mcp.json", fs)).toEqual({
|
|
119
|
+
kind: "read-only",
|
|
120
|
+
path: "/mnt/ro",
|
|
121
|
+
} satisfies WriteBlock);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("blocks nothing when the walk runs out of parents", () => {
|
|
125
|
+
// `dirname("/")` is `"/"`, so the walk terminates on the fixed point. With
|
|
126
|
+
// nothing existing anywhere there is no permission answer to give, and
|
|
127
|
+
// inventing one either way would be a guess.
|
|
128
|
+
const fs = probe({});
|
|
129
|
+
expect(probeWritable("/nowhere/at/all", fs)).toBeUndefined();
|
|
130
|
+
expect(fs.seen.at(-1)).toBe("/");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("blocks nothing when realpath itself fails — the write is the honest judge", () => {
|
|
134
|
+
// The node existed a moment ago and cannot be resolved now: a race, or a
|
|
135
|
+
// symlink loop. Reporting a skip would be a claim this probe cannot back.
|
|
136
|
+
const fs = probe({
|
|
137
|
+
present: ["/home/u/.vscode/extensions"],
|
|
138
|
+
realpathThrows: true,
|
|
139
|
+
});
|
|
140
|
+
expect(probeWritable("/home/u/.vscode/extensions", fs)).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("defaults to the real filesystem — a writable temp dir passes", () => {
|
|
144
|
+
// The one case that touches the disk, so the default argument is covered
|
|
145
|
+
// by something other than a fixture claiming to be it. A temp dir, not
|
|
146
|
+
// `process.cwd()`: the case says "a writable directory", and the repo
|
|
147
|
+
// working tree is neither guaranteed to be one nor this suite's business.
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "pragma-writability-"));
|
|
149
|
+
expect(probeWritable(dir)).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Could this command write there?" — asked BEFORE anything is written.
|
|
3
|
+
*
|
|
4
|
+
* Two machines answer no, and until this probe existed both answered it the
|
|
5
|
+
* same wrong way. A NixOS or home-manager user's `~/.vscode/extensions` is a
|
|
6
|
+
* symlink into `/nix/store`, which is a read-only filesystem by design:
|
|
7
|
+
* `--install-extension` failed with a raw fs error, or appeared to succeed and
|
|
8
|
+
* changed nothing. A managed config directory turned the MCP write into a
|
|
9
|
+
* `failed` row carrying an EACCES message and no next step. Neither is a
|
|
10
|
+
* failure of this command — the machine is configured exactly as its owner
|
|
11
|
+
* intends — so both become NAMED SKIPS whose remedy is the one action that
|
|
12
|
+
* works there: declaring the extension, or the MCP entry, in the config that
|
|
13
|
+
* owns the directory.
|
|
14
|
+
*
|
|
15
|
+
* Per the owner, 2026-09-16: the decision is made by the DIRECTORY, never by
|
|
16
|
+
* the OS. Nix is not a platform to branch on — a Nix user's home directory is
|
|
17
|
+
* ordinary, a non-Nix user can have a read-only mount, and `/etc/nixos` says
|
|
18
|
+
* nothing about whether THIS path is writable. So the probe walks the path it
|
|
19
|
+
* was actually given.
|
|
20
|
+
*
|
|
21
|
+
* The whole filesystem surface is the injected {@link FsProbe}, so every
|
|
22
|
+
* branch is exercised over a seam rather than by arranging a real read-only
|
|
23
|
+
* directory (which a test running as root cannot do at all). It lives under
|
|
24
|
+
* `setup/operations/` because setup is its one consumer: doctor reads the
|
|
25
|
+
* result off the detection records setup already produced, and never probes
|
|
26
|
+
* a second time.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { accessSync, constants, existsSync, realpathSync } from "node:fs";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
import type { WriteBlock } from "../types.js";
|
|
32
|
+
|
|
33
|
+
/** The store prefix a Nix-managed path resolves under. */
|
|
34
|
+
const NIX_STORE_PREFIX = "/nix/store/";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The filesystem this probe uses — the seam, defaulting to `node:fs`.
|
|
38
|
+
*
|
|
39
|
+
* Narrowed to the three calls, so a fixture is three functions rather than a
|
|
40
|
+
* module mock, and so nothing here can reach a writer by accident.
|
|
41
|
+
*/
|
|
42
|
+
export interface FsProbe {
|
|
43
|
+
existsSync(path: string): boolean;
|
|
44
|
+
accessSync(path: string, mode?: number): void;
|
|
45
|
+
realpathSync(path: string): string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The real filesystem — the default, and the only impure thing in this module. */
|
|
49
|
+
const nodeFs: FsProbe = { existsSync, accessSync, realpathSync };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The nearest node at or above `target` that exists, or `undefined` when the
|
|
53
|
+
* walk runs out of parents.
|
|
54
|
+
*
|
|
55
|
+
* `dirname("/")` is `"/"`, so the loop terminates on the fixed point rather
|
|
56
|
+
* than on a path shape — the same is true of a win32 drive root, which is why
|
|
57
|
+
* the comparison is against the previous value and not against a separator.
|
|
58
|
+
*
|
|
59
|
+
* @param target - The path to start at.
|
|
60
|
+
* @param fs - The filesystem seam.
|
|
61
|
+
* @returns The nearest existing node, or `undefined`.
|
|
62
|
+
* @note Impure — stats each path it walks through the injected seam.
|
|
63
|
+
*/
|
|
64
|
+
const findNearestExisting = (
|
|
65
|
+
target: string,
|
|
66
|
+
fs: FsProbe,
|
|
67
|
+
): string | undefined => {
|
|
68
|
+
let current = target;
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (fs.existsSync(current)) return current;
|
|
71
|
+
const parent = dirname(current);
|
|
72
|
+
if (parent === current) return undefined;
|
|
73
|
+
current = parent;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Probe whether `target` could be written, returning the {@link WriteBlock}
|
|
79
|
+
* that says why not — or `undefined` when the write can go ahead.
|
|
80
|
+
*
|
|
81
|
+
* Three rules, in this order:
|
|
82
|
+
*
|
|
83
|
+
* 1. **Walk up to the nearest node that exists**, starting AT the target. The
|
|
84
|
+
* target itself when it is there — home-manager symlinks `mcp.json`
|
|
85
|
+
* directly into the store while leaving `User/` writable, so stopping at
|
|
86
|
+
* the parent directory would call that file writable and then fail on the
|
|
87
|
+
* write. Otherwise its nearest existing ancestor, because a path that does
|
|
88
|
+
* not exist yet is writable exactly when the directory that would hold it
|
|
89
|
+
* is.
|
|
90
|
+
* 2. **`realpathSync` it**, so the answer is about the real filesystem rather
|
|
91
|
+
* than the symlink standing in front of it.
|
|
92
|
+
* 3. **`/nix/store/` wins over `W_OK`.** Checked first, deliberately: the
|
|
93
|
+
* store is read-only, so `access(W_OK)` would fail there too and the row
|
|
94
|
+
* would report "read-only" with a remedy about file permissions — advice
|
|
95
|
+
* that cannot work, because `chmod` on the store is not the fix and would
|
|
96
|
+
* be undone by the next `nixos-rebuild`. The remedy for a store path is a
|
|
97
|
+
* declaration in the user's own config, and naming the store is what earns
|
|
98
|
+
* it.
|
|
99
|
+
*
|
|
100
|
+
* @param target - The file or directory a write would create or modify.
|
|
101
|
+
* @param fs - The filesystem seam; defaults to the real `node:fs`.
|
|
102
|
+
* @returns The block, or `undefined` when the path is writable.
|
|
103
|
+
* @note Impure by default — it stats and resolves real paths. Every branch is
|
|
104
|
+
* reachable through the injected {@link FsProbe}.
|
|
105
|
+
*/
|
|
106
|
+
export function probeWritable(
|
|
107
|
+
target: string,
|
|
108
|
+
fs: FsProbe = nodeFs,
|
|
109
|
+
): WriteBlock | undefined {
|
|
110
|
+
const existing = findNearestExisting(target, fs);
|
|
111
|
+
// Nothing at or above the target exists. That is not a permission answer,
|
|
112
|
+
// and inventing one either way would be a guess — the write will report its
|
|
113
|
+
// own error if it is really unreachable.
|
|
114
|
+
if (existing === undefined) return undefined;
|
|
115
|
+
|
|
116
|
+
let resolved: string;
|
|
117
|
+
try {
|
|
118
|
+
resolved = fs.realpathSync(existing);
|
|
119
|
+
} catch {
|
|
120
|
+
// The node existed a moment ago and cannot be resolved now (a race, or a
|
|
121
|
+
// symlink loop). Not a block: the caller's write is the honest judge.
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (resolved.startsWith(NIX_STORE_PREFIX)) {
|
|
126
|
+
return { kind: "nix-store", resolved };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
fs.accessSync(resolved, constants.W_OK);
|
|
131
|
+
} catch {
|
|
132
|
+
return { kind: "read-only", path: resolved };
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
@@ -117,6 +117,17 @@ export interface SetupPlan {
|
|
|
117
117
|
readonly rows: readonly PlanRow[];
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The context a path renders against — the plan's two named roots.
|
|
122
|
+
*
|
|
123
|
+
* It lives here, beside {@link SetupPlan}, because it IS that structure's
|
|
124
|
+
* `roots` field and every reader of it already imports this module: the target
|
|
125
|
+
* table, the renderers, the doctor checks, and the operations whose messages
|
|
126
|
+
* shorten a path. Declaring it in the target table instead put the operations
|
|
127
|
+
* in the position of importing the module that imports them.
|
|
128
|
+
*/
|
|
129
|
+
export type Roots = SetupPlan["roots"];
|
|
130
|
+
|
|
120
131
|
/**
|
|
121
132
|
* Whether a plan action would touch the filesystem. `none` and `skip` are the
|
|
122
133
|
* two quiet outcomes: a converged re-run composes no effects at all, so mtimes
|