Files
harness-tools/scripts/pi-links.mjs
T

664 lines
21 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { constants } from "node:fs";
import {
access,
lstat,
mkdir,
readFile,
readdir,
readlink,
realpath,
symlink,
unlink,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const SCRIPT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const MANIFEST_PATH = "config/pi-links.json";
const COMMANDS = new Set(["check", "sync", "prune"]);
function usage() {
return (
`Usage: node scripts/pi-links.mjs <check|sync|prune> [--dry-run] [--migrate-from /old/repo]\n\n` +
` check Report missing, stale, or conflicting exposure without writing\n` +
` sync Create or repair manifest-declared links and local package entries\n` +
` prune Remove repository-owned links and local package entries not in the manifest\n` +
` --dry-run Print sync/prune changes without applying them\n` +
` --migrate-from Explicit old root for exact manifest-link repairs (check/sync only)`
);
}
function isInside(parent, child) {
const relative = path.relative(parent, child);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
async function pathExists(target) {
try {
await access(target, constants.F_OK);
return true;
} catch {
return false;
}
}
async function statOrUndefined(target) {
try {
return await lstat(target);
} catch (error) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
}
async function canonical(target) {
try {
return await realpath(target);
} catch {
return path.resolve(target);
}
}
function parseSkillName(contents, skillFile) {
const frontmatter = contents.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!frontmatter) throw new Error(`${skillFile}: missing YAML frontmatter`);
const match = frontmatter[1].match(
/^name:\s*(?:"([^"]+)"|'([^']+)'|([^#\r\n]+))\s*$/m,
);
const name = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim();
if (!name) throw new Error(`${skillFile}: missing frontmatter name`);
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) {
throw new Error(`${skillFile}: unsafe skill name ${JSON.stringify(name)}`);
}
return name;
}
async function discoverSkills(root) {
const found = [];
async function visit(directory) {
const skillFile = path.join(directory, "SKILL.md");
if (await pathExists(skillFile)) {
const name = parseSkillName(await readFile(skillFile, "utf8"), skillFile);
found.push({ name, source: directory });
return;
}
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (
!entry.isDirectory() ||
entry.name.startsWith(".") ||
entry.name === "node_modules"
)
continue;
await visit(path.join(directory, entry.name));
}
}
await visit(root);
return found;
}
async function readJson(file) {
const contents = await readFile(file, "utf8");
try {
return JSON.parse(contents);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`${file}: invalid JSON: ${reason}`, { cause: error });
}
}
function assertRecord(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value;
}
function assertStringArray(value, label) {
if (
!Array.isArray(value) ||
value.some((entry) => typeof entry !== "string")
) {
throw new Error(`${label} must be an array of strings`);
}
return value;
}
async function loadManifest(repoRoot) {
const manifestFile = path.join(repoRoot, MANIFEST_PATH);
const manifest = await readJson(manifestFile);
if (manifest.version !== 1)
throw new Error(`${manifestFile}: unsupported version`);
const skills = assertRecord(manifest.skills, "skills");
const extensions = assertRecord(manifest.extensions, "extensions");
const instructions = assertRecord(manifest.instructions, "instructions");
assertRecord(skills.projectOnly, "skills.projectOnly");
assertStringArray(skills.excluded, "skills.excluded");
assertRecord(extensions.globalSymlinks, "extensions.globalSymlinks");
assertRecord(
extensions.globalLocalPackages,
"extensions.globalLocalPackages",
);
assertRecord(instructions.globalSymlinks, "instructions.globalSymlinks");
if (typeof skills.root !== "string")
throw new Error("skills.root must be a string");
return manifest;
}
async function validateSource(repoRoot, relativeSource, kind) {
const source = path.resolve(repoRoot, relativeSource);
if (!isInside(repoRoot, source))
throw new Error(`${relativeSource}: source escapes repository`);
const stat = await statOrUndefined(source);
if (!stat) throw new Error(`${relativeSource}: source does not exist`);
if (kind === "skill") {
if (
!stat.isDirectory() ||
!(await pathExists(path.join(source, "SKILL.md")))
) {
throw new Error(`${relativeSource}: skill source must contain SKILL.md`);
}
} else if (kind === "instruction") {
if (!stat.isFile() || path.extname(source).toLowerCase() !== ".md") {
throw new Error(
`${relativeSource}: instruction source must be a Markdown file`,
);
}
} else if (stat.isDirectory()) {
if (
!(await pathExists(path.join(source, "index.ts"))) &&
!(await pathExists(path.join(source, "index.js")))
) {
throw new Error(
`${relativeSource}: extension directory must contain index.ts or index.js`,
);
}
} else if (!/\.[cm]?[jt]s$/i.test(source)) {
throw new Error(
`${relativeSource}: extension file must be JavaScript or TypeScript`,
);
}
return source;
}
async function resolveDesiredState(options = {}) {
const repoRoot = path.resolve(options.repoRoot ?? SCRIPT_ROOT);
const homeDir = path.resolve(options.homeDir ?? os.homedir());
const agentDir = path.resolve(
options.agentDir ??
process.env.PI_CODING_AGENT_DIR ??
path.join(homeDir, ".pi", "agent"),
);
const globalSkillsDir = path.resolve(
options.globalSkillsDir ?? path.join(homeDir, ".agents", "skills"),
);
const globalExtensionsDir = path.resolve(
options.globalExtensionsDir ?? path.join(agentDir, "extensions"),
);
const projectSkillsDir = path.join(repoRoot, ".pi", "skills");
const settingsFile = path.resolve(
options.settingsFile ?? path.join(agentDir, "settings.json"),
);
const manifest = await loadManifest(repoRoot);
const instructionDestinations = {
pi: path.join(agentDir, "AGENTS.md"),
codex: path.join(homeDir, ".codex", "AGENTS.md"),
claude: path.join(homeDir, ".claude", "CLAUDE.md"),
};
const skillsRoot = await validateSource(
repoRoot,
manifest.skills.root,
"skill-root",
).catch(async (error) => {
const source = path.resolve(repoRoot, manifest.skills.root);
const stat = await statOrUndefined(source);
if (!stat?.isDirectory()) throw error;
return source;
});
const discovered = await discoverSkills(skillsRoot);
const byName = new Map();
for (const skill of discovered) {
const previous = byName.get(skill.name);
if (previous) {
throw new Error(
`duplicate skill name ${skill.name}: ${previous.source} and ${skill.source}`,
);
}
byName.set(skill.name, skill);
}
const projectOnly = new Map(Object.entries(manifest.skills.projectOnly));
const excluded = new Set(manifest.skills.excluded);
for (const name of [...projectOnly.keys(), ...excluded]) {
if (!byName.has(name))
throw new Error(`manifest references unknown skill ${name}`);
}
for (const name of projectOnly.keys()) {
if (excluded.has(name))
throw new Error(`skill ${name} cannot be both project-only and excluded`);
}
const links = [];
for (const skill of discovered) {
if (excluded.has(skill.name)) continue;
const declaredProjectSource = projectOnly.get(skill.name);
if (declaredProjectSource !== undefined) {
const source = await validateSource(
repoRoot,
declaredProjectSource,
"skill",
);
if ((await canonical(source)) !== (await canonical(skill.source))) {
throw new Error(
`project-only skill ${skill.name} source does not match discovered source`,
);
}
links.push({
kind: "skill",
scope: "project",
name: skill.name,
source,
destination: path.join(projectSkillsDir, skill.name),
relative: true,
});
continue;
}
links.push({
kind: "skill",
scope: "global",
name: skill.name,
source: skill.source,
destination: path.join(globalSkillsDir, skill.name),
relative: false,
});
}
const extensionSources = new Set();
for (const [name, relativeSource] of Object.entries(
manifest.extensions.globalSymlinks,
)) {
const source = await validateSource(repoRoot, relativeSource, "extension");
const sourceKey = await canonical(source);
if (extensionSources.has(sourceKey))
throw new Error(
`extension source listed more than once: ${relativeSource}`,
);
extensionSources.add(sourceKey);
links.push({
kind: "extension",
scope: "global",
name,
source,
destination: path.join(globalExtensionsDir, name),
relative: false,
});
}
for (const [name, relativeSource] of Object.entries(
manifest.instructions.globalSymlinks,
)) {
const destination = instructionDestinations[name];
if (!destination)
throw new Error(`unknown global instruction target ${name}`);
const source = await validateSource(
repoRoot,
relativeSource,
"instruction",
);
links.push({
kind: "instruction",
scope: "global",
name,
source,
destination,
relative: false,
type: "file",
});
}
const packages = [];
for (const [name, relativeSource] of Object.entries(
manifest.extensions.globalLocalPackages,
)) {
const source = await validateSource(repoRoot, relativeSource, "extension");
const sourceKey = await canonical(source);
if (extensionSources.has(sourceKey)) {
throw new Error(
`extension ${name} is exposed as both a symlink and local package`,
);
}
extensionSources.add(sourceKey);
packages.push({ kind: "extension", scope: "global", name, source });
}
links.sort((a, b) => a.destination.localeCompare(b.destination));
packages.sort((a, b) => a.source.localeCompare(b.source));
return {
repoRoot,
settingsFile,
managedDirectories: [
globalSkillsDir,
globalExtensionsDir,
projectSkillsDir,
],
managedFiles: Object.values(instructionDestinations),
links,
packages,
};
}
function localPackagePath(source, settingsFile) {
if (typeof source !== "string") return undefined;
if (/^(?:npm:|git:|https?:|ssh:|git\+|file:)/i.test(source)) return undefined;
if (!path.isAbsolute(source) && !source.startsWith(".")) return undefined;
return path.resolve(path.dirname(settingsFile), source);
}
async function readLocalPackages(settingsFile) {
if (!(await pathExists(settingsFile))) return [];
const settings = await readJson(settingsFile);
if (!Array.isArray(settings.packages)) return [];
return settings.packages
.map((entry) => (typeof entry === "string" ? entry : entry?.source))
.map((source) => ({
source,
resolved: localPackagePath(source, settingsFile),
}))
.filter((entry) => entry.resolved);
}
async function inspectDesiredLink(link, repoRoot, migrateFrom) {
const stat = await statOrUndefined(link.destination);
if (!stat) return { type: "create-link", link };
if (!stat.isSymbolicLink()) {
return {
type: "conflict",
path: link.destination,
reason: "destination is not a symlink",
};
}
const rawTarget = await readlink(link.destination);
const resolvedTarget = path.resolve(
path.dirname(link.destination),
rawTarget,
);
if ((await canonical(resolvedTarget)) === (await canonical(link.source)))
return undefined;
const expectedOldTarget = migrateFrom
? path.join(migrateFrom, path.relative(repoRoot, link.source))
: undefined;
if (isInside(repoRoot, resolvedTarget) || resolvedTarget === expectedOldTarget)
return { type: "replace-link", link, previousTarget: resolvedTarget };
return {
type: "conflict",
path: link.destination,
reason: `symlink points outside this repository: ${rawTarget}`,
};
}
async function discoverStaleLinks(state, desiredDestinations) {
const stale = [];
for (const directory of state.managedDirectories) {
const stat = await statOrUndefined(directory);
if (!stat?.isDirectory()) continue;
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isSymbolicLink()) continue;
const destination = path.join(directory, entry.name);
if (desiredDestinations.has(destination)) continue;
const rawTarget = await readlink(destination);
const resolvedTarget = path.resolve(directory, rawTarget);
if (isInside(state.repoRoot, resolvedTarget)) {
stale.push({
type: "remove-link",
destination,
previousTarget: resolvedTarget,
});
}
}
}
for (const destination of state.managedFiles) {
if (desiredDestinations.has(destination)) continue;
const stat = await statOrUndefined(destination);
if (!stat?.isSymbolicLink()) continue;
const rawTarget = await readlink(destination);
const resolvedTarget = path.resolve(path.dirname(destination), rawTarget);
if (isInside(state.repoRoot, resolvedTarget)) {
stale.push({
type: "remove-link",
destination,
previousTarget: resolvedTarget,
});
}
}
return stale;
}
export async function buildPlan(options = {}) {
const state = await resolveDesiredState(options);
let migrateFrom;
if (options.migrateFrom !== undefined) {
if (typeof options.migrateFrom !== "string" || !path.isAbsolute(options.migrateFrom))
throw new Error("migrate-from must be an absolute old repository root");
migrateFrom = path.resolve(options.migrateFrom);
if (isInside(migrateFrom, state.repoRoot) || isInside(state.repoRoot, migrateFrom))
throw new Error("migrate-from must be separate from the current repository root");
}
const desiredDestinations = new Set(
state.links.map((link) => link.destination),
);
const desiredPackageSources = new Set(
await Promise.all(state.packages.map((entry) => canonical(entry.source))),
);
const operations = [];
const conflicts = [];
for (const link of state.links) {
const result = await inspectDesiredLink(link, state.repoRoot, migrateFrom);
if (result?.type === "conflict") conflicts.push(result);
else if (result) operations.push(result);
}
operations.push(...(await discoverStaleLinks(state, desiredDestinations)));
const installedPackages = await readLocalPackages(state.settingsFile);
const installedCounts = new Map();
for (const entry of installedPackages) {
const key = await canonical(entry.resolved);
installedCounts.set(key, (installedCounts.get(key) ?? 0) + 1);
}
for (const [source, count] of installedCounts) {
if (count > 1 && isInside(state.repoRoot, source)) {
conflicts.push({
type: "conflict",
path: state.settingsFile,
reason: `local package appears ${count} times: ${source}`,
});
}
}
for (const pkg of state.packages) {
const source = await canonical(pkg.source);
if (!installedCounts.has(source))
operations.push({ type: "install-package", package: pkg });
}
for (const entry of installedPackages) {
const source = await canonical(entry.resolved);
if (
isInside(state.repoRoot, source) &&
!desiredPackageSources.has(source)
) {
operations.push({ type: "remove-package", source });
}
}
operations.sort((a, b) =>
describeOperation(a).localeCompare(describeOperation(b)),
);
conflicts.sort((a, b) =>
`${a.path}: ${a.reason}`.localeCompare(`${b.path}: ${b.reason}`),
);
return { state, operations, conflicts };
}
function describeOperation(operation) {
switch (operation.type) {
case "create-link":
return `create ${operation.link.destination} -> ${operation.link.source}`;
case "replace-link":
return `repair ${operation.link.destination} -> ${operation.link.source}`;
case "remove-link":
return `remove stale link ${operation.destination} -> ${operation.previousTarget}`;
case "install-package":
return `install local package ${operation.package.source}`;
case "remove-package":
return `remove stale local package ${operation.source}`;
default:
return `${operation.type}`;
}
}
function selectOperations(command, operations) {
if (command === "sync") {
return operations.filter(
(operation) =>
operation.type === "create-link" ||
operation.type === "replace-link" ||
operation.type === "install-package",
);
}
if (command === "prune") {
return operations.filter(
(operation) =>
operation.type === "remove-link" || operation.type === "remove-package",
);
}
return [];
}
async function createOrReplaceLink(operation) {
const { link } = operation;
await mkdir(path.dirname(link.destination), { recursive: true });
if (operation.type === "replace-link") {
const current = await statOrUndefined(link.destination);
if (!current?.isSymbolicLink() ||
path.resolve(path.dirname(link.destination), await readlink(link.destination)) !== operation.previousTarget)
throw new Error(`${link.destination}: destination changed after planning; refusing replacement`);
await unlink(link.destination);
}
const target = link.relative
? path.relative(path.dirname(link.destination), link.source)
: link.source;
await symlink(target, link.destination, link.type ?? "dir");
}
function runPiPackage(command, source) {
const result = spawnSync("pi", [command, source], { stdio: "inherit" });
if (result.error) throw result.error;
if (result.status !== 0)
throw new Error(
`pi ${command} failed for ${source} with exit code ${result.status}`,
);
}
async function applyOperations(operations) {
for (const operation of operations) {
if (operation.type === "create-link" || operation.type === "replace-link") {
await createOrReplaceLink(operation);
} else if (operation.type === "remove-link") {
await unlink(operation.destination);
} else if (operation.type === "install-package") {
runPiPackage("install", operation.package.source);
} else if (operation.type === "remove-package") {
runPiPackage("remove", operation.source);
}
}
}
export async function run(command, options = {}) {
if (!COMMANDS.has(command)) throw new Error("unknown link-manager command");
if (command === "prune" && options.migrateFrom !== undefined)
throw new Error("migrate-from is only supported for check/sync; it never authorizes pruning");
const plan = await buildPlan(options);
for (const conflict of plan.conflicts)
console.error(`CONFLICT ${conflict.path}: ${conflict.reason}`);
if (plan.conflicts.length > 0) return 2;
if (command === "check") {
if (plan.operations.length === 0) {
console.log(
`OK ${plan.state.links.length} links and ${plan.state.packages.length} local package entries match ${MANIFEST_PATH}`,
);
return 0;
}
for (const operation of plan.operations)
console.log(`DRIFT ${describeOperation(operation)}`);
return 1;
}
const selected = selectOperations(command, plan.operations);
if (selected.length === 0) {
console.log(`OK nothing to ${command}`);
return 0;
}
for (const operation of selected)
console.log(
`${options.dryRun ? "WOULD" : "APPLY"} ${describeOperation(operation)}`,
);
if (!options.dryRun) await applyOperations(selected);
return 0;
}
export async function main(argv = process.argv.slice(2)) {
const [command, ...args] = argv;
const options = {};
let invalid = !COMMANDS.has(command);
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === "--dry-run" && options.dryRun === undefined) options.dryRun = true;
else if (arg === "--migrate-from" && options.migrateFrom === undefined &&
args[index + 1] && !args[index + 1].startsWith("--"))
options.migrateFrom = args[++index];
else invalid = true;
}
if (invalid) {
console.error(usage());
return 2;
}
return run(command, options);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
main()
.then((code) => {
process.exitCode = code;
})
.catch((error) => {
console.error(
`ERROR ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 2;
});
}