@juicesharp/rpiv-pi 0.9.1 → 0.11.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.
@@ -0,0 +1,194 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { BUNDLED_AGENTS_DIR, syncBundledAgents } from "./agents.js";
15
+
16
+ let cwd: string;
17
+ let targetDir: string;
18
+ let manifestPath: string;
19
+
20
+ beforeEach(() => {
21
+ cwd = mkdtempSync(join(tmpdir(), "rpiv-agents-"));
22
+ targetDir = join(cwd, ".pi", "agents");
23
+ manifestPath = join(targetDir, ".rpiv-managed.json");
24
+ });
25
+ afterEach(() => {
26
+ rmSync(cwd, { recursive: true, force: true });
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ describe("syncBundledAgents — first run (empty target)", () => {
31
+ it("copies every source .md and writes manifest", () => {
32
+ const r = syncBundledAgents(cwd, false);
33
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
34
+ expect(r.added.sort()).toEqual(bundled.sort());
35
+ expect(r.updated).toEqual([]);
36
+ expect(r.errors).toEqual([]);
37
+ expect(existsSync(manifestPath)).toBe(true);
38
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
39
+ expect(manifest.sort()).toEqual(bundled.sort());
40
+ });
41
+ });
42
+
43
+ describe("syncBundledAgents — bootstrap-claim from manifest-less drift", () => {
44
+ it("claims pre-existing files matching bundled names as managed", () => {
45
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
46
+ if (bundled.length === 0) return;
47
+ mkdirSync(targetDir, { recursive: true });
48
+ writeFileSync(join(targetDir, bundled[0]), "drift content", "utf-8");
49
+ const r = syncBundledAgents(cwd, false);
50
+ expect(r.pendingUpdate).toContain(bundled[0]);
51
+ expect(readFileSync(join(targetDir, bundled[0]), "utf-8")).toBe("drift content");
52
+ });
53
+ });
54
+
55
+ describe("syncBundledAgents — apply=false (detect only)", () => {
56
+ it("reports pendingUpdate for changed managed files without touching them", () => {
57
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
58
+ if (bundled.length === 0) return;
59
+ syncBundledAgents(cwd, true);
60
+ writeFileSync(join(targetDir, bundled[0]), "user-modified", "utf-8");
61
+ const r = syncBundledAgents(cwd, false);
62
+ expect(r.pendingUpdate).toContain(bundled[0]);
63
+ expect(readFileSync(join(targetDir, bundled[0]), "utf-8")).toBe("user-modified");
64
+ });
65
+ });
66
+
67
+ describe("syncBundledAgents — apply=true (mutating sync)", () => {
68
+ it("overwrites changed managed files", () => {
69
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
70
+ if (bundled.length === 0) return;
71
+ syncBundledAgents(cwd, true);
72
+ writeFileSync(join(targetDir, bundled[0]), "user-modified", "utf-8");
73
+ const r = syncBundledAgents(cwd, true);
74
+ expect(r.updated).toContain(bundled[0]);
75
+ const srcContent = readFileSync(join(BUNDLED_AGENTS_DIR, bundled[0]), "utf-8");
76
+ expect(readFileSync(join(targetDir, bundled[0]), "utf-8")).toBe(srcContent);
77
+ });
78
+
79
+ it("removes stale managed files absent from source", () => {
80
+ mkdirSync(targetDir, { recursive: true });
81
+ writeFileSync(join(targetDir, "stale.md"), "x", "utf-8");
82
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
83
+ const r = syncBundledAgents(cwd, true);
84
+ expect(r.removed).toContain("stale.md");
85
+ expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
86
+ });
87
+
88
+ it("leaves unchanged managed files alone", () => {
89
+ syncBundledAgents(cwd, true);
90
+ const r = syncBundledAgents(cwd, true);
91
+ expect(r.updated).toEqual([]);
92
+ expect(r.unchanged.length).toBeGreaterThan(0);
93
+ });
94
+ });
95
+
96
+ describe("syncBundledAgents — error paths", () => {
97
+ it.skipIf(process.platform === "win32")("collects copy error when dest is read-only", () => {
98
+ // Create a read-only target dir so copyFileSync fails with EACCES/EPERM
99
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
100
+ if (bundled.length === 0) return;
101
+ mkdirSync(targetDir, { recursive: true });
102
+ chmodSync(targetDir, 0o500);
103
+ try {
104
+ const r = syncBundledAgents(cwd, false);
105
+ // At least one copy op should have failed; otherwise nothing proves the error path
106
+ const errorTripped = r.errors.some((e) => e.op === "copy") || r.added.length < bundled.length;
107
+ expect(errorTripped).toBe(true);
108
+ } finally {
109
+ chmodSync(targetDir, 0o700);
110
+ }
111
+ });
112
+ });
113
+
114
+ describe("syncBundledAgents — stale-file detection (apply=false)", () => {
115
+ it("reports pendingRemove when a managed file has no matching source", () => {
116
+ mkdirSync(targetDir, { recursive: true });
117
+ writeFileSync(join(targetDir, "stale.md"), "x", "utf-8");
118
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
119
+ const r = syncBundledAgents(cwd, false);
120
+ expect(r.pendingRemove).toContain("stale.md");
121
+ expect(r.removed).toEqual([]);
122
+ expect(existsSync(join(targetDir, "stale.md"))).toBe(true);
123
+ });
124
+
125
+ it("keeps pendingRemove entries in the manifest so the next apply can finish removal", () => {
126
+ mkdirSync(targetDir, { recursive: true });
127
+ writeFileSync(join(targetDir, "stale.md"), "x", "utf-8");
128
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
129
+ syncBundledAgents(cwd, false);
130
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as string[];
131
+ expect(manifest).toContain("stale.md");
132
+ });
133
+
134
+ it("skips pendingRemove when the stale file no longer exists on disk", () => {
135
+ mkdirSync(targetDir, { recursive: true });
136
+ // Manifest claims stale.md but disk does not have it
137
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
138
+ const r = syncBundledAgents(cwd, false);
139
+ expect(r.pendingRemove).not.toContain("stale.md");
140
+ expect(r.removed).not.toContain("stale.md");
141
+ });
142
+ });
143
+
144
+ describe("syncBundledAgents — manifest robustness", () => {
145
+ it("treats a corrupt manifest (invalid JSON) as empty and re-bootstraps", () => {
146
+ mkdirSync(targetDir, { recursive: true });
147
+ writeFileSync(manifestPath, "{ not json ::", "utf-8");
148
+ const r = syncBundledAgents(cwd, false);
149
+ expect(r.errors).toEqual([]);
150
+ // After sync, the manifest should be valid JSON again.
151
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as string[];
152
+ expect(Array.isArray(manifest)).toBe(true);
153
+ });
154
+
155
+ it("treats a non-array manifest as empty and re-bootstraps", () => {
156
+ mkdirSync(targetDir, { recursive: true });
157
+ writeFileSync(manifestPath, JSON.stringify({ oops: true }), "utf-8");
158
+ const r = syncBundledAgents(cwd, false);
159
+ expect(r.errors).toEqual([]);
160
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as string[];
161
+ expect(Array.isArray(manifest)).toBe(true);
162
+ });
163
+
164
+ it("filters non-string manifest entries during parse", () => {
165
+ mkdirSync(targetDir, { recursive: true });
166
+ writeFileSync(join(targetDir, "unrelated.md"), "keep me", "utf-8");
167
+ // Write manifest containing mixed types (must be ignored per-entry rather than whole-file)
168
+ writeFileSync(manifestPath, JSON.stringify([42, null, "unrelated.md"]), "utf-8");
169
+ const r = syncBundledAgents(cwd, false);
170
+ expect(r.errors).toEqual([]);
171
+ // unrelated.md is not in source, so it will be tracked for pendingRemove
172
+ expect(r.pendingRemove).toContain("unrelated.md");
173
+ });
174
+ });
175
+
176
+ describe("syncBundledAgents — subsequent-run bookkeeping", () => {
177
+ it("reports unchanged (not added) on a second run with no changes", () => {
178
+ syncBundledAgents(cwd, true);
179
+ const r = syncBundledAgents(cwd, false);
180
+ expect(r.added).toEqual([]);
181
+ expect(r.updated).toEqual([]);
182
+ expect(r.pendingUpdate).toEqual([]);
183
+ expect(r.unchanged.length).toBeGreaterThan(0);
184
+ });
185
+
186
+ it("treats a destination file that was manually removed as a new add on next sync", () => {
187
+ syncBundledAgents(cwd, true);
188
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
189
+ if (bundled.length === 0) return;
190
+ rmSync(join(targetDir, bundled[0]));
191
+ const r = syncBundledAgents(cwd, false);
192
+ expect(r.added).toContain(bundled[0]);
193
+ });
194
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FLAG_DEBUG, MSG_TYPE_GIT_CONTEXT, MSG_TYPE_GUIDANCE } from "./constants.js";
3
+
4
+ describe("rpiv-core constants", () => {
5
+ it("FLAG_DEBUG is the canonical debug-flag name", () => {
6
+ expect(FLAG_DEBUG).toBe("rpiv-debug");
7
+ });
8
+ it("MSG_TYPE_GIT_CONTEXT is the canonical git-context message type", () => {
9
+ expect(MSG_TYPE_GIT_CONTEXT).toBe("rpiv-git-context");
10
+ });
11
+ it("MSG_TYPE_GUIDANCE is the canonical guidance message type", () => {
12
+ expect(MSG_TYPE_GUIDANCE).toBe("rpiv-guidance");
13
+ });
14
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isGitMutatingCommand } from "./git-context.js";
3
+
4
+ describe("isGitMutatingCommand — positives", () => {
5
+ const mutating = [
6
+ "git checkout main",
7
+ "git switch feature",
8
+ "git commit -m 'x'",
9
+ "git merge main",
10
+ "git rebase main",
11
+ "git pull",
12
+ "git reset --hard HEAD",
13
+ "git revert abc",
14
+ "git cherry-pick abc",
15
+ "git worktree add ../wt",
16
+ "git am < patch",
17
+ "git stash",
18
+ ];
19
+ for (const cmd of mutating) {
20
+ it(`matches: ${cmd}`, () => {
21
+ expect(isGitMutatingCommand(cmd)).toBe(true);
22
+ });
23
+ }
24
+ it("matches when chained with preceding command", () => {
25
+ expect(isGitMutatingCommand("cd x && git commit")).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("isGitMutatingCommand — negatives", () => {
30
+ const nonMutating = [
31
+ "git status",
32
+ "git log",
33
+ "git diff",
34
+ "git rev-parse HEAD",
35
+ "git config user.name",
36
+ "gitmoji commit",
37
+ "git --version",
38
+ ];
39
+ for (const cmd of nonMutating) {
40
+ it(`does NOT match: ${cmd}`, () => {
41
+ expect(isGitMutatingCommand(cmd)).toBe(false);
42
+ });
43
+ }
44
+ it("rejects empty string", () => {
45
+ expect(isGitMutatingCommand("")).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,109 @@
1
+ import { createMockPi, stubGitExec } from "@juicesharp/rpiv-test-utils";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { clearGitContextCache, getGitContext, resetInjectedMarker, takeGitContextIfChanged } from "./git-context.js";
4
+
5
+ beforeEach(() => {
6
+ clearGitContextCache();
7
+ resetInjectedMarker();
8
+ });
9
+
10
+ describe("getGitContext", () => {
11
+ it("parses branch + commit + user from three exec calls", async () => {
12
+ const { pi } = createMockPi({
13
+ exec: stubGitExec({ branch: "main", commit: "abc1234", user: "alice" }) as never,
14
+ });
15
+ const ctx = await getGitContext(pi);
16
+ expect(ctx).toEqual({ branch: "main", commit: "abc1234", user: "alice" });
17
+ });
18
+
19
+ it("remaps literal HEAD to 'detached'", async () => {
20
+ const { pi } = createMockPi({
21
+ exec: stubGitExec({ branch: "HEAD", commit: "abc", user: "alice" }) as never,
22
+ });
23
+ const ctx = await getGitContext(pi);
24
+ expect(ctx?.branch).toBe("detached");
25
+ });
26
+
27
+ it("returns null when both branch and commit are empty (not a repo)", async () => {
28
+ const { pi } = createMockPi({ exec: stubGitExec({}) as never });
29
+ expect(await getGitContext(pi)).toBeNull();
30
+ });
31
+
32
+ it("falls back to process.env.USER when git config user.name errors", async () => {
33
+ const { pi } = createMockPi({
34
+ exec: stubGitExec({ branch: "main", commit: "abc", userError: new Error("no config") }) as never,
35
+ });
36
+ process.env.USER = "env-alice";
37
+ const ctx = await getGitContext(pi);
38
+ expect(ctx?.user).toBe("env-alice");
39
+ });
40
+
41
+ it("falls back to 'unknown' when neither git nor env has user", async () => {
42
+ const origUser = process.env.USER;
43
+ delete process.env.USER;
44
+ try {
45
+ const { pi } = createMockPi({
46
+ exec: stubGitExec({ branch: "main", commit: "abc", userError: new Error("x") }) as never,
47
+ });
48
+ const ctx = await getGitContext(pi);
49
+ expect(ctx?.user).toBe("unknown");
50
+ } finally {
51
+ if (origUser) process.env.USER = origUser;
52
+ }
53
+ });
54
+
55
+ it("memoises: subsequent calls do not re-exec", async () => {
56
+ const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
57
+ const { pi } = createMockPi({ exec: exec as never });
58
+ await getGitContext(pi);
59
+ await getGitContext(pi);
60
+ expect(exec).toHaveBeenCalledTimes(3); // 3 initial exec calls, no second-round
61
+ });
62
+
63
+ it("clearGitContextCache forces re-read", async () => {
64
+ const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
65
+ const { pi } = createMockPi({ exec: exec as never });
66
+ await getGitContext(pi);
67
+ clearGitContextCache();
68
+ await getGitContext(pi);
69
+ expect(exec).toHaveBeenCalledTimes(6);
70
+ });
71
+ });
72
+
73
+ describe("takeGitContextIfChanged", () => {
74
+ it("returns the context-line on first call", async () => {
75
+ const { pi } = createMockPi({
76
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
77
+ });
78
+ const r = await takeGitContextIfChanged(pi);
79
+ expect(r).toContain("- Branch: main");
80
+ expect(r).toContain("- Commit: abc");
81
+ expect(r).toContain("- User: alice");
82
+ });
83
+
84
+ it("returns null on second call when signature unchanged", async () => {
85
+ const { pi } = createMockPi({
86
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
87
+ });
88
+ await takeGitContextIfChanged(pi);
89
+ expect(await takeGitContextIfChanged(pi)).toBeNull();
90
+ });
91
+
92
+ it("re-emits after clearGitContextCache + resetInjectedMarker + signature change", async () => {
93
+ const { pi } = createMockPi({
94
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
95
+ });
96
+ await takeGitContextIfChanged(pi);
97
+ clearGitContextCache();
98
+ resetInjectedMarker();
99
+ const { pi: pi2 } = createMockPi({
100
+ exec: stubGitExec({ branch: "feature", commit: "def", user: "alice" }) as never,
101
+ });
102
+ expect(await takeGitContextIfChanged(pi2)).not.toBeNull();
103
+ });
104
+
105
+ it("returns null when not in a git repo", async () => {
106
+ const { pi } = createMockPi({ exec: stubGitExec({}) as never });
107
+ expect(await takeGitContextIfChanged(pi)).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,136 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createMockPi, writeGuidanceTree } from "@juicesharp/rpiv-test-utils";
5
+ import { afterEach, beforeEach, describe, expect, it, type vi } from "vitest";
6
+ import { clearInjectionState, handleToolCallGuidance, injectRootGuidance, resolveGuidance } from "./guidance.js";
7
+
8
+ let projectDir: string;
9
+
10
+ beforeEach(() => {
11
+ projectDir = mkdtempSync(join(tmpdir(), "rpiv-guidance-"));
12
+ clearInjectionState();
13
+ });
14
+ afterEach(() => {
15
+ rmSync(projectDir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("resolveGuidance — ladder", () => {
19
+ it("AGENTS.md > CLAUDE.md > architecture.md at depth > 0", () => {
20
+ writeGuidanceTree(projectDir, {
21
+ "src/AGENTS.md": "agents-body",
22
+ "src/CLAUDE.md": "claude-body",
23
+ ".rpiv/guidance/src/architecture.md": "arch-body",
24
+ });
25
+ const resolved = resolveGuidance(join(projectDir, "src", "foo.ts"), projectDir);
26
+ const srcEntry = resolved.find((r) => r.relativePath.startsWith("src/"));
27
+ expect(srcEntry?.kind).toBe("agents");
28
+ });
29
+
30
+ it("depth 0 skips AGENTS/CLAUDE but keeps root architecture.md", () => {
31
+ writeGuidanceTree(projectDir, {
32
+ "AGENTS.md": "root-agents",
33
+ ".rpiv/guidance/architecture.md": "root-arch",
34
+ });
35
+ const resolved = resolveGuidance(join(projectDir, "any", "file.ts"), projectDir);
36
+ const rootEntry = resolved.find((r) => r.relativePath === ".rpiv/guidance/architecture.md");
37
+ expect(rootEntry?.kind).toBe("architecture");
38
+ expect(resolved.some((r) => r.relativePath === "AGENTS.md")).toBe(false);
39
+ });
40
+
41
+ it("returns root-first, specific-last order", () => {
42
+ writeGuidanceTree(projectDir, {
43
+ ".rpiv/guidance/architecture.md": "root",
44
+ "a/AGENTS.md": "a",
45
+ "a/b/AGENTS.md": "ab",
46
+ });
47
+ const resolved = resolveGuidance(join(projectDir, "a", "b", "c.ts"), projectDir);
48
+ expect(resolved.map((r) => r.content)).toEqual(["root", "a", "ab"]);
49
+ });
50
+
51
+ it("returns empty when file is outside projectDir", () => {
52
+ expect(resolveGuidance("/totally/elsewhere/foo.ts", projectDir)).toEqual([]);
53
+ });
54
+
55
+ it("returns empty when nothing exists along the ladder", () => {
56
+ expect(resolveGuidance(join(projectDir, "x.ts"), projectDir)).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe("injectRootGuidance", () => {
61
+ it("sends root architecture.md when present", () => {
62
+ writeGuidanceTree(projectDir, { ".rpiv/guidance/architecture.md": "body" });
63
+ const { pi } = createMockPi();
64
+ injectRootGuidance(projectDir, pi);
65
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
66
+ expect((pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content).toContain("body");
67
+ });
68
+
69
+ it("is idempotent across calls within a session", () => {
70
+ writeGuidanceTree(projectDir, { ".rpiv/guidance/architecture.md": "body" });
71
+ const { pi } = createMockPi();
72
+ injectRootGuidance(projectDir, pi);
73
+ injectRootGuidance(projectDir, pi);
74
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("re-injects after clearInjectionState", () => {
78
+ writeGuidanceTree(projectDir, { ".rpiv/guidance/architecture.md": "body" });
79
+ const { pi } = createMockPi();
80
+ injectRootGuidance(projectDir, pi);
81
+ clearInjectionState();
82
+ injectRootGuidance(projectDir, pi);
83
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
84
+ });
85
+
86
+ it("no-ops when root architecture.md is missing", () => {
87
+ const { pi } = createMockPi();
88
+ injectRootGuidance(projectDir, pi);
89
+ expect(pi.sendMessage).not.toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe("handleToolCallGuidance", () => {
94
+ it("skips non-read/edit/write tools", () => {
95
+ const { pi } = createMockPi();
96
+ handleToolCallGuidance({ toolName: "bash", input: {} }, { cwd: projectDir }, pi);
97
+ expect(pi.sendMessage).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("dedupes per-file across multiple tool_calls", () => {
101
+ writeGuidanceTree(projectDir, { "src/AGENTS.md": "a" });
102
+ const { pi } = createMockPi();
103
+ const ev = { toolName: "read", input: { file_path: join(projectDir, "src", "x.ts") } };
104
+ handleToolCallGuidance(ev, { cwd: projectDir }, pi);
105
+ handleToolCallGuidance(ev, { cwd: projectDir }, pi);
106
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("supports both 'path' and 'file_path' input keys", () => {
110
+ writeGuidanceTree(projectDir, { "src/AGENTS.md": "a" });
111
+ const { pi } = createMockPi();
112
+ handleToolCallGuidance(
113
+ { toolName: "edit", input: { path: join(projectDir, "src", "x.ts") } },
114
+ { cwd: projectDir },
115
+ pi,
116
+ );
117
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it("emits one sendMessage combining multiple newly-resolved files", () => {
121
+ writeGuidanceTree(projectDir, {
122
+ ".rpiv/guidance/architecture.md": "root",
123
+ "src/AGENTS.md": "src",
124
+ });
125
+ const { pi } = createMockPi();
126
+ handleToolCallGuidance(
127
+ { toolName: "write", input: { file_path: join(projectDir, "src", "x.ts") } },
128
+ { cwd: projectDir },
129
+ pi,
130
+ );
131
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
132
+ const content = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content;
133
+ expect(content).toContain("root");
134
+ expect(content).toContain("src");
135
+ });
136
+ });
@@ -0,0 +1,59 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { findMissingSiblings } from "./package-checks.js";
5
+ import { SIBLINGS } from "./siblings.js";
6
+
7
+ const SETTINGS_PATH = join(process.env.HOME!, ".pi", "agent", "settings.json");
8
+
9
+ function writeSettings(contents: unknown) {
10
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
11
+ writeFileSync(SETTINGS_PATH, JSON.stringify(contents), "utf-8");
12
+ }
13
+
14
+ describe("findMissingSiblings", () => {
15
+ it("returns all 7 siblings when settings.json is missing", () => {
16
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
17
+ });
18
+
19
+ it("returns all 7 siblings when JSON is invalid", () => {
20
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
21
+ writeFileSync(SETTINGS_PATH, "{not json", "utf-8");
22
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
23
+ });
24
+
25
+ it("returns all 7 siblings when packages field is absent", () => {
26
+ writeSettings({ other: "data" });
27
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
28
+ });
29
+
30
+ it("returns all 7 siblings when packages is not an array", () => {
31
+ writeSettings({ packages: "not-array" });
32
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
33
+ });
34
+
35
+ it("filters out non-string entries defensively", () => {
36
+ writeSettings({ packages: [null, 42, "@juicesharp/rpiv-todo"] });
37
+ const missing = findMissingSiblings();
38
+ expect(missing.find((s) => s.matches.test("@juicesharp/rpiv-todo"))).toBeUndefined();
39
+ });
40
+
41
+ it("matches case-insensitively", () => {
42
+ writeSettings({ packages: ["@JUICESHARP/RPIV-TODO"] });
43
+ const missing = findMissingSiblings();
44
+ expect(missing.find((s) => s.matches.test("@juicesharp/rpiv-todo"))).toBeUndefined();
45
+ });
46
+
47
+ it("rpiv-args word-boundary: treats rpiv-args-extended as non-install", () => {
48
+ writeSettings({ packages: ["@juicesharp/rpiv-args-extended"] });
49
+ const missing = findMissingSiblings();
50
+ expect(missing.find((s) => s.pkg.endsWith("/rpiv-args"))).toBeDefined();
51
+ });
52
+
53
+ it("returns [] when all 7 siblings are installed", () => {
54
+ writeSettings({
55
+ packages: SIBLINGS.map((s) => s.pkg.replace(/^npm:/, "")),
56
+ });
57
+ expect(findMissingSiblings()).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,100 @@
1
+ import { makeSpawnStub } from "@juicesharp/rpiv-test-utils";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { spawnPiInstall } from "./pi-installer.js";
8
+
9
+ beforeEach(() => {
10
+ vi.mocked(spawn).mockReset();
11
+ });
12
+
13
+ describe("spawnPiInstall — success path", () => {
14
+ it("resolves with exit 0 + buffered stdout/stderr", async () => {
15
+ vi.mocked(spawn).mockImplementationOnce(
16
+ () => makeSpawnStub({ stdout: "installed\n", stderr: "", exitCode: 0 }) as unknown as ReturnType<typeof spawn>,
17
+ );
18
+ const r = await spawnPiInstall("@x/y", 30_000);
19
+ expect(r).toEqual({ code: 0, stdout: "installed\n", stderr: "" });
20
+ });
21
+ });
22
+
23
+ describe("spawnPiInstall — non-zero exit", () => {
24
+ it("returns exit code and accumulated stderr", async () => {
25
+ vi.mocked(spawn).mockImplementationOnce(
26
+ () => makeSpawnStub({ stdout: "", stderr: "fail\n", exitCode: 2 }) as unknown as ReturnType<typeof spawn>,
27
+ );
28
+ const r = await spawnPiInstall("@x/y", 30_000);
29
+ expect(r.code).toBe(2);
30
+ expect(r.stderr).toBe("fail\n");
31
+ });
32
+
33
+ it("fallback code=1 when close emits null", async () => {
34
+ const stub = makeSpawnStub({ neverSettles: true });
35
+ vi.mocked(spawn).mockImplementationOnce(() => stub as unknown as ReturnType<typeof spawn>);
36
+ const promise = spawnPiInstall("@x/y", 30_000);
37
+ stub.emit("close", null);
38
+ const r = await promise;
39
+ expect(r.code).toBe(1);
40
+ });
41
+ });
42
+
43
+ describe("spawnPiInstall — error event before close", () => {
44
+ it("settles with code=1 + error.message in stderr", async () => {
45
+ vi.mocked(spawn).mockImplementationOnce(
46
+ () => makeSpawnStub({ error: new Error("ENOENT pi") }) as unknown as ReturnType<typeof spawn>,
47
+ );
48
+ const r = await spawnPiInstall("@x/y", 30_000);
49
+ expect(r.code).toBe(1);
50
+ expect(r.stderr).toContain("ENOENT pi");
51
+ });
52
+ });
53
+
54
+ describe("spawnPiInstall — timeout", () => {
55
+ it("kills with SIGTERM at timeout and resolves with code 124", async () => {
56
+ vi.useFakeTimers();
57
+ const stub = makeSpawnStub({ neverSettles: true });
58
+ const killSpy = vi.spyOn(stub, "kill");
59
+ vi.mocked(spawn).mockImplementationOnce(() => stub as unknown as ReturnType<typeof spawn>);
60
+ const promise = spawnPiInstall("@x/y", 30_000);
61
+ await vi.advanceTimersByTimeAsync(30_000);
62
+ vi.useRealTimers();
63
+ const r = await promise;
64
+ expect(killSpy).toHaveBeenCalledWith("SIGTERM");
65
+ expect(r.code).toBe(124);
66
+ expect(r.stderr).toContain("timed out");
67
+ });
68
+ });
69
+
70
+ describe("spawnPiInstall — settle idempotence", () => {
71
+ it("only resolves once even if close fires after timeout", async () => {
72
+ vi.useFakeTimers();
73
+ const stub = makeSpawnStub({ neverSettles: true });
74
+ vi.mocked(spawn).mockImplementationOnce(() => stub as unknown as ReturnType<typeof spawn>);
75
+ const promise = spawnPiInstall("@x/y", 30_000);
76
+ await vi.advanceTimersByTimeAsync(30_000);
77
+ stub.emit("close", 0); // late close — must not replace the timeout result
78
+ vi.useRealTimers();
79
+ const r = await promise;
80
+ expect(r.code).toBe(124);
81
+ });
82
+ });
83
+
84
+ describe("spawnPiInstall — Windows branch", () => {
85
+ it("invokes via cmd.exe /c pi install on win32", async () => {
86
+ const origPlatform = process.platform;
87
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
88
+ try {
89
+ vi.mocked(spawn).mockImplementationOnce(
90
+ () => makeSpawnStub({ exitCode: 0 }) as unknown as ReturnType<typeof spawn>,
91
+ );
92
+ await spawnPiInstall("@x/y", 30_000);
93
+ const firstCall = vi.mocked(spawn).mock.calls[0];
94
+ expect(firstCall[0]).toBe("cmd.exe");
95
+ expect(firstCall[1]).toEqual(["/c", "pi", "install", "@x/y"]);
96
+ } finally {
97
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
98
+ }
99
+ });
100
+ });