import fs from "node:fs/promises"; import path from "node:path"; import { contentDigest } from "../capability-model/evolution.js"; import { validateMigrationCatalog, type MigrationCatalog, type MigrationDeclaration, } from "../capability-model/migrations.js"; import { formatQuixosLock, type GitSource } from "../resource-lock/index.js"; import { parseQx, walkSyntax } from "./source.js"; import type { StructuralRequest } from "./structural-plan.js"; import { addImplementation } from "./implementation-edit.js"; import { reactPlatformTypes } from "../bindings/react-platform.js"; type Source = { repository: string; commit: string }; type PackageEditModel = { 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; /** Initial authored files for a composed recipe; only used for a new package. */ initialFiles?: Record; tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source }; nixifyPluginUrl?: string; migration?: Omit & { contracts: Record }; }; const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; const source = (value: Source): GitSource => { if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository"); const url = new URL(value.repository); if ( url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit) ) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits"); return { resolver: "git", ...value }; }; const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`; const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${"); const safeName = (name: string | undefined): string => { if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name"); return name; }; const ownedJson = async (root: string, file: string): Promise => { const target = path.join(root, file); if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) throw new Error("Scaffold input escapes repository"); const value = JSON.parse(await fs.readFile(target, "utf8")); if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`); return value; }; /** Recipes describe structural edits; planStructure owns validation/journaling. * Implementation files are created once; later edits preserve authored wiring. */ export const scaffoldRecipe = async ( root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe, ): Promise => { if (command === "refresh") throw new Error( "Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.", ); source(spec.source); if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained"); const prefix = spec.directory ? `${spec.directory}/` : ""; const files: StructuralRequest["files"] = []; const create = (file: string, content: string) => files.push({ file: prefix + file, create: content }); const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content }); let packageModel: PackageEditModel; 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); packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] }; catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] }; if (react) packageModel.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", ...(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", `// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record; render: unknown; dispatch: (action: unknown) => void}) {\n return

${name}

;\n}\n`, ); create( "src/impl/sourceGet.ts", `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`, ); create( "src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`, ); create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes); } 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/gen/qx.ts", ...(react ? { options: { react: { propsExports: [] } }, } : {}), }), ); create( "flake.nix", `{ inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; inputs.nixpkgs.follows = "protocol/nixpkgs"; inputs.flake-utils.follows = "protocol/flake-utils"; inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; }; outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }: (import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake { inherit inputs nixpkgs flake-utils; packageRoot = ./.; bundle = { entry = ${JSON.stringify(react ? "src/server.ts" : "dist/server.js")}; ${react ? "browserSources = true;" : ""} }; migrationEntrypoint = "dist/migrate.js"; installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; }; }; }\n`, ); } else { catalog = await ownedJson(root, prefix + "quixos.migrations.json"); // Derive the edit model from authored declarations; it is never persisted. const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8"); const syntax = parseQx(authored); if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first"); const declaration = [...walkSyntax(syntax.root)].find((node) => node.kind === "packageResourceDecl"); if (!declaration) throw new Error("Expected a package declaration"); const text = (node: typeof declaration) => authored.slice(node.start, node.end); const literals = declaration.children.filter((node) => node.kind === "stringLiteral"); packageModel = { generatedBy: "qx-scaffold-v1", name: text(declaration.children.find((node) => node.kind === "identifier")!), id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: [], }; for (const node of walkSyntax(declaration)) { if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue; const name = safeName(text(node.children.find((child) => child.kind === "identifier")!)); const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!)); if (packageModel.exports.some((entry) => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID"); const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id); packageModel.exports.push({ name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? { migration: true } : {}), }); } { const name = safeName(spec.name); if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID"); const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`; const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`); const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind), ); if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration"); const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`; const node = exports[0]; const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause"); if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) throw new Error( "Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint", ); if ( wrapped.slice( node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end, ) !== name || JSON.parse( wrapped.slice( node.children.find((child) => child.kind === "stringLiteral")!.start, node.children.find((child) => child.kind === "stringLiteral")!.end, ), ) !== spec.id ) throw new Error("Declaration name/ID must match its registration"); files.push({ file: prefix + "package.qx", edits: [ { operation: "append", parent: { kind: "packageResourceDecl", id: packageModel.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 => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\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); packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) }); if (command === "migration") { if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition"); const { contracts, ...transition } = spec.migration; for (const [digest, contract] of Object.entries(contracts)) { if ( contentDigest(contract) !== digest || (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest) ) throw new Error("Retained migration contract mismatch"); catalog.contracts[digest] = contract; } catalog.migrations.push({ ...transition, implementation: { exportId: spec.id, file, digest: contentDigest(implementation) }, }); } } } validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id))); generated("quixos.migrations.json", json(catalog)); if (command !== "package") { const entry = packageModel.exports.at(-1)!; const file = prefix + "src/server.ts"; const before = await fs.readFile(path.join(root, file), "utf8"); files.push({ file, expected: before, replace: addImplementation( before, "createRuntime", entry.name, `./${entry.file.slice(4, -3)}.js`, !!entry.migration, ), }); if (entry.migration) { const file = prefix + "src/migrate.ts"; const before = await fs.readFile(path.join(root, file), "utf8"); files.push({ file, expected: before, replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`), }); } } else { create( "src/server.ts", `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + packageModel.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` + packageModel.exports .map( (entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`, ) .join("\n") + `\n}));\n`, ); const migrations = packageModel.exports.filter((entry) => entry.migration); create( "src/migrate.ts", `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`, ); } generated( "descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + packageModel.exports .map( (entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`, ) .join(""), ); if (spec.initialFiles) { if (command !== "package") throw new Error("initialFiles is only valid when creating a package"); const authored = spec.initialFiles["package.qx"]; if (authored !== undefined) { const parsed = parseQx(authored); const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl"); const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? []; const identifier = declaration?.children.find((n) => n.kind === "identifier"); if ( parsed.diagnostics.length || literals.length !== 2 || !identifier || authored.slice(identifier.start, identifier.end) !== spec.name || JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id || JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision ) throw new Error("Initial package declaration must match the provisioned name, ID and revision"); } for (const [file, content] of Object.entries(spec.initialFiles)) { if ( typeof content !== "string" || ["quixos.lock", "flake.nix", "quixos.toolchain.json", "package.json"].includes(file) ) throw new Error(`Not an initial authored file: ${file}`); if (file === "quixos.check.json") { const check = JSON.parse(content); if ( check.backend !== "typescript" || check.bindingOutput !== "src/gen/qx.ts" || Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key)) ) throw new Error( "Initial check configuration may customize binding options, not the scaffold verification backend or output", ); } const existing = files.findIndex((entry) => entry.file === prefix + file); if (existing >= 0) files.splice(existing, 1); create(file, content); } // A composed React recipe supplies its own modules and complete server. if (reactRecipe(spec)) { for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"]) if (!(file in spec.initialFiles)) { const index = files.findIndex((entry) => entry.file === prefix + file); if (index >= 0) files.splice(index, 1); } } } return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files }; }; const reactRecipe = (spec: ScaffoldRecipe) => spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"];