309 lines
9.7 KiB
JavaScript
309 lines
9.7 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readlink,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
import { buildPlan, run } from "./pi-links.mjs";
|
|
|
|
const repositoryRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
);
|
|
|
|
async function writeJson(file, value) {
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
async function writeSkill(directory, name) {
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(
|
|
path.join(directory, "SKILL.md"),
|
|
`---\nname: ${name}\ndescription: test\n---\n\n# ${name}\n`,
|
|
);
|
|
}
|
|
|
|
async function writeExtension(target) {
|
|
if (path.extname(target)) {
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, "export default function () {}\n");
|
|
return;
|
|
}
|
|
await mkdir(target, { recursive: true });
|
|
await writeFile(
|
|
path.join(target, "index.ts"),
|
|
"export default function () {}\n",
|
|
);
|
|
}
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "pi-links-test-"));
|
|
const repoRoot = path.join(root, "repo");
|
|
const homeDir = path.join(root, "home");
|
|
const agentDir = path.join(homeDir, ".pi", "agent");
|
|
const settingsFile = path.join(agentDir, "settings.json");
|
|
const globalSkill = path.join(repoRoot, "skills", "group", "global-skill");
|
|
const projectSkill = path.join(repoRoot, "skills", "tools", "project-skill");
|
|
const globalExtension = path.join(repoRoot, "extensions", "global-ext");
|
|
const packageExtension = path.join(
|
|
repoRoot,
|
|
"extensions",
|
|
"package-ext",
|
|
"index.ts",
|
|
);
|
|
const globalAgents = path.join(repoRoot, "docs", "global.AGENTS.md");
|
|
const globalClaude = path.join(repoRoot, "docs", "global.CLAUDE.md");
|
|
|
|
await writeSkill(globalSkill, "global-skill");
|
|
await writeSkill(projectSkill, "project-skill");
|
|
await writeExtension(globalExtension);
|
|
await writeExtension(packageExtension);
|
|
await mkdir(path.dirname(globalAgents), { recursive: true });
|
|
await writeFile(globalAgents, "# Global instructions\n");
|
|
await writeFile(globalClaude, "@global.AGENTS.md\n");
|
|
await writeJson(path.join(repoRoot, "config", "pi-links.json"), {
|
|
version: 1,
|
|
skills: {
|
|
root: "skills",
|
|
projectOnly: { "project-skill": "skills/tools/project-skill" },
|
|
excluded: [],
|
|
},
|
|
instructions: {
|
|
globalSymlinks: {
|
|
pi: "docs/global.AGENTS.md",
|
|
codex: "docs/global.AGENTS.md",
|
|
claude: "docs/global.CLAUDE.md",
|
|
},
|
|
},
|
|
extensions: {
|
|
globalSymlinks: { "global-ext": "extensions/global-ext" },
|
|
globalLocalPackages: { "package-ext": "extensions/package-ext/index.ts" },
|
|
},
|
|
});
|
|
await writeJson(settingsFile, {
|
|
packages: [path.relative(agentDir, packageExtension)],
|
|
});
|
|
|
|
return {
|
|
root,
|
|
repoRoot,
|
|
homeDir,
|
|
agentDir,
|
|
settingsFile,
|
|
globalSkill,
|
|
projectSkill,
|
|
globalExtension,
|
|
globalAgents,
|
|
globalClaude,
|
|
};
|
|
}
|
|
|
|
test("sync creates absolute global links and relative project links", async () => {
|
|
const options = await fixture();
|
|
assert.equal(await run("sync", options), 0);
|
|
assert.equal(await run("check", options), 0);
|
|
|
|
const globalSkillLink = path.join(
|
|
options.homeDir,
|
|
".agents",
|
|
"skills",
|
|
"global-skill",
|
|
);
|
|
const projectSkillLink = path.join(
|
|
options.repoRoot,
|
|
".pi",
|
|
"skills",
|
|
"project-skill",
|
|
);
|
|
const extensionLink = path.join(options.agentDir, "extensions", "global-ext");
|
|
const piInstructions = path.join(options.agentDir, "AGENTS.md");
|
|
const codexInstructions = path.join(options.homeDir, ".codex", "AGENTS.md");
|
|
const claudeInstructions = path.join(options.homeDir, ".claude", "CLAUDE.md");
|
|
assert.equal(await readlink(globalSkillLink), options.globalSkill);
|
|
assert.equal(await readlink(extensionLink), options.globalExtension);
|
|
assert.equal(await readlink(piInstructions), options.globalAgents);
|
|
assert.equal(await readlink(codexInstructions), options.globalAgents);
|
|
assert.equal(await readlink(claudeInstructions), options.globalClaude);
|
|
assert.equal(
|
|
await readlink(projectSkillLink),
|
|
path.relative(path.dirname(projectSkillLink), options.projectSkill),
|
|
);
|
|
});
|
|
|
|
test("removed global instruction declarations are prunable without scanning config directories", async () => {
|
|
const options = await fixture();
|
|
await run("sync", options);
|
|
const manifestFile = path.join(options.repoRoot, "config", "pi-links.json");
|
|
const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
delete manifest.instructions.globalSymlinks.claude;
|
|
await writeJson(manifestFile, manifest);
|
|
|
|
const plan = await buildPlan(options);
|
|
assert.ok(
|
|
plan.operations.some(
|
|
(operation) =>
|
|
operation.type === "remove-link" &&
|
|
operation.destination ===
|
|
path.join(options.homeDir, ".claude", "CLAUDE.md"),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("dry-run reports drift without writing", async () => {
|
|
const options = await fixture();
|
|
assert.equal(await run("sync", { ...options, dryRun: true }), 0);
|
|
const plan = await buildPlan(options);
|
|
assert.ok(
|
|
plan.operations.some((operation) => operation.type === "create-link"),
|
|
);
|
|
});
|
|
|
|
test("unmanaged destinations are conflicts and remain untouched", async () => {
|
|
const options = await fixture();
|
|
const destination = path.join(
|
|
options.homeDir,
|
|
".agents",
|
|
"skills",
|
|
"global-skill",
|
|
);
|
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
await writeFile(destination, "keep me\n");
|
|
|
|
const plan = await buildPlan(options);
|
|
assert.deepEqual(
|
|
plan.conflicts.map((entry) => entry.path),
|
|
[destination],
|
|
);
|
|
assert.equal(await run("sync", options), 2);
|
|
});
|
|
|
|
test("prune removes only stale links whose targets are inside the repository", async () => {
|
|
const options = await fixture();
|
|
await run("sync", options);
|
|
const skillsDir = path.join(options.homeDir, ".agents", "skills");
|
|
const stale = path.join(skillsDir, "stale");
|
|
const external = path.join(skillsDir, "external");
|
|
await symlink(options.globalSkill, stale);
|
|
await symlink(path.join(options.root, "outside"), external);
|
|
|
|
const plan = await buildPlan(options);
|
|
assert.ok(
|
|
plan.operations.some(
|
|
(operation) =>
|
|
operation.type === "remove-link" && operation.destination === stale,
|
|
),
|
|
);
|
|
assert.ok(
|
|
!plan.operations.some(
|
|
(operation) =>
|
|
operation.type === "remove-link" && operation.destination === external,
|
|
),
|
|
);
|
|
assert.equal(await run("prune", options), 0);
|
|
assert.equal(await readlink(external), path.join(options.root, "outside"));
|
|
});
|
|
|
|
test("removed update exposure is prunable without touching unrelated extension links", async () => {
|
|
const options = await fixture();
|
|
const updateSource = path.join(options.repoRoot, "extensions", "update");
|
|
const externalSource = path.join(options.root, "external-update");
|
|
const extensionsDir = path.join(options.agentDir, "extensions");
|
|
const updateLink = path.join(extensionsDir, "update");
|
|
const externalLink = path.join(extensionsDir, "external");
|
|
await writeExtension(updateSource);
|
|
await writeExtension(externalSource);
|
|
await mkdir(extensionsDir, { recursive: true });
|
|
await symlink(updateSource, updateLink);
|
|
await symlink(externalSource, externalLink);
|
|
|
|
const plan = await buildPlan(options);
|
|
assert.ok(
|
|
plan.operations.some(
|
|
(operation) =>
|
|
operation.type === "remove-link" &&
|
|
operation.destination === updateLink,
|
|
),
|
|
);
|
|
assert.ok(
|
|
!plan.operations.some(
|
|
(operation) =>
|
|
operation.type === "remove-link" &&
|
|
operation.destination === externalLink,
|
|
),
|
|
);
|
|
});
|
|
|
|
test("normal exposure and package evidence retain the Slice 0 security boundary", async () => {
|
|
const links = JSON.parse(
|
|
await readFile(path.join(repositoryRoot, "config", "pi-links.json"), "utf8"),
|
|
);
|
|
assert.deepEqual(
|
|
links.extensions.globalSymlinks,
|
|
{},
|
|
"published extensions must not be re-exposed as development symlinks",
|
|
);
|
|
assert.deepEqual(
|
|
links.extensions.globalLocalPackages,
|
|
{},
|
|
"the legacy workflow extension must remain unexposed",
|
|
);
|
|
|
|
const exposureRoot = await mkdtemp(
|
|
path.join(os.tmpdir(), "develop-workflow-exposure-test-"),
|
|
);
|
|
const exposurePlan = await buildPlan({
|
|
repoRoot: repositoryRoot,
|
|
homeDir: path.join(exposureRoot, "home"),
|
|
agentDir: path.join(exposureRoot, "home", ".pi", "agent"),
|
|
globalSkillsDir: path.join(exposureRoot, "home", ".agents", "skills"),
|
|
settingsFile: path.join(exposureRoot, "home", ".pi", "agent", "settings.json"),
|
|
});
|
|
assert.ok(
|
|
exposurePlan.operations.some(
|
|
(operation) =>
|
|
operation.type === "create-link" &&
|
|
operation.link.name === "develop-workflow" &&
|
|
operation.link.scope === "global",
|
|
),
|
|
"develop-workflow must be discovered as a shared global skill",
|
|
);
|
|
|
|
const lock = JSON.parse(
|
|
await readFile(
|
|
path.join(repositoryRoot, "config", "pi-package-lock.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
assert.equal(lock.packages.length, 9);
|
|
assert.ok(
|
|
lock.packages.every((entry) =>
|
|
["locked", "blocked"].includes(entry.status),
|
|
),
|
|
);
|
|
assert.ok(lock.extensions.some((entry) => entry.name === "lm-studio"));
|
|
assert.equal(
|
|
lock.extensions.find((entry) => entry.name === "update")?.status,
|
|
"blocked",
|
|
);
|
|
});
|
|
|
|
test("duplicate declared skill names fail before planning changes", async () => {
|
|
const options = await fixture();
|
|
await writeSkill(
|
|
path.join(options.repoRoot, "skills", "other"),
|
|
"global-skill",
|
|
);
|
|
await assert.rejects(
|
|
() => buildPlan(options),
|
|
/duplicate skill name global-skill/,
|
|
);
|
|
});
|