Files
quixos-protocol/src/capability-language/tool-cli.ts
T

194 lines
14 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
import { parseQx, formatQx, lintQx } from "./source.js";
import { scaffoldAtom } from "./scaffold.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution } from "../capability-model/index.js";
import { checkWorkspaceCandidate, checkResourceCandidate, snapshotRepository } from "./candidate-check.js";
import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js";
import path from "node:path";
import os from "node:os";
import {spawnSync} from "node:child_process";
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js";
import {buildCheckedPackage, snapshotCommit} from "./checked-build.js";
import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
const authorSource = async (root: string) => {
const result = spawnSync("git", ["remote", "get-url", "origin"], {cwd: root, encoding: "utf8"});
if (result.error || result.status !== 0) throw new Error("Managed resource has no origin");
return {repository: result.stdout.trim(), commit: await snapshotCommit(root)};
};
const main = async () => {
const [command, ...args] = process.argv.slice(2);
if (command === "scaffold-dependency") {
const [root, kind, name, repository, commit, ...flags] = args;
if (!root || !["interface", "package"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || !repository || !commit || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]");
const resourceKind = kind as "package" | "interface";
let entrypoint: "workspace" | "package" | "interface" | undefined;
for (const candidate of ["workspace", "package", "interface"] as const) {
try {await readFile(path.join(root, `${candidate}.qx`)); if (entrypoint) throw new Error("Ambiguous repository entrypoint"); entrypoint = candidate;}
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
}
if (!entrypoint) throw new Error("No QX repository entrypoint");
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
if (!lock.ok) throw new Error("Invalid resource lock");
let target = "quixos.lock";
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8"));
if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file;
}
const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), files: [
{file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]},
{file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]},
]};
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
process.stdout.write(`${JSON.stringify({...plan, applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
return;
}
if (command === "scaffold-interface") {
const [root, specFile, ...flags] = args;
if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
const spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe;
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
const request: StructuralRequest = {kind: "interface", source: spec.source, files: [
{file: "interface.qx", create: `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`},
{file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})},
{file: ".gitignore", create: ".quixos/\n"},
]};
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
process.stdout.write(`${JSON.stringify({...plan, applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
return;
}
if (command === "build-package") {
if (args.length !== 3) throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID");
process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`);
return;
}
if (command === "source-digest") {
if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT");
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-"));
try {process.stdout.write(`${(await snapshotRepository(args[0], temporary)).treeDigest}\n`);} finally {await rm(temporary, {recursive: true, force: true});}
return;
}
if (command === "pin-upgrade") {
const [workbench, specFile, ...flags] = args;
if (!workbench || !specFile) throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]");
let publish = false, acceptEdits = false, resume: string | undefined;
for (let index = 0; index < flags.length; index++) {
if (flags[index] === "--publish") publish = true;
else if (flags[index] === "--accept-edits") acceptEdits = true;
else if (flags[index] === "--resume" && /^[a-f0-9-]{36}$/.test(flags[index + 1] ?? "")) resume = flags[++index];
else throw new Error(`Unknown pin-upgrade option ${flags[index]}`);
}
if (acceptEdits && !resume) throw new Error("--accept-edits requires an existing refactor journal (--resume)");
const plan = resume ? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan
: await planPinUpgrades(workbench, specFile === "auto" ? await discoverUpgradeSpec(workbench) : JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec);
if (resume && path.resolve(workbench) !== plan.workbench) throw new Error("Upgrade journal belongs to another workbench");
process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`);
return;
}
if (command === "check-resource") {
const [root, kind, repository, commit, output, ...extra] = args;
if (!root || !output || !["package", "interface"].includes(kind) || extra.length) throw new Error("usage: quixos-qx check-resource ROOT package|interface REPOSITORY COMMIT OUTPUT");
const result = await checkResourceCandidate({root, kind: kind as "package" | "interface", source: {repository, commit}, output, publishedOnly: true});
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (result.blockers.length) process.exitCode = 1;
return;
}
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-refresh"].includes(command)) {
const [root, specFile, ...flags] = args;
let spec: ScaffoldRecipe;
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
const registry = JSON.parse(await readFile(path.join(root, "quixos.scaffold.json"), "utf8"));
spec = {source: await authorSource(root), name: specFile, id: `export:${registry.name}:${specFile}`};
const declaration = flags.indexOf("--declaration");
if (declaration >= 0) {
if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file");
spec.declaration = await readFile(flags[declaration + 1], "utf8");
flags.splice(declaration, 2);
}
} else spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe;
if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]");
const request = await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration" | "refresh", spec);
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
if (flags.includes("--install")) {
const cwd = path.resolve(root, spec.directory ?? "");
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8"));
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") throw new Error("Missing scaffold toolchain");
for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]]] as const) {
const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]});
if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`);
}
try {await readFile(path.join(cwd, "yarn-project.nix"));}
catch {throw new Error("Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.");}
await snapshotCommit(cwd);
const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]});
if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed");
}
process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`);
return;
}
if (command === "scaffold-structure") {
const [root, spec, ...flags] = args;
if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]");
const request = JSON.parse(await readFile(spec, "utf8")) as StructuralRequest;
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`);
return;
}
if (command === "scaffold-resume") {
const [root, id, ...extra] = args;
if (!root || !id || extra.length) throw new Error("usage: quixos-qx scaffold-resume ROOT JOURNAL_ID");
process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`);
return;
}
if (command === "check") {
const [root, output, ...flags] = args;
if (!root || !output || flags.length % 2) throw new Error("usage: quixos-qx check ROOT OUTPUT [--snapshot-map FILE] [--baseline FILE] [--reviews FILE]");
const values = new Map<string, string>();
for (let index = 0; index < flags.length; index += 2) {
if (!["--snapshot-map", "--baseline", "--reviews"].includes(flags[index])) throw new Error(`Unknown check option ${flags[index]}`);
values.set(flags[index], flags[index + 1]);
}
const result = await checkWorkspaceCandidate({ root, output, snapshotMap: values.get("--snapshot-map"), baseline: values.get("--baseline"), reviews: values.get("--reviews") });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (result.blockers.length) process.exitCode = 1;
return;
}
if (command === "evolution") {
const [baseline, candidate, reviews, ...extra] = args;
if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]");
const before = baseline === "none" ? null : JSON.parse(await readFile(baseline, "utf8"));
const after = JSON.parse(await readFile(candidate, "utf8"));
const decisions = reviews ? JSON.parse(await readFile(reviews, "utf8")) : [];
if (!Array.isArray(decisions)) throw new Error("Reviews must be an array");
process.stdout.write(`${JSON.stringify(planEvolution(before, after, { reviews: decisions }), null, 2)}\n`);
return;
}
if (command === "scaffold-atom") {
const [root, name, id, ...flags] = args;
if (!root || !name || !id || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-atom ROOT NAME ID [--write]");
const resolveResource = await createGitCapabilityResolver({ checkoutRoot: `${root}/.quixos/resource-checkouts` });
const plan = await scaffoldAtom({ root, name, id, write: flags.includes("--write"), resolveResource });
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return;
}
const [file, flag, ...rest] = args;
if (!file || rest.length || (flag && flag !== "--write") || !["parse", "lint", "format"].includes(command ?? "") ||
(flag && command !== "format")) throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]");
const source = await readFile(file, "utf8");
if (command === "format") {
const formatted = formatQx(source);
if (flag) await writeFile(file, formatted); else process.stdout.write(formatted);
} else {
const result = command === "parse" ? parseQx(source, file) : lintQx(source, file);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (Array.isArray(result) ? result.some((entry) => entry.severity === "error") : result.diagnostics.length) process.exitCode = 1;
}
};
main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });