@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.
- package/dist/pragma +0 -0
- package/package.json +12 -12
- package/src/config/readConfig.test.ts +152 -0
- package/src/config/readConfig.ts +63 -1
- package/src/config/types.ts +9 -0
- package/src/config/writeConfig.ts +6 -0
- package/src/domains/doctor/operations/checks/checkPackageRefs.ts +76 -0
- package/src/domains/doctor/operations/checks/index.ts +1 -0
- package/src/domains/doctor/operations/runChecks.test.ts +11 -5
- package/src/domains/doctor/operations/runChecks.ts +2 -0
- package/src/domains/info/formatters/info.test.ts +34 -0
- package/src/domains/info/formatters/info.ts +15 -0
- package/src/domains/info/operations/collectInfo.ts +30 -1
- package/src/domains/info/types.ts +8 -0
- package/src/domains/refs/commands/updateRefs.ts +52 -0
- package/src/domains/refs/formatters/updateRefs.ts +27 -0
- package/src/domains/refs/index.ts +27 -0
- package/src/domains/refs/operations/gitOps.test.ts +155 -0
- package/src/domains/refs/operations/gitOps.ts +92 -0
- package/src/domains/refs/operations/index.ts +10 -0
- package/src/domains/refs/operations/parseRef.test.ts +157 -0
- package/src/domains/refs/operations/parseRef.ts +114 -0
- package/src/domains/refs/operations/paths.test.ts +98 -0
- package/src/domains/refs/operations/paths.ts +63 -0
- package/src/domains/refs/operations/readGlobalRefs.test.ts +63 -0
- package/src/domains/refs/operations/readGlobalRefs.ts +37 -0
- package/src/domains/refs/operations/updateRefs.test.ts +164 -0
- package/src/domains/refs/operations/updateRefs.ts +215 -0
- package/src/domains/shared/bootStore.ts +31 -15
- package/src/domains/shared/packages.ts +99 -49
- package/src/domains/shared/runtime.ts +73 -3
- package/src/domains/skill/helpers/resolveSkillSources.ts +16 -12
- package/src/pipeline/resolveCommandKind.ts +1 -1
- package/src/pipeline/runCli.ts +2 -1
- package/src/testing/integration/boot.test.ts +38 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { cacheRoot, gitCacheDir, globalConfigDir } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
describe("cacheRoot", () => {
|
|
7
|
+
const origEnv = { ...process.env };
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
process.env = { ...origEnv };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses PRAGMA_CACHE_DIR when set", () => {
|
|
14
|
+
process.env.PRAGMA_CACHE_DIR = "/tmp/pragma-test-cache";
|
|
15
|
+
expect(cacheRoot()).toBe("/tmp/pragma-test-cache");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses XDG_CACHE_HOME/pragma when set", () => {
|
|
19
|
+
delete process.env.PRAGMA_CACHE_DIR;
|
|
20
|
+
process.env.XDG_CACHE_HOME = "/tmp/xdg-cache";
|
|
21
|
+
expect(cacheRoot()).toBe(join("/tmp/xdg-cache", "pragma"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("PRAGMA_CACHE_DIR takes precedence over XDG_CACHE_HOME", () => {
|
|
25
|
+
process.env.PRAGMA_CACHE_DIR = "/tmp/pragma-override";
|
|
26
|
+
process.env.XDG_CACHE_HOME = "/tmp/xdg-cache";
|
|
27
|
+
expect(cacheRoot()).toBe("/tmp/pragma-override");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("falls back to ~/.cache/pragma", () => {
|
|
31
|
+
delete process.env.PRAGMA_CACHE_DIR;
|
|
32
|
+
delete process.env.XDG_CACHE_HOME;
|
|
33
|
+
expect(cacheRoot()).toBe(join(homedir(), ".cache", "pragma"));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("globalConfigDir", () => {
|
|
38
|
+
const origEnv = { ...process.env };
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
process.env = { ...origEnv };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("uses XDG_CONFIG_HOME/pragma when set", () => {
|
|
45
|
+
process.env.XDG_CONFIG_HOME = "/tmp/xdg-config";
|
|
46
|
+
expect(globalConfigDir()).toBe(join("/tmp/xdg-config", "pragma"));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to ~/.config/pragma", () => {
|
|
50
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
51
|
+
expect(globalConfigDir()).toBe(join(homedir(), ".config", "pragma"));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("gitCacheDir", () => {
|
|
56
|
+
const origEnv = { ...process.env };
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
process.env.PRAGMA_CACHE_DIR = "/tmp/pragma-test-cache";
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
process.env = { ...origEnv };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns cache path for a package and ref", () => {
|
|
67
|
+
expect(gitCacheDir("@canonical/design-system", "main")).toBe(
|
|
68
|
+
join(
|
|
69
|
+
"/tmp/pragma-test-cache",
|
|
70
|
+
"refs",
|
|
71
|
+
"@canonical/design-system",
|
|
72
|
+
"main",
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("sanitizes slashes in ref", () => {
|
|
78
|
+
expect(gitCacheDir("@canonical/design-system", "feature/branch")).toBe(
|
|
79
|
+
join(
|
|
80
|
+
"/tmp/pragma-test-cache",
|
|
81
|
+
"refs",
|
|
82
|
+
"@canonical/design-system",
|
|
83
|
+
"feature_branch",
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("sanitizes colons in ref", () => {
|
|
89
|
+
expect(gitCacheDir("@canonical/design-system", "v1:beta")).toBe(
|
|
90
|
+
join(
|
|
91
|
+
"/tmp/pragma-test-cache",
|
|
92
|
+
"refs",
|
|
93
|
+
"@canonical/design-system",
|
|
94
|
+
"v1_beta",
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform path resolution for pragma cache and global config.
|
|
3
|
+
*
|
|
4
|
+
* Respects XDG Base Directory Specification:
|
|
5
|
+
* - XDG_CACHE_HOME for cache (default: ~/.cache)
|
|
6
|
+
* - XDG_CONFIG_HOME for global config (default: ~/.config)
|
|
7
|
+
*
|
|
8
|
+
* Override via PRAGMA_CACHE_DIR for CI and testing.
|
|
9
|
+
* Uses os.homedir() for macOS/WSL/Linux portability.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Root directory for pragma cache data.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order:
|
|
19
|
+
* 1. PRAGMA_CACHE_DIR environment variable (CI/testing override)
|
|
20
|
+
* 2. XDG_CACHE_HOME/pragma (XDG spec)
|
|
21
|
+
* 3. ~/.cache/pragma (default)
|
|
22
|
+
*/
|
|
23
|
+
export function cacheRoot(): string {
|
|
24
|
+
if (process.env.PRAGMA_CACHE_DIR) {
|
|
25
|
+
return process.env.PRAGMA_CACHE_DIR;
|
|
26
|
+
}
|
|
27
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
28
|
+
const base = xdg ?? join(homedir(), ".cache");
|
|
29
|
+
return join(base, "pragma");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Root directory for pragma global configuration.
|
|
34
|
+
*
|
|
35
|
+
* Resolution order:
|
|
36
|
+
* 1. XDG_CONFIG_HOME/pragma (XDG spec)
|
|
37
|
+
* 2. ~/.config/pragma (default)
|
|
38
|
+
*/
|
|
39
|
+
export function globalConfigDir(): string {
|
|
40
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
41
|
+
const base = xdg ?? join(homedir(), ".config");
|
|
42
|
+
return join(base, "pragma");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cache directory for a specific git-ref-resolved package.
|
|
47
|
+
*
|
|
48
|
+
* @param pkg - Package name (e.g., "@canonical/design-system").
|
|
49
|
+
* @param ref - Git ref (e.g., "main", "v0.3.0", "abc1234").
|
|
50
|
+
* @returns Absolute path to the cached clone directory.
|
|
51
|
+
*/
|
|
52
|
+
export function gitCacheDir(pkg: string, ref: string): string {
|
|
53
|
+
const sanitizedRef = sanitizeRef(ref);
|
|
54
|
+
return join(cacheRoot(), "refs", pkg, sanitizedRef);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sanitize a git ref for use as a directory name.
|
|
59
|
+
* Replaces characters that are invalid or confusing in path segments.
|
|
60
|
+
*/
|
|
61
|
+
function sanitizeRef(ref: string): string {
|
|
62
|
+
return ref.replace(/[/\\:*?"<>|]/g, "_");
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, 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 } from "vitest";
|
|
5
|
+
import readGlobalRefs from "./readGlobalRefs.js";
|
|
6
|
+
|
|
7
|
+
describe("readGlobalRefs", () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
const origEnv = { ...process.env };
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = mkdtempSync(join(tmpdir(), "pragma-global-refs-"));
|
|
13
|
+
// Point XDG_CONFIG_HOME to our temp dir so globalConfigDir() resolves there
|
|
14
|
+
process.env.XDG_CONFIG_HOME = tmpDir;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
process.env = { ...origEnv };
|
|
19
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns empty array when refs.json does not exist", () => {
|
|
23
|
+
expect(readGlobalRefs()).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns empty array when refs.json is malformed JSON", () => {
|
|
27
|
+
const dir = join(tmpDir, "pragma");
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
writeFileSync(join(dir, "refs.json"), "{bad json");
|
|
30
|
+
expect(readGlobalRefs()).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns empty array when packages field is missing", () => {
|
|
34
|
+
const dir = join(tmpDir, "pragma");
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
writeFileSync(join(dir, "refs.json"), "{}");
|
|
37
|
+
expect(readGlobalRefs()).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns empty array when packages is not an array", () => {
|
|
41
|
+
const dir = join(tmpDir, "pragma");
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(dir, "refs.json"),
|
|
45
|
+
JSON.stringify({ packages: "not-array" }),
|
|
46
|
+
);
|
|
47
|
+
expect(readGlobalRefs()).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns packages array from valid refs.json", () => {
|
|
51
|
+
const dir = join(tmpDir, "pragma");
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
const packages = [
|
|
54
|
+
"@canonical/design-system",
|
|
55
|
+
{
|
|
56
|
+
name: "@canonical/code-standards",
|
|
57
|
+
source: "file:///home/user/code/code-standards",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
writeFileSync(join(dir, "refs.json"), JSON.stringify({ packages }));
|
|
61
|
+
expect(readGlobalRefs()).toEqual(packages);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read global package refs from ~/.config/pragma/refs.json.
|
|
3
|
+
*
|
|
4
|
+
* Global refs provide user-level overrides that apply to all projects.
|
|
5
|
+
* Project-level `pragma.config.json` overrides global refs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { RawPackageEntry } from "./parseRef.js";
|
|
11
|
+
import { globalConfigDir } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read global package references.
|
|
15
|
+
*
|
|
16
|
+
* @returns The `packages` array from `~/.config/pragma/refs.json`, or
|
|
17
|
+
* an empty array when the file is missing or malformed.
|
|
18
|
+
*/
|
|
19
|
+
export default function readGlobalRefs(): ReadonlyArray<RawPackageEntry> {
|
|
20
|
+
const refsPath = join(globalConfigDir(), "refs.json");
|
|
21
|
+
|
|
22
|
+
let raw: string;
|
|
23
|
+
try {
|
|
24
|
+
raw = readFileSync(refsPath, "utf-8");
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
31
|
+
const packages = parsed.packages;
|
|
32
|
+
if (!Array.isArray(packages)) return [];
|
|
33
|
+
return packages as RawPackageEntry[];
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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 updateRefs from "./updateRefs.js";
|
|
7
|
+
|
|
8
|
+
describe("updateRefs", () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let projectDir: string;
|
|
11
|
+
let bareRepo: string;
|
|
12
|
+
const origEnv = { ...process.env };
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = mkdtempSync(join(tmpdir(), "pragma-update-refs-"));
|
|
16
|
+
projectDir = join(tmpDir, "project");
|
|
17
|
+
mkdirSync(projectDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
// Point cache to temp dir
|
|
20
|
+
process.env.PRAGMA_CACHE_DIR = join(tmpDir, "cache");
|
|
21
|
+
// No global refs
|
|
22
|
+
process.env.XDG_CONFIG_HOME = join(tmpDir, "no-global-config");
|
|
23
|
+
|
|
24
|
+
// Create a bare git repo for testing
|
|
25
|
+
bareRepo = join(tmpDir, "origin.git");
|
|
26
|
+
execFileSync("git", ["init", "--bare", "-b", "main", bareRepo], {
|
|
27
|
+
stdio: "pipe",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const workDir = join(tmpDir, "work");
|
|
31
|
+
execFileSync("git", ["clone", bareRepo, workDir], { stdio: "pipe" });
|
|
32
|
+
execFileSync("git", ["-C", workDir, "config", "user.email", "t@t.com"], {
|
|
33
|
+
stdio: "pipe",
|
|
34
|
+
});
|
|
35
|
+
execFileSync("git", ["-C", workDir, "config", "user.name", "T"], {
|
|
36
|
+
stdio: "pipe",
|
|
37
|
+
});
|
|
38
|
+
execFileSync(
|
|
39
|
+
"git",
|
|
40
|
+
["-C", workDir, "commit", "--allow-empty", "-m", "init"],
|
|
41
|
+
{ stdio: "pipe" },
|
|
42
|
+
);
|
|
43
|
+
execFileSync("git", ["-C", workDir, "push", "origin", "main"], {
|
|
44
|
+
stdio: "pipe",
|
|
45
|
+
});
|
|
46
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
process.env = { ...origEnv };
|
|
51
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("clones a git ref on first run", async () => {
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(projectDir, "pragma.config.json"),
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
packages: [
|
|
59
|
+
{
|
|
60
|
+
name: "@test/pkg",
|
|
61
|
+
source: `git+file://${bareRepo}#main`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const results = await updateRefs({ cwd: projectDir });
|
|
68
|
+
expect(results).toHaveLength(1);
|
|
69
|
+
expect(results[0].kind).toBe("cloned");
|
|
70
|
+
expect(results[0].pkg).toBe("@test/pkg");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reports up-to-date on second run without changes", async () => {
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(projectDir, "pragma.config.json"),
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
packages: [
|
|
78
|
+
{
|
|
79
|
+
name: "@test/pkg",
|
|
80
|
+
source: `git+file://${bareRepo}#main`,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await updateRefs({ cwd: projectDir });
|
|
87
|
+
const results = await updateRefs({ cwd: projectDir });
|
|
88
|
+
expect(results).toHaveLength(1);
|
|
89
|
+
expect(results[0].kind).toBe("up-to-date");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports ok for file:// ref pointing to existing path", async () => {
|
|
93
|
+
const localPkg = join(tmpDir, "local-pkg");
|
|
94
|
+
mkdirSync(localPkg, { recursive: true });
|
|
95
|
+
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(projectDir, "pragma.config.json"),
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
packages: [{ name: "@test/local", source: `file://${localPkg}` }],
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const results = await updateRefs({ cwd: projectDir });
|
|
104
|
+
expect(results).toHaveLength(1);
|
|
105
|
+
expect(results[0].kind).toBe("ok");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports error for file:// ref pointing to missing path", async () => {
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(projectDir, "pragma.config.json"),
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
packages: [
|
|
113
|
+
{ name: "@test/missing", source: "file:///nonexistent/path" },
|
|
114
|
+
],
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const results = await updateRefs({ cwd: projectDir });
|
|
119
|
+
expect(results).toHaveLength(1);
|
|
120
|
+
expect(results[0].kind).toBe("error");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("skips npm packages", async () => {
|
|
124
|
+
writeFileSync(
|
|
125
|
+
join(projectDir, "pragma.config.json"),
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
packages: ["@canonical/design-system"],
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const results = await updateRefs({ cwd: projectDir });
|
|
132
|
+
expect(results).toHaveLength(1);
|
|
133
|
+
expect(results[0].kind).toBe("skipped");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("filters by --package flag", async () => {
|
|
137
|
+
writeFileSync(
|
|
138
|
+
join(projectDir, "pragma.config.json"),
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
packages: [
|
|
141
|
+
"@canonical/design-system",
|
|
142
|
+
{
|
|
143
|
+
name: "@test/pkg",
|
|
144
|
+
source: `git+file://${bareRepo}#main`,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const results = await updateRefs({
|
|
151
|
+
cwd: projectDir,
|
|
152
|
+
package: "@test/pkg",
|
|
153
|
+
});
|
|
154
|
+
expect(results).toHaveLength(1);
|
|
155
|
+
expect(results[0].pkg).toBe("@test/pkg");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("uses defaults when no config exists", async () => {
|
|
159
|
+
const results = await updateRefs({ cwd: projectDir });
|
|
160
|
+
// All 4 defaults are npm, so all skipped
|
|
161
|
+
expect(results.every((r) => r.kind === "skipped")).toBe(true);
|
|
162
|
+
expect(results).toHaveLength(4);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update operation: fetch/clone git refs into the local cache.
|
|
3
|
+
*
|
|
4
|
+
* Reads project + global config, merges, and for each git ref either
|
|
5
|
+
* clones (first time) or fetches (subsequent). Reports per-package status.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { readConfig } from "#config";
|
|
11
|
+
import { DEFAULT_PACKAGES } from "../../shared/packages.js";
|
|
12
|
+
import { cloneRef, fetchRef, pruneCache } from "./gitOps.js";
|
|
13
|
+
import type { PackageRef, RawPackageEntry } from "./parseRef.js";
|
|
14
|
+
import { parsePackageEntry } from "./parseRef.js";
|
|
15
|
+
import { cacheRoot, gitCacheDir } from "./paths.js";
|
|
16
|
+
import readGlobalRefs from "./readGlobalRefs.js";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface UpdateResult {
|
|
23
|
+
readonly pkg: string;
|
|
24
|
+
readonly kind:
|
|
25
|
+
| "cloned"
|
|
26
|
+
| "updated"
|
|
27
|
+
| "up-to-date"
|
|
28
|
+
| "ok"
|
|
29
|
+
| "skipped"
|
|
30
|
+
| "error";
|
|
31
|
+
readonly detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UpdateRefsOptions {
|
|
35
|
+
/** Working directory for reading project config. */
|
|
36
|
+
cwd: string;
|
|
37
|
+
/** Update only this package (by name). */
|
|
38
|
+
package?: string;
|
|
39
|
+
/** Remove orphaned cache directories. */
|
|
40
|
+
prune?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Operation
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Update cached git refs.
|
|
49
|
+
*
|
|
50
|
+
* For each configured package:
|
|
51
|
+
* - git refs: clone or fetch into cache
|
|
52
|
+
* - file refs: verify the path exists
|
|
53
|
+
* - npm refs: skip (nothing to update)
|
|
54
|
+
*/
|
|
55
|
+
export default async function updateRefs(
|
|
56
|
+
options: UpdateRefsOptions,
|
|
57
|
+
): Promise<UpdateResult[]> {
|
|
58
|
+
const config = readConfig(options.cwd);
|
|
59
|
+
const refs = mergeEntries(config.packages);
|
|
60
|
+
const results: UpdateResult[] = [];
|
|
61
|
+
|
|
62
|
+
for (const ref of refs) {
|
|
63
|
+
if (options.package && ref.pkg !== options.package) continue;
|
|
64
|
+
|
|
65
|
+
switch (ref.kind) {
|
|
66
|
+
case "git": {
|
|
67
|
+
const dest = gitCacheDir(ref.pkg, ref.ref);
|
|
68
|
+
try {
|
|
69
|
+
if (existsSync(dest)) {
|
|
70
|
+
const { updated, oldHead, newHead } = fetchRef(
|
|
71
|
+
ref.url,
|
|
72
|
+
ref.ref,
|
|
73
|
+
dest,
|
|
74
|
+
);
|
|
75
|
+
results.push({
|
|
76
|
+
pkg: ref.pkg,
|
|
77
|
+
kind: updated ? "updated" : "up-to-date",
|
|
78
|
+
detail: updated
|
|
79
|
+
? `${oldHead.slice(0, 7)} → ${newHead.slice(0, 7)}`
|
|
80
|
+
: `at ${newHead.slice(0, 7)}`,
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
cloneRef(ref.url, ref.ref, dest);
|
|
84
|
+
results.push({
|
|
85
|
+
pkg: ref.pkg,
|
|
86
|
+
kind: "cloned",
|
|
87
|
+
detail: `${ref.url}#${ref.ref}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
results.push({
|
|
92
|
+
pkg: ref.pkg,
|
|
93
|
+
kind: "error",
|
|
94
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case "file": {
|
|
101
|
+
results.push({
|
|
102
|
+
pkg: ref.pkg,
|
|
103
|
+
kind: existsSync(ref.path) ? "ok" : "error",
|
|
104
|
+
detail: existsSync(ref.path)
|
|
105
|
+
? `path exists: ${ref.path}`
|
|
106
|
+
: `path not found: ${ref.path}`,
|
|
107
|
+
});
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "npm": {
|
|
112
|
+
results.push({
|
|
113
|
+
pkg: ref.pkg,
|
|
114
|
+
kind: "skipped",
|
|
115
|
+
detail: "using npm (no ref configured)",
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (options.prune) {
|
|
123
|
+
const pruned = pruneOrphanedCaches(refs);
|
|
124
|
+
for (const dir of pruned) {
|
|
125
|
+
results.push({
|
|
126
|
+
pkg: "(orphan)",
|
|
127
|
+
kind: "ok",
|
|
128
|
+
detail: `pruned: ${dir}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Helpers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/** Merge global + project entries, parse into PackageRef[]. */
|
|
141
|
+
function mergeEntries(
|
|
142
|
+
projectPackages?: ReadonlyArray<RawPackageEntry>,
|
|
143
|
+
): PackageRef[] {
|
|
144
|
+
const globalEntries = readGlobalRefs();
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
(!projectPackages || projectPackages.length === 0) &&
|
|
148
|
+
globalEntries.length === 0
|
|
149
|
+
) {
|
|
150
|
+
return DEFAULT_PACKAGES.map((pkg) => parsePackageEntry(pkg));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const merged = new Map<string, RawPackageEntry>();
|
|
154
|
+
|
|
155
|
+
for (const pkg of DEFAULT_PACKAGES) {
|
|
156
|
+
merged.set(pkg, pkg);
|
|
157
|
+
}
|
|
158
|
+
for (const entry of globalEntries) {
|
|
159
|
+
const name = typeof entry === "string" ? entry : entry.name;
|
|
160
|
+
merged.set(name, entry);
|
|
161
|
+
}
|
|
162
|
+
if (projectPackages) {
|
|
163
|
+
merged.clear();
|
|
164
|
+
for (const entry of projectPackages) {
|
|
165
|
+
const name = typeof entry === "string" ? entry : entry.name;
|
|
166
|
+
merged.set(name, entry);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return [...merged.values()].map(parsePackageEntry);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Remove cache directories that don't match any configured git ref.
|
|
175
|
+
* Returns list of removed directories.
|
|
176
|
+
*/
|
|
177
|
+
function pruneOrphanedCaches(refs: PackageRef[]): string[] {
|
|
178
|
+
const validDirs = new Set(
|
|
179
|
+
refs
|
|
180
|
+
.filter(
|
|
181
|
+
(r): r is Extract<PackageRef, { kind: "git" }> => r.kind === "git",
|
|
182
|
+
)
|
|
183
|
+
.map((r) => gitCacheDir(r.pkg, r.ref)),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const refsDir = join(cacheRoot(), "refs");
|
|
187
|
+
if (!existsSync(refsDir)) return [];
|
|
188
|
+
|
|
189
|
+
const pruned: string[] = [];
|
|
190
|
+
|
|
191
|
+
// Walk two levels: @scope/package/ref/
|
|
192
|
+
for (const scope of safeReaddir(refsDir)) {
|
|
193
|
+
const scopeDir = join(refsDir, scope);
|
|
194
|
+
for (const pkg of safeReaddir(scopeDir)) {
|
|
195
|
+
const pkgDir = join(scopeDir, pkg);
|
|
196
|
+
for (const ref of safeReaddir(pkgDir)) {
|
|
197
|
+
const refDir = join(pkgDir, ref);
|
|
198
|
+
if (!validDirs.has(refDir)) {
|
|
199
|
+
pruneCache(refDir);
|
|
200
|
+
pruned.push(refDir);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return pruned;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function safeReaddir(dir: string): string[] {
|
|
210
|
+
try {
|
|
211
|
+
return readdirSync(dir);
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|