@canonical/pragma-cli 0.24.0 → 0.26.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 (35) hide show
  1. package/dist/pragma +0 -0
  2. package/package.json +12 -12
  3. package/src/config/readConfig.test.ts +152 -0
  4. package/src/config/readConfig.ts +63 -1
  5. package/src/config/types.ts +9 -0
  6. package/src/config/writeConfig.ts +6 -0
  7. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +76 -0
  8. package/src/domains/doctor/operations/checks/index.ts +1 -0
  9. package/src/domains/doctor/operations/runChecks.test.ts +11 -5
  10. package/src/domains/doctor/operations/runChecks.ts +2 -0
  11. package/src/domains/info/formatters/info.test.ts +34 -0
  12. package/src/domains/info/formatters/info.ts +15 -0
  13. package/src/domains/info/operations/collectInfo.ts +30 -1
  14. package/src/domains/info/types.ts +8 -0
  15. package/src/domains/refs/commands/updateRefs.ts +52 -0
  16. package/src/domains/refs/formatters/updateRefs.ts +27 -0
  17. package/src/domains/refs/index.ts +27 -0
  18. package/src/domains/refs/operations/gitOps.test.ts +155 -0
  19. package/src/domains/refs/operations/gitOps.ts +92 -0
  20. package/src/domains/refs/operations/index.ts +10 -0
  21. package/src/domains/refs/operations/parseRef.test.ts +157 -0
  22. package/src/domains/refs/operations/parseRef.ts +114 -0
  23. package/src/domains/refs/operations/paths.test.ts +98 -0
  24. package/src/domains/refs/operations/paths.ts +63 -0
  25. package/src/domains/refs/operations/readGlobalRefs.test.ts +63 -0
  26. package/src/domains/refs/operations/readGlobalRefs.ts +37 -0
  27. package/src/domains/refs/operations/updateRefs.test.ts +164 -0
  28. package/src/domains/refs/operations/updateRefs.ts +215 -0
  29. package/src/domains/shared/bootStore.ts +31 -15
  30. package/src/domains/shared/packages.ts +99 -49
  31. package/src/domains/shared/runtime.ts +73 -3
  32. package/src/domains/skill/helpers/resolveSkillSources.ts +16 -12
  33. package/src/pipeline/resolveCommandKind.ts +1 -1
  34. package/src/pipeline/runCli.ts +2 -1
  35. package/src/testing/integration/boot.test.ts +38 -0
@@ -7,27 +7,43 @@
7
7
  * @note Impure — reads filesystem, creates ke store.
8
8
  */
9
9
 
10
+ import { existsSync } from "node:fs";
10
11
  import { join } from "node:path";
11
12
  import type { SourceSpec, Store } from "@canonical/ke";
12
13
  import { createStore } from "@canonical/ke";
13
- import type { PragmaConfig } from "#config";
14
14
  import { PragmaError } from "../../error/index.js";
15
- import { PACKAGES, resolvePackages } from "./packages.js";
15
+ import type { PackageRef } from "../refs/operations/parseRef.js";
16
+ import { resolvePackages } from "./packages.js";
16
17
  import { PREFIX_MAP } from "./prefixes.js";
17
18
 
18
19
  /**
19
- * Resolve default TTL sources from the package registry.
20
- * Package-manager agnostic — works with bun, npm, pnpm, and yarn.
20
+ * Convention-based TTL directories to scan in each package.
21
+ * Only directories that exist are globbed — packages that lack
22
+ * a `data/` or `definitions/` dir are silently skipped.
21
23
  */
22
- export function defaultSources(): SourceSpec[] {
24
+ const TTL_DIRS: readonly { dir: string; glob: string }[] = [
25
+ { dir: "definitions", glob: "definitions/**/*.ttl" },
26
+ { dir: "data", glob: "data/**/*.ttl" },
27
+ ];
28
+
29
+ /**
30
+ * Resolve default TTL sources from resolved packages.
31
+ *
32
+ * Uses convention-based TTL discovery: definitions and data globs
33
+ * relative to each package root. Only adds globs for directories
34
+ * that actually exist in the package.
35
+ *
36
+ * @param refs - Parsed package references. Omit for defaults.
37
+ */
38
+ export function defaultSources(refs?: ReadonlyArray<PackageRef>): SourceSpec[] {
23
39
  const sources: SourceSpec[] = [];
24
- const resolved = resolvePackages();
40
+ const resolved = resolvePackages(refs);
25
41
 
26
- for (const { pkg, dir } of resolved) {
27
- const def = PACKAGES.find((p) => p.pkg === pkg);
28
- if (!def) continue;
29
- for (const glob of def.ttl) {
30
- sources.push(join(dir, glob));
42
+ for (const { dir } of resolved) {
43
+ for (const entry of TTL_DIRS) {
44
+ if (existsSync(join(dir, entry.dir))) {
45
+ sources.push(join(dir, entry.glob));
46
+ }
31
47
  }
32
48
  }
33
49
 
@@ -35,14 +51,14 @@ export function defaultSources(): SourceSpec[] {
35
51
  }
36
52
 
37
53
  export interface BootStoreOptions {
38
- /** Override config (skip reading from disk). */
39
- config?: PragmaConfig;
40
- /** Override sources (skip config sources field). */
54
+ /** Override sources (skip filesystem resolution). */
41
55
  sources?: SourceSpec[];
42
56
  /** Working directory for resolving relative paths. */
43
57
  cwd?: string;
44
58
  /** Cache path for serialized store. */
45
59
  cache?: string;
60
+ /** Parsed package references for ref-based resolution. */
61
+ refs?: ReadonlyArray<PackageRef>;
46
62
  }
47
63
 
48
64
  /**
@@ -53,7 +69,7 @@ export interface BootStoreOptions {
53
69
  export async function bootStore(
54
70
  options: BootStoreOptions = {},
55
71
  ): Promise<Store> {
56
- const sources = options.sources ?? defaultSources();
72
+ const sources = options.sources ?? defaultSources(options.refs);
57
73
 
58
74
  try {
59
75
  const store = await createStore({
@@ -1,53 +1,39 @@
1
1
  /**
2
- * Design system package registry.
2
+ * Design system package registry and resolution.
3
3
  *
4
- * Single source of truth for all external packages that provide TTL data
5
- * and/or agent skills. Both `bootStore` (TTL sources) and `resolveSkillSources`
6
- * (skill discovery) derive their paths from this registry.
4
+ * Resolves semantic packages from three sources (in priority order):
5
+ * 1. file:// local path (development)
6
+ * 2. git ref cache (shared/CI)
7
+ * 3. require.resolve from node_modules (npm fallback)
7
8
  *
8
- * Each entry declares:
9
- * - `pkg` — npm package name (resolved via `require.resolve`)
10
- * - `ttl` — glob patterns for TTL data, relative to the package root
11
- * - `skills` — subpath to the skills directory, or `undefined` if none
9
+ * The package list itself is configurable via pragma.config.json `packages`
10
+ * field. When absent, the hardcoded DEFAULT_PACKAGES are used.
12
11
  */
13
12
 
13
+ import { existsSync } from "node:fs";
14
14
  import { createRequire } from "node:module";
15
15
  import { dirname } from "node:path";
16
+ import type { PackageRef } from "../refs/operations/parseRef.js";
17
+ import { gitCacheDir } from "../refs/operations/paths.js";
16
18
 
17
19
  const require = createRequire(import.meta.url);
18
20
 
19
- export interface PackageDefinition {
20
- /** npm package name. */
21
- readonly pkg: string;
22
- /** Glob patterns for TTL data (relative to package root). */
23
- readonly ttl: readonly string[];
24
- /** Subpath to skills directory, or undefined if the package has no skills. */
25
- readonly skills: string | undefined;
26
- }
21
+ // ---------------------------------------------------------------------------
22
+ // Default package registry (used when config.packages is absent)
23
+ // ---------------------------------------------------------------------------
27
24
 
28
- export const PACKAGES: readonly PackageDefinition[] = [
29
- {
30
- pkg: "@canonical/design-system",
31
- ttl: ["definitions/ontology.ttl", "data/**/*.ttl"],
32
- skills: "skills",
33
- },
34
- {
35
- pkg: "@canonical/anatomy-dsl",
36
- ttl: ["definitions/**/*.ttl"],
37
- skills: undefined,
38
- },
39
- {
40
- pkg: "@canonical/code-standards",
41
- ttl: ["definitions/**/*.ttl", "data/**/*.ttl"],
42
- skills: "skills",
43
- },
44
- {
45
- pkg: "@canonical/pragma-cli",
46
- ttl: [],
47
- skills: "skills",
48
- },
25
+ /** Default semantic packages loaded when no config overrides are present. */
26
+ export const DEFAULT_PACKAGES: readonly string[] = [
27
+ "@canonical/design-system",
28
+ "@canonical/anatomy-dsl",
29
+ "@canonical/code-standards",
30
+ "@canonical/pragma-cli",
49
31
  ];
50
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Types
35
+ // ---------------------------------------------------------------------------
36
+
51
37
  export interface ResolvedPackage {
52
38
  /** npm package name. */
53
39
  readonly pkg: string;
@@ -55,22 +41,86 @@ export interface ResolvedPackage {
55
41
  readonly dir: string;
56
42
  }
57
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // Resolution
46
+ // ---------------------------------------------------------------------------
47
+
58
48
  /**
59
- * Resolve installed packages from the registry via `require.resolve`.
60
- * Package-manager agnostic — works with bun, npm, pnpm, yarn.
61
- * Skips packages that are not installed.
49
+ * Resolve packages to filesystem directories.
50
+ *
51
+ * When `refs` is provided, each PackageRef is resolved according to its kind.
52
+ * When `refs` is omitted, the DEFAULT_PACKAGES are resolved via require.resolve.
53
+ *
54
+ * @param refs - Parsed package references (from config merge). Omit for defaults.
55
+ * @returns Array of resolved packages with absolute directory paths.
62
56
  */
63
- export function resolvePackages(): ResolvedPackage[] {
57
+ export function resolvePackages(
58
+ refs?: ReadonlyArray<PackageRef>,
59
+ ): ResolvedPackage[] {
60
+ if (!refs) {
61
+ return resolveNpmPackages(DEFAULT_PACKAGES);
62
+ }
63
+
64
64
  const resolved: ResolvedPackage[] = [];
65
- for (const { pkg } of PACKAGES) {
66
- try {
67
- resolved.push({
68
- pkg,
69
- dir: dirname(require.resolve(`${pkg}/package.json`)),
70
- });
71
- } catch {
72
- // package not installed — skip
65
+
66
+ for (const ref of refs) {
67
+ switch (ref.kind) {
68
+ case "file": {
69
+ if (!existsSync(ref.path)) {
70
+ console.warn(
71
+ `pragma: package "${ref.pkg}" points to ${ref.path} which does not exist. Check the path or remove the file:// source.`,
72
+ );
73
+ break;
74
+ }
75
+ resolved.push({ pkg: ref.pkg, dir: ref.path });
76
+ break;
77
+ }
78
+
79
+ case "git": {
80
+ const cacheDir = gitCacheDir(ref.pkg, ref.ref);
81
+ if (existsSync(cacheDir)) {
82
+ resolved.push({ pkg: ref.pkg, dir: cacheDir });
83
+ break;
84
+ }
85
+ // Cache miss — warn and fall through to npm
86
+ console.warn(
87
+ `pragma: git ref for "${ref.pkg}" not cached. Run "pragma update-refs" to fetch it.`,
88
+ );
89
+ resolveNpmFallback(ref.pkg, resolved);
90
+ break;
91
+ }
92
+
93
+ case "npm": {
94
+ resolveNpmFallback(ref.pkg, resolved);
95
+ break;
96
+ }
73
97
  }
74
98
  }
99
+
75
100
  return resolved;
76
101
  }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** Resolve a list of package names via require.resolve. */
108
+ function resolveNpmPackages(pkgs: readonly string[]): ResolvedPackage[] {
109
+ const resolved: ResolvedPackage[] = [];
110
+ for (const pkg of pkgs) {
111
+ resolveNpmFallback(pkg, resolved);
112
+ }
113
+ return resolved;
114
+ }
115
+
116
+ /** Try to resolve a single package via require.resolve, push if found. */
117
+ function resolveNpmFallback(pkg: string, resolved: ResolvedPackage[]): void {
118
+ try {
119
+ resolved.push({
120
+ pkg,
121
+ dir: dirname(require.resolve(`${pkg}/package.json`)),
122
+ });
123
+ } catch {
124
+ // package not installed — skip
125
+ }
126
+ }
@@ -10,7 +10,14 @@
10
10
 
11
11
  import type { SourceSpec } from "@canonical/ke";
12
12
  import { readConfig } from "#config";
13
+ import {
14
+ type PackageRef,
15
+ parsePackageEntry,
16
+ type RawPackageEntry,
17
+ } from "../refs/operations/parseRef.js";
18
+ import readGlobalRefs from "../refs/operations/readGlobalRefs.js";
13
19
  import { bootStore } from "./bootStore.js";
20
+ import { DEFAULT_PACKAGES } from "./packages.js";
14
21
  import type { PragmaRuntime } from "./types/index.js";
15
22
 
16
23
  export type { PragmaRuntime } from "./types/index.js";
@@ -18,9 +25,11 @@ export type { PragmaRuntime } from "./types/index.js";
18
25
  /**
19
26
  * Create a fully initialized `PragmaRuntime`.
20
27
  *
21
- * Reads config from `cwd` (defaults to `process.cwd()`), boots the ke
22
- * store from resolved sources, and returns a runtime ready for operation
23
- * calls. The caller owns the lifecycle and must call `dispose()`.
28
+ * Reads config from `cwd` (defaults to `process.cwd()`), merges global
29
+ * and project package refs, boots the ke store from resolved sources,
30
+ * and returns a runtime ready for operation calls.
31
+ *
32
+ * The caller owns the lifecycle and must call `dispose()`.
24
33
  *
25
34
  * @param options.cwd - Working directory for config and source resolution.
26
35
  * @param options.sources - Override sources for testing (skip filesystem resolution).
@@ -33,9 +42,16 @@ export async function bootPragma(options?: {
33
42
  }): Promise<PragmaRuntime> {
34
43
  const cwd = options?.cwd ?? process.cwd();
35
44
  const config = readConfig(cwd);
45
+
46
+ // Merge package refs: project overrides global, both override defaults.
47
+ const refs = options?.sources
48
+ ? undefined
49
+ : mergeAndParseRefs(config.packages);
50
+
36
51
  const store = await bootStore({
37
52
  cwd,
38
53
  sources: options?.sources,
54
+ refs,
39
55
  });
40
56
 
41
57
  return {
@@ -45,3 +61,57 @@ export async function bootPragma(options?: {
45
61
  dispose: () => store.dispose(),
46
62
  };
47
63
  }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Ref merging
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Merge global refs, project config packages, and defaults into a final
71
+ * parsed PackageRef array.
72
+ *
73
+ * Priority: project packages > global refs > hardcoded defaults.
74
+ * Merging is by package name — a project entry for "@canonical/foo"
75
+ * overrides the global entry for the same package.
76
+ */
77
+ function mergeAndParseRefs(
78
+ projectPackages?: ReadonlyArray<RawPackageEntry>,
79
+ ): PackageRef[] {
80
+ const globalEntries = readGlobalRefs();
81
+
82
+ // If neither project nor global defines packages, return undefined
83
+ // so resolvePackages() uses hardcoded defaults.
84
+ if (
85
+ (!projectPackages || projectPackages.length === 0) &&
86
+ globalEntries.length === 0
87
+ ) {
88
+ return DEFAULT_PACKAGES.map((pkg) => parsePackageEntry(pkg));
89
+ }
90
+
91
+ // Build a name → entry map, global first, project overrides.
92
+ const merged = new Map<string, RawPackageEntry>();
93
+
94
+ // Start with defaults (lowest priority)
95
+ for (const pkg of DEFAULT_PACKAGES) {
96
+ merged.set(pkg, pkg);
97
+ }
98
+
99
+ // Global overrides defaults
100
+ for (const entry of globalEntries) {
101
+ const name = typeof entry === "string" ? entry : entry.name;
102
+ merged.set(name, entry);
103
+ }
104
+
105
+ // Project overrides global
106
+ if (projectPackages) {
107
+ // When project explicitly declares packages, it's a full replacement
108
+ // of the package list — clear defaults and global, use project only.
109
+ merged.clear();
110
+ for (const entry of projectPackages) {
111
+ const name = typeof entry === "string" ? entry : entry.name;
112
+ merged.set(name, entry);
113
+ }
114
+ }
115
+
116
+ return [...merged.values()].map(parsePackageEntry);
117
+ }
@@ -1,5 +1,7 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join } from "node:path";
2
- import { PACKAGES, resolvePackages } from "../../shared/packages.js";
3
+ import type { PackageRef } from "../../refs/operations/parseRef.js";
4
+ import { resolvePackages } from "../../shared/packages.js";
3
5
 
4
6
  /** A resolved skill source directory from an installed package. */
5
7
  export interface SkillSource {
@@ -10,24 +12,26 @@ export interface SkillSource {
10
12
  }
11
13
 
12
14
  /**
13
- * Resolve skill source directories from the shared package registry.
14
- * Package-manager agnostic -- works with bun, npm, pnpm, yarn.
15
+ * Resolve skill source directories from resolved packages.
15
16
  *
17
+ * Auto-detects skills by checking for a `skills/` subdirectory
18
+ * in each resolved package root.
19
+ *
20
+ * @param refs - Parsed package references. Omit for defaults.
16
21
  * @returns Array of resolved skill sources with absolute paths.
17
22
  * @note Impure
18
23
  */
19
- export default function resolveSkillSources(): SkillSource[] {
24
+ export default function resolveSkillSources(
25
+ refs?: ReadonlyArray<PackageRef>,
26
+ ): SkillSource[] {
20
27
  const sources: SkillSource[] = [];
21
- const resolved = resolvePackages();
28
+ const resolved = resolvePackages(refs);
22
29
 
23
30
  for (const { pkg, dir } of resolved) {
24
- const def = PACKAGES.find((p) => p.pkg === pkg);
25
- if (!def?.skills) continue;
26
-
27
- sources.push({
28
- dir: join(dir, def.skills),
29
- packageName: pkg,
30
- });
31
+ const skillsDir = join(dir, "skills");
32
+ if (existsSync(skillsDir)) {
33
+ sources.push({ dir: skillsDir, packageName: pkg });
34
+ }
31
35
  }
32
36
 
33
37
  return sources;
@@ -8,7 +8,7 @@
8
8
  import type { CommandKind } from "./types.js";
9
9
 
10
10
  /** Commands that do not require the ke store to be booted. */
11
- const STORE_SKIP_COMMANDS = new Set(["setup", "mcp"]);
11
+ const STORE_SKIP_COMMANDS = new Set(["setup", "mcp", "update-refs"]);
12
12
 
13
13
  function findCommandArg(argv: readonly string[]): string | undefined {
14
14
  const args = argv.slice(2);
@@ -1,5 +1,6 @@
1
1
  import type { GlobalFlags } from "@canonical/cli-core";
2
2
  import { CommanderError } from "commander";
3
+ import { commands as refsCommands } from "../domains/refs/index.js";
3
4
  import { commands as setupCommands } from "../domains/setup/index.js";
4
5
  import type { PragmaContext } from "../domains/shared/context.js";
5
6
  import type { PragmaRuntime } from "../domains/shared/runtime.js";
@@ -140,7 +141,7 @@ async function runStoreSkip(
140
141
  globalFlags,
141
142
  interactive: runInteractiveCommand,
142
143
  };
143
- const commands = [...setupCommands()];
144
+ const commands = [...setupCommands(), ...refsCommands()];
144
145
  const program = createProgram(commands, stubCtx);
145
146
 
146
147
  try {
@@ -5,9 +5,12 @@
5
5
  * consistency from the canonical fixture.
6
6
  */
7
7
 
8
+ import { mkdtempSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
8
10
  import { afterAll, describe, expect, it } from "vitest";
9
11
  import { listBlocks } from "../../domains/block/operations/index.js";
10
12
  import type { PragmaRuntime } from "../../domains/shared/runtime.js";
13
+ import { bootPragma } from "../../domains/shared/runtime.js";
11
14
  import createTestRuntime from "../helpers/createTestRuntime.js";
12
15
 
13
16
  describe("PragmaRuntime boot", () => {
@@ -54,6 +57,41 @@ describe("PragmaRuntime boot", () => {
54
57
  expect(names).toContain("Button");
55
58
  });
56
59
 
60
+ it("boots with file:// package ref", async () => {
61
+ // Create a temp "package" with TTL in the convention structure
62
+ const { mkdirSync, copyFileSync, writeFileSync } = await import("node:fs");
63
+ const { join } = await import("node:path");
64
+
65
+ const fixturesDir = new URL("../fixtures/", import.meta.url).pathname;
66
+ const tmpPkg = mkdtempSync(join(tmpdir(), "pragma-ref-pkg-"));
67
+ const dataDir = join(tmpPkg, "data");
68
+ mkdirSync(dataDir, { recursive: true });
69
+ copyFileSync(
70
+ join(fixturesDir, "canonical.ttl"),
71
+ join(dataDir, "canonical.ttl"),
72
+ );
73
+
74
+ // Create a temp project dir with config pointing to the file:// package
75
+ const tmpProject = mkdtempSync(join(tmpdir(), "pragma-ref-project-"));
76
+ writeFileSync(
77
+ join(tmpProject, "pragma.config.json"),
78
+ JSON.stringify({
79
+ packages: [{ name: "@test/fixture", source: `file://${tmpPkg}` }],
80
+ }),
81
+ );
82
+
83
+ const rt = await bootPragma({ cwd: tmpProject });
84
+ runtimes.push(rt);
85
+
86
+ expect(rt.store).toBeDefined();
87
+ expect(rt.config.packages).toHaveLength(1);
88
+
89
+ // Clean up
90
+ const { rmSync } = await import("node:fs");
91
+ rmSync(tmpPkg, { recursive: true, force: true });
92
+ rmSync(tmpProject, { recursive: true, force: true });
93
+ });
94
+
57
95
  it("two runtimes produce identical store content", async () => {
58
96
  const rt1 = await createTestRuntime();
59
97
  const rt2 = await createTestRuntime();