Simplify workspace authoring and isolate managed component styles
Use imperative scaffolds without a registry/refresh lifecycle; preserve authored runtime wiring and generate checked descriptors. Consolidate binding options, embed checked TSX/CSS source, queue mutable convergence while allowing parallel immutable checks, and preload real CLI observations. Add Shadow DOM boundaries with scoped overlays and composed-event handling. Update template/toolchain pins and document VM-free authoring, browser, and stateful acceptance; no deployment or default-template selection.
This commit is contained in:
@@ -5,9 +5,10 @@ import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclarati
|
||||
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
|
||||
import {parseQx, walkSyntax} from "./source.js";
|
||||
import type {StructuralRequest} from "./structural-plan.js";
|
||||
import {addImplementation} from "./implementation-edit.js";
|
||||
|
||||
type Source = {repository: string; commit: string};
|
||||
type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
|
||||
type PackageEditModel = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
|
||||
export type ScaffoldRecipe = {
|
||||
template?: "typescript" | "typescript-react";
|
||||
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
||||
@@ -16,9 +17,9 @@ export type ScaffoldRecipe = {
|
||||
nixifyPluginUrl?: string;
|
||||
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>};
|
||||
};
|
||||
const marker = "// Generated by qx-scaffold-v1\n";
|
||||
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
||||
const source = (value: Source): GitSource => {
|
||||
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
|
||||
const url = new URL(value.repository);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
||||
return {resolver: "git", ...value};
|
||||
@@ -38,15 +39,16 @@ const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
||||
};
|
||||
|
||||
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
||||
* Implementation files are created once and never rewritten by refresh. */
|
||||
* Implementation files are created once; later edits preserve authored wiring. */
|
||||
export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): 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.");
|
||||
source(spec.source);
|
||||
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
|
||||
const prefix = spec.directory ? `${spec.directory}/` : "";
|
||||
const files: StructuralRequest["files"] = [];
|
||||
const create = (file: string, content: string) => files.push({file: prefix + file, create: content});
|
||||
const generated = (file: string, content: string) => files.push({file: prefix + file, generated: content});
|
||||
let registry: Registry;
|
||||
let packageModel: PackageEditModel;
|
||||
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
||||
if (command === "package") {
|
||||
const name = safeName(spec.name);
|
||||
@@ -54,19 +56,19 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
const react = spec.template === "typescript-react";
|
||||
if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
||||
Object.values(spec.tools).forEach(source);
|
||||
registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
||||
packageModel = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
||||
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
|
||||
if (react) registry.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"});
|
||||
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${react ? " && node scripts/build-component.mjs" : ""}`, typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`},
|
||||
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", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`);
|
||||
create("src/impl/sourceGet.ts", `import {readFile} from "node:fs/promises";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => readFile(new URL("./component.mjs", import.meta.url), "utf8");\n`);
|
||||
create("scripts/build-component.mjs", `import {build} from "esbuild";\nconst platform = new Map([\n ["react", "/__quixos/platform/react/v18.mjs"],\n ["react/jsx-runtime", "/__quixos/platform/react-jsx-runtime/v18.mjs"],\n ["react/jsx-dev-runtime", "/__quixos/platform/react-jsx-dev-runtime/v18.mjs"],\n ["@quixos/web-studio-react-runtime", "/__quixos/platform/web-studio-react-runtime/v1.mjs"],\n]);\nawait build({entryPoints: ["dist/component.js"], outfile: "dist/component.mjs", bundle: true, format: "esm", platform: "browser", target: "es2022", plugins: [{name: "quixos-platform", setup(api) {api.onResolve({filter: /.*/}, ({path}) => platform.has(path) ? {path: platform.get(path), external: true} : undefined);}}]});\n`);
|
||||
create("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; }\n`);
|
||||
}
|
||||
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
|
||||
create(".yarnrc.yml", `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`);
|
||||
@@ -74,7 +76,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
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"}));
|
||||
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts", ...(react ? {options: {messages: {"org.quixos.web-studio.ReactProps": {module: "@quixos/camino-package-runtime", export: "opaqueReactPropsBinding"}}}} : {})}));
|
||||
create("flake.nix", `{
|
||||
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
||||
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
||||
@@ -83,47 +85,34 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
||||
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
||||
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
||||
bundle = { entry = "dist/server.js"; };
|
||||
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"; ${react ? 'extraFiles = [ { source = "dist/component.mjs"; target = "component.mjs"; } ];' : ""} };
|
||||
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
||||
};
|
||||
}\n`);
|
||||
} else {
|
||||
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
||||
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
||||
// package.qx is authoritative. The registry remembers implementation paths,
|
||||
// not a second declaration list that can erase an author's new exports.
|
||||
// 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 refresh an invalid package.qx; fix the reported syntax first");
|
||||
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");
|
||||
registry.name = text(declaration.children.find(node => node.kind === "identifier")!);
|
||||
registry.id = JSON.parse(text(literals[0]));
|
||||
registry.revision = JSON.parse(text(literals[1]));
|
||||
const previousExports = registry.exports;
|
||||
registry.exports = [];
|
||||
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 (registry.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID");
|
||||
const old = previousExports.find(entry => entry.id === id);
|
||||
const file = old?.file ?? `src/impl/${name}.ts`;
|
||||
if (!old) {
|
||||
const exists = await fs.access(path.join(root, prefix, file)).then(() => true, () => false);
|
||||
if (!exists) {
|
||||
const derived = [...walkSyntax(node)].some(child => child.kind === "eventClause");
|
||||
create(file, `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`);
|
||||
}
|
||||
}
|
||||
registry.exports.push({...old, name, id, file});
|
||||
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} : {})});
|
||||
}
|
||||
if (command !== "refresh") {
|
||||
{
|
||||
const name = safeName(spec.name);
|
||||
if (!spec.id || registry.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID");
|
||||
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));
|
||||
@@ -134,12 +123,12 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
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: registry.id}, source: declaration}]});
|
||||
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);
|
||||
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
||||
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;
|
||||
@@ -150,19 +139,25 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}});
|
||||
}
|
||||
}
|
||||
if (command === "refresh") for (const migration of catalog.migrations) {
|
||||
const file = path.join(root, prefix, migration.implementation.file);
|
||||
if (!(await fs.realpath(file)).startsWith(`${await fs.realpath(path.join(root, prefix))}/`)) throw new Error("Migration implementation escapes package");
|
||||
migration.implementation.digest = contentDigest(await fs.readFile(file, "utf8"));
|
||||
}
|
||||
}
|
||||
validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id)));
|
||||
generated("quixos.scaffold.json", json(registry));
|
||||
validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id)));
|
||||
generated("quixos.migrations.json", json(catalog));
|
||||
generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") +
|
||||
`servePackageRuntime(createRuntime({\n` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`);
|
||||
const migrations = registry.exports.filter((entry) => entry.migration);
|
||||
generated("src/migrate.ts", marker + `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`);
|
||||
generated("descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(registry.id)}\npackage_revision_id: ${JSON.stringify(registry.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + registry.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join(""));
|
||||
return {kind: "package", source: spec.source, resourceRoot: spec.directory, files};
|
||||
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(""));
|
||||
return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user