Build and activate checked component packages through normal source authoring (WI-12)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parseQx, walkSyntax } from "./source.js";
|
||||
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||
import type { StructuralRequest } from "./structural-plan.js";
|
||||
|
||||
/** Add one ordinary component entry without regenerating existing application code. */
|
||||
export async function scaffoldComponent(
|
||||
root: string,
|
||||
prefix: string,
|
||||
spec: ScaffoldRecipe,
|
||||
): Promise<StructuralRequest> {
|
||||
if (!spec.declaration || !spec.name || !/^[A-Z][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
||||
throw Error("Component scaffold requires a PascalCase name, ID and QX declaration");
|
||||
const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`;
|
||||
const syntax = parseQx(source);
|
||||
const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "componentDecl");
|
||||
const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||
if (
|
||||
syntax.diagnostics.length ||
|
||||
syntax.root.children.length !== 1 ||
|
||||
declarations.length !== 1 ||
|
||||
pkg?.children.filter((node) => node.kind === "packageExport").length !== 1
|
||||
)
|
||||
throw Error("Expected exactly one component declaration");
|
||||
const declaration = declarations[0]!;
|
||||
const identifier = declaration.children.find((node) => node.kind === "identifier");
|
||||
const id = declaration.children.find((node) => node.kind === "stringLiteral");
|
||||
if (
|
||||
!identifier ||
|
||||
source.slice(identifier.start, identifier.end) !== spec.name ||
|
||||
!id ||
|
||||
JSON.parse(source.slice(id.start, id.end)) !== spec.id
|
||||
)
|
||||
throw Error("Component declaration name/ID must match its registration");
|
||||
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||
const existing = parseQx(authored);
|
||||
const parent = existing.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||
if (existing.diagnostics.length || !parent) throw Error("Fix package.qx before adding a component");
|
||||
for (const item of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) {
|
||||
const name = item.children.find((node) => node.kind === "identifier");
|
||||
const literal = item.children.find((node) => node.kind === "stringLiteral");
|
||||
if (
|
||||
(name && authored.slice(name.start, name.end) === spec.name) ||
|
||||
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||||
)
|
||||
throw Error("Component requires a unique name and ID");
|
||||
}
|
||||
const filename = "quixos.components.json";
|
||||
const before = await fs.readFile(path.join(root, prefix, filename), "utf8");
|
||||
const entries: Record<string, { entry: string; export: string }> = JSON.parse(before);
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries) || Object.hasOwn(entries, spec.id))
|
||||
throw Error("Invalid or duplicate component entry");
|
||||
entries[spec.id] = { entry: `src/components/${spec.name}.tsx`, export: spec.name };
|
||||
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
|
||||
return {
|
||||
kind: "package",
|
||||
source: spec.source,
|
||||
resourceRoot: spec.directory,
|
||||
validation: "syntax",
|
||||
files: [
|
||||
{
|
||||
file: prefix + "package.qx",
|
||||
edits: [
|
||||
{
|
||||
operation: "append",
|
||||
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(parentID.start, parentID.end)) },
|
||||
source: spec.declaration,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ file: prefix + filename, expected: before, replace: JSON.stringify(entries, null, 2) + "\n" },
|
||||
{
|
||||
file: prefix + `src/components/${spec.name}.tsx`,
|
||||
create: `import type { Components } from "../gen/client.js";\n\n// Add explicit query admission and named interface operations from the generated client.\nexport const ${spec.name}: Components[${JSON.stringify(spec.name)}] = ({subject}) => <p>{subject.object}</p>;\n`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||
import type { StructuralRequest } from "./structural-plan.js";
|
||||
export async function scaffoldController(
|
||||
root: string,
|
||||
prefix: string,
|
||||
spec: ScaffoldRecipe,
|
||||
): Promise<StructuralRequest> {
|
||||
const entry = { id: spec.id, ...spec.controller };
|
||||
const fields = ["id", "subject", "snapshot", "remote", "input"];
|
||||
if (
|
||||
Object.keys(entry).length !== fields.length ||
|
||||
fields.some((key) => typeof (entry as Record<string, unknown>)[key] !== "string") ||
|
||||
!["unit", "value"].includes(entry.input ?? "")
|
||||
)
|
||||
throw Error("Controller requires id, subject, snapshot, remote and input (unit/value)");
|
||||
const file = prefix + "quixos.controllers.json";
|
||||
let before: string | undefined;
|
||||
try {
|
||||
before = await fs.readFile(path.join(root, file), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const catalog = before ? JSON.parse(before) : { schemaVersion: 1, controllers: [] };
|
||||
if (
|
||||
catalog.schemaVersion !== 1 ||
|
||||
!Array.isArray(catalog.controllers) ||
|
||||
catalog.controllers.length >= 1000 ||
|
||||
catalog.controllers.some((c: { id: string }) => c.id === entry.id)
|
||||
)
|
||||
throw Error("Invalid catalog or duplicate controller ID");
|
||||
catalog.controllers.push(entry);
|
||||
const content = JSON.stringify(catalog, null, 2) + "\n";
|
||||
return {
|
||||
kind: "package",
|
||||
source: spec.source,
|
||||
resourceRoot: spec.directory,
|
||||
validation: "syntax",
|
||||
files: [before === undefined ? { file, create: content } : { file, expected: before, replace: content }],
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function executionScaffold(
|
||||
): Promise<StructuralRequest> {
|
||||
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
||||
throw new Error("Execution scaffold requires a simple name and an ID");
|
||||
if (spec.initialFiles) throw new Error("Edit execution source after scaffolding; initialFiles is not supported");
|
||||
if (spec.initialFiles && command !== "package") throw new Error("Initial files are only for new packages");
|
||||
const files: StructuralRequest["files"] = [];
|
||||
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
||||
if (command === "package") {
|
||||
@@ -37,6 +37,7 @@ export async function executionScaffold(
|
||||
formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }),
|
||||
);
|
||||
create("quixos.check.json", json({ backend: "execution" }));
|
||||
create("quixos.components.json", "{}\n");
|
||||
create("package.json", json({ name: `@quixos/${spec.name.toLowerCase()}`, private: true, type: "module" }));
|
||||
create(".gitignore", "node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n");
|
||||
for (const lane of ["browser", "server", "remote"])
|
||||
@@ -55,11 +56,15 @@ export async function executionScaffold(
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
||||
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId, react ? null }:
|
||||
(import (helpers + "/artifacts.nix")) {
|
||||
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
||||
protocol = generator;
|
||||
src = ./.;
|
||||
components = let entries = builtins.fromJSON (builtins.readFile ./quixos.components.json); in
|
||||
if entries == {} then null else
|
||||
if react == null then throw "Component entries require the workspace-selected React runtime" else
|
||||
{ inherit (react) sdk runtime; inherit entries; };
|
||||
portable = {
|
||||
entry = "src/server.ts";
|
||||
entries.browser = "src/browser.ts";
|
||||
@@ -73,7 +78,7 @@ export async function executionScaffold(
|
||||
);
|
||||
create(
|
||||
"README.md",
|
||||
`# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The coordinator supplies the checked workspace plan\nand runtime dependencies to checkedArtifacts.\n\nThe execution scaffold currently requires the execution artifact build path;\nlegacy checkedServer verification and activation do not install these artifacts.\nA source-only structural check is not runtime qualification.\n`,
|
||||
`# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The coordinator supplies the checked workspace plan\nand runtime dependencies to checkedArtifacts.\n\nRun qx-workspace check from the workspace root to compile exact source and build\nchecked artifacts. Activate the resulting report separately through the workspace\nhost. A source-only structural check is not runtime qualification.\n`,
|
||||
);
|
||||
} else if (command === "function") {
|
||||
if (!spec.declaration)
|
||||
@@ -154,5 +159,20 @@ export async function executionScaffold(
|
||||
}
|
||||
} else
|
||||
throw new Error("Execution packages use ordinary factory transactions; migration scaffolding is not supported");
|
||||
if (spec.initialFiles) {
|
||||
for (const [file, content] of Object.entries(spec.initialFiles)) {
|
||||
if (
|
||||
typeof content !== "string" ||
|
||||
!/^(?:package\.qx|quixos\.components\.json|quixos\.controllers\.json|README\.md|(?:src|queries)\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+)$/.test(
|
||||
file,
|
||||
) ||
|
||||
file.split("/").some((part) => part === ".." || part === "." || part === "gen")
|
||||
)
|
||||
throw new Error("Not an initial authored execution file: " + file);
|
||||
const previous = files.findIndex((entry) => entry.file === prefix + file);
|
||||
if (previous >= 0) files.splice(previous, 1);
|
||||
create(file, content);
|
||||
}
|
||||
}
|
||||
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
||||
}
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
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 { GitSource } from "../resource-lock/index.js";
|
||||
import type { StructuralRequest } from "./structural-plan.js";
|
||||
import { addImplementation } from "./implementation-edit.js";
|
||||
import { reactPlatformTypes } from "../bindings/react-platform.js";
|
||||
import { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js";
|
||||
import { scaffoldController } from "./controller-scaffold.js";
|
||||
import { scaffoldComponent } from "./component-scaffold.js";
|
||||
import { scaffoldQuery } from "./query-scaffold.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" | "typescript-execution";
|
||||
template?: "typescript-execution" | "typescript-component";
|
||||
source: Source;
|
||||
directory?: string;
|
||||
name?: string;
|
||||
@@ -31,16 +14,13 @@ export type ScaffoldRecipe = {
|
||||
revision?: string;
|
||||
declaration?: string;
|
||||
queryDocument?: string;
|
||||
/** Initial authored files for a composed recipe; only used for a new package. */
|
||||
initialFiles?: Record<string, string>;
|
||||
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
|
||||
nixifyPluginUrl?: string;
|
||||
migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
|
||||
controller?: { subject: string; snapshot: string; remote: string; input: "unit" | "value" };
|
||||
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk?: Source };
|
||||
};
|
||||
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");
|
||||
throw Error("Scaffold requires an exact source identity; use a registered repository");
|
||||
const url = new URL(value.repository);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
@@ -50,383 +30,36 @@ const source = (value: Source): GitSource => {
|
||||
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");
|
||||
throw 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 <T>(root: string, file: string): Promise<T> => {
|
||||
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 (
|
||||
/** One-shot edits; structural planning retains interrupted work and authored files. */
|
||||
export async function scaffoldRecipe(
|
||||
root: string,
|
||||
command: "package" | "function" | "migration" | "refresh" | "query",
|
||||
command: "package" | "function" | "component" | "controller" | "query" | "migration" | "refresh",
|
||||
spec: ScaffoldRecipe,
|
||||
): Promise<StructuralRequest> => {
|
||||
): Promise<StructuralRequest> {
|
||||
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.",
|
||||
throw Error(
|
||||
"Scaffold refresh is removed. Edit authored code and run check; add entries with scaffold function/component/query.",
|
||||
);
|
||||
if (command === "migration")
|
||||
throw Error(
|
||||
"Old-workspace migration scaffolding is retired. Current disposable workspaces do not require migration code.",
|
||||
);
|
||||
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}/` : "";
|
||||
throw Error("Scaffold directory must be contained");
|
||||
if (spec.template && !["typescript-execution", "typescript-component"].includes(spec.template))
|
||||
throw Error("Use typescript-execution or typescript-component; old server/source-string presets are retired");
|
||||
const prefix = spec.directory ? spec.directory + "/" : "";
|
||||
if (command !== "package" && !(await isExecutionScaffold(root, prefix)))
|
||||
throw Error("Scaffolding requires a checked execution package");
|
||||
if (spec.tools) Object.values(spec.tools).forEach(source);
|
||||
if (command === "controller") return scaffoldController(root, prefix, spec);
|
||||
if (command === "component") return scaffoldComponent(root, prefix, spec);
|
||||
if (command === "query") return scaffoldQuery(root, prefix, spec);
|
||||
if (
|
||||
spec.template === "typescript-execution" ||
|
||||
(command !== "package" && (await isExecutionScaffold(root, prefix)))
|
||||
) {
|
||||
if (spec.tools) Object.values(spec.tools).forEach(source);
|
||||
return executionScaffold(root, command, spec, prefix, nixSource);
|
||||
}
|
||||
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<string, never>; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1></section>;\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<typeof catalog>(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<void> => { 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"];
|
||||
return executionScaffold(root, command, spec, prefix, nixSource);
|
||||
}
|
||||
|
||||
@@ -168,16 +168,21 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
||||
});
|
||||
if (compiled.resource.kind === "package") {
|
||||
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
|
||||
const artifacts = [
|
||||
{
|
||||
file: configuration.bindingOutput as string,
|
||||
after: generateTypeScriptBindings(
|
||||
bindingSchema(compiled),
|
||||
compiled.resource.revision.revisionId,
|
||||
configuration.options,
|
||||
),
|
||||
},
|
||||
];
|
||||
// Execution bindings require the complete workspace plan and are owned
|
||||
// by checkedArtifacts, not a package-only structural preview.
|
||||
const artifacts =
|
||||
configuration.backend === "execution"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
file: configuration.bindingOutput as string,
|
||||
after: generateTypeScriptBindings(
|
||||
bindingSchema(compiled),
|
||||
compiled.resource.revision.revisionId,
|
||||
configuration.options,
|
||||
),
|
||||
},
|
||||
];
|
||||
for (const artifact of artifacts) {
|
||||
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
|
||||
safeFile(file);
|
||||
|
||||
@@ -12,7 +12,6 @@ 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 { isExecutionScaffold } from "./execution-scaffold.js";
|
||||
import { checkBundleSources } from "../bindings/bundle-policy.js";
|
||||
import { sealMigrations } from "./migration-seal.js";
|
||||
import { generatePackageDescriptor } from "../bindings/index.js";
|
||||
@@ -386,12 +385,19 @@ const main = async () => {
|
||||
}
|
||||
if (command === "scaffold-refresh")
|
||||
throw new Error(
|
||||
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.",
|
||||
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependencies are provided by checkedArtifacts.",
|
||||
);
|
||||
if (command === "scaffold-install")
|
||||
throw Error("Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead");
|
||||
if (
|
||||
["scaffold-package", "scaffold-function", "scaffold-query", "scaffold-migration", "scaffold-install"].includes(
|
||||
command,
|
||||
)
|
||||
[
|
||||
"scaffold-package",
|
||||
"scaffold-function",
|
||||
"scaffold-query",
|
||||
"scaffold-component",
|
||||
"scaffold-controller",
|
||||
"scaffold-migration",
|
||||
].includes(command)
|
||||
) {
|
||||
const [root, specFile, ...flags] = args;
|
||||
let spec: ScaffoldRecipe;
|
||||
@@ -425,54 +431,24 @@ const main = async () => {
|
||||
flags.some((flag) => !["--write", "--install"].includes(flag)) ||
|
||||
(flags.includes("--install") && !flags.includes("--write"))
|
||||
)
|
||||
throw new Error(
|
||||
"usage: quixos-qx scaffold-package|function|query|migration ROOT SPEC_JSON [--write [--install]]",
|
||||
);
|
||||
throw new Error("usage: quixos-qx scaffold-package|function|query|component|controller ROOT SPEC_JSON [--write]");
|
||||
const plan =
|
||||
command === "scaffold-install"
|
||||
? undefined
|
||||
: await planStructure(
|
||||
root,
|
||||
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "query" | "migration", spec),
|
||||
await scaffoldRecipe(
|
||||
root,
|
||||
command.slice(9) as "package" | "function" | "query" | "component" | "controller" | "migration",
|
||||
spec,
|
||||
),
|
||||
process.env.QUIXOS_SNAPSHOT_MAP,
|
||||
);
|
||||
if (
|
||||
flags.includes("--install") &&
|
||||
(spec.template === "typescript-execution" ||
|
||||
(await isExecutionScaffold(root, spec.directory ? `${spec.directory}/` : "")))
|
||||
)
|
||||
throw new Error(
|
||||
"Execution package dependencies are supplied by checkedArtifacts; omit --install and run workspace-root check after authoring.",
|
||||
if (flags.includes("--install"))
|
||||
throw Error(
|
||||
"Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead of scaffold install",
|
||||
);
|
||||
const applied = plan && 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 ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user