|
#!/usr/bin/env node |
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; |
|
import { dirname, isAbsolute, normalize, relative, resolve } from "node:path"; |
|
import { fileURLToPath } from "node:url"; |
|
|
|
const scriptPath = fileURLToPath(import.meta.url); |
|
const skillRoot = dirname(dirname(scriptPath)); |
|
const registryPath = resolve(skillRoot, "registry.json"); |
|
|
|
const validEnvironments = new Set(["local", "worktree"]); |
|
|
|
function usage() { |
|
return `Usage: |
|
node scripts/repo-registry.js lookup <absolute-repo-path> |
|
node scripts/repo-registry.js add <absolute-repo-path> --project-id <id> [--default-env local|worktree] [--label <name>] |
|
node scripts/repo-registry.js list |
|
node scripts/repo-registry.js validate`; |
|
} |
|
|
|
function fail(message, code = 1) { |
|
console.error(message); |
|
process.exit(code); |
|
} |
|
|
|
function normalizeRepoPath(value) { |
|
if (!value || !isAbsolute(value)) { |
|
fail(`repo path must be absolute: ${value || "<missing>"}`); |
|
} |
|
const normalized = normalize(value); |
|
return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; |
|
} |
|
|
|
function parseOptions(args) { |
|
const options = {}; |
|
for (let index = 0; index < args.length; index += 1) { |
|
const arg = args[index]; |
|
if (!arg.startsWith("--")) { |
|
fail(`unexpected argument: ${arg}\n${usage()}`); |
|
} |
|
const key = arg.slice(2); |
|
const value = args[index + 1]; |
|
if (!value || value.startsWith("--")) { |
|
fail(`missing value for --${key}`); |
|
} |
|
options[key] = value; |
|
index += 1; |
|
} |
|
return options; |
|
} |
|
|
|
async function readRegistry() { |
|
try { |
|
const text = await readFile(registryPath, "utf8"); |
|
return JSON.parse(text); |
|
} catch (error) { |
|
if (error && error.code === "ENOENT") { |
|
return { version: 1, repos: {} }; |
|
} |
|
if (error instanceof SyntaxError) { |
|
fail(`registry is not valid JSON: ${registryPath}`); |
|
} |
|
throw error; |
|
} |
|
} |
|
|
|
async function writeRegistry(registry) { |
|
await mkdir(dirname(registryPath), { recursive: true }); |
|
const tempPath = `${registryPath}.tmp-${process.pid}-${Date.now()}`; |
|
await writeFile(tempPath, `${JSON.stringify(registry, null, 2)}\n`, "utf8"); |
|
await rename(tempPath, registryPath); |
|
} |
|
|
|
function validateRegistry(registry) { |
|
const errors = []; |
|
if (!registry || typeof registry !== "object" || Array.isArray(registry)) { |
|
errors.push("registry must be an object"); |
|
return errors; |
|
} |
|
if (registry.version !== 1) { |
|
errors.push("registry.version must be 1"); |
|
} |
|
if (!registry.repos || typeof registry.repos !== "object" || Array.isArray(registry.repos)) { |
|
errors.push("registry.repos must be an object"); |
|
return errors; |
|
} |
|
|
|
for (const [repoPath, entry] of Object.entries(registry.repos)) { |
|
if (!isAbsolute(repoPath)) { |
|
errors.push(`repo key must be absolute: ${repoPath}`); |
|
} |
|
if (normalizeRepoPathForValidation(repoPath) !== repoPath) { |
|
errors.push(`repo key must be normalized: ${repoPath}`); |
|
} |
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) { |
|
errors.push(`entry must be an object: ${repoPath}`); |
|
continue; |
|
} |
|
if (!entry.projectId || typeof entry.projectId !== "string") { |
|
errors.push(`entry.projectId must be a non-empty string: ${repoPath}`); |
|
} |
|
if ( |
|
entry.defaultEnvironment !== undefined && |
|
!validEnvironments.has(entry.defaultEnvironment) |
|
) { |
|
errors.push(`entry.defaultEnvironment must be local or worktree: ${repoPath}`); |
|
} |
|
if (entry.label !== undefined && typeof entry.label !== "string") { |
|
errors.push(`entry.label must be a string when present: ${repoPath}`); |
|
} |
|
if (entry.updatedAt !== undefined && typeof entry.updatedAt !== "string") { |
|
errors.push(`entry.updatedAt must be a string when present: ${repoPath}`); |
|
} |
|
} |
|
return errors; |
|
} |
|
|
|
function normalizeRepoPathForValidation(value) { |
|
const normalized = normalize(value); |
|
return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; |
|
} |
|
|
|
function findMapping(registry, requestedPath) { |
|
if (registry.repos[requestedPath]) { |
|
return { repoPath: requestedPath, match: "exact", entry: registry.repos[requestedPath] }; |
|
} |
|
|
|
const candidates = Object.keys(registry.repos) |
|
.filter((repoPath) => isSameOrChildPath(requestedPath, repoPath)) |
|
.sort((left, right) => right.length - left.length); |
|
|
|
if (candidates.length === 0) { |
|
return null; |
|
} |
|
|
|
const repoPath = candidates[0]; |
|
return { repoPath, match: "ancestor", entry: registry.repos[repoPath] }; |
|
} |
|
|
|
function isSameOrChildPath(childPath, parentPath) { |
|
const rel = relative(parentPath, childPath); |
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); |
|
} |
|
|
|
function printJson(value) { |
|
console.log(JSON.stringify(value, null, 2)); |
|
} |
|
|
|
async function main() { |
|
const [command, repoArg, ...rest] = process.argv.slice(2); |
|
if (!command || command === "--help" || command === "-h") { |
|
console.log(usage()); |
|
return; |
|
} |
|
|
|
const registry = await readRegistry(); |
|
|
|
if (command === "validate") { |
|
const errors = validateRegistry(registry); |
|
printJson({ ok: errors.length === 0, registryPath, errors }); |
|
process.exit(errors.length === 0 ? 0 : 1); |
|
} |
|
|
|
if (command === "list") { |
|
const errors = validateRegistry(registry); |
|
if (errors.length > 0) { |
|
printJson({ ok: false, registryPath, errors }); |
|
process.exit(1); |
|
} |
|
const repos = Object.entries(registry.repos) |
|
.sort(([left], [right]) => left.localeCompare(right)) |
|
.map(([repoPath, entry]) => ({ repoPath, ...entry })); |
|
printJson({ ok: true, registryPath, repos }); |
|
return; |
|
} |
|
|
|
if (command === "lookup") { |
|
const requestedPath = normalizeRepoPath(repoArg); |
|
const errors = validateRegistry(registry); |
|
if (errors.length > 0) { |
|
printJson({ ok: false, registryPath, errors }); |
|
process.exit(1); |
|
} |
|
const mapping = findMapping(registry, requestedPath); |
|
printJson({ |
|
ok: Boolean(mapping), |
|
registryPath, |
|
requestedPath, |
|
mapping, |
|
}); |
|
process.exit(mapping ? 0 : 2); |
|
} |
|
|
|
if (command === "add") { |
|
const repoPath = normalizeRepoPath(repoArg); |
|
const options = parseOptions(rest); |
|
const projectId = options["project-id"]; |
|
const defaultEnvironment = options["default-env"] || "worktree"; |
|
const label = options.label; |
|
|
|
if (!projectId) { |
|
fail("missing required --project-id <id>"); |
|
} |
|
if (!validEnvironments.has(defaultEnvironment)) { |
|
fail("--default-env must be local or worktree"); |
|
} |
|
|
|
registry.version = 1; |
|
registry.repos = registry.repos && typeof registry.repos === "object" && !Array.isArray(registry.repos) |
|
? registry.repos |
|
: {}; |
|
registry.repos[repoPath] = { |
|
projectId, |
|
defaultEnvironment, |
|
...(label ? { label } : {}), |
|
updatedAt: new Date().toISOString().slice(0, 10), |
|
}; |
|
|
|
const errors = validateRegistry(registry); |
|
if (errors.length > 0) { |
|
printJson({ ok: false, registryPath, errors }); |
|
process.exit(1); |
|
} |
|
|
|
await writeRegistry(registry); |
|
printJson({ ok: true, registryPath, repoPath, entry: registry.repos[repoPath] }); |
|
return; |
|
} |
|
|
|
fail(`unknown command: ${command}\n${usage()}`); |
|
} |
|
|
|
main().catch((error) => { |
|
console.error(error && error.stack ? error.stack : String(error)); |
|
process.exit(1); |
|
}); |
|
|