@canonical/pragma-cli 0.29.0 → 0.30.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 +67 -15
- package/dist/pragma +0 -0
- package/package.json +13 -13
- package/src/config/defaultConfig.test.ts +33 -0
- package/src/config/defaultConfig.ts +59 -0
- package/src/config/defaults.json +17 -0
- package/src/config/findProjectConfigPath.test.ts +53 -0
- package/src/config/findProjectConfigPath.ts +36 -0
- package/src/config/index.ts +17 -2
- package/src/config/mergeConfigFileUpdate.ts +58 -0
- package/src/config/parseConfigValues.ts +215 -0
- package/src/config/readConfig.test.ts +104 -13
- package/src/config/readConfig.ts +12 -153
- package/src/config/readConfigLayers.test.ts +107 -0
- package/src/config/readConfigLayers.ts +115 -0
- package/src/config/resolveGlobalConfigPath.ts +13 -0
- package/src/config/resolveWriteConfigPath.test.ts +53 -0
- package/src/config/resolveWriteConfigPath.ts +31 -0
- package/src/config/types.ts +76 -1
- package/src/config/writeConfig.ts +25 -55
- package/src/domains/block/commands/list.ts +5 -90
- package/src/domains/block/commands/lookup.ts +8 -167
- package/src/domains/block/mcp/specs.ts +16 -196
- package/src/domains/block/orchestration/resolveBlockLookup.ts +2 -1
- package/src/domains/block/stories.test.ts +28 -0
- package/src/domains/block/stories.ts +254 -0
- package/src/domains/config/commands/channel.test.ts +14 -3
- package/src/domains/config/commands/channel.ts +11 -7
- package/src/domains/config/commands/configScope.ts +46 -0
- package/src/domains/config/commands/framework.test.ts +18 -1
- package/src/domains/config/commands/framework.ts +13 -7
- package/src/domains/config/commands/show.ts +5 -45
- package/src/domains/config/commands/tier.test.ts +13 -2
- package/src/domains/config/commands/tier.ts +11 -7
- package/src/domains/config/commands/trace.test.ts +19 -2
- package/src/domains/config/commands/trace.ts +6 -3
- package/src/domains/config/formatters/channel.test.ts +20 -6
- package/src/domains/config/formatters/channel.ts +14 -11
- package/src/domains/config/formatters/framework.ts +14 -11
- package/src/domains/config/formatters/show.test.ts +27 -0
- package/src/domains/config/formatters/show.ts +27 -4
- package/src/domains/config/formatters/tier.test.ts +26 -10
- package/src/domains/config/formatters/tier.ts +19 -11
- package/src/domains/config/formatters/trace.ts +7 -7
- package/src/domains/config/mcp/specs.ts +33 -53
- package/src/domains/config/operations/show.test.ts +18 -0
- package/src/domains/config/operations/show.ts +7 -1
- package/src/domains/config/operations/types.ts +7 -0
- package/src/domains/config/stories.ts +42 -0
- package/src/domains/config/tasks/index.ts +1 -0
- package/src/domains/config/tasks/setChannel.ts +13 -28
- package/src/domains/config/tasks/setFramework.ts +11 -30
- package/src/domains/config/tasks/setTier.ts +10 -29
- package/src/domains/config/tasks/setTrace.ts +10 -28
- package/src/domains/config/tasks/writeConfigField.ts +41 -0
- package/src/domains/create/commands/component.ts +5 -5
- package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
- package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
- package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
- package/src/domains/doctor/operations/checks/checkPackageRefs.ts +2 -14
- package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
- package/src/domains/doctor/operations/checks/index.ts +1 -0
- package/src/domains/doctor/operations/checks.test.ts +158 -7
- package/src/domains/doctor/operations/runChecks.test.ts +15 -6
- package/src/domains/doctor/operations/runChecks.ts +2 -0
- package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
- package/src/domains/graph/helpers/resolveUri.ts +28 -21
- package/src/domains/graphql/commands/build.test.ts +5 -3
- package/src/domains/graphql/commands/build.ts +8 -16
- package/src/domains/graphql/commands/check.test.ts +5 -3
- package/src/domains/graphql/commands/check.ts +7 -13
- package/src/domains/graphql/commands/index.ts +1 -0
- package/src/domains/graphql/commands/serve.test.ts +40 -0
- package/src/domains/graphql/commands/serve.ts +131 -0
- package/src/domains/graphql/constants.ts +10 -0
- package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
- package/src/domains/graphql/helpers/gatherSources.ts +58 -0
- package/src/domains/graphql/helpers/index.ts +2 -0
- package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
- package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
- package/src/domains/graphql/index.ts +9 -6
- package/src/domains/graphql/operations/compileSchema.test.ts +21 -45
- package/src/domains/graphql/operations/compileSchema.ts +23 -25
- package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
- package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
- package/src/domains/graphql/operations/index.ts +5 -0
- package/src/domains/info/operations/collectInfo.test.ts +14 -3
- package/src/domains/info/operations/collectInfo.ts +12 -15
- package/src/domains/llm/capabilities.test.ts +4 -4
- package/src/domains/llm/data/toolCatalog.ts +6 -0
- package/src/domains/modifier/commands/list.ts +5 -38
- package/src/domains/modifier/commands/lookup.ts +8 -79
- package/src/domains/modifier/mcp/specs.ts +15 -94
- package/src/domains/modifier/orchestration/resolveModifierLookup.ts +2 -1
- package/src/domains/modifier/stories.test.ts +20 -0
- package/src/domains/modifier/stories.ts +77 -0
- package/src/domains/ontology/commands/list.ts +5 -58
- package/src/domains/ontology/commands/show.ts +6 -58
- package/src/domains/ontology/formatters/show.ts +7 -1
- package/src/domains/ontology/mcp/specs.ts +7 -84
- package/src/domains/ontology/stories.test.ts +36 -0
- package/src/domains/ontology/stories.ts +108 -0
- package/src/domains/refs/operations/updateRefs.ts +2 -37
- package/src/domains/setup/commands/skills.test.ts +6 -2
- package/src/domains/setup/commands/skills.ts +6 -2
- package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
- package/src/domains/setup/helpers/runSetupTask.ts +62 -70
- package/src/domains/setup/operations/setupMcp.test.ts +6 -1
- package/src/domains/shared/bootStore.ts +6 -3
- package/src/domains/shared/compactUri.test.ts +11 -0
- package/src/domains/shared/compactUri.ts +10 -5
- package/src/domains/shared/context.ts +2 -2
- package/src/domains/shared/contracts.ts +11 -0
- package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
- package/src/domains/shared/createInteractivePromptSession.ts +236 -0
- package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
- package/src/domains/shared/loaders/bundledLoader.ts +110 -12
- package/src/domains/shared/loaders/gitLoader.ts +2 -2
- package/src/domains/shared/loaders/index.ts +4 -1
- package/src/domains/shared/loaders/localLoader.ts +18 -4
- package/src/domains/shared/loaders/readPackageDir.ts +36 -1
- package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
- package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
- package/src/domains/shared/lookupMany.test.ts +120 -0
- package/src/domains/shared/lookupMany.ts +34 -2
- package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
- package/src/domains/shared/mergeAndParseRefs.ts +44 -0
- package/src/domains/shared/packages.ts +10 -17
- package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
- package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
- package/src/domains/shared/runtime.ts +4 -63
- package/src/domains/shared/semanticPackage.ts +17 -1
- package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
- package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
- package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
- package/src/domains/shared/stories/compileLookupTool.ts +96 -0
- package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
- package/src/domains/shared/stories/compileReadCommand.ts +48 -0
- package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
- package/src/domains/shared/stories/compileReadTool.ts +38 -0
- package/src/domains/shared/stories/condense.test.ts +18 -0
- package/src/domains/shared/stories/condense.ts +15 -0
- package/src/domains/shared/stories/index.ts +20 -0
- package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
- package/src/domains/shared/stories/normalizeNames.ts +22 -0
- package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
- package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
- package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
- package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
- package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
- package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
- package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
- package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
- package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
- package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
- package/src/domains/shared/stories/pack/index.ts +28 -0
- package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
- package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
- package/src/domains/shared/stories/pack/types.ts +104 -0
- package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
- package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
- package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
- package/src/domains/shared/stories/projectCliParameters.ts +57 -0
- package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
- package/src/domains/shared/stories/projectToolParams.ts +32 -0
- package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
- package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
- package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
- package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
- package/src/domains/shared/stories/types.ts +171 -0
- package/src/domains/shared/suggestions/expandGlob.ts +1 -0
- package/src/domains/shared/suggestions/suggestNames.ts +43 -10
- package/src/domains/skill/commands/index.ts +3 -1
- package/src/domains/skill/commands/list.test.ts +6 -2
- package/src/domains/skill/commands/list.ts +5 -63
- package/src/domains/skill/commands/lookup.test.ts +118 -0
- package/src/domains/skill/commands/lookup.ts +14 -0
- package/src/domains/skill/formatters/index.ts +1 -0
- package/src/domains/skill/formatters/lookup.test.ts +46 -0
- package/src/domains/skill/formatters/lookup.ts +57 -0
- package/src/domains/skill/helpers/resolveSkillSources.ts +0 -1
- package/src/domains/skill/index.ts +7 -3
- package/src/domains/skill/mcp/specs.ts +10 -29
- package/src/domains/skill/operations/discover.test.ts +61 -30
- package/src/domains/skill/operations/discover.ts +28 -37
- package/src/domains/skill/operations/index.ts +2 -1
- package/src/domains/skill/operations/list.test.ts +15 -7
- package/src/domains/skill/operations/lookup.test.ts +91 -0
- package/src/domains/skill/operations/lookup.ts +70 -0
- package/src/domains/skill/stories.test.ts +43 -0
- package/src/domains/skill/stories.ts +103 -0
- package/src/domains/skill/types.ts +8 -0
- package/src/domains/standard/commands/list.ts +5 -84
- package/src/domains/standard/commands/lookup.ts +8 -93
- package/src/domains/standard/mcp/specs.ts +16 -171
- package/src/domains/standard/orchestration/resolveStandardLookup.ts +2 -1
- package/src/domains/standard/stories.test.ts +24 -0
- package/src/domains/standard/stories.ts +151 -0
- package/src/domains/tier/commands/list.ts +5 -55
- package/src/domains/tier/mcp/specs.ts +5 -31
- package/src/domains/tier/stories.test.ts +22 -0
- package/src/domains/tier/stories.ts +52 -0
- package/src/domains/token/mcp/specs.ts +5 -1
- package/src/domains/token/orchestration/resolveTokenLookup.ts +2 -1
- package/src/domains/trace/tracePlugin.test.ts +1 -1
- package/src/mcp/registerResources.test.ts +382 -74
- package/src/mcp/registerResources.ts +5 -348
- package/src/mcp/resources/buildGraphIndex.ts +167 -0
- package/src/mcp/resources/buildResourceList.test.ts +97 -0
- package/src/mcp/resources/buildResourceList.ts +176 -0
- package/src/mcp/resources/classifyEntity.test.ts +40 -0
- package/src/mcp/resources/classifyEntity.ts +27 -0
- package/src/mcp/resources/constants.ts +105 -0
- package/src/mcp/resources/displayPredicates.ts +62 -0
- package/src/mcp/resources/pickFirstValue.test.ts +29 -0
- package/src/mcp/resources/pickFirstValue.ts +26 -0
- package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
- package/src/mcp/resources/pickPrimaryType.ts +26 -0
- package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
- package/src/mcp/resources/rankUriCompletions.ts +64 -0
- package/src/mcp/resources/readEntity.ts +169 -0
- package/src/mcp/resources/registerResources.ts +126 -0
- package/src/mcp/resources/types.ts +128 -0
- package/src/mcp/tools/index.ts +9 -0
- package/src/mcp/tools/registerTools.test.ts +65 -8
- package/src/mcp/types.ts +4 -0
- package/src/pipeline/collectCommands.ts +8 -1
- package/src/pipeline/runCli.ts +40 -6
- package/src/testing/createTestMcpClient.ts +4 -1
- package/src/testing/index.ts +10 -0
- package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
- package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
- package/src/testing/integration/executor-execution.test.ts +101 -0
- package/src/testing/integration/executor-golden.test.ts +145 -0
- package/src/testing/integration/mcp-surface.test.ts +1 -1
- package/src/testing/integration/parity.test.ts +17 -12
- package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
- package/src/testing/setupXdgIsolation.ts +15 -0
- package/src/testing/standardParityFixtures.ts +92 -0
- package/src/testing/storyFixtures.ts +63 -0
- package/src/pipeline/runInteractiveCommand.ts +0 -198
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Channel,
|
|
3
|
+
type Framework,
|
|
4
|
+
VALID_CHANNELS,
|
|
5
|
+
VALID_FRAMEWORKS,
|
|
6
|
+
} from "../constants.js";
|
|
7
|
+
import {
|
|
8
|
+
parsePackageEntry,
|
|
9
|
+
type RawPackageEntry,
|
|
10
|
+
} from "../domains/refs/operations/parseRef.js";
|
|
11
|
+
import type { StoryPackDefinition } from "../domains/shared/stories/pack/types.js";
|
|
12
|
+
import validateStoryPackDefinition from "../domains/shared/stories/pack/validateStoryPackDefinition.js";
|
|
13
|
+
import { PragmaError } from "../error/PragmaError.js";
|
|
14
|
+
import type { ConfigFileValues } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Type guard: check whether a value is a recognized channel string.
|
|
18
|
+
*
|
|
19
|
+
* @param value - Candidate value.
|
|
20
|
+
* @returns `true` if value is a valid Channel.
|
|
21
|
+
*/
|
|
22
|
+
function isValidChannel(value: unknown): value is Channel {
|
|
23
|
+
return typeof value === "string" && VALID_CHANNELS.includes(value as Channel);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Type guard: check whether a value is a recognized framework string.
|
|
28
|
+
*
|
|
29
|
+
* @param value - Candidate value.
|
|
30
|
+
* @returns `true` if value is a valid Framework.
|
|
31
|
+
*/
|
|
32
|
+
function isValidFramework(value: unknown): value is Framework {
|
|
33
|
+
return (
|
|
34
|
+
typeof value === "string" && VALID_FRAMEWORKS.includes(value as Framework)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse and validate one config file's raw contents.
|
|
40
|
+
*
|
|
41
|
+
* Returns only the fields the file actually sets — presence drives layer
|
|
42
|
+
* merging in `readConfigLayers`. An empty or whitespace-only file sets
|
|
43
|
+
* nothing.
|
|
44
|
+
*
|
|
45
|
+
* @param raw - The file's raw text.
|
|
46
|
+
* @param sourcePath - Absolute path, used in error messages.
|
|
47
|
+
* @returns The values this file declares.
|
|
48
|
+
* @throws PragmaError with code `CONFIG_ERROR` on invalid JSON or values.
|
|
49
|
+
*/
|
|
50
|
+
export default function parseConfigValues(
|
|
51
|
+
raw: string,
|
|
52
|
+
sourcePath: string,
|
|
53
|
+
): ConfigFileValues {
|
|
54
|
+
const trimmed = raw.trim();
|
|
55
|
+
if (trimmed === "") {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let parsed: Record<string, unknown>;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
62
|
+
} catch {
|
|
63
|
+
throw PragmaError.configError(`Invalid JSON in ${sourcePath}.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const tier = typeof parsed.tier === "string" ? parsed.tier : undefined;
|
|
67
|
+
|
|
68
|
+
let channel: Channel | undefined;
|
|
69
|
+
if (parsed.channel !== undefined) {
|
|
70
|
+
if (!isValidChannel(parsed.channel)) {
|
|
71
|
+
throw PragmaError.configError(
|
|
72
|
+
`Invalid channel "${String(parsed.channel)}".`,
|
|
73
|
+
{ validOptions: [...VALID_CHANNELS] },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
channel = parsed.channel;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const trace = typeof parsed.trace === "boolean" ? parsed.trace : undefined;
|
|
80
|
+
|
|
81
|
+
let framework: Framework | undefined;
|
|
82
|
+
if (parsed.framework !== undefined) {
|
|
83
|
+
if (!isValidFramework(parsed.framework)) {
|
|
84
|
+
throw PragmaError.configError(
|
|
85
|
+
`Invalid framework "${String(parsed.framework)}".`,
|
|
86
|
+
{ validOptions: [...VALID_FRAMEWORKS] },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
framework = parsed.framework;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const packages = parsePackagesField(parsed.packages);
|
|
93
|
+
const stories = parseStoriesField(parsed.stories, sourcePath);
|
|
94
|
+
const prefixes = parsePrefixesField(parsed.prefixes);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
...(tier !== undefined ? { tier } : {}),
|
|
98
|
+
...(channel !== undefined ? { channel } : {}),
|
|
99
|
+
...(packages ? { packages } : {}),
|
|
100
|
+
...(trace !== undefined ? { trace } : {}),
|
|
101
|
+
...(framework !== undefined ? { framework } : {}),
|
|
102
|
+
...(stories ? { stories } : {}),
|
|
103
|
+
...(prefixes ? { prefixes } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate and parse the experimental `stories` config field.
|
|
109
|
+
*
|
|
110
|
+
* @returns The validated definitions, or `undefined` when absent.
|
|
111
|
+
* @throws PragmaError if the field is present but malformed.
|
|
112
|
+
*/
|
|
113
|
+
function parseStoriesField(
|
|
114
|
+
raw: unknown,
|
|
115
|
+
sourcePath: string,
|
|
116
|
+
): ReadonlyArray<StoryPackDefinition> | undefined {
|
|
117
|
+
if (raw === undefined || raw === null) return undefined;
|
|
118
|
+
if (!Array.isArray(raw)) {
|
|
119
|
+
throw PragmaError.configError(
|
|
120
|
+
'"stories" must be an array of story-pack definitions.',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return raw.map((entry, index) =>
|
|
124
|
+
validateStoryPackDefinition(entry, `${sourcePath} (stories[${index}])`),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate and parse the `prefixes` config field.
|
|
130
|
+
*
|
|
131
|
+
* @returns The prefix map, or `undefined` when absent.
|
|
132
|
+
* @throws PragmaError if the field is present but malformed.
|
|
133
|
+
*/
|
|
134
|
+
function parsePrefixesField(
|
|
135
|
+
raw: unknown,
|
|
136
|
+
): Readonly<Record<string, string>> | undefined {
|
|
137
|
+
if (raw === undefined || raw === null) return undefined;
|
|
138
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
139
|
+
throw PragmaError.configError(
|
|
140
|
+
'"prefixes" must be an object mapping prefix to namespace IRI.',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const entries = Object.entries(raw as Record<string, unknown>);
|
|
144
|
+
for (const [prefix, namespace] of entries) {
|
|
145
|
+
if (typeof namespace !== "string" || !namespace.includes("://")) {
|
|
146
|
+
throw PragmaError.configError(
|
|
147
|
+
`Invalid namespace for prefix "${prefix}": expected an absolute IRI.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return Object.fromEntries(entries) as Record<string, string>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Packages field parsing
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validate and parse the `packages` config field.
|
|
160
|
+
*
|
|
161
|
+
* @returns The validated array, or `undefined` when the field is absent.
|
|
162
|
+
* @throws PragmaError if the field is present but malformed.
|
|
163
|
+
*/
|
|
164
|
+
function parsePackagesField(
|
|
165
|
+
raw: unknown,
|
|
166
|
+
): ReadonlyArray<RawPackageEntry> | undefined {
|
|
167
|
+
if (raw === undefined || raw === null) return undefined;
|
|
168
|
+
|
|
169
|
+
if (!Array.isArray(raw)) {
|
|
170
|
+
throw PragmaError.configError(
|
|
171
|
+
'"packages" must be an array of strings or { name, source? } objects.',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// An explicitly empty list means "not configured": ref resolution falls
|
|
176
|
+
// back to global refs/defaults for an empty list (see mergeAndParseRefs),
|
|
177
|
+
// so dropping the field here keeps provenance in config show/doctor/info
|
|
178
|
+
// consistent with the behavior instead of reporting a project/global
|
|
179
|
+
// origin that resolution then ignores.
|
|
180
|
+
if (raw.length === 0) return undefined;
|
|
181
|
+
|
|
182
|
+
const entries: RawPackageEntry[] = [];
|
|
183
|
+
for (const item of raw) {
|
|
184
|
+
if (typeof item === "string") {
|
|
185
|
+
entries.push(item);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
189
|
+
const obj = item as Record<string, unknown>;
|
|
190
|
+
if (typeof obj.name !== "string" || obj.name.length === 0) {
|
|
191
|
+
throw PragmaError.configError(
|
|
192
|
+
'Each object in "packages" must have a non-empty "name" string.',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const entry: { name: string; source?: string } = { name: obj.name };
|
|
196
|
+
if (obj.source !== undefined) {
|
|
197
|
+
if (typeof obj.source !== "string") {
|
|
198
|
+
throw PragmaError.configError(
|
|
199
|
+
`Invalid source for "${obj.name}": expected a string.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
entry.source = obj.source;
|
|
203
|
+
}
|
|
204
|
+
// Validate format eagerly — fail fast on bad config
|
|
205
|
+
parsePackageEntry(entry);
|
|
206
|
+
entries.push(entry);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
throw PragmaError.configError(
|
|
210
|
+
'Each entry in "packages" must be a string or { name, source? } object.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return entries;
|
|
215
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
2
8
|
import { tmpdir } from "node:os";
|
|
3
9
|
import { join } from "node:path";
|
|
4
10
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
@@ -6,11 +12,22 @@ import { PragmaError } from "../error/PragmaError.js";
|
|
|
6
12
|
import readConfig from "./readConfig.js";
|
|
7
13
|
import writeConfig from "./writeConfig.js";
|
|
8
14
|
|
|
15
|
+
/** Write to `<dir>/pragma.config.json` — these tests pin file mechanics. */
|
|
16
|
+
function writeConfigLocal(
|
|
17
|
+
dir: string,
|
|
18
|
+
update: Parameters<typeof writeConfig>[1],
|
|
19
|
+
): string {
|
|
20
|
+
return writeConfig(dir, update, "local");
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
describe("readConfig", () => {
|
|
10
24
|
let dir: string;
|
|
11
25
|
|
|
12
26
|
beforeEach(() => {
|
|
13
27
|
dir = mkdtempSync(join(tmpdir(), "pragma-config-"));
|
|
28
|
+
// Bound the project-config walk at this fixture root so a stray
|
|
29
|
+
// pragma.config.json in an ancestor directory can never leak in.
|
|
30
|
+
mkdirSync(join(dir, ".git"));
|
|
14
31
|
});
|
|
15
32
|
|
|
16
33
|
afterEach(() => {
|
|
@@ -174,6 +191,64 @@ describe("readConfig", () => {
|
|
|
174
191
|
});
|
|
175
192
|
});
|
|
176
193
|
|
|
194
|
+
describe("readConfig — stories and prefixes fields", () => {
|
|
195
|
+
let dir: string;
|
|
196
|
+
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
dir = mkdtempSync(join(tmpdir(), "pragma-config-stories-"));
|
|
199
|
+
mkdirSync(join(dir, ".git"));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterEach(() => {
|
|
203
|
+
rmSync(dir, { recursive: true, force: true });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("parses a valid stories array and prefixes map", () => {
|
|
207
|
+
writeFileSync(
|
|
208
|
+
join(dir, "pragma.config.json"),
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
prefixes: { ex: "http://example.org/" },
|
|
211
|
+
stories: [
|
|
212
|
+
{
|
|
213
|
+
noun: "recipe",
|
|
214
|
+
list: {
|
|
215
|
+
query: "SELECT ?uri WHERE { ?uri a ex:Recipe }",
|
|
216
|
+
columns: [{ field: "uri" }],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const config = readConfig(dir);
|
|
223
|
+
expect(config.prefixes).toEqual({ ex: "http://example.org/" });
|
|
224
|
+
expect(config.stories?.at(0)?.noun).toBe("recipe");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("throws on a non-array stories field", () => {
|
|
228
|
+
writeFileSync(
|
|
229
|
+
join(dir, "pragma.config.json"),
|
|
230
|
+
'{"stories": {"noun": "x"}}',
|
|
231
|
+
);
|
|
232
|
+
expect(() => readConfig(dir)).toThrow(/stories/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("throws on an invalid story definition", () => {
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(dir, "pragma.config.json"),
|
|
238
|
+
'{"stories": [{"noun": "Bad Noun"}]}',
|
|
239
|
+
);
|
|
240
|
+
expect(() => readConfig(dir)).toThrow(PragmaError);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("throws on a prefixes value that is not an IRI", () => {
|
|
244
|
+
writeFileSync(
|
|
245
|
+
join(dir, "pragma.config.json"),
|
|
246
|
+
'{"prefixes": {"ex": "not-an-iri"}}',
|
|
247
|
+
);
|
|
248
|
+
expect(() => readConfig(dir)).toThrow(/prefix/);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
177
252
|
describe("writeConfig", () => {
|
|
178
253
|
let dir: string;
|
|
179
254
|
|
|
@@ -186,14 +261,14 @@ describe("writeConfig", () => {
|
|
|
186
261
|
});
|
|
187
262
|
|
|
188
263
|
it("creates a new config file with tier", () => {
|
|
189
|
-
|
|
264
|
+
writeConfigLocal(dir, { tier: "apps/lxd" });
|
|
190
265
|
const config = readConfig(dir);
|
|
191
266
|
expect(config.tier).toBe("apps/lxd");
|
|
192
267
|
expect(config.channel).toBe("normal");
|
|
193
268
|
});
|
|
194
269
|
|
|
195
270
|
it("creates a new config file with channel", () => {
|
|
196
|
-
|
|
271
|
+
writeConfigLocal(dir, { channel: "experimental" });
|
|
197
272
|
const config = readConfig(dir);
|
|
198
273
|
expect(config.channel).toBe("experimental");
|
|
199
274
|
expect(config.tier).toBeUndefined();
|
|
@@ -201,7 +276,7 @@ describe("writeConfig", () => {
|
|
|
201
276
|
|
|
202
277
|
it("merges tier into existing config", () => {
|
|
203
278
|
writeFileSync(join(dir, "pragma.config.json"), '{"channel":"prerelease"}');
|
|
204
|
-
|
|
279
|
+
writeConfigLocal(dir, { tier: "global" });
|
|
205
280
|
const config = readConfig(dir);
|
|
206
281
|
expect(config.tier).toBe("global");
|
|
207
282
|
expect(config.channel).toBe("prerelease");
|
|
@@ -212,7 +287,7 @@ describe("writeConfig", () => {
|
|
|
212
287
|
join(dir, "pragma.config.json"),
|
|
213
288
|
'{"tier":"apps","channel":"normal"}',
|
|
214
289
|
);
|
|
215
|
-
|
|
290
|
+
writeConfigLocal(dir, { tier: undefined });
|
|
216
291
|
const config = readConfig(dir);
|
|
217
292
|
expect(config.tier).toBeUndefined();
|
|
218
293
|
expect(config.channel).toBe("normal");
|
|
@@ -223,7 +298,7 @@ describe("writeConfig", () => {
|
|
|
223
298
|
join(dir, "pragma.config.json"),
|
|
224
299
|
'{"tier":"apps","channel":"experimental"}',
|
|
225
300
|
);
|
|
226
|
-
|
|
301
|
+
writeConfigLocal(dir, { channel: undefined });
|
|
227
302
|
const config = readConfig(dir);
|
|
228
303
|
expect(config.tier).toBe("apps");
|
|
229
304
|
// Channel defaults to "normal" when absent from file
|
|
@@ -231,7 +306,7 @@ describe("writeConfig", () => {
|
|
|
231
306
|
});
|
|
232
307
|
|
|
233
308
|
it("round-trips tier and channel through write then read", () => {
|
|
234
|
-
|
|
309
|
+
writeConfigLocal(dir, { tier: "apps/lxd", channel: "prerelease" });
|
|
235
310
|
const config = readConfig(dir);
|
|
236
311
|
expect(config.tier).toBe("apps/lxd");
|
|
237
312
|
expect(config.channel).toBe("prerelease");
|
|
@@ -239,19 +314,19 @@ describe("writeConfig", () => {
|
|
|
239
314
|
|
|
240
315
|
it("writes valid empty JSON when all fields removed", () => {
|
|
241
316
|
writeFileSync(join(dir, "pragma.config.json"), '{"tier":"apps"}');
|
|
242
|
-
|
|
317
|
+
writeConfigLocal(dir, { tier: undefined });
|
|
243
318
|
const raw = readFileSync(join(dir, "pragma.config.json"), "utf-8");
|
|
244
319
|
expect(raw).toBe("{}\n");
|
|
245
320
|
});
|
|
246
321
|
|
|
247
322
|
it("throws on unparseable existing config instead of silently overwriting", () => {
|
|
248
323
|
writeFileSync(join(dir, "pragma.config.json"), "{invalid json");
|
|
249
|
-
expect(() =>
|
|
324
|
+
expect(() => writeConfigLocal(dir, { tier: "apps" })).toThrow();
|
|
250
325
|
});
|
|
251
326
|
|
|
252
327
|
describe("packages field", () => {
|
|
253
328
|
it("writes packages array", () => {
|
|
254
|
-
|
|
329
|
+
writeConfigLocal(dir, {
|
|
255
330
|
packages: [
|
|
256
331
|
"@canonical/design-system",
|
|
257
332
|
{
|
|
@@ -272,7 +347,7 @@ describe("writeConfig", () => {
|
|
|
272
347
|
|
|
273
348
|
it("merges packages into existing config without touching tier", () => {
|
|
274
349
|
writeFileSync(join(dir, "pragma.config.json"), '{"tier":"global"}');
|
|
275
|
-
|
|
350
|
+
writeConfigLocal(dir, { packages: ["@canonical/design-system"] });
|
|
276
351
|
const config = readConfig(dir);
|
|
277
352
|
expect(config.tier).toBe("global");
|
|
278
353
|
expect(config.packages).toEqual(["@canonical/design-system"]);
|
|
@@ -283,7 +358,7 @@ describe("writeConfig", () => {
|
|
|
283
358
|
join(dir, "pragma.config.json"),
|
|
284
359
|
JSON.stringify({ packages: ["@canonical/design-system"] }),
|
|
285
360
|
);
|
|
286
|
-
|
|
361
|
+
writeConfigLocal(dir, { packages: undefined });
|
|
287
362
|
const config = readConfig(dir);
|
|
288
363
|
expect(config.packages).toBeUndefined();
|
|
289
364
|
});
|
|
@@ -296,9 +371,25 @@ describe("writeConfig", () => {
|
|
|
296
371
|
source: "git+https://github.com/canonical/anatomy-dsl.git#main",
|
|
297
372
|
},
|
|
298
373
|
];
|
|
299
|
-
|
|
374
|
+
writeConfigLocal(dir, { packages });
|
|
300
375
|
const config = readConfig(dir);
|
|
301
376
|
expect(config.packages).toEqual(packages);
|
|
302
377
|
});
|
|
303
378
|
});
|
|
304
379
|
});
|
|
380
|
+
|
|
381
|
+
describe("readConfig — empty packages normalization", () => {
|
|
382
|
+
it("treats an explicitly empty packages list as not configured", () => {
|
|
383
|
+
const dir = mkdtempSync(join(tmpdir(), "pragma-empty-pkgs-"));
|
|
384
|
+
mkdirSync(join(dir, ".git"), { recursive: true });
|
|
385
|
+
writeFileSync(
|
|
386
|
+
join(dir, "pragma.config.json"),
|
|
387
|
+
JSON.stringify({ channel: "normal", packages: [] }),
|
|
388
|
+
);
|
|
389
|
+
const config = readConfig(dir);
|
|
390
|
+
// The field is dropped at parse time so provenance matches the ref
|
|
391
|
+
// resolution behavior (empty list falls back to defaults/global refs).
|
|
392
|
+
expect(config.packages).toBeUndefined();
|
|
393
|
+
rmSync(dir, { recursive: true, force: true });
|
|
394
|
+
});
|
|
395
|
+
});
|
package/src/config/readConfig.ts
CHANGED
|
@@ -1,162 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
type Channel,
|
|
4
|
-
type Framework,
|
|
5
|
-
VALID_CHANNELS,
|
|
6
|
-
VALID_FRAMEWORKS,
|
|
7
|
-
} from "../constants.js";
|
|
8
|
-
import {
|
|
9
|
-
parsePackageEntry,
|
|
10
|
-
type RawPackageEntry,
|
|
11
|
-
} from "../domains/refs/operations/parseRef.js";
|
|
12
|
-
import { PragmaError } from "../error/PragmaError.js";
|
|
13
|
-
import resolveConfigPath from "./resolveConfigPath.js";
|
|
1
|
+
import readConfigLayers from "./readConfigLayers.js";
|
|
14
2
|
import type { PragmaConfig } from "./types.js";
|
|
15
3
|
|
|
16
4
|
/**
|
|
17
|
-
*
|
|
5
|
+
* Read the effective pragma configuration for a directory.
|
|
18
6
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Type guard: check whether a value is a recognized framework string.
|
|
28
|
-
*
|
|
29
|
-
* @param value - Candidate value.
|
|
30
|
-
* @returns `true` if value is a valid Framework.
|
|
31
|
-
*/
|
|
32
|
-
function isValidFramework(value: unknown): value is Framework {
|
|
33
|
-
return (
|
|
34
|
-
typeof value === "string" && VALID_FRAMEWORKS.includes(value as Framework)
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Read pragma config from `pragma.config.json` in the given directory.
|
|
7
|
+
* Layered resolution: built-in defaults, then the global XDG file
|
|
8
|
+
* (`~/.config/pragma/config.json`), then the nearest project
|
|
9
|
+
* `pragma.config.json` at or above `cwd` — each field won by the most
|
|
10
|
+
* specific layer that sets it. Returns defaults (no tier, `"normal"`
|
|
11
|
+
* channel) when no layer configures anything.
|
|
40
12
|
*
|
|
41
|
-
*
|
|
13
|
+
* @param cwd - Directory the project layer is resolved from (defaults to `process.cwd()`).
|
|
14
|
+
* @returns The effective merged configuration.
|
|
15
|
+
* @throws PragmaError with code `CONFIG_ERROR` if a layer file has invalid JSON or values.
|
|
42
16
|
*
|
|
43
|
-
* @
|
|
44
|
-
* @returns Parsed configuration.
|
|
45
|
-
* @throws PragmaError with code `CONFIG_ERROR` if JSON is invalid or channel is unrecognized.
|
|
46
|
-
*
|
|
47
|
-
* @note Impure — reads config from filesystem.
|
|
17
|
+
* @note Impure — reads config files from the filesystem.
|
|
48
18
|
*/
|
|
49
19
|
export default function readConfig(cwd: string = process.cwd()): PragmaConfig {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let raw: string;
|
|
53
|
-
try {
|
|
54
|
-
raw = readFileSync(configPath, "utf-8");
|
|
55
|
-
} catch {
|
|
56
|
-
return { tier: undefined, channel: "normal" };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const trimmed = raw.trim();
|
|
60
|
-
if (trimmed === "") {
|
|
61
|
-
return { tier: undefined, channel: "normal" };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let parsed: Record<string, unknown>;
|
|
65
|
-
try {
|
|
66
|
-
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
67
|
-
} catch {
|
|
68
|
-
throw PragmaError.configError(`Invalid JSON in ${configPath}.`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const tier = typeof parsed.tier === "string" ? parsed.tier : undefined;
|
|
72
|
-
|
|
73
|
-
let channel: Channel = "normal";
|
|
74
|
-
if (parsed.channel !== undefined) {
|
|
75
|
-
if (!isValidChannel(parsed.channel)) {
|
|
76
|
-
throw PragmaError.configError(
|
|
77
|
-
`Invalid channel "${String(parsed.channel)}".`,
|
|
78
|
-
{ validOptions: [...VALID_CHANNELS] },
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
channel = parsed.channel;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const trace = typeof parsed.trace === "boolean" ? parsed.trace : undefined;
|
|
85
|
-
|
|
86
|
-
let framework: Framework | undefined;
|
|
87
|
-
if (parsed.framework !== undefined) {
|
|
88
|
-
if (!isValidFramework(parsed.framework)) {
|
|
89
|
-
throw PragmaError.configError(
|
|
90
|
-
`Invalid framework "${String(parsed.framework)}".`,
|
|
91
|
-
{ validOptions: [...VALID_FRAMEWORKS] },
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
framework = parsed.framework;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const packages = parsePackagesField(parsed.packages);
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
tier,
|
|
101
|
-
channel,
|
|
102
|
-
...(packages ? { packages } : {}),
|
|
103
|
-
...(trace !== undefined ? { trace } : {}),
|
|
104
|
-
...(framework !== undefined ? { framework } : {}),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// Packages field parsing
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Validate and parse the `packages` config field.
|
|
114
|
-
*
|
|
115
|
-
* @returns The validated array, or `undefined` when the field is absent.
|
|
116
|
-
* @throws PragmaError if the field is present but malformed.
|
|
117
|
-
*/
|
|
118
|
-
function parsePackagesField(
|
|
119
|
-
raw: unknown,
|
|
120
|
-
): ReadonlyArray<RawPackageEntry> | undefined {
|
|
121
|
-
if (raw === undefined || raw === null) return undefined;
|
|
122
|
-
|
|
123
|
-
if (!Array.isArray(raw)) {
|
|
124
|
-
throw PragmaError.configError(
|
|
125
|
-
'"packages" must be an array of strings or { name, source? } objects.',
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const entries: RawPackageEntry[] = [];
|
|
130
|
-
for (const item of raw) {
|
|
131
|
-
if (typeof item === "string") {
|
|
132
|
-
entries.push(item);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
136
|
-
const obj = item as Record<string, unknown>;
|
|
137
|
-
if (typeof obj.name !== "string" || obj.name.length === 0) {
|
|
138
|
-
throw PragmaError.configError(
|
|
139
|
-
'Each object in "packages" must have a non-empty "name" string.',
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
const entry: { name: string; source?: string } = { name: obj.name };
|
|
143
|
-
if (obj.source !== undefined) {
|
|
144
|
-
if (typeof obj.source !== "string") {
|
|
145
|
-
throw PragmaError.configError(
|
|
146
|
-
`Invalid source for "${obj.name}": expected a string.`,
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
entry.source = obj.source;
|
|
150
|
-
}
|
|
151
|
-
// Validate format eagerly — fail fast on bad config
|
|
152
|
-
parsePackageEntry(entry);
|
|
153
|
-
entries.push(entry);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
throw PragmaError.configError(
|
|
157
|
-
'Each entry in "packages" must be a string or { name, source? } object.',
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return entries;
|
|
20
|
+
return readConfigLayers(cwd).config;
|
|
162
21
|
}
|