@canonical/pragma-cli 0.25.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
@@ -0,0 +1,52 @@
1
+ /**
2
+ * CLI command: pragma update-refs
3
+ *
4
+ * Fetches/clones git-referenced semantic packages into the local cache.
5
+ * Store-skip command — does not boot the ke store.
6
+ */
7
+
8
+ import type { CommandDefinition } from "@canonical/cli-core";
9
+ import formatUpdateResults from "../formatters/updateRefs.js";
10
+ import updateRefs from "../operations/updateRefs.js";
11
+
12
+ const updateRefsCommand: CommandDefinition = {
13
+ path: ["update-refs"],
14
+ description:
15
+ "Fetch or clone git-referenced semantic packages into the local cache",
16
+ parameters: [
17
+ {
18
+ name: "package",
19
+ description: "Update only this package (by name)",
20
+ type: "string",
21
+ },
22
+ {
23
+ name: "prune",
24
+ description:
25
+ "Remove orphaned cache entries that no longer match any configured ref",
26
+ type: "boolean",
27
+ default: false,
28
+ },
29
+ ],
30
+ meta: {
31
+ examples: [
32
+ "pragma update-refs",
33
+ "pragma update-refs --package @canonical/design-system",
34
+ "pragma update-refs --prune",
35
+ ],
36
+ },
37
+ execute: async (params, ctx) => {
38
+ const results = await updateRefs({
39
+ cwd: ctx.cwd,
40
+ package: params.package as string | undefined,
41
+ prune: params.prune === true,
42
+ });
43
+
44
+ const text = formatUpdateResults(results);
45
+ process.stdout.write(`${text}\n`);
46
+
47
+ const hasErrors = results.some((r) => r.kind === "error");
48
+ return { tag: "exit" as const, code: hasErrors ? 1 : 0 };
49
+ },
50
+ };
51
+
52
+ export default updateRefsCommand;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Plain-text formatter for update-refs results.
3
+ */
4
+
5
+ import type { UpdateResult } from "../operations/updateRefs.js";
6
+
7
+ const KIND_LABELS: Record<UpdateResult["kind"], string> = {
8
+ cloned: "cloned",
9
+ updated: "updated",
10
+ "up-to-date": "up to date",
11
+ ok: "ok",
12
+ skipped: "skipped",
13
+ error: "ERROR",
14
+ };
15
+
16
+ export default function formatUpdateResults(
17
+ results: ReadonlyArray<UpdateResult>,
18
+ ): string {
19
+ if (results.length === 0) return "No packages configured.";
20
+
21
+ const lines: string[] = [];
22
+ for (const r of results) {
23
+ const label = KIND_LABELS[r.kind] ?? r.kind;
24
+ lines.push(`${r.pkg}: ${label} — ${r.detail}`);
25
+ }
26
+ return lines.join("\n");
27
+ }
@@ -0,0 +1,27 @@
1
+ /** @module Refs domain — package reference resolution and update-refs command. */
2
+
3
+ import type { CommandDefinition } from "@canonical/cli-core";
4
+ import updateRefsCommand from "./commands/updateRefs.js";
5
+
6
+ /**
7
+ * Return all refs command definitions.
8
+ * Store-skip domain — does not require a booted ke store.
9
+ */
10
+ export function commands(): CommandDefinition[] {
11
+ return [updateRefsCommand];
12
+ }
13
+
14
+ export type {
15
+ PackageRef,
16
+ RawPackageEntry,
17
+ UpdateRefsOptions,
18
+ UpdateResult,
19
+ } from "./operations/index.js";
20
+ export {
21
+ cacheRoot,
22
+ gitCacheDir,
23
+ globalConfigDir,
24
+ parsePackageEntry,
25
+ readGlobalRefs,
26
+ updateRefs,
27
+ } from "./operations/index.js";
@@ -0,0 +1,155 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { cloneRef, fetchRef, isSha, pruneCache } from "./gitOps.js";
7
+
8
+ describe("isSha", () => {
9
+ it("recognizes a 7-char hex string", () => {
10
+ expect(isSha("abc1234")).toBe(true);
11
+ });
12
+
13
+ it("recognizes a 40-char hex string", () => {
14
+ expect(isSha("abc1234def5678901234567890abcdef12345678")).toBe(true);
15
+ });
16
+
17
+ it("rejects non-hex characters", () => {
18
+ expect(isSha("main")).toBe(false);
19
+ expect(isSha("v1.0.0")).toBe(false);
20
+ expect(isSha("feature/branch")).toBe(false);
21
+ });
22
+
23
+ it("rejects too-short strings", () => {
24
+ expect(isSha("abc12")).toBe(false);
25
+ });
26
+
27
+ it("rejects uppercase hex", () => {
28
+ expect(isSha("ABC1234")).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("cloneRef + fetchRef (integration, requires git)", () => {
33
+ let tmpDir: string;
34
+ let bareRepo: string;
35
+
36
+ beforeEach(() => {
37
+ tmpDir = mkdtempSync(join(tmpdir(), "pragma-gitops-"));
38
+
39
+ // Create a bare repo to clone from (avoids network)
40
+ bareRepo = join(tmpDir, "origin.git");
41
+ execFileSync("git", ["init", "--bare", "-b", "main", bareRepo], {
42
+ stdio: "pipe",
43
+ });
44
+
45
+ // Push an initial commit to the bare repo via a temp working copy
46
+ const workDir = join(tmpDir, "work");
47
+ execFileSync("git", ["clone", bareRepo, workDir], { stdio: "pipe" });
48
+ execFileSync(
49
+ "git",
50
+ ["-C", workDir, "config", "user.email", "test@test.com"],
51
+ { stdio: "pipe" },
52
+ );
53
+ execFileSync("git", ["-C", workDir, "config", "user.name", "Test"], {
54
+ stdio: "pipe",
55
+ });
56
+ execFileSync(
57
+ "git",
58
+ ["-C", workDir, "commit", "--allow-empty", "-m", "initial"],
59
+ { stdio: "pipe" },
60
+ );
61
+ execFileSync("git", ["-C", workDir, "push", "origin", "main"], {
62
+ stdio: "pipe",
63
+ });
64
+ // Tag it
65
+ execFileSync("git", ["-C", workDir, "tag", "v0.1.0"], { stdio: "pipe" });
66
+ execFileSync("git", ["-C", workDir, "push", "origin", "v0.1.0"], {
67
+ stdio: "pipe",
68
+ });
69
+ // Add another commit for update testing
70
+ execFileSync(
71
+ "git",
72
+ ["-C", workDir, "commit", "--allow-empty", "-m", "second"],
73
+ { stdio: "pipe" },
74
+ );
75
+ execFileSync("git", ["-C", workDir, "push", "origin", "main"], {
76
+ stdio: "pipe",
77
+ });
78
+
79
+ rmSync(workDir, { recursive: true, force: true });
80
+ });
81
+
82
+ afterEach(() => {
83
+ rmSync(tmpDir, { recursive: true, force: true });
84
+ });
85
+
86
+ it("clones a branch ref", () => {
87
+ const dest = join(tmpDir, "clone-branch");
88
+ cloneRef(bareRepo, "main", dest);
89
+ expect(existsSync(join(dest, ".git"))).toBe(true);
90
+ });
91
+
92
+ it("clones a tag ref", () => {
93
+ const dest = join(tmpDir, "clone-tag");
94
+ cloneRef(bareRepo, "v0.1.0", dest);
95
+ expect(existsSync(join(dest, ".git"))).toBe(true);
96
+ });
97
+
98
+ it("clones a SHA ref", () => {
99
+ // Get the SHA of the initial commit
100
+ const sha = execFileSync("git", ["-C", bareRepo, "rev-parse", "v0.1.0"], {
101
+ stdio: "pipe",
102
+ })
103
+ .toString()
104
+ .trim();
105
+
106
+ const dest = join(tmpDir, "clone-sha");
107
+ cloneRef(bareRepo, sha, dest);
108
+ expect(existsSync(join(dest, ".git"))).toBe(true);
109
+ });
110
+
111
+ it("fetches updates on an existing clone", () => {
112
+ // Clone at tag (first commit)
113
+ const dest = join(tmpDir, "clone-fetch");
114
+ cloneRef(bareRepo, "v0.1.0", dest);
115
+
116
+ // Fetch main (which has a second commit)
117
+ const result = fetchRef(bareRepo, "main", dest);
118
+ expect(result.updated).toBe(true);
119
+ expect(result.oldHead).not.toBe(result.newHead);
120
+ });
121
+
122
+ it("reports up-to-date when no changes", () => {
123
+ const dest = join(tmpDir, "clone-noop");
124
+ cloneRef(bareRepo, "main", dest);
125
+
126
+ const result = fetchRef(bareRepo, "main", dest);
127
+ expect(result.updated).toBe(false);
128
+ expect(result.oldHead).toBe(result.newHead);
129
+ });
130
+ });
131
+
132
+ describe("pruneCache", () => {
133
+ let tmpDir: string;
134
+
135
+ beforeEach(() => {
136
+ tmpDir = mkdtempSync(join(tmpdir(), "pragma-prune-"));
137
+ });
138
+
139
+ afterEach(() => {
140
+ rmSync(tmpDir, { recursive: true, force: true });
141
+ });
142
+
143
+ it("removes an existing directory", () => {
144
+ const dir = join(tmpDir, "to-remove");
145
+ execFileSync("mkdir", ["-p", dir]);
146
+ expect(existsSync(dir)).toBe(true);
147
+ pruneCache(dir);
148
+ expect(existsSync(dir)).toBe(false);
149
+ });
150
+
151
+ it("does nothing for a non-existent directory", () => {
152
+ pruneCache(join(tmpDir, "does-not-exist"));
153
+ // no throw
154
+ });
155
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Git operations for managing cached package clones.
3
+ *
4
+ * All operations use execFileSync with shell: false for security.
5
+ * Relies entirely on the user's git client for authentication
6
+ * (SSH keys, credential helpers). Zero custom auth logic.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+
13
+ /** Check whether a ref looks like a commit SHA (7-40 hex chars). */
14
+ export function isSha(ref: string): boolean {
15
+ return /^[0-9a-f]{7,40}$/.test(ref);
16
+ }
17
+
18
+ /**
19
+ * Clone a repository at a specific ref into a destination directory.
20
+ * Uses shallow clone (--depth 1) for speed.
21
+ *
22
+ * @param url - Git remote URL (https:// or ssh://).
23
+ * @param ref - Branch, tag, or commit SHA.
24
+ * @param dest - Target directory (must not exist).
25
+ */
26
+ export function cloneRef(url: string, ref: string, dest: string): void {
27
+ mkdirSync(dirname(dest), { recursive: true });
28
+
29
+ if (isSha(ref)) {
30
+ // Git doesn't support shallow clone by SHA directly.
31
+ // Init, fetch the specific commit, checkout.
32
+ mkdirSync(dest, { recursive: true });
33
+ git(dest, ["init"]);
34
+ git(dest, ["fetch", "--depth", "1", url, ref]);
35
+ git(dest, ["checkout", "FETCH_HEAD"]);
36
+ } else {
37
+ // Branches and tags: --branch works with --depth 1
38
+ execFileSync("git", ["clone", "--depth", "1", "--branch", ref, url, dest], {
39
+ stdio: "pipe",
40
+ });
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Update an existing cached clone by fetching the latest for its ref.
46
+ *
47
+ * @param url - Git remote URL.
48
+ * @param ref - Branch, tag, or commit SHA.
49
+ * @param dest - Existing cache directory.
50
+ * @returns Whether the HEAD changed after fetch.
51
+ */
52
+ export function fetchRef(
53
+ url: string,
54
+ ref: string,
55
+ dest: string,
56
+ ): { updated: boolean; oldHead: string; newHead: string } {
57
+ const oldHead = gitOutput(dest, ["rev-parse", "HEAD"]);
58
+
59
+ if (isSha(ref)) {
60
+ git(dest, ["fetch", "--depth", "1", url, ref]);
61
+ git(dest, ["checkout", "FETCH_HEAD"]);
62
+ } else {
63
+ git(dest, ["fetch", "--depth", "1", url, ref]);
64
+ git(dest, ["checkout", "FETCH_HEAD"]);
65
+ }
66
+
67
+ const newHead = gitOutput(dest, ["rev-parse", "HEAD"]);
68
+ return { updated: oldHead !== newHead, oldHead, newHead };
69
+ }
70
+
71
+ /**
72
+ * Remove a cached directory.
73
+ */
74
+ export function pruneCache(dir: string): void {
75
+ if (existsSync(dir)) {
76
+ rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Run a git command in a directory. Throws on non-zero exit. */
85
+ function git(cwd: string, args: string[]): void {
86
+ execFileSync("git", args, { cwd, stdio: "pipe" });
87
+ }
88
+
89
+ /** Run a git command and return trimmed stdout. */
90
+ function gitOutput(cwd: string, args: string[]): string {
91
+ return execFileSync("git", args, { cwd, stdio: "pipe" }).toString().trim();
92
+ }
@@ -0,0 +1,10 @@
1
+ /** @module Refs operations — parsing, resolution, caching, and update logic. */
2
+
3
+ export { cloneRef, fetchRef, isSha, pruneCache } from "./gitOps.js";
4
+ export type { PackageRef, RawPackageEntry } from "./parseRef.js";
5
+ export { parsePackageEntry } from "./parseRef.js";
6
+ export { cacheRoot, gitCacheDir, globalConfigDir } from "./paths.js";
7
+ export { default as readGlobalRefs } from "./readGlobalRefs.js";
8
+
9
+ export type { UpdateRefsOptions, UpdateResult } from "./updateRefs.js";
10
+ export { default as updateRefs } from "./updateRefs.js";
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { PragmaError } from "../../../error/index.js";
3
+ import { parsePackageEntry } from "./parseRef.js";
4
+
5
+ describe("parsePackageEntry", () => {
6
+ describe("npm references", () => {
7
+ it("parses a bare string as npm", () => {
8
+ const ref = parsePackageEntry("@canonical/design-system");
9
+ expect(ref).toEqual({ kind: "npm", pkg: "@canonical/design-system" });
10
+ });
11
+
12
+ it("parses an object without source as npm", () => {
13
+ const ref = parsePackageEntry({ name: "@canonical/design-system" });
14
+ expect(ref).toEqual({ kind: "npm", pkg: "@canonical/design-system" });
15
+ });
16
+
17
+ it("parses an object with undefined source as npm", () => {
18
+ const ref = parsePackageEntry({
19
+ name: "@canonical/design-system",
20
+ source: undefined,
21
+ });
22
+ expect(ref).toEqual({ kind: "npm", pkg: "@canonical/design-system" });
23
+ });
24
+ });
25
+
26
+ describe("file references", () => {
27
+ it("parses file:// source with absolute path", () => {
28
+ const ref = parsePackageEntry({
29
+ name: "@canonical/design-system",
30
+ source: "file:///home/user/code/design-system",
31
+ });
32
+ expect(ref).toEqual({
33
+ kind: "file",
34
+ pkg: "@canonical/design-system",
35
+ path: "/home/user/code/design-system",
36
+ });
37
+ });
38
+
39
+ it("parses file:// source with relative path", () => {
40
+ const ref = parsePackageEntry({
41
+ name: "@canonical/design-system",
42
+ source: "file://./local-packages/design-system",
43
+ });
44
+ expect(ref).toEqual({
45
+ kind: "file",
46
+ pkg: "@canonical/design-system",
47
+ path: "./local-packages/design-system",
48
+ });
49
+ });
50
+
51
+ it("throws on empty file:// path", () => {
52
+ expect(() =>
53
+ parsePackageEntry({
54
+ name: "@canonical/design-system",
55
+ source: "file://",
56
+ }),
57
+ ).toThrow(PragmaError);
58
+ });
59
+ });
60
+
61
+ describe("git references", () => {
62
+ it("parses git+https:// source with branch ref", () => {
63
+ const ref = parsePackageEntry({
64
+ name: "@canonical/design-system",
65
+ source: "git+https://github.com/canonical/design-system.git#main",
66
+ });
67
+ expect(ref).toEqual({
68
+ kind: "git",
69
+ pkg: "@canonical/design-system",
70
+ url: "https://github.com/canonical/design-system.git",
71
+ ref: "main",
72
+ });
73
+ });
74
+
75
+ it("parses git+https:// source with tag ref", () => {
76
+ const ref = parsePackageEntry({
77
+ name: "@canonical/design-system",
78
+ source: "git+https://github.com/canonical/design-system.git#v0.3.0",
79
+ });
80
+ expect(ref).toEqual({
81
+ kind: "git",
82
+ pkg: "@canonical/design-system",
83
+ url: "https://github.com/canonical/design-system.git",
84
+ ref: "v0.3.0",
85
+ });
86
+ });
87
+
88
+ it("parses git+https:// source with SHA ref", () => {
89
+ const ref = parsePackageEntry({
90
+ name: "@canonical/design-system",
91
+ source:
92
+ "git+https://github.com/canonical/design-system.git#abc1234def5678",
93
+ });
94
+ expect(ref).toEqual({
95
+ kind: "git",
96
+ pkg: "@canonical/design-system",
97
+ url: "https://github.com/canonical/design-system.git",
98
+ ref: "abc1234def5678",
99
+ });
100
+ });
101
+
102
+ it("parses git+ssh:// source", () => {
103
+ const ref = parsePackageEntry({
104
+ name: "@canonical/design-system",
105
+ source: "git+ssh://git@github.com/canonical/design-system.git#main",
106
+ });
107
+ expect(ref).toEqual({
108
+ kind: "git",
109
+ pkg: "@canonical/design-system",
110
+ url: "ssh://git@github.com/canonical/design-system.git",
111
+ ref: "main",
112
+ });
113
+ });
114
+
115
+ it("throws on git URL without #ref", () => {
116
+ expect(() =>
117
+ parsePackageEntry({
118
+ name: "@canonical/design-system",
119
+ source: "git+https://github.com/canonical/design-system.git",
120
+ }),
121
+ ).toThrow(PragmaError);
122
+ });
123
+
124
+ it("throws on git URL with empty ref after #", () => {
125
+ expect(() =>
126
+ parsePackageEntry({
127
+ name: "@canonical/design-system",
128
+ source: "git+https://github.com/canonical/design-system.git#",
129
+ }),
130
+ ).toThrow(PragmaError);
131
+ });
132
+ });
133
+
134
+ describe("invalid entries", () => {
135
+ it("throws on unknown source scheme", () => {
136
+ expect(() =>
137
+ parsePackageEntry({
138
+ name: "@canonical/design-system",
139
+ source: "https://example.com/package.tar.gz",
140
+ }),
141
+ ).toThrow(PragmaError);
142
+ });
143
+
144
+ it("throws on empty name", () => {
145
+ expect(() => parsePackageEntry({ name: "" })).toThrow(PragmaError);
146
+ });
147
+
148
+ it("throws on non-string source", () => {
149
+ expect(() =>
150
+ parsePackageEntry({
151
+ name: "@canonical/design-system",
152
+ source: 42 as unknown as string,
153
+ }),
154
+ ).toThrow(PragmaError);
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Package reference parser.
3
+ *
4
+ * Parses pragma.config.json `packages` entries into a discriminated union
5
+ * of resolution strategies: npm (require.resolve), file (local path), or
6
+ * git (cached shallow clone).
7
+ */
8
+
9
+ import { PragmaError } from "../../../error/index.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Raw entry shape accepted in pragma.config.json `packages` array. */
16
+ export type RawPackageEntry =
17
+ | string
18
+ | { readonly name: string; readonly source?: string };
19
+
20
+ /** Parsed, validated package reference — discriminated by `kind`. */
21
+ export type PackageRef =
22
+ | { readonly kind: "npm"; readonly pkg: string }
23
+ | { readonly kind: "file"; readonly pkg: string; readonly path: string }
24
+ | {
25
+ readonly kind: "git";
26
+ readonly pkg: string;
27
+ readonly url: string;
28
+ readonly ref: string;
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Parser
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Parse a raw package entry into a typed PackageRef.
37
+ *
38
+ * @param entry - String (npm package name) or object with optional source.
39
+ * @returns Parsed PackageRef.
40
+ * @throws PragmaError with code CONFIG_ERROR on invalid format.
41
+ */
42
+ export function parsePackageEntry(entry: RawPackageEntry): PackageRef {
43
+ if (typeof entry === "string") {
44
+ return { kind: "npm", pkg: entry };
45
+ }
46
+
47
+ const { name, source } = entry;
48
+
49
+ if (typeof name !== "string" || name.length === 0) {
50
+ throw PragmaError.configError(
51
+ 'Package entry must have a non-empty "name" field.',
52
+ {
53
+ recovery: {
54
+ message:
55
+ 'Each object in the "packages" array requires a "name" string.',
56
+ },
57
+ },
58
+ );
59
+ }
60
+
61
+ if (source === undefined || source === null) {
62
+ return { kind: "npm", pkg: name };
63
+ }
64
+
65
+ if (typeof source !== "string") {
66
+ throw PragmaError.configError(
67
+ `Invalid source for "${name}": expected a string.`,
68
+ );
69
+ }
70
+
71
+ // file:// — local path override
72
+ if (source.startsWith("file://")) {
73
+ const path = source.slice(7);
74
+ if (path.length === 0) {
75
+ throw PragmaError.configError(
76
+ `Invalid source for "${name}": file:// must be followed by a path.`,
77
+ {
78
+ recovery: {
79
+ message: "Example: file:///home/user/code/my-package",
80
+ },
81
+ },
82
+ );
83
+ }
84
+ return { kind: "file", pkg: name, path };
85
+ }
86
+
87
+ // git+<protocol>:// — git ref (https, ssh, file)
88
+ if (source.startsWith("git+")) {
89
+ const hashIdx = source.indexOf("#");
90
+ if (hashIdx === -1 || hashIdx === source.length - 1) {
91
+ throw PragmaError.configError(
92
+ `Invalid source for "${name}": git URL must include a ref after #.`,
93
+ {
94
+ recovery: {
95
+ message: "Example: git+https://github.com/org/repo.git#main",
96
+ },
97
+ },
98
+ );
99
+ }
100
+ const url = source.slice(4, hashIdx); // strip "git+" prefix, keep up to #
101
+ const ref = source.slice(hashIdx + 1);
102
+ return { kind: "git", pkg: name, url, ref };
103
+ }
104
+
105
+ throw PragmaError.configError(
106
+ `Invalid source for "${name}": "${source}". Expected file://, git+https://, git+ssh://, or git+file://.`,
107
+ {
108
+ recovery: {
109
+ message:
110
+ "Valid formats:\n file:///absolute/path\n git+https://host/repo.git#ref\n git+ssh://git@host/repo.git#ref\n git+file:///local/bare/repo.git#ref",
111
+ },
112
+ },
113
+ );
114
+ }