Compare commits
2 Commits
dcef58d6c5
...
6ecf565549
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ecf565549 | |||
| 73cdeadc2c |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||
"sourceCommit": "22bb3d02264980d74de65c34bbbcb81764dc65c0",
|
||||
"sourceCommit": "7fd5e15105cef21d2b1c3da860c53c5033f66256",
|
||||
"sourcePath": "quixos-protocol",
|
||||
"exportName": "quixos-protocol",
|
||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
pkgs.esbuild
|
||||
pkgs.protobuf
|
||||
pkgs.git
|
||||
pkgs.jujutsu
|
||||
pkgs.gnutar
|
||||
];
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
@@ -60,6 +62,7 @@
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out"
|
||||
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
|
||||
cp --reflink=auto --recursive grammar "$out/grammar"
|
||||
cp --reflink=auto --recursive proto "$out/proto"
|
||||
cp --reflink=auto --recursive dist "$out/dist"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# One derivation path for provisional checking and activation. The source and
|
||||
# schema come from exact committed inputs resolved by the Quixos compiler.
|
||||
{ source, schema, generator, packageRevisionId, system ? builtins.currentSystem }:
|
||||
let
|
||||
packageSource = builtins.path {
|
||||
path = /. + source;
|
||||
name = "quixos-package-source";
|
||||
filter = path: _: let name = baseNameOf path; in name != ".git" && name != ".jj";
|
||||
};
|
||||
package = builtins.getFlake ("path:" + builtins.unsafeDiscardStringContext (toString packageSource));
|
||||
checked = package.quixosPackages.${system}.checkedServer or
|
||||
(throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold.");
|
||||
in checked {
|
||||
inherit packageRevisionId;
|
||||
schema = builtins.path { path = /. + schema; name = "candidate-package-bindings.json"; };
|
||||
generator = builtins.storePath generator;
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { createHash } from "node:crypto";
|
||||
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||
import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
|
||||
import { bindingSchema, generateTypeScriptBindings, type BindingSchema, type TypeScriptBindingOptions } from "../bindings/index.js";
|
||||
import { bindingSchema } from "../bindings/index.js";
|
||||
import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js";
|
||||
const execFile = promisify(execFileCallback);
|
||||
const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
|
||||
@@ -64,116 +65,87 @@ export const snapshotRepository = async (source: string, destination: string) =>
|
||||
return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents };
|
||||
};
|
||||
|
||||
// Local mirrors accelerate resolution, but only their committed locked trees
|
||||
// may stand in for published dependencies. Never relabel dirty files as a pin.
|
||||
async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) {
|
||||
const map = publishedOnly ? {resources: []} : await localResourceSnapshots(root, filename);
|
||||
const resources = [];
|
||||
for (const [index, entry] of map.resources.entries()) {
|
||||
const directory = path.join(temporary, `dependency-${index}`);
|
||||
await checkoutCommit(entry.directory, entry.commit, directory);
|
||||
resources.push({...entry, directory});
|
||||
}
|
||||
const snapshotMap = path.join(temporary, "snapshots.json");
|
||||
await fs.writeFile(snapshotMap, JSON.stringify({resources}));
|
||||
return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap});
|
||||
}
|
||||
|
||||
export const checkResourceCandidate = async (options: {root: string; output: string; kind: "package" | "interface"; source: {repository: string; commit: string}; snapshotMap?: string; publishedOnly?: boolean}) => {
|
||||
await fs.mkdir(options.output, {mode: 0o700});
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-"));
|
||||
const blockers: string[] = [];
|
||||
let diagnostics = "";
|
||||
let treeDigest: string | undefined;
|
||||
let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined;
|
||||
try {
|
||||
const root = await snapshotRepository(options.root, path.join(temporary, "root"));
|
||||
treeDigest = root.treeDigest;
|
||||
const map = options.publishedOnly ? {resources: []} : await localResourceSnapshots(options.root, options.snapshotMap);
|
||||
const resources = [];
|
||||
for (const [index, entry] of map.resources.entries()) {
|
||||
const snapshot = await snapshotRepository(entry.directory, path.join(temporary, `dependency-${index}`));
|
||||
resources.push({...entry, directory: snapshot.directory});
|
||||
}
|
||||
const snapshotMap = path.join(temporary, "snapshots.json");
|
||||
await fs.writeFile(snapshotMap, JSON.stringify({resources}));
|
||||
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap});
|
||||
const compiled = await compileCapabilityResourceRepository({rootDirectory: root.directory, kind: options.kind, source: {resolver: "git", ...options.source}, resolveResource});
|
||||
commit = await snapshotCommit(options.root);
|
||||
treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest;
|
||||
const root = path.join(temporary, "source");
|
||||
await checkoutCommit(options.root, commit, root);
|
||||
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly);
|
||||
const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource});
|
||||
if (compiled.resource.kind === "package") {
|
||||
const configuration = JSON.parse(await fs.readFile(path.join(root.directory, "quixos.check.json"), "utf8"));
|
||||
const output = configuration.bindingOutput as string;
|
||||
if (configuration.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(output)) throw new Error("Unsupported candidate checker configuration");
|
||||
const modules = path.join(root.source, "node_modules");
|
||||
await fs.access(path.join(modules, ".bin/tsc"));
|
||||
await fs.symlink(modules, path.join(root.directory, "node_modules"), "dir");
|
||||
const destination = path.join(root.directory, output);
|
||||
await fs.mkdir(path.dirname(destination), {recursive: true});
|
||||
await fs.writeFile(destination, generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options));
|
||||
diagnostics = (await execFile(path.join(modules, ".bin/tsc"), ["--noEmit", "--pretty", "false"], {cwd: root.directory, maxBuffer: 16 * 1024 * 1024})).stdout;
|
||||
const schema = path.join(temporary, "bindings.json");
|
||||
await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled)));
|
||||
artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId);
|
||||
}
|
||||
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
|
||||
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2));
|
||||
} catch (error) {
|
||||
blockers.push(error instanceof Error ? error.message : String(error));
|
||||
diagnostics += (error as {stdout?: string; stderr?: string}).stdout ?? "";
|
||||
diagnostics += (error as {stderr?: string}).stderr ?? "";
|
||||
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||
const result = {candidateOnly: true, activationEvidence: false, treeDigest, blockers, diagnostics};
|
||||
const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers,
|
||||
note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."};
|
||||
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkWorkspaceCandidate = async (options: { root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string }) => {
|
||||
// A new output directory is the whole artifact boundary; never overwrite a prior check.
|
||||
await fs.mkdir(options.output, { mode: 0o700 });
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "quixos-candidate-"));
|
||||
const blockers: string[] = [];
|
||||
const checks: unknown[] = [];
|
||||
const snapshots = [];
|
||||
export const checkWorkspaceCandidate = async (options: {root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string}) => {
|
||||
await fs.mkdir(options.output, {mode: 0o700});
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-"));
|
||||
const blockers: string[] = [], checks: {packageRevisionId: string; artifactPath: string}[] = [];
|
||||
let commit: string | undefined;
|
||||
try {
|
||||
const root = await snapshotRepository(options.root, path.join(temporary, "root"));
|
||||
snapshots.push(root);
|
||||
const map = await localResourceSnapshots(options.root, options.snapshotMap);
|
||||
const resources = [];
|
||||
for (const [index, entry] of map.resources.entries()) {
|
||||
const source = entry.directory;
|
||||
const snapshot = await snapshotRepository(source, path.join(temporary, `resource-${index}`));
|
||||
snapshots.push(snapshot);
|
||||
resources.push({ ...entry, directory: snapshot.directory });
|
||||
commit = await snapshotCommit(options.root);
|
||||
for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) {
|
||||
const current = await snapshotCommit(entry.directory);
|
||||
const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim();
|
||||
if (await tree(current) !== await tree(entry.commit)) throw new Error(`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`);
|
||||
}
|
||||
const mapFile = path.join(temporary, "snapshots.json");
|
||||
await fs.writeFile(mapFile, JSON.stringify({ resources }));
|
||||
const resolveResource = await createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resolved"), snapshotMap: mapFile });
|
||||
const compiled = await compileWorkspaceRepository({ rootDirectory: root.directory, resolveResource });
|
||||
const root = path.join(temporary, "source");
|
||||
await checkoutCommit(options.root, commit, root);
|
||||
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap);
|
||||
const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource});
|
||||
const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
|
||||
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||
const evolution = planEvolution(baseline, compiled.workspace, { reviews });
|
||||
const evolution = planEvolution(baseline, compiled.workspace, {reviews});
|
||||
blockers.push(...evolution.blockers);
|
||||
const schema: BindingSchema = { format: "quixos-bindings", version: 1, interfaces: compiled.workspace.interfaceImports, packages: compiled.workspace.packageImports };
|
||||
for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) {
|
||||
if (resource.resource.kind !== "package") continue;
|
||||
const revision = resource.resource.revision;
|
||||
let config: { backend: string; bindingOutput: string; options?: TypeScriptBindingOptions };
|
||||
try { config = JSON.parse(await fs.readFile(path.join(resource.directory, "quixos.check.json"), "utf8")); }
|
||||
catch { blockers.push(`No candidate checker configured for ${revision.revisionId} (quixos.check.json)`); continue; }
|
||||
if (config.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(config.bindingOutput)
|
||||
|| config.bindingOutput.split("/").includes("..")) { blockers.push(`Unsupported checker or binding path for ${revision.revisionId}`); continue; }
|
||||
const sourceSnapshot = snapshots.find((entry) => entry.directory === resource.directory);
|
||||
const dependencyRoot = sourceSnapshot?.source ?? resource.directory;
|
||||
const modules = path.join(dependencyRoot, "node_modules");
|
||||
try { await fs.access(path.join(modules, ".bin", "tsc")); }
|
||||
catch { blockers.push(`Missing installed TypeScript checker/dependencies for ${revision.revisionId}; install its locked development dependencies first`); continue; }
|
||||
if (sourceSnapshot) await fs.symlink(modules, path.join(resource.directory, "node_modules"), "dir");
|
||||
const generated = generateTypeScriptBindings(schema, revision.revisionId, config.options);
|
||||
const destination = path.join(resource.directory, config.bindingOutput);
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
await fs.writeFile(destination, generated);
|
||||
let success = false, diagnostics = "";
|
||||
try { diagnostics = (await execFile(path.join(modules, ".bin", "tsc"), ["--noEmit", "--pretty", "false", "--listFiles"], { cwd: resource.directory, maxBuffer: 16 * 1024 * 1024 })).stdout; success = true; }
|
||||
catch (error) { const result = error as Error & {stdout?: string; stderr?: string}; diagnostics = `${result.stdout ?? ""}\n${result.stderr ?? result.message}`; }
|
||||
const typeInputs = [];
|
||||
for (const line of diagnostics.split(/\r?\n/)) if (path.isAbsolute(line) && /\.[cm]?tsx?$/.test(line)) {
|
||||
try { typeInputs.push({file: line, digest: bytesDigest(await fs.readFile(line))}); } catch { success = false; }
|
||||
}
|
||||
const checker = await fs.realpath(path.join(modules, ".bin", "tsc"));
|
||||
const check = { packageRevisionId: revision.revisionId, success, bindingSchemaDigest: contentDigest(schema), generatedDigest: contentDigest(generated),
|
||||
checkerDigest: contentDigest({ executable: bytesDigest(await fs.readFile(checker)), typeInputs }), diagnostics };
|
||||
checks.push(check);
|
||||
if (!success) blockers.push(`Typecheck failed for ${revision.revisionId}`);
|
||||
for (const resource of compiled.resources.filter(entry => entry.kind === "package")) {
|
||||
// Per-package recursive schema, identical to host activation, not unrelated
|
||||
// workspace declarations that would unnecessarily invalidate build caches.
|
||||
const candidate = await compileCapabilityResourceRepository({rootDirectory: resource.directory, kind: "package", source: resource.source, resolveResource});
|
||||
const schema = path.join(temporary, "bindings.json");
|
||||
await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate)));
|
||||
const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId);
|
||||
checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath});
|
||||
}
|
||||
const result = { schemaVersion: 1, candidateOnly: true, activationEvidence: false,
|
||||
sourceDigest: contentDigest(snapshots.map(({source, treeDigest}) => ({source, treeDigest}))),
|
||||
snapshots: snapshots.map(({source, treeDigest}) => ({source, treeDigest})), evolution, checks, blockers,
|
||||
note: "Local source-tree checks do not certify old Git revisions. Publication must repin the DAG and recheck final immutable artifacts." };
|
||||
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
|
||||
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers,
|
||||
note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed."};
|
||||
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.workspace, null, 2));
|
||||
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = { schemaVersion: 1, candidateOnly: true, activationEvidence: false, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)] };
|
||||
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)]};
|
||||
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {execFile as callback, spawn} from "node:child_process";
|
||||
import {promisify} from "node:util";
|
||||
import {fileURLToPath} from "node:url";
|
||||
const execFile = promisify(callback);
|
||||
const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"});
|
||||
|
||||
/** Checking snapshots jj, but never publishes or activates the working copy. */
|
||||
export async function snapshotCommit(root: string): Promise<string> {
|
||||
const run = async (...args: string[]) => (await execFile("jj", args, {cwd: root, env: environment()})).stdout.trim();
|
||||
await run("status");
|
||||
// jj resolve --list exits 1 on a clean revision. Query structured revision
|
||||
// metadata instead of depending on diagnostic wording or swallowing errors.
|
||||
if (await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict") !== "false") throw new Error("Resolve source conflicts before verification");
|
||||
const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id");
|
||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit");
|
||||
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {cwd: root, env: environment()});
|
||||
const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root});
|
||||
if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification");
|
||||
return commit;
|
||||
}
|
||||
|
||||
/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */
|
||||
export async function checkoutCommit(root: string, commit: string, destination: string) {
|
||||
await fs.mkdir(destination, {recursive: true});
|
||||
const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tar", ["-xf", "-", "-C", destination], {stdio: ["pipe", "ignore", "pipe"]});
|
||||
let error = "";
|
||||
child.stderr.on("data", chunk => {error += chunk;});
|
||||
child.on("error", reject);
|
||||
child.on("close", code => code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)));
|
||||
child.stdin.on("error", reject);
|
||||
child.stdin.end(archive.stdout);
|
||||
});
|
||||
}
|
||||
|
||||
/** Shared by provisional checks, template publication and host activation.
|
||||
* The Nix derivation is the cached check; its metadata is internal provenance,
|
||||
* not a certificate authored or approved by the workspace agent.
|
||||
*/
|
||||
export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise<string> {
|
||||
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)");
|
||||
const builder = path.join(generator, "share/checked-package.nix");
|
||||
await fs.access(builder);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn("nix", ["build", "--impure", "--file", builder,
|
||||
"--argstr", "source", source, "--argstr", "schema", schema,
|
||||
"--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId,
|
||||
"--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]});
|
||||
let output = "";
|
||||
child.stdout.on("data", chunk => {output += chunk;});
|
||||
child.on("error", reject);
|
||||
child.on("close", code => {
|
||||
const artifact = output.trim();
|
||||
if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`));
|
||||
else resolve(artifact);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {planStructure, applyStructure, type StructuralRequest} from "./structura
|
||||
import {snapshotRepository, checkResourceCandidate, checkWorkspaceCandidate} from "./candidate-check.js";
|
||||
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js";
|
||||
import {createGitCapabilityResolver} from "./git-resolver.js";
|
||||
import {snapshotCommit} from "./checked-build.js";
|
||||
const execFile = promisify(callback);
|
||||
type Source = {repository: string; commit: string};
|
||||
export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source};
|
||||
@@ -47,7 +48,7 @@ export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpe
|
||||
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||
const root = await location(workbench, "root");
|
||||
const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: {
|
||||
repository: await command(root, "git", ["remote", "get-url", "origin"]),
|
||||
repository: await command(root, "git", ["config", "--get", "remote.origin.url"]),
|
||||
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
|
||||
}}, ...graph.resources.map((entry: {kind: "interface" | "package"; directory: string; source: Source}) => ({kind: entry.kind, source: entry.source,
|
||||
directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))];
|
||||
@@ -75,7 +76,7 @@ export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec):
|
||||
const nodes: NodePlan[] = [];
|
||||
for (const node of spec.nodes) {
|
||||
const root = await location(workbench, node.directory);
|
||||
if (await command(root, "git", ["remote", "get-url", "origin"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
|
||||
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
|
||||
const loaded = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||
if (!loaded.ok) throw new Error(`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||
const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value));
|
||||
@@ -113,11 +114,7 @@ const effects: UpgradeEffects = {
|
||||
if (evolution?.reviews.some((review) => !review.accepted)) throw new Error("Explicit semantic-major review required before publishing the workspace");
|
||||
},
|
||||
async snapshot(root) {
|
||||
// Unlike checking, publication deliberately captures the working copy.
|
||||
await command(root, "jj", ["status"]);
|
||||
const conflicts = await command(root, "jj", ["resolve", "--list"]);
|
||||
if (conflicts) throw new Error("Resolve source conflicts before publication");
|
||||
const commit = await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
const commit = await snapshotCommit(root);
|
||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit");
|
||||
await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]);
|
||||
const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0"));
|
||||
@@ -152,7 +149,7 @@ export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, im
|
||||
if (!journalId) await writeJournal(filename, journal);
|
||||
for (const node of plan.nodes) {
|
||||
const root = await location(plan.workbench, node.directory);
|
||||
if (await command(root, "git", ["remote", "get-url", "origin"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning");
|
||||
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning");
|
||||
let step = journal.steps.find((entry) => entry.directory === node.directory);
|
||||
if (step?.phase === "published") continue;
|
||||
if (!step) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {StructuralRequest} from "./structural-plan.js";
|
||||
type Source = {repository: string; commit: string};
|
||||
type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
|
||||
export type ScaffoldRecipe = {
|
||||
template?: "typescript" | "typescript-react";
|
||||
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
||||
declaration?: string;
|
||||
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
|
||||
@@ -49,23 +50,31 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
||||
if (command === "package") {
|
||||
const name = safeName(spec.name);
|
||||
if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) throw new Error("Unknown package template");
|
||||
const react = spec.template === "typescript-react";
|
||||
if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
||||
Object.values(spec.tools).forEach(source);
|
||||
registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
||||
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
|
||||
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`);
|
||||
if (react) registry.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"});
|
||||
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`);
|
||||
create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []}));
|
||||
create("package.json", json({name: `@quixos/${name.toLowerCase()}`, version: "0.1.0", private: true, type: "module", packageManager: "yarn@4.18.0",
|
||||
scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`},
|
||||
devDependencies: {"@types/node": "^24", typescript: "^7.0.2"}}));
|
||||
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, outDir: "dist", skipLibCheck: true}, include: ["src/**/*.ts"]}));
|
||||
scripts: {build: `tsc -p tsconfig.json${react ? " && node scripts/build-component.mjs" : ""}`, typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`},
|
||||
devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(react ? {react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12"} : {})}}));
|
||||
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, types: ["node", ...(react ? ["react"] : [])], outDir: "dist", rootDir: "src", skipLibCheck: true, ...(react ? {jsx: "react-jsx", esModuleInterop: true} : {})}, include: ["src/**/*.ts", "src/**/*.tsx"]}));
|
||||
if (react) {
|
||||
create("src/component.tsx", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`);
|
||||
create("src/impl/sourceGet.ts", `import {readFile} from "node:fs/promises";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => readFile(new URL("./component.mjs", import.meta.url), "utf8");\n`);
|
||||
create("scripts/build-component.mjs", `import {build} from "esbuild";\nconst platform = new Map([\n ["react", "/__quixos/platform/react/v18.mjs"],\n ["react/jsx-runtime", "/__quixos/platform/react-jsx-runtime/v18.mjs"],\n ["react/jsx-dev-runtime", "/__quixos/platform/react-jsx-dev-runtime/v18.mjs"],\n ["@quixos/web-studio-react-runtime", "/__quixos/platform/web-studio-react-runtime/v1.mjs"],\n]);\nawait build({entryPoints: ["dist/component.js"], outfile: "dist/component.mjs", bundle: true, format: "esm", platform: "browser", target: "es2022", plugins: [{name: "quixos-platform", setup(api) {api.onResolve({filter: /.*/}, ({path}) => platform.has(path) ? {path: platform.get(path), external: true} : undefined);}}]});\n`);
|
||||
}
|
||||
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
|
||||
create(".yarnrc.yml", `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`);
|
||||
const nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
|
||||
const plugin = new URL(nixifyPluginUrl);
|
||||
if (plugin.protocol !== "https:" || plugin.username || plugin.password || plugin.search || plugin.hash || !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)) throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
|
||||
generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl}));
|
||||
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/generated-bindings.ts"}));
|
||||
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts"}));
|
||||
create("flake.nix", `{
|
||||
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
||||
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
||||
@@ -74,17 +83,9 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
||||
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
||||
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
||||
bindings = { system, ... }: {
|
||||
generator = (builtins.getAttr system protocol.packages).default;
|
||||
repository = ${nixString(spec.source.repository)};
|
||||
commit = self.rev or (throw "Publish an exact package revision before building an activation artifact");
|
||||
packageRevisionId = ${nixString(spec.revision)};
|
||||
output = "src/generated-bindings.ts";
|
||||
resources = map (entry: entry // { directory = builtins.fetchGit { url = entry.repository; rev = entry.commit; ref = "refs/tags/quixos-reachability/" + entry.commit; }; }) (builtins.fromJSON (builtins.readFile ./quixos.resources.json)).resources;
|
||||
};
|
||||
bundle = { entry = "dist/server.js"; };
|
||||
migrationEntrypoint = "dist/migrate.js";
|
||||
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
||||
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; ${react ? 'extraFiles = [ { source = "dist/component.mjs"; target = "component.mjs"; } ];' : ""} };
|
||||
};
|
||||
}\n`);
|
||||
generated("quixos.resources.json", json({generatedBy: "qx-scaffold-v1", resources: []}));
|
||||
@@ -107,7 +108,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: registry.id}, source: declaration}]});
|
||||
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
|
||||
const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
|
||||
: `import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
|
||||
: `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
|
||||
create(file, implementation);
|
||||
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
||||
if (command === "migration") {
|
||||
@@ -129,7 +130,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id)));
|
||||
generated("quixos.scaffold.json", json(registry));
|
||||
generated("quixos.migrations.json", json(catalog));
|
||||
generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./generated-bindings.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") +
|
||||
generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") +
|
||||
`servePackageRuntime(createRuntime({\n` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`);
|
||||
const migrations = registry.exports.filter((entry) => entry.migration);
|
||||
generated("src/migrate.ts", marker + `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type StructuralRequest = {
|
||||
type Change = {file: string; before: string | null; after: string; mode: number};
|
||||
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]};
|
||||
const safeFile = (file: string) => {
|
||||
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|json|nix|txtpb))$/.test(file)
|
||||
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|mjs|json|nix|txtpb))$/.test(file)
|
||||
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`);
|
||||
};
|
||||
const read = async (root: string, file: string): Promise<string | null> => {
|
||||
|
||||
@@ -11,9 +11,61 @@ 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-"));
|
||||
@@ -47,8 +99,18 @@ const main = async () => {
|
||||
}
|
||||
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 spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe;
|
||||
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;
|
||||
@@ -56,10 +118,15 @@ const main = async () => {
|
||||
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"]], ["corepack", ["yarn", "typecheck"]], ["nix", ["flake", "lock"]]] as const) {
|
||||
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;
|
||||
|
||||
@@ -37,7 +37,7 @@ test("candidate check produces explicitly non-activation evidence and never over
|
||||
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "source"), output = path.join(directory, "check");
|
||||
await fs.mkdir(root);
|
||||
await execFile("git", ["-C", root, "init"]);
|
||||
await execFile("jj", ["git", "init", "--colocate", root]);
|
||||
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`);
|
||||
await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`);
|
||||
const result = await checkWorkspaceCandidate({root, output});
|
||||
|
||||
@@ -8,6 +8,48 @@ import {execFile as callback} from "node:child_process";
|
||||
import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js";
|
||||
const execFile = promisify(callback);
|
||||
|
||||
test("real jj snapshots and immutable Git publication propagate a changed interface into the root", async (context) => {
|
||||
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-"));
|
||||
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||
// Exercise the actual effects with local bare remotes, without external writes.
|
||||
const environment = {
|
||||
GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`,
|
||||
GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1",
|
||||
};
|
||||
const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]]));
|
||||
Object.assign(process.env, environment);
|
||||
context.after(() => {for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value;});
|
||||
const run = async (cwd: string, command: string, args: string[]) => (await execFile(command, args, {cwd})).stdout.trim();
|
||||
await fs.mkdir(path.join(workbench, "remotes"));
|
||||
const nodes = [];
|
||||
for (const [kind, directory, remote] of [["interface", "resources/Named", "named.git"], ["workspace", "root", "workspace.git"]] as const) {
|
||||
const root = path.join(workbench, directory);
|
||||
await fs.mkdir(root, {recursive: true});
|
||||
await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]);
|
||||
await run(root, "jj", ["git", "init", "--colocate"]);
|
||||
await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]);
|
||||
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
|
||||
const child = nodes[0];
|
||||
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://upgrade.test/quixos.git"; commit "${"a".repeat(40)}"; } ${child ? `interface Named source { repository "${child.source.repository}"; commit "${child.source.commit}"; }` : ""} }`);
|
||||
await fs.writeFile(path.join(root, `${kind}.qx`), kind === "interface" ? 'interface Named id "interface:named" revision "interface:named@1" {}' : `workspace W id "workspace:w" revision "workspace:w@1" commit "${"a".repeat(40)}" { import interface Named; atom A id "atom:a"; }`);
|
||||
await run(root, "jj", ["describe", "-m", "Initial source"]);
|
||||
const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]);
|
||||
nodes.push({kind, directory, source: {repository: `https://upgrade.test/${remote}`, commit}});
|
||||
}
|
||||
await fs.mkdir(path.join(workbench, ".quixos"));
|
||||
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [nodes[0]]}));
|
||||
await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n");
|
||||
const plan = await planPinUpgrades(workbench, {nodes, bootstrap: true});
|
||||
const result = await applyPinUpgrades(plan);
|
||||
assert.equal(result.activated, false);
|
||||
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||
const commit = graph.resources[0].source.commit;
|
||||
assert.notEqual(commit, nodes[0].source.commit);
|
||||
assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(commit));
|
||||
assert.match(await run(workbench, "git", ["--git-dir", path.join(workbench, "remotes/named.git"), "show-ref"]), new RegExp(commit));
|
||||
});
|
||||
|
||||
test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => {
|
||||
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-"));
|
||||
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||
|
||||
@@ -10,6 +10,17 @@ import {planStructure, applyStructure} from "../src/capability-language/structur
|
||||
import {contentDigest} from "../src/capability-model/evolution.js";
|
||||
const execFile = promisify(callback);
|
||||
|
||||
test("React preset applies its browser build script and shared-platform imports", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-react-recipe-"));
|
||||
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||
await execFile("git", ["-C", root, "init"]);
|
||||
const source = {repository: "https://example.test/react.git", commit: "a".repeat(40)};
|
||||
const request = await scaffoldRecipe(root, "package", {source, name: "React", id: "package:react", revision: "package:react@1", template: "typescript-react", tools: {quixos: source, protocol: source, helpers: source, sdk: source}});
|
||||
await applyStructure(await planStructure(root, request));
|
||||
assert.match(await fs.readFile(path.join(root, "scripts/build-component.mjs"), "utf8"), /__quixos\/platform\/react/);
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, "react-jsx");
|
||||
});
|
||||
|
||||
test("package/function/migration scaffolds register implementations and refresh code digests without overwriting code", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-"));
|
||||
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||
@@ -21,7 +32,7 @@ test("package/function/migration scaffolds register implementations and refresh
|
||||
await apply("package", {...base, name: "Chess", id: "package:chess", revision: "package:chess@1", tools: {quixos: source, protocol: source, helpers: source, sdk: source}});
|
||||
await apply("function", {...base, name: "play", id: "export:play"});
|
||||
const filename = path.join(root, base.directory, "src/impl/play.ts");
|
||||
const edited = 'import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation["play"] = async () => null;\n';
|
||||
const edited = 'import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["play"] = async () => null;\n';
|
||||
await fs.writeFile(filename, edited);
|
||||
const old = {version: 1}, next = {version: 2}, from = contentDigest(old), to = contentDigest(next);
|
||||
await apply("migration", {...base, name: "upgrade", id: "export:upgrade", migration: {id: "upgrade-v2", scopeId: "board", from, to, predecessors: [], ports: [], contracts: {[from]: old, [to]: next}}});
|
||||
@@ -31,7 +42,7 @@ test("package/function/migration scaffolds register implementations and refresh
|
||||
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
||||
const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8"));
|
||||
assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8")));
|
||||
const bindings = await fs.readFile(path.join(root, base.directory, "src/generated-bindings.ts"), "utf8");
|
||||
const bindings = await fs.readFile(path.join(root, base.directory, "src/gen/qx.ts"), "utf8");
|
||||
assert.match(bindings, /export:play/);
|
||||
assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/);
|
||||
if (process.env.QX_SCAFFOLD_TEST_SDK) {
|
||||
|
||||
Reference in New Issue
Block a user