@jaybeeuu/agent-cortex 1.41.1 → 1.43.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/.agents/skills/style-versioning/SKILL.md +73 -2
- package/.github/workflows/ci.yml +52 -32
- package/AGENTS.md +23 -2
- package/CHANGELOG.md +189 -0
- package/README.md +125 -73
- package/agents/README.md +17 -4
- package/agents/ralph-plan/agent.md +7 -3
- package/agents/ralph-plan.agent.md +7 -3
- package/bin/agent-cortex.mjs +48 -30
- package/bin/installers/pi.mjs +885 -237
- package/claude-extras/scripts/statusline-command.sh +0 -0
- package/docs/claude-hooks.md +5 -4
- package/docs/commit-signing.md +231 -0
- package/docs/ideas/ban-content-assertion-tests.md +3 -1
- package/docs/ideas/dedicated-review-tests-skill.md +71 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
- package/docs/ideas/extension-manifests.md +5 -0
- package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +71 -0
- package/docs/research/disable-model-invocation.md +103 -0
- package/docs/research/skill-design-research.md +2 -2
- package/docs/skills/skill-anatomy.md +5 -1
- package/docs/technical-direction/harness-config.md +25 -1
- package/docs/technical-direction/migration-execution-labels.md +17 -9
- package/extensions/agent-modes/discover.test.ts +19 -0
- package/extensions/agent-modes/discover.ts +25 -3
- package/extensions/session-start/README.md +38 -0
- package/extensions/session-start/index.test.ts +122 -0
- package/extensions/session-start/index.ts +27 -0
- package/extensions/session-start/package.json +19 -0
- package/extensions/session-start/tsconfig.json +12 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/subagent/agents.test.ts +176 -0
- package/extensions/subagent/agents.ts +102 -11
- package/extensions/subagent/index.ts +2 -1
- package/extensions/subagent/package.json +1 -1
- package/lib/cli.mjs +35 -9
- package/package.json +10 -6
- package/pi/settings.json +1 -7
- package/plugin.json +1 -1
- package/pnpm-workspace.yaml +6 -0
- package/scripts/check-changeset.mjs +42 -0
- package/scripts/install-instructions.sh +0 -0
- package/scripts/lib/release-gates.mjs +61 -0
- package/scripts/lib/verify-worker-tree.mjs +107 -0
- package/scripts/resolve-publish-command.mjs +17 -0
- package/scripts/sync-plugin-version.sh +0 -0
- package/skills/engineering/grill-with-docs/SKILL.md +3 -2
- package/skills/engineering/improve-codebase-architecture/REFERENCE.md +5 -0
- package/skills/engineering/improve-codebase-architecture/SKILL.md +2 -1
- package/skills/planning/create-task/SKILL.md +8 -3
- package/skills/planning/create-task/scripts/create-chores.ts +4 -1
- package/skills/planning/prd-to-tasks/REFERENCE.md +2 -2
- package/skills/planning/prd-to-tasks/SKILL.md +9 -4
- package/skills/planning/request-refactor-plan/FORMAT.md +5 -0
- package/skills/planning/request-refactor-plan/SKILL.md +3 -2
- package/skills/planning/spec-to-tickets/REFERENCE.md +82 -0
- package/skills/planning/spec-to-tickets/SKILL.md +143 -0
- package/skills/planning/write-a-prd/FORMAT.md +6 -0
- package/skills/planning/write-a-prd/SKILL.md +9 -6
- package/skills/planning/write-a-ticket/EXAMPLES.md +133 -0
- package/skills/planning/write-a-ticket/SKILL.md +77 -59
- package/skills/productivity/bd-tool/SKILL.md +27 -2
- package/skills/productivity/grill-me/SKILL.md +11 -0
- package/skills/review/review-security/scripts/install-gitleaks.sh +0 -0
- package/skills/workflow/git-workflow/SKILL.md +35 -1
- package/skills/workflow/plan/SKILL.md +20 -8
- package/skills/workflow/ralph/REFERENCE.md +20 -2
- package/skills/workflow/ralph/SKILL.md +4 -1
- package/skills/workflow/run-pipeline-stage/SKILL.md +17 -5
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
- package/skills/workflow/using-agent-skills/SKILL.md +1 -0
- package/test/cli.test.mjs +26 -29
- package/test/create-chores.test.mjs +12 -0
- package/test/fake-bd.test.mjs +72 -0
- package/test/helpers/fake-bd.mjs +26 -6
- package/test/helpers/fake-pi.mjs +132 -0
- package/test/pi-installer.test.mjs +345 -286
- package/test/pi-register.test.mjs +342 -0
- package/test/pi-sweep.test.mjs +619 -0
- package/test/release-gates.test.mjs +190 -0
- package/test/verify-worker-tree.test.mjs +120 -0
- package/token-map.README.md +7 -6
- package/token-map.json +6 -5
- package/extensions/agent-guardrails/node_modules/.bin/pi +0 -21
- package/extensions/agent-guardrails/node_modules/.bin/tsc +0 -21
- package/extensions/agent-guardrails/node_modules/.bin/tsserver +0 -21
- package/extensions/agent-guardrails/node_modules/.bin/tsx +0 -21
- package/extensions/agent-modes/node_modules/.bin/pi +0 -21
- package/extensions/agent-modes/node_modules/.bin/tsc +0 -21
- package/extensions/agent-modes/node_modules/.bin/tsserver +0 -21
- package/extensions/agent-modes/node_modules/.bin/tsx +0 -21
- package/extensions/session-start.ts +0 -44
- package/extensions/skill-stats/node_modules/.bin/pi +0 -21
- package/extensions/skill-stats/node_modules/.bin/tsc +0 -21
- package/extensions/skill-stats/node_modules/.bin/tsserver +0 -21
- package/extensions/skill-stats/node_modules/.bin/tsx +0 -21
- package/extensions/subagent/node_modules/.bin/pi +0 -21
- package/extensions/subagent/node_modules/.bin/pi-ai +0 -21
- package/extensions/subagent/node_modules/.bin/tsc +0 -21
- package/extensions/subagent/node_modules/.bin/tsserver +0 -21
- package/extensions/subagent/node_modules/.bin/tsx +0 -21
- package/lib/pi-packages.mjs +0 -169
- package/skills/planning/create-task/scripts/node_modules/.bin/tsc +0 -21
- package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +0 -21
- package/skills/planning/create-task/scripts/node_modules/.bin/tsx +0 -21
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +0 -21
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +0 -21
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +0 -21
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +0 -21
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +0 -21
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +0 -21
- package/test/pi-packages.test.mjs +0 -295
package/test/cli.test.mjs
CHANGED
|
@@ -123,11 +123,6 @@ describe("parseArgs", () => {
|
|
|
123
123
|
assert.deepStrictEqual(result, { command: "install", harness: "pi", pluginRoot: "/tmp/plugin" });
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
it("parses --no-provision for install", async () => {
|
|
127
|
-
const result = parseArgs(["install", "pi", "--no-provision"]);
|
|
128
|
-
assert.deepStrictEqual(result, { command: "install", harness: "pi", noProvision: true });
|
|
129
|
-
});
|
|
130
|
-
|
|
131
126
|
it("combines multiple install options", async () => {
|
|
132
127
|
const result = parseArgs(["install", "pi", "--dry-run", "--output", "/tmp/out", "--plugin-root", "/tmp/plugin"]);
|
|
133
128
|
assert.deepStrictEqual(result, {
|
|
@@ -236,14 +231,6 @@ describe("CLI integration", () => {
|
|
|
236
231
|
}
|
|
237
232
|
});
|
|
238
233
|
|
|
239
|
-
it("exits 0 for install pi --no-provision", async () => {
|
|
240
|
-
// --no-provision keeps this hermetic: without it the CLI would install the
|
|
241
|
-
// third-party packages declared in the package manifest.
|
|
242
|
-
const { exitCode, stdout } = await runCli(["install", "pi", "--no-provision"]);
|
|
243
|
-
assert.equal(exitCode, 0);
|
|
244
|
-
assert.ok(stdout.includes("pi"));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
234
|
it("exits 1 and prints error for install with no harness", async () => {
|
|
248
235
|
const { exitCode, stderr } = await runCli(["install"]);
|
|
249
236
|
assert.equal(exitCode, 1);
|
|
@@ -256,6 +243,12 @@ describe("CLI integration", () => {
|
|
|
256
243
|
assert.ok(stderr.includes("--bogus"));
|
|
257
244
|
});
|
|
258
245
|
|
|
246
|
+
it("exits 1 for the removed --no-provision flag", async () => {
|
|
247
|
+
const { exitCode, stderr } = await runCli(["install", "pi", "--no-provision"]);
|
|
248
|
+
assert.equal(exitCode, 1);
|
|
249
|
+
assert.ok(stderr.includes("--no-provision"), "the removed flag is named in the error");
|
|
250
|
+
});
|
|
251
|
+
|
|
259
252
|
it("exits 1 and prints error for install with a missing option value", async () => {
|
|
260
253
|
const { exitCode, stderr } = await runCli(["install", "pi", "--output"]);
|
|
261
254
|
assert.equal(exitCode, 1);
|
|
@@ -263,30 +256,33 @@ describe("CLI integration", () => {
|
|
|
263
256
|
});
|
|
264
257
|
|
|
265
258
|
it("exits 0 for install pi --dry-run and writes nothing", async () => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
259
|
+
// A throwaway HOME keeps the registration plan (which reads pi's settings)
|
|
260
|
+
// off the developer's real ~/.pi/agent.
|
|
261
|
+
const fakeHome = await mkdtemp(join(tmpdir(), "cli-pi-dry-run-home-"));
|
|
262
|
+
try {
|
|
263
|
+
const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"], { HOME: fakeHome });
|
|
264
|
+
assert.equal(exitCode, 0);
|
|
265
|
+
assert.ok(stdout.includes("ralph.agent.md"));
|
|
266
|
+
assert.ok(stdout.includes("dry-run"));
|
|
267
|
+
} finally {
|
|
268
|
+
await rm(fakeHome, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it("install pi --dry-run
|
|
273
|
-
// Fake HOME keeps the pi user scope hermetic: both manifest packages are
|
|
274
|
-
// missing, so the plan contains exactly the declared sources.
|
|
272
|
+
it("install pi --dry-run reports the bundled pi packages it would stage", async () => {
|
|
275
273
|
const fakeHome = await mkdtemp(join(tmpdir(), "cli-pi-home-"));
|
|
276
274
|
try {
|
|
277
275
|
const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"], { HOME: fakeHome });
|
|
278
276
|
assert.equal(exitCode, 0);
|
|
279
|
-
assert.ok(stdout.includes("
|
|
280
|
-
assert.ok(
|
|
281
|
-
|
|
282
|
-
"planned pi-web-access shown",
|
|
283
|
-
);
|
|
277
|
+
assert.ok(stdout.includes("Bundled pi package pi-questions"), "pi-questions staged");
|
|
278
|
+
assert.ok(stdout.includes("Bundled pi package pi-web-access"), "pi-web-access staged");
|
|
279
|
+
assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "pi")), "default output is the home staging dir");
|
|
284
280
|
} finally {
|
|
285
281
|
await rm(fakeHome, { recursive: true, force: true });
|
|
286
282
|
}
|
|
287
283
|
});
|
|
288
284
|
|
|
289
|
-
it("exits 0 for install pi --output <tmp> and
|
|
285
|
+
it("exits 0 for install pi --output <tmp> and stages a loadable pi package", async () => {
|
|
290
286
|
const outDir = await mkdtemp(join(tmpdir(), "cli-pi-install-"));
|
|
291
287
|
try {
|
|
292
288
|
const { exitCode, stdout } = await runCli(["install", "pi", "--output", outDir]);
|
|
@@ -294,9 +290,10 @@ describe("CLI integration", () => {
|
|
|
294
290
|
assert.ok(stdout.includes("ralph.agent.md"));
|
|
295
291
|
const ralph = await readFile(join(outDir, "agents", "ralph.agent.md"), "utf-8");
|
|
296
292
|
assert.ok(ralph.includes("agent-cortex:ralph"));
|
|
297
|
-
const
|
|
298
|
-
assert.
|
|
299
|
-
assert.ok(
|
|
293
|
+
const manifest = JSON.parse(await readFile(join(outDir, "package.json"), "utf-8"));
|
|
294
|
+
assert.deepEqual(manifest.keywords, ["pi-package"]);
|
|
295
|
+
assert.ok(manifest.pi.extensions.includes("./extensions"));
|
|
296
|
+
assert.ok(manifest.pi.skills.includes("./skills"));
|
|
300
297
|
} finally {
|
|
301
298
|
await rm(outDir, { recursive: true, force: true });
|
|
302
299
|
}
|
|
@@ -70,4 +70,16 @@ describe("create-chores", () => {
|
|
|
70
70
|
["implementation-type:afk", "stage:code"],
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("validates the PR gate task but not the stage chores", async () => {
|
|
75
|
+
const { ids, beads } = await createChoresAgainstFakeBd(["implementation-type:afk"]);
|
|
76
|
+
|
|
77
|
+
const gate = beads.find((b) => b.id === ids.featurePrReview);
|
|
78
|
+
assert.equal(gate.type, "task");
|
|
79
|
+
assert.equal(gate.validated, true);
|
|
80
|
+
assert.match(gate.description, /## Acceptance Criteria/);
|
|
81
|
+
|
|
82
|
+
const codeChore = beads.find((b) => b.id === ids.code);
|
|
83
|
+
assert.equal(codeChore.validated, false);
|
|
84
|
+
});
|
|
73
85
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Tests for the stateful fake `bd` helper's `--validate` model.
|
|
2
|
+
// The fake mirrors `bd create --validate`: a substantive bead whose description lacks the
|
|
3
|
+
// required section(s) for its type is rejected; chores have no requirements.
|
|
4
|
+
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { makeFakeBd } from "./helpers/fake-bd.mjs";
|
|
13
|
+
|
|
14
|
+
/** Run the fake `bd` binary with the given args. */
|
|
15
|
+
function runFake(bin, state, args) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
execFile(bin, args, { env: { ...process.env, FAKE_BD_STATE: state } }, (error, stdout, stderr) => {
|
|
18
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Create a fresh fake workspace, run `fn`, then remove it. */
|
|
24
|
+
async function withFake(fn) {
|
|
25
|
+
const dir = await mkdtemp(join(tmpdir(), "fake-bd-"));
|
|
26
|
+
try {
|
|
27
|
+
const { bin, state } = await makeFakeBd(dir, { beads: [] });
|
|
28
|
+
await fn(bin, state);
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("fake bd --validate", () => {
|
|
35
|
+
it("rejects a section-less task", async () => {
|
|
36
|
+
await withFake(async (bin, state) => {
|
|
37
|
+
const { exitCode, stderr } = await runFake(bin, state, ["create", "empty", "--type", "task", "--validate"]);
|
|
38
|
+
assert.equal(exitCode, 1);
|
|
39
|
+
assert.match(stderr, /Acceptance Criteria/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("accepts a task carrying ## Acceptance Criteria (case-insensitive)", async () => {
|
|
44
|
+
await withFake(async (bin, state) => {
|
|
45
|
+
const { exitCode } = await runFake(bin, state, [
|
|
46
|
+
"create", "ok", "--type", "task",
|
|
47
|
+
"--description", "## Acceptance criteria\n- [ ] done",
|
|
48
|
+
"--validate",
|
|
49
|
+
]);
|
|
50
|
+
assert.equal(exitCode, 0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("requires ## Success Criteria for an epic", async () => {
|
|
55
|
+
await withFake(async (bin, state) => {
|
|
56
|
+
const { exitCode, stderr } = await runFake(bin, state, [
|
|
57
|
+
"create", "ep", "--type", "epic",
|
|
58
|
+
"--description", "## Acceptance Criteria\n- [ ] x",
|
|
59
|
+
"--validate",
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(exitCode, 1);
|
|
62
|
+
assert.match(stderr, /Success Criteria/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not require sections for a chore", async () => {
|
|
67
|
+
await withFake(async (bin, state) => {
|
|
68
|
+
const { exitCode } = await runFake(bin, state, ["create", "c", "--type", "chore", "--validate"]);
|
|
69
|
+
assert.equal(exitCode, 0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
package/test/helpers/fake-bd.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Shared test helper: a stateful fake `bd` binary for create-chores.ts.
|
|
2
2
|
// `bd` is a genuine external system (its real database must never be touched
|
|
3
3
|
// by tests), so the script is driven against this fake via the BD_PATH
|
|
4
|
-
// override. The fake reproduces the
|
|
5
|
-
// `bd create --parent`
|
|
6
|
-
//
|
|
7
|
-
// state file the test can read back.
|
|
4
|
+
// override. The fake reproduces the real behaviours the tests depend on —
|
|
5
|
+
// `bd create --parent` label inheritance (unless `--no-inherit-labels` is
|
|
6
|
+
// passed) and `bd create --validate` section enforcement — and records every
|
|
7
|
+
// created bead to a JSON state file the test can read back.
|
|
8
8
|
//
|
|
9
9
|
// State lives in `state.json` next to the binary; the test seeds it with the
|
|
10
10
|
// parent bead and reads the children back after the script runs. Written async
|
|
@@ -20,7 +20,7 @@ const FAKE_BD_SCRIPT = [
|
|
|
20
20
|
"",
|
|
21
21
|
"const stateFile = process.env.FAKE_BD_STATE;",
|
|
22
22
|
"const VALUE_FLAGS = new Set(['--type', '--description', '--priority', '--labels', '--parent']);",
|
|
23
|
-
"const BOOL_FLAGS = new Set(['--silent', '--no-inherit-labels']);",
|
|
23
|
+
"const BOOL_FLAGS = new Set(['--silent', '--no-inherit-labels', '--validate']);",
|
|
24
24
|
"",
|
|
25
25
|
"(async () => {",
|
|
26
26
|
" const argv = process.argv.slice(2);",
|
|
@@ -37,13 +37,33 @@ const FAKE_BD_SCRIPT = [
|
|
|
37
37
|
" }",
|
|
38
38
|
"",
|
|
39
39
|
" const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));",
|
|
40
|
+
"",
|
|
41
|
+
" // Model `bd create --validate`: reject a substantive bead whose description lacks the",
|
|
42
|
+
" // required section(s) for its type. Chores have no requirements and always pass.",
|
|
43
|
+
" if (bools.has('--validate')) {",
|
|
44
|
+
" const type = flags['--type'] || 'task';",
|
|
45
|
+
" const required = {",
|
|
46
|
+
" task: ['## Acceptance Criteria'],",
|
|
47
|
+
" feature: ['## Acceptance Criteria'],",
|
|
48
|
+
" bug: ['## Steps to Reproduce', '## Acceptance Criteria'],",
|
|
49
|
+
" epic: ['## Success Criteria'],",
|
|
50
|
+
" chore: [],",
|
|
51
|
+
" }[type] || ['## Acceptance Criteria'];",
|
|
52
|
+
" const description = flags['--description'] || '';",
|
|
53
|
+
" const missing = required.filter((section) => !description.toLowerCase().includes(section.toLowerCase()));",
|
|
54
|
+
" if (missing.length > 0) {",
|
|
55
|
+
" console.error('missing required sections for ' + type + ': ' + missing.join(', '));",
|
|
56
|
+
" process.exit(1);",
|
|
57
|
+
" }",
|
|
58
|
+
" }",
|
|
59
|
+
"",
|
|
40
60
|
" const explicit = (flags['--labels'] || '').split(',').filter(Boolean);",
|
|
41
61
|
" const parent = state.beads.find((b) => b.id === flags['--parent']);",
|
|
42
62
|
" const inherited = parent && !bools.has('--no-inherit-labels') ? parent.labels : [];",
|
|
43
63
|
" const labels = [...new Set([...inherited, ...explicit])];",
|
|
44
64
|
"",
|
|
45
65
|
" const id = 'bd-' + state.nextId++;",
|
|
46
|
-
" state.beads.push({ id, title, labels, parent: flags['--parent'] ?? null });",
|
|
66
|
+
" state.beads.push({ id, title, labels, parent: flags['--parent'] ?? null, type: flags['--type'] || 'task', description: flags['--description'] || '', validated: bools.has('--validate') });",
|
|
47
67
|
" await fs.writeFile(stateFile, JSON.stringify(state));",
|
|
48
68
|
" process.stdout.write(id + '\\n');",
|
|
49
69
|
"})().catch((err) => {",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Shared test helper: a stateful fake `pi` binary for pi's package commands.
|
|
2
|
+
// The real CLI is a genuine external system (and its user settings must never
|
|
3
|
+
// be touched by tests), so tests drive this fake instead. It records every
|
|
4
|
+
// invocation to `calls.log` (argv, space-joined, one line each) and simulates
|
|
5
|
+
// just enough real behaviour to assert registration state:
|
|
6
|
+
//
|
|
7
|
+
// --version → exit 0 (availability probe)
|
|
8
|
+
// list → configured package sources
|
|
9
|
+
// install <source> → adds <source> to settings.json `packages`, persisted
|
|
10
|
+
// relative to the settings file's directory (what real
|
|
11
|
+
// pi does) unless an entry already resolves to it
|
|
12
|
+
// remove <source> → drops the matching entry; exit 1 when none matches
|
|
13
|
+
//
|
|
14
|
+
// Local sources are resolved the way pi resolves them: relative to the
|
|
15
|
+
// directory holding settings.json. `failWhen` makes any invocation whose argv
|
|
16
|
+
// contains the substring exit 1 (command failures).
|
|
17
|
+
//
|
|
18
|
+
// Written async throughout (the fake is a child process and the helper's own
|
|
19
|
+
// I/O is test code) — the repo's check-no-sync lint scans test/ for *Sync.
|
|
20
|
+
|
|
21
|
+
import { chmod, writeFile } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const FAKE_PI_SCRIPT = [
|
|
25
|
+
"#!/usr/bin/env node",
|
|
26
|
+
"// Stateful fake for the `pi` package CLI (test/helpers/fake-pi.mjs).",
|
|
27
|
+
"const fs = require('node:fs/promises');",
|
|
28
|
+
"const path = require('node:path');",
|
|
29
|
+
"const os = require('node:os');",
|
|
30
|
+
"",
|
|
31
|
+
"const dir = __dirname;",
|
|
32
|
+
"const settingsFile = path.join(dir, 'settings.json');",
|
|
33
|
+
"const logFile = path.join(dir, 'calls.log');",
|
|
34
|
+
"const failWhenFile = path.join(dir, 'failWhen.txt');",
|
|
35
|
+
"",
|
|
36
|
+
"const expand = (p) => (p === '~' ? os.homedir() : p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p);",
|
|
37
|
+
"const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry && entry.source);",
|
|
38
|
+
"// pi resolves a configured local source against the settings file's directory.",
|
|
39
|
+
"const resolvedSource = (entry) => path.resolve(dir, expand(sourceOf(entry)));",
|
|
40
|
+
"const resolvedArgument = (arg) => path.resolve(dir, expand(arg));",
|
|
41
|
+
"",
|
|
42
|
+
"(async () => {",
|
|
43
|
+
" const argv = process.argv.slice(2);",
|
|
44
|
+
" const joined = argv.join(' ');",
|
|
45
|
+
" await fs.appendFile(logFile, joined + '\\n');",
|
|
46
|
+
"",
|
|
47
|
+
" const failWhen = await fs.readFile(failWhenFile, 'utf8').catch(() => null);",
|
|
48
|
+
" if (failWhen && joined.includes(failWhen)) process.exit(1);",
|
|
49
|
+
"",
|
|
50
|
+
" if (argv[0] === '--version' || argv[0] === '-v') {",
|
|
51
|
+
" console.log('1.4.0');",
|
|
52
|
+
" process.exit(0);",
|
|
53
|
+
" }",
|
|
54
|
+
"",
|
|
55
|
+
" const readSettings = async () => {",
|
|
56
|
+
" try {",
|
|
57
|
+
" return JSON.parse(await fs.readFile(settingsFile, 'utf8'));",
|
|
58
|
+
" } catch {",
|
|
59
|
+
" return {};",
|
|
60
|
+
" }",
|
|
61
|
+
" };",
|
|
62
|
+
" const writePackages = async (settings, packages) =>",
|
|
63
|
+
" fs.writeFile(settingsFile, JSON.stringify({ ...settings, packages }, null, 2) + '\\n');",
|
|
64
|
+
"",
|
|
65
|
+
" const [command, argument] = argv;",
|
|
66
|
+
" const settings = await readSettings();",
|
|
67
|
+
" const packages = settings.packages ?? [];",
|
|
68
|
+
"",
|
|
69
|
+
" if (command === 'list') {",
|
|
70
|
+
" if (packages.length === 0) {",
|
|
71
|
+
" console.log('No packages installed.');",
|
|
72
|
+
" process.exit(0);",
|
|
73
|
+
" }",
|
|
74
|
+
" console.log('User packages:');",
|
|
75
|
+
" for (const entry of packages) console.log(' ' + sourceOf(entry));",
|
|
76
|
+
" process.exit(0);",
|
|
77
|
+
" }",
|
|
78
|
+
"",
|
|
79
|
+
" if (command === 'install') {",
|
|
80
|
+
" const target = resolvedArgument(argument);",
|
|
81
|
+
" const persisted = path.relative(dir, target) || '.';",
|
|
82
|
+
" const index = packages.findIndex((entry) => resolvedSource(entry) === target);",
|
|
83
|
+
" if (index === -1) {",
|
|
84
|
+
" packages.push(persisted);",
|
|
85
|
+
" } else if (typeof packages[index] === 'string') {",
|
|
86
|
+
" packages[index] = persisted;",
|
|
87
|
+
" } else {",
|
|
88
|
+
" packages[index] = { ...packages[index], source: persisted };",
|
|
89
|
+
" }",
|
|
90
|
+
" await writePackages(settings, packages);",
|
|
91
|
+
" console.log('Installed ' + argument);",
|
|
92
|
+
" process.exit(0);",
|
|
93
|
+
" }",
|
|
94
|
+
"",
|
|
95
|
+
" if (command === 'remove') {",
|
|
96
|
+
" const target = resolvedArgument(argument);",
|
|
97
|
+
" const next = packages.filter((entry) => resolvedSource(entry) !== target);",
|
|
98
|
+
" if (next.length === packages.length) {",
|
|
99
|
+
" console.error('No matching package found for ' + argument);",
|
|
100
|
+
" process.exit(1);",
|
|
101
|
+
" }",
|
|
102
|
+
" await writePackages(settings, next);",
|
|
103
|
+
" console.log('Removed ' + argument);",
|
|
104
|
+
" process.exit(0);",
|
|
105
|
+
" }",
|
|
106
|
+
"",
|
|
107
|
+
" process.exit(0);",
|
|
108
|
+
"})().catch((err) => {",
|
|
109
|
+
" console.error(err);",
|
|
110
|
+
" process.exit(1);",
|
|
111
|
+
"});",
|
|
112
|
+
].join("\n");
|
|
113
|
+
|
|
114
|
+
/** Create a stateful fake `pi` binary in `dir`.
|
|
115
|
+
*
|
|
116
|
+
* The binary's own directory doubles as pi's settings directory, so a local
|
|
117
|
+
* package source is persisted relative to it exactly as real pi does.
|
|
118
|
+
* @param {string} dir Directory to hold bin, calls.log, settings.json
|
|
119
|
+
* @param {{ failWhen?: string|null, packages?: unknown[] }} [options]
|
|
120
|
+
* failWhen: invocations whose argv contains the substring exit 1;
|
|
121
|
+
* packages: initial `packages` array for settings.json
|
|
122
|
+
* @returns {Promise<{ bin: string, log: string, settingsPath: string }>} */
|
|
123
|
+
export async function makeFakePi(dir, { failWhen = null, packages = [] } = {}) {
|
|
124
|
+
const bin = join(dir, "pi");
|
|
125
|
+
const log = join(dir, "calls.log");
|
|
126
|
+
const settingsPath = join(dir, "settings.json");
|
|
127
|
+
await writeFile(bin, FAKE_PI_SCRIPT + "\n");
|
|
128
|
+
await chmod(bin, 0o755);
|
|
129
|
+
await writeFile(settingsPath, `${JSON.stringify({ packages }, null, 2)}\n`);
|
|
130
|
+
if (failWhen) await writeFile(join(dir, "failWhen.txt"), failWhen);
|
|
131
|
+
return { bin, log, settingsPath };
|
|
132
|
+
}
|