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> {
|
): Promise<StructuralRequest> {
|
||||||
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
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");
|
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 files: StructuralRequest["files"] = [];
|
||||||
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
||||||
if (command === "package") {
|
if (command === "package") {
|
||||||
@@ -37,6 +37,7 @@ export async function executionScaffold(
|
|||||||
formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }),
|
formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }),
|
||||||
);
|
);
|
||||||
create("quixos.check.json", json({ backend: "execution" }));
|
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("package.json", json({ name: `@quixos/${spec.name.toLowerCase()}`, private: true, type: "module" }));
|
||||||
create(".gitignore", "node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n");
|
create(".gitignore", "node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n");
|
||||||
for (const lane of ["browser", "server", "remote"])
|
for (const lane of ["browser", "server", "remote"])
|
||||||
@@ -55,11 +56,15 @@ export async function executionScaffold(
|
|||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
in {
|
in {
|
||||||
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId, react ? null }:
|
||||||
(import (helpers + "/artifacts.nix")) {
|
(import (helpers + "/artifacts.nix")) {
|
||||||
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
||||||
protocol = generator;
|
protocol = generator;
|
||||||
src = ./.;
|
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 = {
|
portable = {
|
||||||
entry = "src/server.ts";
|
entry = "src/server.ts";
|
||||||
entries.browser = "src/browser.ts";
|
entries.browser = "src/browser.ts";
|
||||||
@@ -73,7 +78,7 @@ export async function executionScaffold(
|
|||||||
);
|
);
|
||||||
create(
|
create(
|
||||||
"README.md",
|
"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") {
|
} else if (command === "function") {
|
||||||
if (!spec.declaration)
|
if (!spec.declaration)
|
||||||
@@ -154,5 +159,20 @@ export async function executionScaffold(
|
|||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
throw new Error("Execution packages use ordinary factory transactions; migration scaffolding is not supported");
|
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 };
|
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import fs from "node:fs/promises";
|
import type { GitSource } from "../resource-lock/index.js";
|
||||||
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 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 { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js";
|
||||||
|
import { scaffoldController } from "./controller-scaffold.js";
|
||||||
|
import { scaffoldComponent } from "./component-scaffold.js";
|
||||||
import { scaffoldQuery } from "./query-scaffold.js";
|
import { scaffoldQuery } from "./query-scaffold.js";
|
||||||
|
|
||||||
type Source = { repository: string; commit: string };
|
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 = {
|
export type ScaffoldRecipe = {
|
||||||
template?: "typescript" | "typescript-react" | "typescript-execution";
|
template?: "typescript-execution" | "typescript-component";
|
||||||
source: Source;
|
source: Source;
|
||||||
directory?: string;
|
directory?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -31,16 +14,13 @@ export type ScaffoldRecipe = {
|
|||||||
revision?: string;
|
revision?: string;
|
||||||
declaration?: string;
|
declaration?: string;
|
||||||
queryDocument?: string;
|
queryDocument?: string;
|
||||||
/** Initial authored files for a composed recipe; only used for a new package. */
|
|
||||||
initialFiles?: Record<string, string>;
|
initialFiles?: Record<string, string>;
|
||||||
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
|
controller?: { subject: string; snapshot: string; remote: string; input: "unit" | "value" };
|
||||||
nixifyPluginUrl?: string;
|
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk?: Source };
|
||||||
migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
|
|
||||||
};
|
};
|
||||||
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
|
||||||
const source = (value: Source): GitSource => {
|
const source = (value: Source): GitSource => {
|
||||||
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string")
|
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);
|
const url = new URL(value.repository);
|
||||||
if (
|
if (
|
||||||
url.protocol !== "https:" ||
|
url.protocol !== "https:" ||
|
||||||
@@ -50,383 +30,36 @@ const source = (value: Source): GitSource => {
|
|||||||
url.hash ||
|
url.hash ||
|
||||||
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)
|
!/^(?:[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 };
|
return { resolver: "git", ...value };
|
||||||
};
|
};
|
||||||
const nixSource = (value: Source) =>
|
const nixSource = (value: Source) =>
|
||||||
`git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
`git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
||||||
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
|
/** One-shot edits; structural planning retains interrupted work and authored files. */
|
||||||
const safeName = (name: string | undefined): string => {
|
export async function scaffoldRecipe(
|
||||||
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 (
|
|
||||||
root: string,
|
root: string,
|
||||||
command: "package" | "function" | "migration" | "refresh" | "query",
|
command: "package" | "function" | "component" | "controller" | "query" | "migration" | "refresh",
|
||||||
spec: ScaffoldRecipe,
|
spec: ScaffoldRecipe,
|
||||||
): Promise<StructuralRequest> => {
|
): Promise<StructuralRequest> {
|
||||||
if (command === "refresh")
|
if (command === "refresh")
|
||||||
throw new Error(
|
throw 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.",
|
"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);
|
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))
|
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");
|
throw Error("Scaffold directory must be contained");
|
||||||
const prefix = spec.directory ? `${spec.directory}/` : "";
|
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 (command === "query") return scaffoldQuery(root, prefix, spec);
|
||||||
if (
|
return executionScaffold(root, command, spec, prefix, nixSource);
|
||||||
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"];
|
|
||||||
|
|||||||
@@ -168,16 +168,21 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
});
|
});
|
||||||
if (compiled.resource.kind === "package") {
|
if (compiled.resource.kind === "package") {
|
||||||
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
|
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
|
||||||
const artifacts = [
|
// Execution bindings require the complete workspace plan and are owned
|
||||||
{
|
// by checkedArtifacts, not a package-only structural preview.
|
||||||
file: configuration.bindingOutput as string,
|
const artifacts =
|
||||||
after: generateTypeScriptBindings(
|
configuration.backend === "execution"
|
||||||
bindingSchema(compiled),
|
? []
|
||||||
compiled.resource.revision.revisionId,
|
: [
|
||||||
configuration.options,
|
{
|
||||||
),
|
file: configuration.bindingOutput as string,
|
||||||
},
|
after: generateTypeScriptBindings(
|
||||||
];
|
bindingSchema(compiled),
|
||||||
|
compiled.resource.revision.revisionId,
|
||||||
|
configuration.options,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
|
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
|
||||||
safeFile(file);
|
safeFile(file);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import os from "node:os";
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
||||||
import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js";
|
import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
import { isExecutionScaffold } from "./execution-scaffold.js";
|
|
||||||
import { checkBundleSources } from "../bindings/bundle-policy.js";
|
import { checkBundleSources } from "../bindings/bundle-policy.js";
|
||||||
import { sealMigrations } from "./migration-seal.js";
|
import { sealMigrations } from "./migration-seal.js";
|
||||||
import { generatePackageDescriptor } from "../bindings/index.js";
|
import { generatePackageDescriptor } from "../bindings/index.js";
|
||||||
@@ -386,12 +385,19 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
if (command === "scaffold-refresh")
|
if (command === "scaffold-refresh")
|
||||||
throw new Error(
|
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 (
|
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;
|
const [root, specFile, ...flags] = args;
|
||||||
let spec: ScaffoldRecipe;
|
let spec: ScaffoldRecipe;
|
||||||
@@ -425,54 +431,24 @@ const main = async () => {
|
|||||||
flags.some((flag) => !["--write", "--install"].includes(flag)) ||
|
flags.some((flag) => !["--write", "--install"].includes(flag)) ||
|
||||||
(flags.includes("--install") && !flags.includes("--write"))
|
(flags.includes("--install") && !flags.includes("--write"))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error("usage: quixos-qx scaffold-package|function|query|component|controller ROOT SPEC_JSON [--write]");
|
||||||
"usage: quixos-qx scaffold-package|function|query|migration ROOT SPEC_JSON [--write [--install]]",
|
|
||||||
);
|
|
||||||
const plan =
|
const plan =
|
||||||
command === "scaffold-install"
|
command === "scaffold-install"
|
||||||
? undefined
|
? undefined
|
||||||
: await planStructure(
|
: await planStructure(
|
||||||
root,
|
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,
|
process.env.QUIXOS_SNAPSHOT_MAP,
|
||||||
);
|
);
|
||||||
if (
|
if (flags.includes("--install"))
|
||||||
flags.includes("--install") &&
|
throw Error(
|
||||||
(spec.template === "typescript-execution" ||
|
"Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead of scaffold install",
|
||||||
(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.",
|
|
||||||
);
|
);
|
||||||
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
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(
|
process.stdout.write(
|
||||||
`${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`,
|
`${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,3 +15,5 @@ export * from "./projections.js";
|
|||||||
export * from "./queries.js";
|
export * from "./queries.js";
|
||||||
export { generatePortableClientBindings } from "../bindings/portable-client.js";
|
export { generatePortableClientBindings } from "../bindings/portable-client.js";
|
||||||
export * from "./candidate.js";
|
export * from "./candidate.js";
|
||||||
|
|
||||||
|
export { reviewSemanticMajors } from "../capability-model/evolution.js";
|
||||||
|
|||||||
@@ -462,12 +462,15 @@ export async function compileQuery(
|
|||||||
const definitionDigest = createHash("sha256")
|
const definitionDigest = createHash("sha256")
|
||||||
.update(
|
.update(
|
||||||
canonicalJson({
|
canonicalJson({
|
||||||
semantics: 2,
|
semantics: 3,
|
||||||
declaration,
|
declaration,
|
||||||
normalized,
|
normalized,
|
||||||
interfaces: [...generated.byName.values()].sort((a, b) =>
|
// Source provenance belongs to the checked installation. Identical public
|
||||||
a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0,
|
// contracts keep their query identity when retained in another repository.
|
||||||
),
|
// Closed generic identity remains explicit in revisionId/application.
|
||||||
|
interfaces: [...generated.byName.values()]
|
||||||
|
.map(({ source: _source, ...contract }) => contract)
|
||||||
|
.sort((a, b) => (a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js";
|
||||||
|
import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
test("checked component scaffolding composes with execution handlers without replacing authored files", async (context) => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-component-scaffold-"));
|
||||||
|
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
await execFile("git", ["-C", root, "init"]);
|
||||||
|
const source = { repository: "https://example.test/component.git", commit: "a".repeat(40) };
|
||||||
|
const apply = async (
|
||||||
|
command: Parameters<typeof scaffoldRecipe>[1],
|
||||||
|
spec: Omit<Parameters<typeof scaffoldRecipe>[2], "source">,
|
||||||
|
) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { ...spec, source })));
|
||||||
|
await apply("package", {
|
||||||
|
name: "Widgets",
|
||||||
|
id: "package:widgets",
|
||||||
|
revision: "package:widgets@1",
|
||||||
|
template: "typescript-component",
|
||||||
|
tools: { quixos: source, protocol: source, helpers: source, sdk: source },
|
||||||
|
});
|
||||||
|
const packageFile = path.join(root, "package.qx");
|
||||||
|
await fs.writeFile(packageFile, "import interface Data;\n" + (await fs.readFile(packageFile, "utf8")));
|
||||||
|
await apply("component", {
|
||||||
|
name: "Card",
|
||||||
|
id: "component:card",
|
||||||
|
declaration: 'component Card id "component:card" subject Data runtime react;',
|
||||||
|
});
|
||||||
|
assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, "quixos.components.json"), "utf8")), {
|
||||||
|
"component:card": { entry: "src/components/Card.tsx", export: "Card" },
|
||||||
|
});
|
||||||
|
const card = path.join(root, "src/components/Card.tsx");
|
||||||
|
await fs.appendFile(card, "\n// authored extension\n");
|
||||||
|
const retained = await fs.readFile(card, "utf8");
|
||||||
|
await apply("function", {
|
||||||
|
name: "echo",
|
||||||
|
id: "echo",
|
||||||
|
declaration: 'function echo id "echo" : string -> string execution snapshot targets [browser,server];',
|
||||||
|
});
|
||||||
|
await apply("component", {
|
||||||
|
name: "Details",
|
||||||
|
id: "component:details",
|
||||||
|
declaration: 'component Details id "component:details" subject Data runtime react;',
|
||||||
|
});
|
||||||
|
assert.equal(await fs.readFile(card, "utf8"), retained);
|
||||||
|
assert.match(await fs.readFile(path.join(root, "src/server.ts"), "utf8"), /echo/);
|
||||||
|
assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /react \? null/);
|
||||||
|
await assert.rejects(
|
||||||
|
apply("component", {
|
||||||
|
name: "Card",
|
||||||
|
id: "component:other",
|
||||||
|
declaration: 'component Card id "component:other" subject Data runtime react;',
|
||||||
|
}),
|
||||||
|
/unique/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
apply("component", {
|
||||||
|
name: "Injected",
|
||||||
|
id: "component:inject",
|
||||||
|
declaration:
|
||||||
|
'component Injected id "component:inject" subject Data runtime react; function sneak id "sneak" : unit -> unit;',
|
||||||
|
}),
|
||||||
|
/exactly one/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("controller scaffolding preserves existing registrations and rejects duplicate identities", async (context) => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-controller-scaffold-"));
|
||||||
|
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
await execFile("git", ["-C", root, "init"]);
|
||||||
|
const source = { repository: "https://example.test/adapter.git", commit: "a".repeat(40) };
|
||||||
|
const apply = async (
|
||||||
|
command: Parameters<typeof scaffoldRecipe>[1],
|
||||||
|
spec: Omit<Parameters<typeof scaffoldRecipe>[2], "source">,
|
||||||
|
) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { ...spec, source })));
|
||||||
|
await apply("package", {
|
||||||
|
name: "Adapter",
|
||||||
|
id: "adapter",
|
||||||
|
revision: "adapter@1",
|
||||||
|
tools: { quixos: source, protocol: source, helpers: source },
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
subject: "display@1",
|
||||||
|
snapshot: "display:requested",
|
||||||
|
remote: "display:apply",
|
||||||
|
input: "value" as const,
|
||||||
|
};
|
||||||
|
await apply("controller", { id: "display:controller", controller });
|
||||||
|
await apply("controller", { id: "display:monitor", controller: { ...controller, snapshot: "display:status" } });
|
||||||
|
const entries = JSON.parse(await fs.readFile(path.join(root, "quixos.controllers.json"), "utf8")).controllers;
|
||||||
|
assert.equal(entries.length, 2);
|
||||||
|
assert.deepEqual(entries[0], { id: "display:controller", ...controller });
|
||||||
|
await assert.rejects(apply("controller", { id: "display:controller", controller }), /duplicate/);
|
||||||
|
});
|
||||||
@@ -84,3 +84,21 @@ test("explicit budgets can exceed defaults without exceeding wire bounds", () =>
|
|||||||
assert.equal(result.resource.revision.queries![0]!.budgets.rows, 5000);
|
assert.equal(result.resource.revision.queries![0]!.budgets.rows, 5000);
|
||||||
assert.equal(compile("4294967296").ok, false);
|
assert.equal(compile("4294967296").ok, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("query identity follows public contracts, not source retention commits", async () => {
|
||||||
|
const document = async () => "query Rows {root {objects(first:10) {entries {node {range {start}}}}}}";
|
||||||
|
const before = await compileQuery(declaration, [iface], document);
|
||||||
|
const retained = {
|
||||||
|
...iface,
|
||||||
|
source: { ...iface.source, repository: "https://other.test/interfaces.git", commit: "b".repeat(40) },
|
||||||
|
};
|
||||||
|
assert.equal((await compileQuery(declaration, [retained], document)).definitionDigest, before.definitionDigest);
|
||||||
|
const changed = structuredClone(retained);
|
||||||
|
changed.members[0]!.operations[0]!.id = "changed:get" as (typeof changed.members)[0]["operations"][0]["id"];
|
||||||
|
assert.notEqual((await compileQuery(declaration, [changed], document)).definitionDigest, before.definitionDigest);
|
||||||
|
assert.notEqual(
|
||||||
|
(await compileQuery({ ...declaration, budgets: { ...declaration.budgets, rows: 17 } }, [retained], document))
|
||||||
|
.definitionDigest,
|
||||||
|
before.definitionDigest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,122 +7,9 @@ import { execFile as callback } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js";
|
import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js";
|
||||||
import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js";
|
import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js";
|
||||||
import { contentDigest } from "../src/capability-model/evolution.js";
|
|
||||||
import { sealMigrations } from "../src/capability-language/migration-seal.js";
|
|
||||||
import { reactPlatformTypes } from "../src/bindings/react-platform.js";
|
import { reactPlatformTypes } from "../src/bindings/react-platform.js";
|
||||||
const execFile = promisify(callback);
|
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, "src/impl/sourceGet.ts"), "utf8"), /component.js\?browser-source/);
|
|
||||||
assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/);
|
|
||||||
assert.equal(await fs.readFile(path.join(root, "src/gen/web-studio-react-runtime.d.ts"), "utf8"), reactPlatformTypes);
|
|
||||||
assert.match(await fs.readFile(path.join(root, "src/browser-assets.d.ts"), "utf8"), /declare module "\*\.css"/);
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.react.propsExports,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json")));
|
|
||||||
assert.equal(
|
|
||||||
JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx,
|
|
||||||
"react-jsx",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("imperative scaffolds preserve authored wiring; migration sealing is explicit and separate", async (context) => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-"));
|
|
||||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
||||||
await execFile("git", ["-C", root, "init"]);
|
|
||||||
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
|
|
||||||
const source = { repository: "https://example.test/chess.git", commit: "a".repeat(40) };
|
|
||||||
const base = { source, directory: "packages/Chess" };
|
|
||||||
const apply = async (command: Parameters<typeof scaffoldRecipe>[1], input: Parameters<typeof scaffoldRecipe>[2]) =>
|
|
||||||
applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input)));
|
|
||||||
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 "../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 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts");
|
|
||||||
await fs.appendFile(migrationFile, "\n// authored migration change\n");
|
|
||||||
await sealMigrations(path.join(root, base.directory));
|
|
||||||
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")));
|
|
||||||
assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/);
|
|
||||||
await assert.rejects(() => apply("function", { ...base, name: "play", id: "export:play" }), /unique/);
|
|
||||||
const declarations = path.join(root, base.directory, "package.qx");
|
|
||||||
await fs.writeFile(
|
|
||||||
declarations,
|
|
||||||
(await fs.readFile(declarations, "utf8")).replace(
|
|
||||||
/}\s*$/,
|
|
||||||
' function authored id "export:authored" : unit -> unit;\n}\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const server = path.join(root, base.directory, "src/server.ts");
|
|
||||||
await fs.appendFile(server, "\n// authored comment must survive\n");
|
|
||||||
await apply("function", { ...base, name: "another", id: "export:another" });
|
|
||||||
assert.match(await fs.readFile(server, "utf8"), /authored comment must survive/);
|
|
||||||
await assert.rejects(() => apply("refresh", base), /removed/);
|
|
||||||
await assert.rejects(fs.access(path.join(root, base.directory, "src/impl/authored.ts")));
|
|
||||||
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
|
||||||
await planStructure(root, {
|
|
||||||
kind: "package",
|
|
||||||
source,
|
|
||||||
resourceRoot: base.directory,
|
|
||||||
validation: "syntax",
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
file: `${base.directory}/package.qx`,
|
|
||||||
edits: [
|
|
||||||
{
|
|
||||||
operation: "replace",
|
|
||||||
target: { kind: "packageResourceDecl", id: "package:chess" },
|
|
||||||
source: 'package Other id "package:other" revision "package:other@1" {}',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("execution scaffolds route typed handlers by declared effect and target without legacy lifecycle files", async (context) => {
|
test("execution scaffolds route typed handlers by declared effect and target without legacy lifecycle files", async (context) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-execution-recipe-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-execution-recipe-"));
|
||||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
@@ -164,7 +51,7 @@ test("execution scaffolds route typed handlers by declared effect and target wit
|
|||||||
await assert.rejects(add("both", "transaction targets [browser, server]"), /unique/);
|
await assert.rejects(add("both", "transaction targets [browser, server]"), /unique/);
|
||||||
await assert.rejects(add("invalid", "snapshot targets [remote]"), /browser\/server/);
|
await assert.rejects(add("invalid", "snapshot targets [remote]"), /browser\/server/);
|
||||||
await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/);
|
await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/);
|
||||||
await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /not supported/);
|
await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /retired/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("query scaffold preserves placement and inserts its named GraphQL document atomically", async (context) => {
|
test("query scaffold preserves placement and inserts its named GraphQL document atomically", async (context) => {
|
||||||
@@ -176,6 +63,7 @@ test("query scaffold preserves placement and inserts its named GraphQL document
|
|||||||
path.join(root, "package.qx"),
|
path.join(root, "package.qx"),
|
||||||
'import interface Task;\npackage Tasks id "tasks" revision "tasks@1" {}\n',
|
'import interface Task;\npackage Tasks id "tasks" revision "tasks@1" {}\n',
|
||||||
);
|
);
|
||||||
|
await fs.writeFile(path.join(root, "quixos.check.json"), JSON.stringify({ backend: "execution" }));
|
||||||
const request = (name: string, placement: string) => ({
|
const request = (name: string, placement: string) => ({
|
||||||
source,
|
source,
|
||||||
name,
|
name,
|
||||||
|
|||||||
Reference in New Issue
Block a user