@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.
Files changed (241) hide show
  1. package/README.md +67 -15
  2. package/dist/pragma +0 -0
  3. package/package.json +13 -13
  4. package/src/config/defaultConfig.test.ts +33 -0
  5. package/src/config/defaultConfig.ts +59 -0
  6. package/src/config/defaults.json +17 -0
  7. package/src/config/findProjectConfigPath.test.ts +53 -0
  8. package/src/config/findProjectConfigPath.ts +36 -0
  9. package/src/config/index.ts +17 -2
  10. package/src/config/mergeConfigFileUpdate.ts +58 -0
  11. package/src/config/parseConfigValues.ts +215 -0
  12. package/src/config/readConfig.test.ts +104 -13
  13. package/src/config/readConfig.ts +12 -153
  14. package/src/config/readConfigLayers.test.ts +107 -0
  15. package/src/config/readConfigLayers.ts +115 -0
  16. package/src/config/resolveGlobalConfigPath.ts +13 -0
  17. package/src/config/resolveWriteConfigPath.test.ts +53 -0
  18. package/src/config/resolveWriteConfigPath.ts +31 -0
  19. package/src/config/types.ts +76 -1
  20. package/src/config/writeConfig.ts +25 -55
  21. package/src/domains/block/commands/list.ts +5 -90
  22. package/src/domains/block/commands/lookup.ts +8 -167
  23. package/src/domains/block/mcp/specs.ts +16 -196
  24. package/src/domains/block/orchestration/resolveBlockLookup.ts +2 -1
  25. package/src/domains/block/stories.test.ts +28 -0
  26. package/src/domains/block/stories.ts +254 -0
  27. package/src/domains/config/commands/channel.test.ts +14 -3
  28. package/src/domains/config/commands/channel.ts +11 -7
  29. package/src/domains/config/commands/configScope.ts +46 -0
  30. package/src/domains/config/commands/framework.test.ts +18 -1
  31. package/src/domains/config/commands/framework.ts +13 -7
  32. package/src/domains/config/commands/show.ts +5 -45
  33. package/src/domains/config/commands/tier.test.ts +13 -2
  34. package/src/domains/config/commands/tier.ts +11 -7
  35. package/src/domains/config/commands/trace.test.ts +19 -2
  36. package/src/domains/config/commands/trace.ts +6 -3
  37. package/src/domains/config/formatters/channel.test.ts +20 -6
  38. package/src/domains/config/formatters/channel.ts +14 -11
  39. package/src/domains/config/formatters/framework.ts +14 -11
  40. package/src/domains/config/formatters/show.test.ts +27 -0
  41. package/src/domains/config/formatters/show.ts +27 -4
  42. package/src/domains/config/formatters/tier.test.ts +26 -10
  43. package/src/domains/config/formatters/tier.ts +19 -11
  44. package/src/domains/config/formatters/trace.ts +7 -7
  45. package/src/domains/config/mcp/specs.ts +33 -53
  46. package/src/domains/config/operations/show.test.ts +18 -0
  47. package/src/domains/config/operations/show.ts +7 -1
  48. package/src/domains/config/operations/types.ts +7 -0
  49. package/src/domains/config/stories.ts +42 -0
  50. package/src/domains/config/tasks/index.ts +1 -0
  51. package/src/domains/config/tasks/setChannel.ts +13 -28
  52. package/src/domains/config/tasks/setFramework.ts +11 -30
  53. package/src/domains/config/tasks/setTier.ts +10 -29
  54. package/src/domains/config/tasks/setTrace.ts +10 -28
  55. package/src/domains/config/tasks/writeConfigField.ts +41 -0
  56. package/src/domains/create/commands/component.ts +5 -5
  57. package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
  58. package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
  59. package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
  60. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +2 -14
  61. package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
  62. package/src/domains/doctor/operations/checks/index.ts +1 -0
  63. package/src/domains/doctor/operations/checks.test.ts +158 -7
  64. package/src/domains/doctor/operations/runChecks.test.ts +15 -6
  65. package/src/domains/doctor/operations/runChecks.ts +2 -0
  66. package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
  67. package/src/domains/graph/helpers/resolveUri.ts +28 -21
  68. package/src/domains/graphql/commands/build.test.ts +5 -3
  69. package/src/domains/graphql/commands/build.ts +8 -16
  70. package/src/domains/graphql/commands/check.test.ts +5 -3
  71. package/src/domains/graphql/commands/check.ts +7 -13
  72. package/src/domains/graphql/commands/index.ts +1 -0
  73. package/src/domains/graphql/commands/serve.test.ts +40 -0
  74. package/src/domains/graphql/commands/serve.ts +131 -0
  75. package/src/domains/graphql/constants.ts +10 -0
  76. package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
  77. package/src/domains/graphql/helpers/gatherSources.ts +58 -0
  78. package/src/domains/graphql/helpers/index.ts +2 -0
  79. package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
  80. package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
  81. package/src/domains/graphql/index.ts +9 -6
  82. package/src/domains/graphql/operations/compileSchema.test.ts +21 -45
  83. package/src/domains/graphql/operations/compileSchema.ts +23 -25
  84. package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
  85. package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
  86. package/src/domains/graphql/operations/index.ts +5 -0
  87. package/src/domains/info/operations/collectInfo.test.ts +14 -3
  88. package/src/domains/info/operations/collectInfo.ts +12 -15
  89. package/src/domains/llm/capabilities.test.ts +4 -4
  90. package/src/domains/llm/data/toolCatalog.ts +6 -0
  91. package/src/domains/modifier/commands/list.ts +5 -38
  92. package/src/domains/modifier/commands/lookup.ts +8 -79
  93. package/src/domains/modifier/mcp/specs.ts +15 -94
  94. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +2 -1
  95. package/src/domains/modifier/stories.test.ts +20 -0
  96. package/src/domains/modifier/stories.ts +77 -0
  97. package/src/domains/ontology/commands/list.ts +5 -58
  98. package/src/domains/ontology/commands/show.ts +6 -58
  99. package/src/domains/ontology/formatters/show.ts +7 -1
  100. package/src/domains/ontology/mcp/specs.ts +7 -84
  101. package/src/domains/ontology/stories.test.ts +36 -0
  102. package/src/domains/ontology/stories.ts +108 -0
  103. package/src/domains/refs/operations/updateRefs.ts +2 -37
  104. package/src/domains/setup/commands/skills.test.ts +6 -2
  105. package/src/domains/setup/commands/skills.ts +6 -2
  106. package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
  107. package/src/domains/setup/helpers/runSetupTask.ts +62 -70
  108. package/src/domains/setup/operations/setupMcp.test.ts +6 -1
  109. package/src/domains/shared/bootStore.ts +6 -3
  110. package/src/domains/shared/compactUri.test.ts +11 -0
  111. package/src/domains/shared/compactUri.ts +10 -5
  112. package/src/domains/shared/context.ts +2 -2
  113. package/src/domains/shared/contracts.ts +11 -0
  114. package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
  115. package/src/domains/shared/createInteractivePromptSession.ts +236 -0
  116. package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
  117. package/src/domains/shared/loaders/bundledLoader.ts +110 -12
  118. package/src/domains/shared/loaders/gitLoader.ts +2 -2
  119. package/src/domains/shared/loaders/index.ts +4 -1
  120. package/src/domains/shared/loaders/localLoader.ts +18 -4
  121. package/src/domains/shared/loaders/readPackageDir.ts +36 -1
  122. package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
  123. package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
  124. package/src/domains/shared/lookupMany.test.ts +120 -0
  125. package/src/domains/shared/lookupMany.ts +34 -2
  126. package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
  127. package/src/domains/shared/mergeAndParseRefs.ts +44 -0
  128. package/src/domains/shared/packages.ts +10 -17
  129. package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
  130. package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
  131. package/src/domains/shared/runtime.ts +4 -63
  132. package/src/domains/shared/semanticPackage.ts +17 -1
  133. package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
  134. package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
  135. package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
  136. package/src/domains/shared/stories/compileLookupTool.ts +96 -0
  137. package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
  138. package/src/domains/shared/stories/compileReadCommand.ts +48 -0
  139. package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
  140. package/src/domains/shared/stories/compileReadTool.ts +38 -0
  141. package/src/domains/shared/stories/condense.test.ts +18 -0
  142. package/src/domains/shared/stories/condense.ts +15 -0
  143. package/src/domains/shared/stories/index.ts +20 -0
  144. package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
  145. package/src/domains/shared/stories/normalizeNames.ts +22 -0
  146. package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
  147. package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
  148. package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
  149. package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
  150. package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
  151. package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
  152. package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
  153. package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
  154. package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
  155. package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
  156. package/src/domains/shared/stories/pack/index.ts +28 -0
  157. package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
  158. package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
  159. package/src/domains/shared/stories/pack/types.ts +104 -0
  160. package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
  161. package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
  162. package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
  163. package/src/domains/shared/stories/projectCliParameters.ts +57 -0
  164. package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
  165. package/src/domains/shared/stories/projectToolParams.ts +32 -0
  166. package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
  167. package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
  168. package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
  169. package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
  170. package/src/domains/shared/stories/types.ts +171 -0
  171. package/src/domains/shared/suggestions/expandGlob.ts +1 -0
  172. package/src/domains/shared/suggestions/suggestNames.ts +43 -10
  173. package/src/domains/skill/commands/index.ts +3 -1
  174. package/src/domains/skill/commands/list.test.ts +6 -2
  175. package/src/domains/skill/commands/list.ts +5 -63
  176. package/src/domains/skill/commands/lookup.test.ts +118 -0
  177. package/src/domains/skill/commands/lookup.ts +14 -0
  178. package/src/domains/skill/formatters/index.ts +1 -0
  179. package/src/domains/skill/formatters/lookup.test.ts +46 -0
  180. package/src/domains/skill/formatters/lookup.ts +57 -0
  181. package/src/domains/skill/helpers/resolveSkillSources.ts +0 -1
  182. package/src/domains/skill/index.ts +7 -3
  183. package/src/domains/skill/mcp/specs.ts +10 -29
  184. package/src/domains/skill/operations/discover.test.ts +61 -30
  185. package/src/domains/skill/operations/discover.ts +28 -37
  186. package/src/domains/skill/operations/index.ts +2 -1
  187. package/src/domains/skill/operations/list.test.ts +15 -7
  188. package/src/domains/skill/operations/lookup.test.ts +91 -0
  189. package/src/domains/skill/operations/lookup.ts +70 -0
  190. package/src/domains/skill/stories.test.ts +43 -0
  191. package/src/domains/skill/stories.ts +103 -0
  192. package/src/domains/skill/types.ts +8 -0
  193. package/src/domains/standard/commands/list.ts +5 -84
  194. package/src/domains/standard/commands/lookup.ts +8 -93
  195. package/src/domains/standard/mcp/specs.ts +16 -171
  196. package/src/domains/standard/orchestration/resolveStandardLookup.ts +2 -1
  197. package/src/domains/standard/stories.test.ts +24 -0
  198. package/src/domains/standard/stories.ts +151 -0
  199. package/src/domains/tier/commands/list.ts +5 -55
  200. package/src/domains/tier/mcp/specs.ts +5 -31
  201. package/src/domains/tier/stories.test.ts +22 -0
  202. package/src/domains/tier/stories.ts +52 -0
  203. package/src/domains/token/mcp/specs.ts +5 -1
  204. package/src/domains/token/orchestration/resolveTokenLookup.ts +2 -1
  205. package/src/domains/trace/tracePlugin.test.ts +1 -1
  206. package/src/mcp/registerResources.test.ts +382 -74
  207. package/src/mcp/registerResources.ts +5 -348
  208. package/src/mcp/resources/buildGraphIndex.ts +167 -0
  209. package/src/mcp/resources/buildResourceList.test.ts +97 -0
  210. package/src/mcp/resources/buildResourceList.ts +176 -0
  211. package/src/mcp/resources/classifyEntity.test.ts +40 -0
  212. package/src/mcp/resources/classifyEntity.ts +27 -0
  213. package/src/mcp/resources/constants.ts +105 -0
  214. package/src/mcp/resources/displayPredicates.ts +62 -0
  215. package/src/mcp/resources/pickFirstValue.test.ts +29 -0
  216. package/src/mcp/resources/pickFirstValue.ts +26 -0
  217. package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
  218. package/src/mcp/resources/pickPrimaryType.ts +26 -0
  219. package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
  220. package/src/mcp/resources/rankUriCompletions.ts +64 -0
  221. package/src/mcp/resources/readEntity.ts +169 -0
  222. package/src/mcp/resources/registerResources.ts +126 -0
  223. package/src/mcp/resources/types.ts +128 -0
  224. package/src/mcp/tools/index.ts +9 -0
  225. package/src/mcp/tools/registerTools.test.ts +65 -8
  226. package/src/mcp/types.ts +4 -0
  227. package/src/pipeline/collectCommands.ts +8 -1
  228. package/src/pipeline/runCli.ts +40 -6
  229. package/src/testing/createTestMcpClient.ts +4 -1
  230. package/src/testing/index.ts +10 -0
  231. package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
  232. package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
  233. package/src/testing/integration/executor-execution.test.ts +101 -0
  234. package/src/testing/integration/executor-golden.test.ts +145 -0
  235. package/src/testing/integration/mcp-surface.test.ts +1 -1
  236. package/src/testing/integration/parity.test.ts +17 -12
  237. package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
  238. package/src/testing/setupXdgIsolation.ts +15 -0
  239. package/src/testing/standardParityFixtures.ts +92 -0
  240. package/src/testing/storyFixtures.ts +63 -0
  241. package/src/pipeline/runInteractiveCommand.ts +0 -198
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { PragmaError } from "#error";
3
+ import resolveUri from "./resolveUri.js";
4
+
5
+ const prefixes = {
6
+ ds: "https://ds.canonical.com/",
7
+ cs: "http://pragma.canonical.com/codestandards#",
8
+ } as const;
9
+
10
+ describe("resolveUri", () => {
11
+ it("expands a registered prefix", () => {
12
+ expect(resolveUri("ds:UIBlock", prefixes)).toBe(
13
+ "https://ds.canonical.com/UIBlock",
14
+ );
15
+ });
16
+
17
+ it("passes through a full http(s) IRI", () => {
18
+ expect(resolveUri("https://ds.canonical.com/global", prefixes)).toBe(
19
+ "https://ds.canonical.com/global",
20
+ );
21
+ });
22
+
23
+ it("passes through a urn: IRI", () => {
24
+ expect(resolveUri("urn:isbn:0451450523", prefixes)).toBe(
25
+ "urn:isbn:0451450523",
26
+ );
27
+ });
28
+
29
+ it("passes through a foreign scheme:// IRI", () => {
30
+ expect(resolveUri("ftp://example.org/thing", prefixes)).toBe(
31
+ "ftp://example.org/thing",
32
+ );
33
+ });
34
+
35
+ it("throws for an unknown prefix", () => {
36
+ expect(() => resolveUri("unknown:something", prefixes)).toThrow(
37
+ PragmaError,
38
+ );
39
+ });
40
+
41
+ it("throws for a bare string without a prefix", () => {
42
+ expect(() => resolveUri("button", prefixes)).toThrow(PragmaError);
43
+ });
44
+
45
+ it("throws for an IRI with unsafe characters", () => {
46
+ expect(() =>
47
+ resolveUri("https://example.com/<injected>", prefixes),
48
+ ).toThrow(PragmaError);
49
+ });
50
+ });
@@ -6,12 +6,20 @@ import { PragmaError } from "#error";
6
6
  */
7
7
  const UNSAFE_IRI_PATTERN = /[<>"{}|\\^`\s]/;
8
8
 
9
+ /**
10
+ * Matches a general absolute IRI: any scheme with an authority (`scheme://…`,
11
+ * covering http, https, ftp, ws, and foreign schemes) or a `urn:` opaque IRI.
12
+ * Broader than an http(s)-only check so foreign ontologies resolve.
13
+ */
14
+ const ABSOLUTE_IRI_PATTERN = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|urn:)\S/i;
15
+
9
16
  /**
10
17
  * Resolves a potentially prefixed URI (e.g. `ds:UIBlock`) to a full URI.
11
18
  *
12
- * If the URI is already a full URI (starts with `http`), validates IRI safety
13
- * and returns it as-is. Otherwise, expands the prefix using the provided
14
- * prefix map.
19
+ * A registered prefix always wins: `ds:UIBlock` expands via the prefix map.
20
+ * Otherwise a general absolute IRI (`scheme://…` or `urn:…`) is validated and
21
+ * returned as-is, so foreign ontologies are not rejected as unknown prefixes.
22
+ * Any other `token:rest` is treated as an unknown prefix.
15
23
  *
16
24
  * @param uri - A full or prefixed URI string.
17
25
  * @param prefixes - Registered prefix-to-namespace mapping from the store.
@@ -22,11 +30,6 @@ export default function resolveUri(
22
30
  uri: string,
23
31
  prefixes: Readonly<Record<string, string>>,
24
32
  ): string {
25
- if (uri.startsWith("http://") || uri.startsWith("https://")) {
26
- assertSafeIri(uri);
27
- return uri;
28
- }
29
-
30
33
  const colonIdx = uri.indexOf(":");
31
34
  if (colonIdx === -1) {
32
35
  throw PragmaError.invalidInput("uri", uri, {
@@ -38,23 +41,27 @@ export default function resolveUri(
38
41
  }
39
42
 
40
43
  const prefix = uri.slice(0, colonIdx);
41
- const localName = uri.slice(colonIdx + 1);
42
44
  const namespace = prefixes[prefix];
43
45
 
44
- if (namespace === undefined) {
45
- throw PragmaError.invalidInput("prefix", prefix, {
46
- validOptions: Object.keys(prefixes),
47
- recovery: {
48
- message: "List known ontology prefixes.",
49
- cli: "pragma ontology list",
50
- mcp: { tool: "ontology_list" },
51
- },
52
- });
46
+ if (namespace !== undefined) {
47
+ const resolved = `${namespace}${uri.slice(colonIdx + 1)}`;
48
+ assertSafeIri(resolved);
49
+ return resolved;
50
+ }
51
+
52
+ if (ABSOLUTE_IRI_PATTERN.test(uri)) {
53
+ assertSafeIri(uri);
54
+ return uri;
53
55
  }
54
56
 
55
- const resolved = `${namespace}${localName}`;
56
- assertSafeIri(resolved);
57
- return resolved;
57
+ throw PragmaError.invalidInput("prefix", prefix, {
58
+ validOptions: Object.keys(prefixes),
59
+ recovery: {
60
+ message: "List known ontology prefixes.",
61
+ cli: "pragma ontology list",
62
+ mcp: { tool: "ontology_list" },
63
+ },
64
+ });
58
65
  }
59
66
 
60
67
  /**
@@ -181,13 +181,15 @@ describe("graphql build command", () => {
181
181
  expect(printed).toContain("C003");
182
182
  });
183
183
 
184
- it("throws structured error when sources are missing", async () => {
184
+ it("throws a structured error when explicit sources match nothing", async () => {
185
185
  const ctx = makeCtx();
186
186
 
187
- await expect(buildCommand.execute({}, ctx)).rejects.toMatchObject({
187
+ await expect(
188
+ buildCommand.execute({ sources: ["no-such-*.ttl"] }, ctx),
189
+ ).rejects.toMatchObject({
188
190
  code: "INVALID_INPUT",
189
191
  recovery: {
190
- message: "Provide at least one TTL file or glob pattern.",
192
+ message: "No files matched the given paths or glob patterns.",
191
193
  },
192
194
  });
193
195
  });
@@ -7,16 +7,13 @@ import {
7
7
  createOutputResult,
8
8
  } from "@canonical/cli-core";
9
9
  import { serializeExtraction } from "@canonical/ke-graphql";
10
- import { PragmaError } from "#error";
11
10
  import { selectFormatter } from "../../shared/formatters.js";
11
+ import { DEFAULT_EXTRACTION_PATH, DEFAULT_SDL_PATH } from "../constants.js";
12
12
  import { reportFormatters } from "../formatters/index.js";
13
- import { parsePrefixes } from "../helpers/index.js";
13
+ import { gatherSources, parsePrefixes } from "../helpers/index.js";
14
14
  import { compileSchema } from "../operations/index.js";
15
15
  import type { GraphqlCompileReport } from "../types.js";
16
16
 
17
- const DEFAULT_SDL_PATH = "./schema.graphql";
18
- const DEFAULT_EXTRACTION_PATH = "./extraction.json";
19
-
20
17
  /** Narrow an unknown param to a string array. */
21
18
  function readStringArray(value: unknown): string[] {
22
19
  return Array.isArray(value)
@@ -37,10 +34,10 @@ const buildCommand: CommandDefinition = {
37
34
  parameters: [
38
35
  {
39
36
  name: "sources",
40
- description: "TTL source files or glob patterns",
37
+ description:
38
+ "TTL files or globs (default: the configured semantic packages)",
41
39
  type: "multiselect",
42
40
  positional: true,
43
- required: true,
44
41
  },
45
42
  {
46
43
  name: "sdl",
@@ -68,15 +65,10 @@ const buildCommand: CommandDefinition = {
68
65
  ],
69
66
  },
70
67
  async execute(params, ctx): Promise<CommandResult> {
71
- const sources = readStringArray(params.sources);
72
- if (sources.length === 0) {
73
- throw PragmaError.invalidInput("sources", "(empty)", {
74
- recovery: {
75
- message: "Provide at least one TTL file or glob pattern.",
76
- },
77
- });
78
- }
79
-
68
+ const sources = await gatherSources(
69
+ readStringArray(params.sources),
70
+ ctx.cwd,
71
+ );
80
72
  const prefixes = parsePrefixes(readStringArray(params.prefix));
81
73
  const outcome = await compileSchema({ sources, prefixes, cwd: ctx.cwd });
82
74
  const format = selectFormatter(ctx, reportFormatters);
@@ -126,13 +126,15 @@ describe("graphql check command", () => {
126
126
  expect(parsed.artifacts).toBeUndefined();
127
127
  });
128
128
 
129
- it("throws structured error when sources are missing", async () => {
129
+ it("throws a structured error when explicit sources match nothing", async () => {
130
130
  const ctx = makeCtx();
131
131
 
132
- await expect(checkCommand.execute({}, ctx)).rejects.toMatchObject({
132
+ await expect(
133
+ checkCommand.execute({ sources: ["no-such-*.ttl"] }, ctx),
134
+ ).rejects.toMatchObject({
133
135
  code: "INVALID_INPUT",
134
136
  recovery: {
135
- message: "Provide at least one TTL file or glob pattern.",
137
+ message: "No files matched the given paths or glob patterns.",
136
138
  },
137
139
  });
138
140
  });
@@ -4,10 +4,9 @@ import {
4
4
  createExitResult,
5
5
  createOutputResult,
6
6
  } from "@canonical/cli-core";
7
- import { PragmaError } from "#error";
8
7
  import { selectFormatter } from "../../shared/formatters.js";
9
8
  import { reportFormatters } from "../formatters/index.js";
10
- import { parsePrefixes } from "../helpers/index.js";
9
+ import { gatherSources, parsePrefixes } from "../helpers/index.js";
11
10
  import { compileSchema } from "../operations/index.js";
12
11
  import type { GraphqlCompileReport } from "../types.js";
13
12
 
@@ -31,10 +30,10 @@ const checkCommand: CommandDefinition = {
31
30
  parameters: [
32
31
  {
33
32
  name: "sources",
34
- description: "TTL source files or glob patterns",
33
+ description:
34
+ "TTL files or globs (default: the configured semantic packages)",
35
35
  type: "multiselect",
36
36
  positional: true,
37
- required: true,
38
37
  },
39
38
  {
40
39
  name: "prefix",
@@ -49,15 +48,10 @@ const checkCommand: CommandDefinition = {
49
48
  ],
50
49
  },
51
50
  async execute(params, ctx): Promise<CommandResult> {
52
- const sources = readStringArray(params.sources);
53
- if (sources.length === 0) {
54
- throw PragmaError.invalidInput("sources", "(empty)", {
55
- recovery: {
56
- message: "Provide at least one TTL file or glob pattern.",
57
- },
58
- });
59
- }
60
-
51
+ const sources = await gatherSources(
52
+ readStringArray(params.sources),
53
+ ctx.cwd,
54
+ );
61
55
  const prefixes = parsePrefixes(readStringArray(params.prefix));
62
56
  const outcome = await compileSchema({ sources, prefixes, cwd: ctx.cwd });
63
57
 
@@ -2,3 +2,4 @@
2
2
 
3
3
  export { default as buildCommand } from "./build.js";
4
4
  export { default as checkCommand } from "./check.js";
5
+ export { default as serveCommand } from "./serve.js";
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import type { PragmaContext } from "../../shared/context.js";
3
+ import serveCommand, { readPort } from "./serve.js";
4
+
5
+ describe("readPort", () => {
6
+ it("parses a valid port", () => {
7
+ expect(readPort("4001")).toBe(4001);
8
+ });
9
+
10
+ it("falls back to the default for non-string values", () => {
11
+ expect(readPort(undefined)).toBe(4000);
12
+ expect(readPort(4001)).toBe(4000);
13
+ });
14
+
15
+ it("falls back to the default for out-of-range or unparseable values", () => {
16
+ expect(readPort("0")).toBe(4000);
17
+ expect(readPort("70000")).toBe(4000);
18
+ expect(readPort("not-a-port")).toBe(4000);
19
+ });
20
+ });
21
+
22
+ describe("graphql serve command", () => {
23
+ const ctx = { cwd: process.cwd() } as PragmaContext;
24
+ const original = (globalThis as Record<string, unknown>).Bun;
25
+
26
+ afterEach(() => {
27
+ (globalThis as Record<string, unknown>).Bun = original;
28
+ });
29
+
30
+ it("requires the Bun runtime", async () => {
31
+ (globalThis as Record<string, unknown>).Bun = undefined;
32
+
33
+ await expect(serveCommand.execute({}, ctx)).rejects.toMatchObject({
34
+ code: "STORE_ERROR",
35
+ recovery: {
36
+ message: "Run the compiled `pragma` binary or use `bun run`.",
37
+ },
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,131 @@
1
+ import {
2
+ type CommandDefinition,
3
+ type CommandResult,
4
+ createOutputResult,
5
+ } from "@canonical/cli-core";
6
+ import { PragmaError } from "#error";
7
+ import { DEFAULT_PORT } from "../constants.js";
8
+ import { gatherSources, parsePrefixes } from "../helpers/index.js";
9
+ import routeGraphql from "../helpers/routeGraphql.js";
10
+ import createSchemaServer, {
11
+ type SchemaServer,
12
+ } from "../operations/createSchemaServer.js";
13
+
14
+ /** Narrow an unknown param to a string array. */
15
+ function readStringArray(value: unknown): string[] {
16
+ return Array.isArray(value)
17
+ ? value.filter((item): item is string => typeof item === "string")
18
+ : [];
19
+ }
20
+
21
+ /** Parse the --port flag, falling back to the default on anything invalid. */
22
+ export function readPort(value: unknown): number {
23
+ if (typeof value !== "string") {
24
+ return DEFAULT_PORT;
25
+ }
26
+ const port = Number.parseInt(value, 10);
27
+ return Number.isInteger(port) && port > 0 && port < 65536
28
+ ? port
29
+ : DEFAULT_PORT;
30
+ }
31
+
32
+ /**
33
+ * Listen on `port` and block until the process is interrupted, then stop
34
+ * the server and dispose the store.
35
+ *
36
+ * @note Impure — opens a network listener and registers signal handlers.
37
+ */
38
+ /* v8 ignore start -- the live Bun.serve listener and signal loop run under `pragma graphql serve`, not unit tests */
39
+ async function listen(server: SchemaServer, port: number): Promise<void> {
40
+ const httpServer = Bun.serve({
41
+ port,
42
+ fetch: (request) => routeGraphql(request, server.handler, port),
43
+ });
44
+ await new Promise<void>((resolve) => {
45
+ const shutdown = (): void => {
46
+ httpServer.stop();
47
+ server.dispose();
48
+ resolve();
49
+ };
50
+ process.once("SIGINT", shutdown);
51
+ process.once("SIGTERM", shutdown);
52
+ });
53
+ }
54
+ /* v8 ignore stop */
55
+
56
+ /**
57
+ * Builds the `pragma graphql serve` command definition.
58
+ *
59
+ * Compiles the configured ontologies (or explicit TTL sources) and serves
60
+ * a local GraphQL + GraphiQL endpoint until interrupted. With no positional
61
+ * sources it serves every TTL across the semantic packages in
62
+ * `pragma.config.json`.
63
+ */
64
+ const serveCommand: CommandDefinition = {
65
+ path: ["graphql", "serve"],
66
+ description:
67
+ "Serve a local GraphQL + GraphiQL endpoint over the configured ontologies",
68
+ parameters: [
69
+ {
70
+ name: "sources",
71
+ description:
72
+ "TTL files or globs to serve (default: the configured semantic packages)",
73
+ type: "multiselect",
74
+ positional: true,
75
+ },
76
+ {
77
+ name: "port",
78
+ description: `Port to listen on (default ${DEFAULT_PORT})`,
79
+ type: "string",
80
+ },
81
+ {
82
+ name: "prefix",
83
+ description: "Ontology prefix as name=namespace (repeatable)",
84
+ type: "multiselect",
85
+ },
86
+ ],
87
+ meta: {
88
+ examples: [
89
+ "pragma graphql serve",
90
+ "pragma graphql serve ontology.ttl --port 4001",
91
+ ],
92
+ },
93
+ async execute(params, ctx): Promise<CommandResult> {
94
+ if (typeof Bun === "undefined") {
95
+ throw PragmaError.storeError(
96
+ "`pragma graphql serve` requires the Bun runtime.",
97
+ {
98
+ recovery: {
99
+ message: "Run the compiled `pragma` binary or use `bun run`.",
100
+ },
101
+ },
102
+ );
103
+ }
104
+
105
+ const sources = await gatherSources(
106
+ readStringArray(params.sources),
107
+ ctx.cwd,
108
+ );
109
+ const prefixes = parsePrefixes(readStringArray(params.prefix));
110
+ const port = readPort(params.port);
111
+
112
+ const server = await createSchemaServer({ sources, prefixes });
113
+ const errorCount = server.diagnostics.filter(
114
+ (d) => d.severity === "error",
115
+ ).length;
116
+ process.stdout.write(
117
+ `[ke-graphql] schema compiled — ${server.diagnostics.length} diagnostic(s)${
118
+ errorCount > 0 ? `, ${errorCount} error(s)` : ""
119
+ }\n[ke-graphql] GraphiQL on http://localhost:${port}/graphql (Ctrl-C to stop)\n`,
120
+ );
121
+
122
+ await listen(server, port);
123
+
124
+ return createOutputResult(
125
+ { port, diagnostics: server.diagnostics },
126
+ { plain: () => `Stopped the graphql server on port ${port}.` },
127
+ );
128
+ },
129
+ };
130
+
131
+ export default serveCommand;
@@ -0,0 +1,10 @@
1
+ /** Default port `pragma graphql serve` listens on. */
2
+ const DEFAULT_PORT = 4000;
3
+
4
+ /** Default output path for the SDL written by `pragma graphql build`. */
5
+ const DEFAULT_SDL_PATH = "./schema.graphql";
6
+
7
+ /** Default output path for the extraction artifact written by `pragma graphql build`. */
8
+ const DEFAULT_EXTRACTION_PATH = "./extraction.json";
9
+
10
+ export { DEFAULT_EXTRACTION_PATH, DEFAULT_PORT, DEFAULT_SDL_PATH };
@@ -0,0 +1,92 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { GRAPHQL_CLEAN_TTL } from "#testing";
6
+ import resolveConfiguredGraphs from "../../shared/resolveConfiguredGraphs.js";
7
+ import gatherSources from "./gatherSources.js";
8
+
9
+ vi.mock("../../shared/resolveConfiguredGraphs.js", () => ({
10
+ default: vi.fn(),
11
+ }));
12
+
13
+ const mockResolve = vi.mocked(resolveConfiguredGraphs);
14
+
15
+ describe("gatherSources", () => {
16
+ let dir: string;
17
+
18
+ beforeEach(() => {
19
+ dir = mkdtempSync(join(tmpdir(), "pragma-gather-"));
20
+ writeFileSync(join(dir, "a.ttl"), GRAPHQL_CLEAN_TTL);
21
+ writeFileSync(join(dir, "b.ttl"), GRAPHQL_CLEAN_TTL);
22
+ mockResolve.mockReset();
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ it("reads explicit files into inline sources, ignoring the config", async () => {
30
+ const sources = await gatherSources(["a.ttl"], dir);
31
+
32
+ expect(sources).toEqual([
33
+ {
34
+ content: GRAPHQL_CLEAN_TTL,
35
+ format: "turtle",
36
+ path: join(dir, "a.ttl"),
37
+ },
38
+ ]);
39
+ expect(mockResolve).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it("expands glob patterns", async () => {
43
+ const sources = await gatherSources(["*.ttl"], dir);
44
+
45
+ expect(sources.map((source) => source.path).sort()).toEqual([
46
+ join(dir, "a.ttl"),
47
+ join(dir, "b.ttl"),
48
+ ]);
49
+ });
50
+
51
+ it("throws when explicit sources match nothing", async () => {
52
+ await expect(gatherSources(["none-*.ttl"], dir)).rejects.toMatchObject({
53
+ code: "INVALID_INPUT",
54
+ recovery: {
55
+ message: "No files matched the given paths or glob patterns.",
56
+ },
57
+ });
58
+ });
59
+
60
+ it("falls back to the configured package graphs when no sources are given", async () => {
61
+ mockResolve.mockResolvedValue([
62
+ {
63
+ path: "definitions/ontology.ttl",
64
+ content: GRAPHQL_CLEAN_TTL,
65
+ format: "turtle",
66
+ },
67
+ ]);
68
+
69
+ const sources = await gatherSources([], dir);
70
+
71
+ expect(mockResolve).toHaveBeenCalledWith(dir);
72
+ expect(sources).toEqual([
73
+ {
74
+ content: GRAPHQL_CLEAN_TTL,
75
+ format: "turtle",
76
+ path: "definitions/ontology.ttl",
77
+ },
78
+ ]);
79
+ });
80
+
81
+ it("throws when no sources are given and no packages resolve", async () => {
82
+ mockResolve.mockResolvedValue([]);
83
+
84
+ await expect(gatherSources([], dir)).rejects.toMatchObject({
85
+ code: "INVALID_INPUT",
86
+ recovery: {
87
+ message:
88
+ "No TTL sources given and no semantic packages resolved. Pass TTL files or globs, or configure `packages` in pragma.config.json and install them.",
89
+ },
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Resolve the TTL sources for a graphql command.
3
+ *
4
+ * When positional paths or globs are given they win; otherwise the sources
5
+ * are the union of every configured semantic package's graphs (the
6
+ * `pragma.config.json` packages, falling back to the defaults). Either way
7
+ * the result is a list of ke `InlineSource`s (content + provenance path),
8
+ * so the compiler and the dev server load the exact same bytes.
9
+ *
10
+ * @note Impure — reads files and resolves packages from disk.
11
+ *
12
+ * @param positional - Explicit TTL file paths or glob patterns (may be empty).
13
+ * @param cwd - Working directory for file and config resolution.
14
+ * @returns In-memory TTL sources to compile or serve.
15
+ * @throws PragmaError (INVALID_INPUT) when nothing resolves.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import type { InlineSource } from "@canonical/ke";
20
+ import { PragmaError } from "#error";
21
+ import resolveConfiguredGraphs from "../../shared/resolveConfiguredGraphs.js";
22
+ import resolveSourceFiles from "./resolveSourceFiles.js";
23
+
24
+ export default async function gatherSources(
25
+ positional: readonly string[],
26
+ cwd: string,
27
+ ): Promise<InlineSource[]> {
28
+ if (positional.length > 0) {
29
+ const files = resolveSourceFiles([...positional], cwd);
30
+ if (files.length === 0) {
31
+ throw PragmaError.invalidInput("sources", positional.join(", "), {
32
+ recovery: {
33
+ message: "No files matched the given paths or glob patterns.",
34
+ },
35
+ });
36
+ }
37
+ return files.map((path) => ({
38
+ content: readFileSync(path, "utf-8"),
39
+ format: "turtle" as const,
40
+ path,
41
+ }));
42
+ }
43
+
44
+ const graphs = await resolveConfiguredGraphs(cwd);
45
+ if (graphs.length === 0) {
46
+ throw PragmaError.invalidInput("sources", "(none)", {
47
+ recovery: {
48
+ message:
49
+ "No TTL sources given and no semantic packages resolved. Pass TTL files or globs, or configure `packages` in pragma.config.json and install them.",
50
+ },
51
+ });
52
+ }
53
+ return graphs.map((graph) => ({
54
+ content: graph.content,
55
+ format: graph.format,
56
+ path: graph.path,
57
+ }));
58
+ }
@@ -1,4 +1,6 @@
1
1
  /** @module Barrel for graphql helpers. */
2
2
 
3
+ export { default as gatherSources } from "./gatherSources.js";
3
4
  export { default as parsePrefixes } from "./parsePrefixes.js";
4
5
  export { default as resolveSourceFiles } from "./resolveSourceFiles.js";
6
+ export { default as routeGraphql } from "./routeGraphql.js";
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import routeGraphql from "./routeGraphql.js";
3
+
4
+ describe("routeGraphql", () => {
5
+ it("delegates /graphql to the handler", async () => {
6
+ const handler = vi.fn(() => new Response("handled"));
7
+ const request = new Request("http://localhost:4000/graphql");
8
+
9
+ const response = await routeGraphql(request, handler, 4000);
10
+
11
+ expect(handler).toHaveBeenCalledWith(request);
12
+ expect(await response.text()).toBe("handled");
13
+ });
14
+
15
+ it("redirects / to /graphql", async () => {
16
+ const handler = vi.fn();
17
+
18
+ const response = await routeGraphql(
19
+ new Request("http://localhost:4000/"),
20
+ handler,
21
+ 4000,
22
+ );
23
+
24
+ expect(response.status).toBe(302);
25
+ expect(response.headers.get("location")).toBe(
26
+ "http://localhost:4000/graphql",
27
+ );
28
+ expect(handler).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("404s any other path", async () => {
32
+ const handler = vi.fn();
33
+
34
+ const response = await routeGraphql(
35
+ new Request("http://localhost:4000/favicon.ico"),
36
+ handler,
37
+ 4000,
38
+ );
39
+
40
+ expect(response.status).toBe(404);
41
+ expect(handler).not.toHaveBeenCalled();
42
+ });
43
+ });