2026-09-10 18:27:41 -07:00
import fs from "node:fs/promises" ;
import path from "node:path" ;
import { contentDigest } from "../capability-model/evolution.js" ;
import { validateMigrationCatalog , type MigrationCatalog , type MigrationDeclaration } from "../capability-model/migrations.js" ;
import { formatQuixosLock , type GitSource } from "../resource-lock/index.js" ;
import { parseQx , walkSyntax } from "./source.js" ;
import type { StructuralRequest } from "./structural-plan.js" ;
2026-09-14 20:35:53 -07:00
import { addImplementation } from "./implementation-edit.js" ;
2026-09-14 23:38:07 -07:00
import { reactPlatformTypes } from "../bindings/react-platform.js" ;
2026-09-10 18:27:41 -07:00
type Source = { repository : string ; commit : string };
2026-09-14 20:35:53 -07:00
type PackageEditModel = { generatedBy : "qx-scaffold-v1" ; name : string ; id : string ; revision : string ; exports : { name : string ; id : string ; file : string ; migration? : boolean }[]};
2026-09-10 18:27:41 -07:00
export type ScaffoldRecipe = {
2026-09-13 22:07:18 -07:00
template ?: "typescript" | "typescript-react" ;
2026-09-10 18:27:41 -07:00
source : Source ; directory? : string ; name? : string ; id? : string ; revision? : string ;
declaration? : string ;
tools ?: { quixos : Source ; protocol : Source ; helpers : Source ; sdk : Source };
nixifyPluginUrl? : string ;
migration? : Omit < MigrationDeclaration , "implementation" > & { contracts : Record < string , unknown >};
};
const json = ( value : unknown ) => ` ${ JSON . stringify ( value , null , 2 ) } \ n` ;
const source = ( value : Source ) : GitSource => {
2026-09-14 20:35:53 -07:00
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" );
2026-09-10 18:27:41 -07:00
const url = new URL ( value . repository );
if ( url . protocol !== "https:" || url . username || url . password || url . search || url . hash || ! /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/ . test ( value . commit )) throw new Error ( "Scaffolds require credential-free HTTPS sources and full exact commits" );
return { resolver : "git" , ... value };
};
const nixSource = ( value : Source ) => `git+ ${ source ( value ). repository } ?ref=refs/tags/quixos-reachability/ ${ value . commit } &rev= ${ value . commit } ` ;
const nixString = ( value : string ) => JSON . stringify ( value ). replaceAll ( "${" , "\\${" );
const safeName = ( name : string | undefined ) : string => {
if ( ! name || ! /^[A-Za-z_][A-Za-z0-9_]*$/ . test ( name )) throw new Error ( "Scaffold requires a simple authored name" );
return name ;
};
const ownedJson = async < 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.
2026-09-14 20:35:53 -07:00
* Implementation files are created once; later edits preserve authored wiring. */
2026-09-10 18:27:41 -07:00
export const scaffoldRecipe = async ( root : string , command : "package" | "function" | "migration" | "refresh" , spec : ScaffoldRecipe ) : Promise < StructuralRequest > => {
2026-09-14 20:35:53 -07:00
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." );
2026-09-10 18:27:41 -07:00
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 });
2026-09-14 20:35:53 -07:00
let packageModel : PackageEditModel ;
2026-09-10 18:27:41 -07:00
let catalog : MigrationCatalog & { generatedBy : "qx-scaffold-v1" };
if ( command === "package" ) {
const name = safeName ( spec . name );
2026-09-13 22:07:18 -07:00
if ( spec . template && ! [ "typescript" , "typescript-react" ]. includes ( spec . template )) throw new Error ( "Unknown package template" );
const react = spec . template === "typescript-react" ;
2026-09-10 18:27:41 -07:00
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 );
2026-09-14 20:35:53 -07:00
packageModel = { generatedBy : "qx-scaffold-v1" , name , id : spec.id , revision : spec.revision , exports : []};
2026-09-10 18:27:41 -07:00
catalog = { generatedBy : "qx-scaffold-v1" , schemaVersion : 1 , contracts : {}, migrations : []};
2026-09-14 20:35:53 -07:00
if ( react ) packageModel . exports . push ({ name : "sourceGet" , id : `export: ${ name } :source` , file : "src/impl/sourceGet.ts" });
2026-09-13 22:07:18 -07:00
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` );
2026-09-10 18:27:41 -07:00
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" ,
2026-09-14 20:35:53 -07:00
scripts : { build : "tsc -p tsconfig.json" , typecheck : "tsc --noEmit" }, dependencies : { "@quixos/camino-package-runtime" : ` ${ spec . tools . sdk . repository } #commit= ${ spec . tools . sdk . commit } ` },
2026-09-13 22:07:18 -07:00
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` );
2026-09-14 20:35:53 -07:00
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` );
2026-09-14 23:38:07 -07:00
create ( "src/gen/web-studio-react-runtime.d.ts" , reactPlatformTypes );
2026-09-13 22:07:18 -07:00
}
2026-09-10 18:27:41 -07:00
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 }));
2026-09-14 20:35:53 -07:00
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" }}}} : {})}));
2026-09-10 18:27:41 -07:00
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 = ./.;
2026-09-14 20:35:53 -07:00
bundle = { entry = ${ JSON . stringify ( react ? "src/server.ts" : "dist/server.js" ) } ; ${ react ? "browserSources = true;" : "" } };
2026-09-10 18:27:41 -07:00
migrationEntrypoint = "dist/migrate.js";
2026-09-14 20:35:53 -07:00
installServer = { libexecName = ${ JSON . stringify ( name . toLowerCase ()) } ; descriptorPath = "descriptor.quixos-package.txtpb"; };
2026-09-10 18:27:41 -07:00
};
} \ n` );
} else {
catalog = await ownedJson < typeof catalog >( root , prefix + "quixos.migrations.json" );
2026-09-14 20:35:53 -07:00
// Derive the edit model from authored declarations; it is never persisted.
2026-09-14 15:24:37 -07:00
const authored = await fs . readFile ( path . join ( root , prefix , "package.qx" ), "utf8" );
const syntax = parseQx ( authored );
2026-09-14 20:35:53 -07:00
if ( syntax . diagnostics . length ) throw new Error ( "Cannot scaffold into an invalid package.qx; fix the reported syntax first" );
2026-09-14 15:24:37 -07:00
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" );
2026-09-14 20:35:53 -07:00
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 : []};
2026-09-14 15:24:37 -07:00
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" ) ! ));
2026-09-14 20:35:53 -07:00
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 } : {})});
2026-09-14 15:24:37 -07:00
}
2026-09-14 20:35:53 -07:00
{
2026-09-10 18:27:41 -07:00
const name = safeName ( spec . name );
2026-09-14 20:35:53 -07:00
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" );
2026-09-10 18:27:41 -07:00
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" );
2026-09-14 20:35:53 -07:00
files . push ({ file : prefix + "package.qx" , edits : [{ operation : "append" , parent : { kind : "packageResourceDecl" , id : packageModel.id }, source : declaration }]});
2026-09-10 18:27:41 -07:00
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`
2026-09-13 22:07:18 -07:00
: `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` ;
2026-09-10 18:27:41 -07:00
create ( file , implementation );
2026-09-14 20:35:53 -07:00
packageModel . exports . push ({ name , id : spec.id , file , ...( command === "migration" ? { migration : true } : {})});
2026-09-10 18:27:41 -07:00
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 )}});
}
}
}
2026-09-14 20:35:53 -07:00
validateMigrationCatalog ( catalog , new Set ( packageModel . exports . map (( entry ) => entry . id )));
2026-09-10 18:27:41 -07:00
generated ( "quixos.migrations.json" , json ( catalog ));
2026-09-14 20:35:53 -07:00
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 };
2026-09-10 18:27:41 -07:00
};