From 99b6b5110f3af10ca7dcef92768e77ea92ded1c9 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 22 Sep 2026 14:42:54 -0700 Subject: [PATCH 1/8] Make Camino examples and scaffolds complete Yarn and Nix packages --- .yarn/plugins/yarn-plugin-nixify.cjs | 1 + build-artifacts.mjs | 21 ++++++---- build-components.mjs | 49 +++++++++++++++++----- build-react-contracts.mjs | 7 +++- install-dependencies.mjs | 61 ++++++++++++++++++++++++++++ quixos-package-helpers.nix | 2 + yarn-dependencies.nix | 29 +++++++++++++ yarn-shell.nix | 22 ++++++++++ 8 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 .yarn/plugins/yarn-plugin-nixify.cjs create mode 100644 install-dependencies.mjs create mode 100644 yarn-dependencies.nix create mode 100644 yarn-shell.nix diff --git a/.yarn/plugins/yarn-plugin-nixify.cjs b/.yarn/plugins/yarn-plugin-nixify.cjs new file mode 100644 index 0000000..44f7f79 --- /dev/null +++ b/.yarn/plugins/yarn-plugin-nixify.cjs @@ -0,0 +1 @@ +module.exports={name:"yarn-plugin-nixify",factory:function(e){var t;return(()=>{"use strict";var n={d:(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},r={};n.r(r),n.d(r,{default:()=>E});const a=e("@yarnpkg/core"),o=e("clipanion");class i extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String({required:!1})}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd),n=await a.Cache.find(e),r=e.makeFetcher();return(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async e=>{if(this.locator){const{locatorHash:o}=a.structUtils.parseLocator(this.locator,!0),i=t.originalPackages.get(o);if(!i)return void e.reportError(0,`Invalid locator: ${this.locator}`);await r.fetch(i,{checksums:t.storedChecksums,project:t,cache:n,fetcher:r,report:e})}else await e.startTimerPromise("Resolution step",(async()=>{await t.resolveEverything({report:e,lockfileOnly:!0})})),await e.startTimerPromise("Fetch step",(async()=>{await t.fetchEverything({cache:n,report:e,fetcher:r})}))}))).exitCode()}}i.paths=[["nixify","fetch"]];const s=e("@yarnpkg/fslib"),c=e("crypto");class l extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String(),this.source=o.Option.String(),this.installLocation=o.Option.String()}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd);return await t.restoreInstallState({restoreResolutions:!1}),(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async n=>{await t.resolveEverything({report:n,lockfileOnly:!0});const r=a.structUtils.parseLocator(this.locator,!0),o=t.storedPackages.get(r.locatorHash);if(!o)return void n.reportError(0,`Invalid locator: ${this.locator}`);const i=s.ppath.join(t.cwd,this.installLocation);await s.xfs.mkdirpPromise(s.ppath.dirname(i)),await a.execUtils.execvp("cp",["-R",this.source,i],{cwd:t.cwd,strict:!0}),await a.execUtils.execvp("chmod",["-R","u+w",i],{cwd:t.cwd,strict:!0});const l=(0,c.createHash)("sha512");l.update(process.versions.node),e.triggerHook((e=>e.globalHashGeneration),t,(e=>{l.update("\0"),l.update(e)}));const d=l.digest("hex"),p=new Map,h=e=>{let n=p.get(e.locatorHash);if(void 0!==n)return n;const r=t.storedPackages.get(e.locatorHash);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");const o=(0,c.createHash)("sha512");o.update(e.locatorHash),p.set(e.locatorHash,"");for(const e of r.dependencies.values()){const n=t.storedResolutions.get(e.descriptorHash);if(void 0===n)throw new Error(`Assertion failed: The resolution (${a.structUtils.prettyDescriptor(t.configuration,e)}) should have been registered`);const r=t.storedPackages.get(n);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");o.update(h(r))}return n=o.digest("hex"),p.set(e.locatorHash,n),n},u=(0,c.createHash)("sha512").update(d).update(h(o)).update(i).digest("hex");t.storedBuildState.set(o.locatorHash,u),await t.persistInstallStateFile()}))).exitCode()}}l.paths=[["nixify","inject-build"]];const d=e("@yarnpkg/plugin-pnp"),p=JSON.stringify,h=(e,t,n=!1)=>t.split("\n").map((t=>t||n?e+t:t)).join("\n"),u=(e,t)=>{let n=e;for(const[e,r]of Object.entries(t))if("string"==typeof r&&(n=n.replace(new RegExp(`@@${e}@@`,"g"),r)),"boolean"==typeof r)for(;;){const t=n.split("\n"),a=t.indexOf(`#@@ IF ${e}`),o=t.indexOf(`#@@ ENDIF ${e}`);if(-1===a||o{if(!n)return;const o=s.npath.toPortablePath(this.binDir);for(const[r,a]of n.manifest.bin){const n=s.ppath.join(o,r),i=s.ppath.join(t.cwd,s.npath.toPortablePath(a));await this.writeWrapper(n,i,{configuration:e,project:t})}if(e.get("installNixBinariesForDependencies")){await t.resolveEverything({report:r,lockfileOnly:!0});const n=await a.scriptUtils.getPackageAccessibleBinaries(t.topLevelWorkspace.anchoredLocator,{project:t});for(const[r,[a,i]]of n.entries()){const n=s.ppath.join(o,r);await this.writeWrapper(n,s.npath.toPortablePath(i),{configuration:e,project:t})}}}))).exitCode()}async writeWrapper(e,t,{configuration:n,project:r}){let a;switch(n.get("nodeLinker")){case"pnp":{const e=(0,d.getPnpPath)(r),n=[];await s.xfs.existsPromise(e.cjs)&&n.push(`--require "${s.npath.fromPortablePath(e.cjs)}"`),await s.xfs.existsPromise(e.esmLoader)&&n.push(`--experimental-loader "${(0,f.pathToFileURL)(s.npath.fromPortablePath(e.esmLoader)).href}"`),a=u("#!/bin/sh\nexport NODE_OPTIONS='@@NODE_OPTIONS@@'\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,NODE_OPTIONS:n.join(" "),BINARY_PATH:t});break}case"node-modules":a=u("#!/bin/sh\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,BINARY_PATH:t});break;default:throw Error("Assertion failed: Invalid nodeLinker")}await s.xfs.writeFilePromise(e,a),await s.xfs.chmodPromise(e,493)}}g.paths=[["nixify","install-bin"]];const m=e("os"),y=e("@yarnpkg/plugin-patch"),b=(e,t)=>(0,c.createHash)(e).update(t).digest(),x=(e,t,{storePath:n="/nix/store",recursive:r=!1}={})=>{const[a,o]=t.split("-"),i=Buffer.from(o,"base64").toString("hex"),c=b("sha256",`fixed:out:${r?"r:":""}${a}:${i}:`).toString("hex"),l=(e=>{let t="",n=[...e].reverse().map((e=>e.toString(2).padStart(8,"0"))).join("");for(;n;)t+="0123456789abcdfghijklmnpqrsvwxyz"[parseInt(n.slice(0,5),2)],n=n.slice(5);return t})(((e,t)=>{const n=Buffer.alloc(20);for(let t=0;te.replace(/^\.+/,"").replace(/[^a-zA-Z0-9+._?=-]+/g,"-").slice(0,207)||"unknown",w=(e,t="sha512")=>t+"-"+Buffer.from(e,"hex").toString("base64"),k=e=>Buffer.from(e.split("-")[1],"base64").toString("hex"),I=2**32-1,P=(e,...t)=>{let n=0;const r=t.map((e=>{const t=Buffer.from(e);if(t.byteLength>I)throw Error(`NAR string too long: ${t.byteLength}`);return n+=8+8*Math.ceil(t.byteLength/8),t})),a=Buffer.alloc(n);let o=0;for(const e of r)a.writeUInt32LE(e.byteLength,o),e.copy(a,o+8),o+=8+8*Math.ceil(e.byteLength/8);e.write(a)},$=async(e,t,n)=>{if(t>I)throw Error(`NAR string too long: ${t}`);const r=Buffer.alloc(8);r.writeUInt32LE(t),e.write(r);for await(const t of n)e.write(t);const a=8-t%8;8!==a&&e.write(Buffer.alloc(a))},N=a.YarnVersion?.startsWith("3.")||!1,E={commands:[i,l,g],hooks:{afterAllInstalled:async(e,t)=>{!1!==t.persistProject&&e.configuration.get("enableNixify")&&await(async(e,t)=>{const{configuration:n,cwd:r}=e,{cache:o,report:i}=t,l=await s.xfs.realpathPromise(s.npath.toPortablePath((0,m.tmpdir)()));if(e.cwd.startsWith(l))return void i.reportInfo(0,`Skipping Nixify, because ${e.cwd} appears to be a temporary directory`);const d=n.get("nixExprPath"),f=n.get("yarnPath");let g;if(null===f){let e=(await s.xfs.readFilePromise(process.argv[1])).toString();if(e.startsWith("#!/nix/store/")){const t=e.substring(e.indexOf("\n")+1);e=`#!/usr/bin/env node\n${t}`}const t=a.hashUtils.makeHash(Buffer.from(e));g=["fetchurl {",` url = "https://repo.yarnpkg.com/${a.YarnVersion}/packages/yarnpkg-cli/bin/yarn.js";`,` hash = "${w(t)}";`,"}"].join("\n ")}else f.startsWith(r)?g="./"+s.ppath.relative(s.ppath.dirname(d),f):(g=p(f),i.reportWarning(0,`The Yarn path ${f} is outside the project - it may not be reachable by the Nix build`));const b=n.get("cacheFolder");let I;if(b.startsWith(r))I=p(s.ppath.relative(r,b));else{if(N||!n.get("enableGlobalCache"))throw Error(`The cache folder ${b} is outside the project, this is currently not supported`);I='".yarn/cache"'}const E=new Set;for(const e of n.sources.values())for(const t of e.split(", "))t.startsWith("<")||E.add(t);for(const e of E)s.ppath.resolve(r,e).startsWith(r)||i.reportWarning(0,`The config file ${e} is outside the project - it may not be reachable by the Nix build`);const D="./"+s.ppath.relative(s.ppath.dirname(d),s.ppath.resolve(r,"yarn.lock")),S=new Map,_=new Set(await s.xfs.readdirPromise(o.cwd)),L={unstablePackages:e.conditionalLocators};for(const t of e.storedPackages.values()){const{locatorHash:n}=t,r=e.storedChecksums.get(n),a=N?o.getLocatorPath(t,r||null,L):o.getLocatorPath(t,r||null);if(!a)continue;if(!_.has(s.ppath.basename(a)))continue;const i=r?o.getChecksumFilename(t,r):o.getVersionFilename(t);S.set(i,{pkg:t,checksum:r,cachePath:a})}const O=new Map,A=n.get("individualNixPackaging");let j="",T="";if(A){for(const[e,{pkg:t,checksum:n,cachePath:r}]of S.entries()){const o=a.structUtils.stringifyLocator(t),i=n?n.split("/").pop():await a.hashUtils.checksumFile(r);O.set(o,{cachePath:r,filename:e,hash:w(i)})}j="cacheEntries = {\n";for(const e of[...O.keys()].sort()){const t=O.get(e);j+=`${p(e)} = { ${[`filename = ${p(t.filename)};`,`hash = "${t.hash}";`].join(" ")} };\n`}j+="};"}else{const e=(0,c.createHash)("sha512");P(e,"nix-archive-1","(","type","directory");for(const t of[...S.keys()].sort()){const{cachePath:n}=S.get(t),{size:r}=await s.xfs.statPromise(n);P(e,"entry","(","name",t,"node","(","type","regular","contents"),await $(e,r,s.xfs.createReadStream(n)),P(e,")",")")}P(e,")"),e.end();for await(const t of e)T=w(t)}const C=n.get("isolatedNixBuilds");let R=new Set,F=[],B=[];const H=n.get("nodeLinker"),U=n.get("pnpUnpluggedFolder"),M=(t,n=new Set)=>{const r=a.structUtils.stringifyLocator(t);if(O.has(r)&&n.add(r),a.structUtils.isVirtualLocator(t)){const r=e.storedPackages.get(a.structUtils.devirtualizeLocator(t).locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}if(t.reference.startsWith("patch:")){const r=e.storedPackages.get(y.patchUtils.parseLocator(t).sourceLocator.locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}for(const r of t.dependencies.values()){const t=e.storedResolutions.get(r.descriptorHash);if(!t)throw Error("Assertion failed: The descriptor should have been registered");const a=e.storedPackages.get(t);if(!a)throw Error("Assertion failed: The locator should have been registered");M(a,n)}return n};for(const t of e.storedBuildState.keys()){const n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");if(!C.includes(n.name))continue;let r;if("pnp"!==H)throw Error(`The nodeLinker ${H} is not supported for isolated Nix builds`);r=s.ppath.relative(e.cwd,s.ppath.join(U,a.structUtils.slugifyLocator(n),a.structUtils.getIdentVendorPath(n)));let o=n;if(a.structUtils.isVirtualLocator(o)){const{locatorHash:t}=a.structUtils.devirtualizeLocator(o),n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");o=n}const i=a.structUtils.stringifyLocator(o),c=a.structUtils.stringifyLocator(n),l=`isolated.${p(i)}`;if(!R.has(o)){R.add(o);const e=[`pname = ${p(n.name)};`,`version = ${p(n.version)};`,`reference = ${p(o.reference)};`];if(A){const t=[...M(n)].sort().map((e=>`${p(e)}\n`)).join("");t&&e.push(`locators = [\n${t}];`)}const t=`override${V=n.name,V.split(/[^a-zA-Z0-9]+/g).filter((e=>e)).map((e=>{return(t=e).slice(0,1).toUpperCase()+t.slice(1);var t})).join("")}Attrs`;B.push(`${l} = optionalOverride (args.${t} or null) (mkIsolatedBuild { ${e.join(" ")} });`)}0===F.length&&F.push("# Copy in isolated builds."),F.push(`echo 'injecting build for ${n.name}'`,"yarn nixify inject-build \\",` ${p(c)} \\`,` \${${l}} \\`,` ${p(r)}`)}var V;if(F.length>0&&F.push("echo 'running yarn install'"),null==t.mode||0===C.length){const t=e.topLevelWorkspace.manifest.name,o=t?a.structUtils.stringifyIdent(t):"workspace",c=u("# This file is generated by running \"yarn install\" inside your project.\n# Manual changes might be lost - proceed with caution!\n\n{ lib, stdenv, nodejs, git, cacert, fetchurl, writeShellScript, writeShellScriptBin }:\n{ src, overrideAttrs ? null, ... } @ args:\n\nlet\n\n yarnBin = @@YARN_BIN@@;\n\n cacheFolder = @@CACHE_FOLDER@@;\n lockfile = @@LOCKFILE@@;\n\n # Call overrideAttrs on a derivation if a function is provided.\n optionalOverride = fn: drv:\n if fn == null then drv else drv.overrideAttrs fn;\n\n # Simple stub that provides the global yarn command.\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${nodejs}/bin/node' '${yarnBin}' \"$@\"\n '';\n\n # Common attributes between Yarn derivations.\n drvCommon = {\n # Make sure the build uses the right Node.js version everywhere.\n buildInputs = [ nodejs yarn ];\n # All dependencies should already be cached.\n yarn_enable_network = \"0\";\n # Tell node-gyp to use the provided Node.js headers for native code builds.\n npm_config_nodedir = nodejs;\n };\n\n # Comman variables that we set in a Nix build, but not in a Nix shell.\n buildVars = ''\n # Make Yarn produce friendlier logging for automated builds.\n export CI=1\n # Tell node-pre-gyp to never fetch binaries / always build from source.\n export npm_config_build_from_source=true\n '';\n\n#@@ IF COMBINED_DRV\n cacheDrv = stdenv.mkDerivation {\n name = \"yarn-cache\";\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cp --reflink=auto --recursive '${src}' ./src\n cd ./src/\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$out\" \\\n yarn nixify fetch\n rm $out/.gitignore\n '';\n outputHashMode = \"recursive\";\n outputHash = \"@@COMBINED_HASH@@\";\n };\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n # Create derivations for fetching dependencies.\n cacheDrvs = let\n in lib.mapAttrs (locator: { filename, hash }: stdenv.mkDerivation {\n name = lib.strings.sanitizeDerivationName locator;\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cd '${src}'\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$TMP\" \\\n yarn nixify fetch ${lib.escapeShellArg locator}\n # Because we change the cache dir, Yarn may generate a different name.\n mv \"$TMP/$(sed 's/-[^-]*\\.[^-]*$//' <<< \"$outputFilename\")\"-* $out\n '';\n outputFilename = filename;\n outputHash = hash;\n }) cacheEntries;\n\n # Create a shell snippet to copy dependencies from a list of derivations.\n mkCacheBuilderForDrvs = drvs:\n writeShellScript \"collect-yarn-cache\" (lib.concatMapStrings (drv: ''\n cp --reflink=auto ${drv} '${drv.outputFilename}'\n '') drvs);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n#@@ IF NEED_ISOLATED_BUILD_SUPPRORT\n#@@ IF INDIVIDUAL_DRVS\n # Create a shell snippet to copy dependencies from a list of locators.\n mkCacheBuilderForLocators = let\n pickCacheDrvs = map (locator: cacheDrvs.${locator});\n in locators:\n mkCacheBuilderForDrvs (pickCacheDrvs locators);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Create a derivation that builds a module in isolation.\n mkIsolatedBuild = { pname, version, reference, locators ? [] }: stdenv.mkDerivation (drvCommon // {\n inherit pname version;\n dontUnpack = true;\n\n configurePhase = ''\n ${buildVars}\n unset yarn_enable_nixify # plugin is not present\n '';\n\n buildPhase = ''\n mkdir -p .yarn/cache\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* .yarn/cache/\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd .yarn/cache > /dev/null\n source ${mkCacheBuilderForLocators locators}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n echo '{ \"dependencies\": { \"${pname}\": \"${reference}\" } }' > package.json\n install -m 0600 ${lockfile} ./yarn.lock\n export yarn_global_folder=\"$TMP\"\n export yarn_enable_global_cache=false\n export yarn_enable_immutable_installs=false\n yarn\n '';\n\n installPhase = ''\n unplugged=( .yarn/unplugged/${pname}-*/node_modules/* )\n if [[ ! -e \"''${unplugged[@]}\" ]]; then\n echo >&2 \"Could not find the unplugged path for ${pname}\"\n exit 1\n fi\n\n mv \"$unplugged\" $out\n '';\n });\n#@@ ENDIF NEED_ISOLATED_BUILD_SUPPRORT\n\n # Main project derivation.\n project = stdenv.mkDerivation (drvCommon // {\n inherit src;\n name = @@PROJECT_NAME@@;\n\n configurePhase = ''\n ${buildVars}\n\n # Copy over the Yarn cache.\n rm -fr '${cacheFolder}'\n mkdir -p '${cacheFolder}'\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* '${cacheFolder}/'\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd '${cacheFolder}' > /dev/null\n source ${mkCacheBuilderForDrvs (lib.attrValues cacheDrvs)}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Yarn may need a writable home directory.\n export yarn_global_folder=\"$TMP\"\n\n # Ensure global cache is disabled. Cache must be part of our output.\n touch .yarnrc.yml\n sed -i -e '/^enableGlobalCache/d' .yarnrc.yml\n echo 'enableGlobalCache: false' >> .yarnrc.yml\n\n # Some node-gyp calls may call out to npm, which could fail due to an\n # read-only home dir.\n export HOME=\"$TMP\"\n\n # running preConfigure after the cache is populated allows for\n # preConfigure to contain substituteInPlace for dependencies as well as the\n # main project. This is necessary for native bindings that maybe have\n # hardcoded values.\n runHook preConfigure\n\n@@ISOLATED_INTEGRATION@@\n\n # Run normal Yarn install to complete dependency installation.\n yarn install --immutable --immutable-cache\n\n runHook postConfigure\n '';\n\n buildPhase = ''\n runHook preBuild\n runHook postBuild\n '';\n\n installPhase = ''\n runHook preInstall\n\n # Move the package contents to the output directory.\n if grep -q '\"workspaces\"' package.json; then\n # We can't use `yarn pack` in a workspace setup, because it only\n # packages the outer workspace.\n mkdir -p \"$out/libexec\"\n mv $PWD \"$out/libexec/$name\"\n else\n # - If the package.json has a `files` field, only files matching those patterns are copied\n # - Otherwise all files are copied.\n yarn pack --out package.tgz\n mkdir -p \"$out/libexec/$name\"\n tar xzf package.tgz --directory \"$out/libexec/$name\" --strip-components=1\n\n cp --reflink=auto .yarnrc* \"$out/libexec/$name\"\n cp --reflink=auto ${lockfile} \"$out/libexec/$name/yarn.lock\"\n cp --reflink=auto --recursive .yarn \"$out/libexec/$name\"\n\n # Copy the Yarn linker output into the package.\n#@@ IF USES_PNP_LINKER\n cp --reflink=auto .pnp.* \"$out/libexec/$name\"\n#@@ ENDIF USES_PNP_LINKER\n#@@ IF USES_NM_LINKER\n cp --reflink=auto --recursive node_modules \"$out/libexec/$name\"\n#@@ ENDIF USES_NM_LINKER\n fi\n\n cd \"$out/libexec/$name\"\n\n # Invoke a plugin internal command to setup binaries.\n mkdir -p \"$out/bin\"\n yarn nixify install-bin $out/bin\n\n#@@ IF USES_NM_LINKER\n # A package with node_modules doesn't need the cache\n yarn cache clean\n#@@ ENDIF USES_NM_LINKER\n\n runHook postInstall\n '';\n\n passthru = {\n inherit nodejs;\n yarn-freestanding = yarn;\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${yarn}/bin/yarn' --cwd '${overriddenProject}/libexec/${overriddenProject.name}' \"$@\"\n '';\n };\n });\n\n overriddenProject = optionalOverride overrideAttrs project;\n\n@@CACHE_ENTRIES@@\n@@ISOLATED@@\nin overriddenProject\n",{PROJECT_NAME:p(o),YARN_BIN:g,LOCKFILE:D,INDIVIDUAL_DRVS:A,COMBINED_DRV:!A,COMBINED_HASH:T,CACHE_FOLDER:I,CACHE_ENTRIES:j,ISOLATED:B.join("\n"),ISOLATED_INTEGRATION:h(" ",F.join("\n")),NEED_ISOLATED_BUILD_SUPPRORT:F.length>0,USES_PNP_LINKER:"pnp"===n.get("nodeLinker"),USES_NM_LINKER:"node-modules"===n.get("nodeLinker")}).replace(/\n\n\n+/g,"\n\n");if(await s.xfs.mkdirpPromise(s.ppath.dirname(d)),await s.xfs.writeFilePromise(d,c),n.get("generateDefaultNix")){const e=s.ppath.join(r,"default.nix"),t=s.ppath.join(r,"flake.nix");s.xfs.existsSync(e)||s.xfs.existsSync(t)||(await s.xfs.writeFilePromise(e,"# This is a minimal `default.nix` by yarn-plugin-nixify. You can customize it\n# as needed, it will not be overwritten by the plugin.\n\n{ pkgs ? import { } }:\n\npkgs.callPackage ./yarn-project.nix { } { src = ./.; }\n"),i.reportInfo(0,"A minimal default.nix was created. You may want to customize it."))}}n.get("enableNixPreload")&&s.xfs.existsSync(s.npath.toPortablePath("/nix/store"))&&await s.xfs.mktempPromise((async t=>{const n=["--add-fixed","sha512"],r=[];if(A)for(const[e,{cachePath:n,hash:a}]of O.entries()){const o=v(e),i=x(o,a);if(!s.xfs.existsSync(i)){const e=s.ppath.join(t,k(a).slice(0,7));await s.xfs.mkdirPromise(e);const i=s.ppath.join(e,o);await s.xfs.copyFilePromise(n,i),r.push(i)}}else{n.unshift("--recursive");const e=x("yarn-cache",T,{recursive:!0});if(!s.xfs.existsSync(e)){const e=s.ppath.join(t,"yarn-cache");await s.xfs.mkdirPromise(e);for(const[t,{cachePath:n}]of S.entries()){const r=s.ppath.join(e,t);await s.xfs.copyFilePromise(n,r)}r.push(e)}}try{const t=r.length;for(;0!==r.length;){const t=r.splice(0,100);await a.execUtils.execvp("nix-store",[...n,...t],{cwd:e.cwd,strict:!0})}0!==t&&i.reportInfo(0,A?`Preloaded ${t} packages into the Nix store`:"Preloaded cache into the Nix store")}catch(e){if("ENOENT"!==e.code)throw e}}))})(e,t)}},configuration:{enableNixify:{description:"If false, disables the Nixify plugin hook that generates Nix expressions",type:a.SettingsType.BOOLEAN,default:!0},nixExprPath:{description:"Path of the file where the project Nix expression will be written to",type:a.SettingsType.ABSOLUTE_PATH,default:"./yarn-project.nix"},generateDefaultNix:{description:"If true, a default.nix will be generated if it does not exist",type:a.SettingsType.BOOLEAN,default:!0},enableNixPreload:{description:"If true, cached packages will be preloaded into the Nix store",type:a.SettingsType.BOOLEAN,default:!0},individualNixPackaging:{description:"If true, generate one Nix derivation per package. If false, use a single derivation for the entire cache folder.",type:a.SettingsType.BOOLEAN,default:!1},isolatedNixBuilds:{description:"Dependencies with a build step that can be built in an isolated derivation",type:a.SettingsType.STRING,default:[],isArray:!0},installNixBinariesForDependencies:{description:"If true, the Nix output 'bin' directory will also contain executables for binaries defined by dependencies",type:a.SettingsType.BOOLEAN,default:!1}}};t=r})(),t}}; \ No newline at end of file diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 7565a81..8bf757f 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -1,3 +1,4 @@ +import { installDependencies } from "./install-dependencies.mjs"; import { buildService } from "./build-service.mjs"; import { buildComponents } from "./build-components.mjs"; /** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */ @@ -69,14 +70,18 @@ for (const { target, mode, settings } of builds) { await writable(work); const modules = path.join(work, "node_modules"); await fs.mkdir(path.join(modules, "@quixos"), { recursive: true }); - if (config.nodeModules) { - for (const item of await fs.readdir(config.nodeModules, { withFileTypes: true })) { - if (item.name === "@quixos") throw Error("Application dependencies cannot substitute the checked SDK"); - await fs.symlink(path.join(config.nodeModules, item.name), path.join(modules, item.name)); - } - } - await fs.symlink(config.core, path.join(modules, "@quixos/camino-replica-core")); - await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); + await installDependencies({ + destination: modules, + application: config.nodeModules, + selected: { "@quixos/camino-replica-core": config.core }, + reserved: ["@quixos/camino-replica-core", "@quixos/camino-replica-client", "@quixos/camino-replica-engine"], + }); + const packageFile = path.join(work, "package.json"); + const packageMetadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => { + if (error.code === "ENOENT") return {}; + throw error; + }); + await fs.writeFile(packageFile, JSON.stringify({ ...packageMetadata, type: "module" })); const bindingPath = relative(settings.bindingOutput); const source = mode === "remote" diff --git a/build-components.mjs b/build-components.mjs index c74636b..ba34594 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -1,3 +1,4 @@ +import { installDependencies } from "./install-dependencies.mjs"; import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; @@ -66,17 +67,43 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec await writable(work); const modules = path.join(work, "node_modules"); await fs.mkdir(path.join(modules, "@quixos"), { recursive: true }); - if (config.nodeModules) - for (const item of await fs.readdir(config.nodeModules, { withFileTypes: true })) { - if (["@quixos", "react", "react-dom", "scheduler", "@types", "csstype"].includes(item.name)) - throw Error("Application dependencies cannot substitute selected React/client types"); - await fs.symlink(path.join(config.nodeModules, item.name), path.join(modules, item.name)); - } - for (const item of await fs.readdir(path.join(settings.dependencies, "node_modules"))) { - await fs.symlink(path.join(settings.dependencies, "node_modules", item), path.join(modules, item)); + await installDependencies({ + destination: modules, + application: config.nodeModules, + defaults: [path.join(settings.dependencies, "node_modules")], + selected: { "@quixos/camino-react": settings.sdk }, + reserved: [ + "react", + "react-dom", + "scheduler", + "csstype", + "@types/react", + "@types/react-dom", + "@types/prop-types", + "@quixos/camino-react", + "@quixos/camino-replica-core", + "@quixos/camino-replica-client", + "@quixos/camino-replica-engine", + ], + }); + const packageFile = path.join(work, "package.json"); + const packageMetadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => { + if (error.code === "ENOENT") return {}; + throw error; + }); + await fs.writeFile(packageFile, JSON.stringify({ ...packageMetadata, type: "module" })); + // CommonJS dependencies may require React. An ESM bridge lets esbuild lower + // that require while retaining the one host-owned runtime instance. + const sharedAliases = []; + for (const [name, url] of Object.entries(imports)) { + const shim = path.join(work, "shared-" + digest(Buffer.from(name)).slice(0, 16) + ".mjs"); + await fs.writeFile( + shim, + `export * from ${JSON.stringify(url)};` + + (name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`), + ); + sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); } - await fs.symlink(settings.sdk, path.join(modules, "@quixos/camino-react")); - await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); const bindingPath = compiler.artifactPath(settings.bindingOutput), bindings = compiler.generateComponentClientBindings(plan, pkg.revision), assets = compiler.componentAssetDeclarations(); @@ -187,7 +214,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec '--define:process.env.NODE_ENV="production"', "--metafile=" + metadata, "--outfile=" + path.join(output, directory, "module.mjs"), - ...Object.entries(imports).flatMap(([name, url]) => ["--alias:" + name + "=" + url, "--external:" + url]), + ...sharedAliases, ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( (extension) => "--loader:." + extension + "=dataurl", ), diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs index 185d858..0081175 100644 --- a/build-react-contracts.mjs +++ b/build-react-contracts.mjs @@ -23,7 +23,12 @@ const writable = async (directory) => { } }; await writable(work); -await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); +const packageFile = path.join(work, "package.json"); +const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => { + if (error.code === "ENOENT") return {}; + throw error; +}); +await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" })); if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); const members = Object.create(null); let index = 0; diff --git a/install-dependencies.mjs b/install-dependencies.mjs new file mode 100644 index 0000000..7ca1b37 --- /dev/null +++ b/install-dependencies.mjs @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +async function packages(directory) { + const result = new Map(); + for (const entry of await fs.readdir(directory)) { + if (entry.startsWith(".")) continue; + if (entry.startsWith("@")) { + for (const name of await fs.readdir(path.join(directory, entry))) + result.set(`${entry}/${name}`, path.join(directory, entry, name)); + } else result.set(entry, path.join(directory, entry)); + } + return result; +} +async function packageDigest(directory) { + const digest = createHash("sha256"); + async function visit(relative) { + for (const entry of (await fs.readdir(path.join(directory, relative), { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + if (entry.name === "node_modules") continue; + const name = path.join(relative, entry.name), + file = path.join(directory, name); + if (entry.isDirectory()) await visit(name); + else { + const bytes = await fs.readFile(file); + digest.update(JSON.stringify([name, bytes.length])); + digest.update(bytes); + } + } + } + await visit(""); + return digest.digest("hex"); +} +/** Merge at package granularity: a scope such as @types is not a reserved package. */ +export async function installDependencies({ destination, application, defaults = [], selected = {}, reserved = [] }) { + const entries = new Map(); + const authored = new Set(); + for (const root of defaults) for (const [name, file] of await packages(root)) entries.set(name, file); + for (const [name, file] of Object.entries(selected)) entries.set(name, file); + if (application) + for (const [name, file] of await packages(application)) { + if (reserved.includes(name)) { + const canonical = entries.get(name); + if (!canonical || (await packageDigest(file)) !== (await packageDigest(canonical))) + throw Error("Application dependencies cannot substitute selected React/client types or checked SDK: " + name); + } else { + entries.set(name, file); + authored.add(name); + } + } + for (const [name, file] of entries) { + await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true }); + // Application packages resolve siblings from this assembled node_modules. + // Keep SDK links canonical: copying those would duplicate branded types and + // React declarations from their own retained dependency closures. + if (authored.has(name)) await fs.cp(file, path.join(destination, name), { recursive: true, dereference: true }); + else await fs.symlink(file, path.join(destination, name)); + } +} diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index 3fa177e..356a7d4 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -848,6 +848,8 @@ in { mkCaminoReactPackage = import ./react-package.nix; mkCaminoReactContracts = import ./react-contracts.nix; + mkCaminoYarnDependencies = import ./yarn-dependencies.nix; + mkCaminoYarnShell = import ./yarn-shell.nix; mkCaminoTypeScriptPackage = import ./typescript-package.nix; mkCaminoTypeScriptService = import ./typescript-service.nix; assembleCaminoPackage = import ./assemble-package.nix; diff --git a/yarn-dependencies.nix b/yarn-dependencies.nix new file mode 100644 index 0000000..b444174 --- /dev/null +++ b/yarn-dependencies.nix @@ -0,0 +1,29 @@ +{ + pkgs, + src, + nodejs ? pkgs.nodejs_24, +}: +let + project = (pkgs.callPackage (src + "/yarn-project.nix") { inherit nodejs; }) { + inherit src; + overrideAttrs = _: { + buildPhase = "runHook preBuild; runHook postBuild"; + installPhase = '' + runHook preInstall + test -d node_modules + mkdir -p "$out" + cp -a node_modules/. "$out/" + runHook postInstall + ''; + }; + }; +in +project +// { + devShell = pkgs.mkShell { + packages = [ + nodejs + project.yarn-freestanding + ]; + }; +} diff --git a/yarn-shell.nix b/yarn-shell.nix new file mode 100644 index 0000000..7f99c11 --- /dev/null +++ b/yarn-shell.nix @@ -0,0 +1,22 @@ +{ pkgs }: +let + yarnCli = pkgs.fetchurl { + url = "https://repo.yarnpkg.com/4.18.0/packages/yarnpkg-cli/bin/yarn.js"; + hash = "sha512-/Lhxb+fNDuzhQf/Bi5IZOp35IEwbqDGJwoiDUiP8C75kr0c7qw1emSen2utcryuwfrJ4fMkzjKBA6hJfKh8vfg=="; + }; + yarn = pkgs.writeShellScriptBin "yarn" '' + exec ${pkgs.nodejs_24}/bin/node ${yarnCli} "$@" + ''; +in +pkgs.mkShell { + packages = [ + pkgs.nodejs_24 + yarn + ]; + shellHook = '' + if [ ! -f .yarn/plugins/yarn-plugin-nixify.cjs ]; then + mkdir -p .yarn/plugins + cp ${./.yarn/plugins/yarn-plugin-nixify.cjs} .yarn/plugins/yarn-plugin-nixify.cjs + fi + ''; +} From d6efe5622d9927d5ca9d034f5b036f274fa25548 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Thu, 24 Sep 2026 11:52:17 -0700 Subject: [PATCH 2/8] Add shared Quixos React controls and delayed component fallback --- build-components.mjs | 4 +++- build-react-contracts.mjs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/build-components.mjs b/build-components.mjs index ba34594..4b0e073 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -100,7 +100,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec await fs.writeFile( shim, `export * from ${JSON.stringify(url)};` + - (name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`), + (["@quixos/camino-react", "@quixos/camino-react/ui"].includes(name) + ? "" + : `export {default} from ${JSON.stringify(url)};`), ); sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); } diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs index 0081175..ed07a9e 100644 --- a/build-react-contracts.mjs +++ b/build-react-contracts.mjs @@ -52,7 +52,7 @@ for (const [id, entry] of Object.entries(config.members)) { `import type {${entry.export} as Props} from ${typeImport}; type Assert = T; type ObjectProps = Assert; -type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>; +type ReservedRendererProps = Assert extends never ? true : false>; ${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""} export type {Props};\n`, ); From 009c5fcb4d65c2c35489c692d640be869d99b196 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 27 Sep 2026 16:34:53 -0700 Subject: [PATCH 3/8] Complete prepared presentation build and runtime integration --- build-components.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-components.mjs b/build-components.mjs index 4b0e073..ae30074 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -170,7 +170,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec witness, `import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` + (module.contract === "org.quixos.react.subject-only/1" - ? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = ;\n` + ? `import type {ComponentProps} from "react";declare const props:Pick,"subject"|"input">;const mount = ;\n` : ""), ); execFileSync( From b0d9453a51e019e45f5fd2e1ea8428e11dae4043 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 28 Sep 2026 20:29:21 -0700 Subject: [PATCH 4/8] Make local graph execution optional across composed packages --- build-artifacts.mjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 8bf757f..996ba36 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -55,6 +55,7 @@ const targets = portable ? [...new Set([...portable.targets, "server"])] : []; const builds = targets.map((target) => ({ target, mode: target, settings: portable })); if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote }); for (const { target, mode, settings } of builds) { + const executionTarget = target === "browser" ? "target:org.quixos.execution.javascript@1" : "server"; if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`); const work = path.resolve(`work-${mode}`); await fs.cp(config.source, work, { recursive: true, dereference: false }); @@ -86,11 +87,19 @@ for (const { target, mode, settings } of builds) { const source = mode === "remote" ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) - : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target }); + : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { + target: executionTarget, + }); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), source); generated.set(mode, { file: path.join(work, bindingPath), source }); - await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source); + await fs.writeFile( + path.join( + output, + mode === "remote" ? "share/quixos/generated/remote.ts" : compiler.generatedBindingPath(executionTarget), + ), + source, + ); const entry = relative(settings.entries?.[target] ?? settings.entry); execFileSync( config.node, @@ -152,12 +161,12 @@ for (const { target, mode, settings } of builds) { .filter((e) => mode === "remote" ? e.execution.kind === "remote" - : e.execution.kind !== "remote" && e.execution.targets.includes(target), + : e.execution.kind !== "remote" && e.execution.targets.includes(executionTarget), ) .map((e) => e.id); const body = { id: `${mode}-module`, - target, + target: executionTarget, compatibility: "quixos-js-module-v1", path: bundlePath, ports: "quixos-transaction-ports-v1", From 8e55a11c4a08128b9407891ec785e4a824cd0af7 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 28 Sep 2026 21:05:55 -0700 Subject: [PATCH 5/8] Correct Reader producer completion and prepare only the active source Route durable completion to the initiating PresentationMount, guarded against stale navigation and unrelated fan-out. Publish Gutenberg passages directly, preserve unfinished manual drafts, and open completed documents in the main reader pane. Prepare the selected provider and explicitly opened inspector only. Consolidate Studio factory contracts so optional execution placement is determined by implementations. Support typed stateful presentation props and local-module role selection in the React build layer. Update integration, browser, portal, and authoring documentation. --- build-react-contracts.mjs | 20 ++++++++++++++++---- react-contracts.nix | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs index ed07a9e..e4d6736 100644 --- a/build-react-contracts.mjs +++ b/build-react-contracts.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { installDependencies } from "./install-dependencies.mjs"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const resource = JSON.parse(await fs.readFile(config.interface, "utf8")); if (resource.kind !== "interface") throw Error("React props belong to an interface resource"); @@ -29,14 +30,24 @@ const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) throw error; }); await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" })); -if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); +const modules = path.join(output, "node_modules"); +await fs.mkdir(modules, { recursive: true }); +await installDependencies({ + destination: modules, + application: config.dependencies, + selected: config.reactSdk ? { "@quixos/camino-react": config.reactSdk } : {}, + reserved: ["@quixos/camino-react"], +}); +await fs.symlink(modules, path.join(work, "node_modules")); const members = Object.create(null); let index = 0; for (const [id, entry] of Object.entries(config.members)) { const member = (iface.template?.members ?? iface.members).find((member) => member.id === id); if ( member?.kind !== "module" || - !["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract) + !["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes( + member.contract, + ) ) throw Error("Props entry is not a React module member: " + id); if ( @@ -52,7 +63,7 @@ for (const [id, entry] of Object.entries(config.members)) { `import type {${entry.export} as Props} from ${typeImport}; type Assert = T; type ObjectProps = Assert; -type ReservedRendererProps = Assert extends never ? true : false>; +type ReservedRendererProps = Assert extends never ? true : false>; ${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""} export type {Props};\n`, ); @@ -69,6 +80,8 @@ execFileSync( "--strict", "--target", "es2024", + "--lib", + "es2024,dom,dom.iterable,esnext.disposable", "--module", "nodenext", "--moduleResolution", @@ -88,7 +101,6 @@ for (const file of declarations) { await fs.copyFile(path.join(work, file), path.join(output, "types", file)); } await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" })); -if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules")); await fs.writeFile( path.join(output, "react-contracts.json"), JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2), diff --git a/react-contracts.nix b/react-contracts.nix index acd5b9b..d9c5bab 100644 --- a/react-contracts.nix +++ b/react-contracts.nix @@ -11,6 +11,7 @@ let source = src; interface = context.interface; inherit members dependencies; + reactSdk = context.reactSdk or null; node = "${pkgs.nodejs_24}/bin/node"; tsc = "${context.typescript}/node_modules/typescript/bin/tsc"; } From 988e10263af2f126c54bc692929853fe440768a3 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 29 Sep 2026 23:51:08 -0700 Subject: [PATCH 6/8] Add graph-aware React controls with stable asynchronous selection Add typed command/live bindings, Choice and Toggle, CRDT text wrappers, and explicit browser-local controls. Migrate Reader and current example inputs. Enforce editable-control authoring checks in the React artifact backend. Command controls display query observations, permit one local execution at a time, and delay pending feedback for 200 ms. Include keyboard, focus, shadow-root, top-layer popup, rejection/no-op, native-field eligibility, and IME coverage. Verified SDK builds and tests, synchronous-editing browser qualification, Reader/Tasks qualification, presentation-mount qualification, and the review-dashboard browser test. Monorepo formatting and lint pass. The full composition qualification stops at the unchanged combined-to-review workspace fixture transition: STORAGE_MODEL_CHANGED from changed/removed stored conformance owners. Its dashboard browser test passes when run directly. --- build-components.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build-components.mjs b/build-components.mjs index ae30074..193e5ac 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) { const settings = config.components; @@ -158,6 +159,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec `\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`, ); const components = []; + const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs"))); + const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js"))); + const checkedControls = new Set(); for (const [id, entry] of entries) { const declaration = declarations.find((component) => component.id === id), source = compiler.artifactPath(entry.entry); @@ -225,6 +229,12 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")), outputs = Object.entries(meta.outputs); + for (const file of Object.keys(meta.inputs)) { + if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes("node_modules/") || checkedControls.has(file)) continue; + checkedControls.add(file); + const errors = checkControls(ts, await fs.readFile(path.resolve(work, file), "utf8"), file); + if (errors.length) throw Error(errors.join("\n")); + } const main = outputs.find(([file]) => file.endsWith("/module.mjs"))?.[1]; if (!main?.exports.includes(entry.export)) throw Error("Component bundle lacks declared export"); if ( From 6227d64b086274df8ae8a0c5d5504738c98d53eb Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Wed, 30 Sep 2026 22:03:17 -0700 Subject: [PATCH 7/8] Build Camino packages from explicit contract inputs Project source-free capability contracts and keep exact source provenance in package assembly. Reuse compiled groups across unrelated graph and dependency implementation edits. Keep React public inputs separate from workspace query lowering and retain explicit type-companion dependencies. Allow independent execution-package checks, scope resource compilation to declared dependencies, and memoize source closure traversal. Document generic-specialization limits and measured Reader rebuild behavior. Verified protocol, artifact, package, React, and managed activation checks; retained-Git independent/workspace artifact equality and incompatible type dependency rejection; nix fmt -- --check and lint. One-off Reader measurements recompiled 1 of 13 groups for a child edit and 0 for a workspace QX default edit. --- PORTABLE_ARTIFACTS.md | 13 +++++++-- artifacts.nix | 55 ++++++++++++++++++++++++++++++++++----- assemble-package.mjs | 34 ++++++++++-------------- assemble-package.nix | 7 ++++- build-artifacts.mjs | 22 ++++++++-------- build-components.mjs | 16 ++++-------- implementation-source.nix | 30 +++++++++++++++++++++ package-context.nix | 2 ++ react-contracts.nix | 19 ++++++++++++-- react-package.nix | 2 +- typescript-package.nix | 2 +- typescript-service.nix | 2 +- 12 files changed, 147 insertions(+), 57 deletions(-) create mode 100644 implementation-source.nix create mode 100644 package-context.nix diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index 0f4618a..55a35a3 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -8,9 +8,9 @@ receipts. It has no migration step or package-defined shell hooks. ```nix # Inside a per-system flake output: quixosPackages.checkedArtifacts = - { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: + { buildContract, sourceDigest, generator, core, sharedRuntime, typescript, packageRevisionId, ... }: helpers.mkCaminoArtifacts { - inherit pkgs plan core sharedRuntime typescript packageRevisionId; + inherit pkgs buildContract sourceDigest core sharedRuntime typescript packageRevisionId; protocol = generator; src = ./.; portable = { @@ -22,6 +22,15 @@ quixosPackages.checkedArtifacts = }; ``` +`buildContract` is a canonical, source-free capability slice. `sourceDigest` +records the exact selected package source in the final installation manifest; +it does not enter the reusable compilation group. Ordinary source dependencies, +SDKs and React props companions remain explicit Nix inputs. Raw QX and resource +lock files enter through the checked contract and evaluated recipe, and are +excluded from the implementation source tree. The `plan` argument on the low-level +helper is a composition convenience for fixtures; it is projected before building. +Package recipes receive the contract directly and can be checked without a workspace. + The handler entry exports `registry = createRegistry(handlers)` from the generated bindings. One registry contains many exports. The builder always supplies a server implementation for the generated process service. A service-only package instead diff --git a/artifacts.nix b/artifacts.nix index 8e2542a..dabb747 100644 --- a/artifacts.nix +++ b/artifacts.nix @@ -2,7 +2,25 @@ { pkgs, src, - plan, + plan ? null, + buildContract ? + (import ./package-context.nix { + inherit + pkgs + protocol + plan + packageRevisionId + ; + }).buildContract, + sourceDigest ? + (import ./package-context.nix { + inherit + pkgs + protocol + plan + packageRevisionId + ; + }).sourceDigest, protocol, core, sharedRuntime, @@ -20,15 +38,15 @@ let config = pkgs.writeText "camino-artifact-build.json" ( builtins.toJSON { inherit - plan + buildContract compiler packageRevisionId core sharedRuntime nodeModules service - group ; + group = true; components = if components == null then null @@ -39,7 +57,10 @@ let bindingOutput = components.bindingOutput or "src/gen/client.ts"; contracts = components.contracts or [ ]; }; - source = src; + source = import ./implementation-source.nix { + inherit (pkgs) lib; + inherit src; + }; portable = if portable == null then null @@ -71,7 +92,27 @@ let serviceAdapter = ./portable-service.mjs; } ); + compiled = pkgs.runCommand "camino-compiled-artifacts" { passthru.buildConfig = config; } '' + ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} + ''; in -pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } '' - ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} -'' +if group then + compiled +else + (import ./assemble-package.nix { + inherit pkgs; + artifacts = [ compiled ]; + context = { + inherit + buildContract + sourceDigest + packageRevisionId + sharedRuntime + ; + generator = protocol; + }; + }) + // { + caminoGroup = compiled; + buildConfig = config; + } diff --git a/assemble-package.mjs b/assemble-package.mjs index 56e226e..04a99c7 100644 --- a/assemble-package.mjs +++ b/assemble-package.mjs @@ -6,9 +6,10 @@ import { buildService } from "./build-service.mjs"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const compiler = await import(pathToFileURL(config.compiler)); -const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); -const pkg = plan.definition.packages.find((pkg) => pkg.revision === config.packageRevisionId); -if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context"); +const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8")); +compiler.checkPackageBuildContract(contract); +const pkg = contract.definition.package; +if (pkg.revision !== config.packageRevisionId) throw Error("Invalid package assembly context"); const output = process.env.out; if (!output) throw Error("Package assembly requires an output directory"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -23,18 +24,17 @@ const recordFile = async (name, mediaType) => { }; await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); for (const root of config.groups) { - const manifest = compiler.parsePackageArtifacts( + const manifest = compiler.parseArtifactGroup( JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")), ); if ( manifest.packageRevisionId !== pkg.revision || - manifest.bindingDigest !== pkg.bindingDigest || - manifest.sourceDigest !== pkg.sourceDigest || - JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions) + manifest.bindingDigest !== contract.digest || + JSON.stringify(manifest.versions) !== JSON.stringify(contract.definition.versions) ) throw Error("Artifact group belongs to a different checked package"); const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8")); - const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); + const expected = compiler.expectedGroupReceipt(contract, manifest, generatorDigest); if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt)) throw Error("Artifact group has an invalid build receipt"); const read = await compiler.artifactFileReader({ [pkg.revision]: root }); @@ -80,24 +80,18 @@ if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1" const manifest = compiler.parsePackageArtifacts({ schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, - versions: plan.definition.versions, + bindingDigest: contract.digest, + sourceDigest: config.sourceDigest, + versions: contract.definition.versions, implementations, ...(components.length ? { components } : {}), }); -const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +const { receipt, sources } = compiler.expectedPackageReceipt(contract, manifest, config.sourceDigest, generatorDigest); for (const [name, bytes] of sources) { const actual = await fs.readFile(path.join(output, name)); if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name); } await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2)); await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2)); -// Check package coverage with the same public validator used at installation. -const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" }; -packagePlan.digest = compiler.hexDigest(packagePlan.definition); -await compiler.checkExecutionWorld( - packagePlan, - [manifest], - await compiler.artifactFileReader({ [pkg.revision]: output }), -); +// Source selection is attested here; compilation groups retain their own receipt. +compiler.checkPackageCoverage({ ...pkg, bindingDigest: contract.digest, sourceDigest: config.sourceDigest }, manifest); diff --git a/assemble-package.nix b/assemble-package.nix index 8550622..87e4e5d 100644 --- a/assemble-package.nix +++ b/assemble-package.nix @@ -8,7 +8,12 @@ let config = pkgs.writeText "camino-package-assembly.json" ( builtins.toJSON { inherit groups; - inherit (context) plan packageRevisionId sharedRuntime; + inherit (context) + buildContract + sourceDigest + packageRevisionId + sharedRuntime + ; compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; node = "${pkgs.nodejs_24}/bin/node"; esbuild = "${pkgs.esbuild}/bin/esbuild"; diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 996ba36..f1bc3c1 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -10,9 +10,10 @@ import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const compiler = await import(pathToFileURL(config.compiler)); -const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); -const pkg = plan.definition.packages.find((p) => p.revision === config.packageRevisionId); -if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid checked execution plan/package"); +const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8")); +compiler.checkPackageBuildContract(contract); +const pkg = contract.definition.package; +if (pkg.revision !== config.packageRevisionId) throw Error("Invalid build contract package"); const output = process.env.out; if (!output) throw Error("Missing Nix output"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -86,8 +87,8 @@ for (const { target, mode, settings } of builds) { const bindingPath = relative(settings.bindingOutput); const source = mode === "remote" - ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) - : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { + ? compiler.generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision) + : compiler.generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, { target: executionTarget, }); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); @@ -179,22 +180,21 @@ for (const { target, mode, settings } of builds) { const components = config.group && !config.components ? [] - : await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); + : await buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }); if (!config.group || config.service.entry) await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile }); -const manifest = compiler.parsePackageArtifacts({ +const manifest = compiler.parseArtifactGroup({ schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, - versions: plan.definition.versions, + bindingDigest: contract.digest, + versions: contract.definition.versions, implementations, ...(components.length ? { components } : {}), }); for (const { file, source } of generated.values()) if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build"); -const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +const { receipt, sources } = compiler.expectedGroupReceipt(contract, manifest, generatorDigest); for (const [file, bytes] of sources) if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file)))) throw Error("Receipt generation differs from compiled bindings"); diff --git a/build-components.mjs b/build-components.mjs index 193e5ac..d87a825 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); -export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) { +export async function buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }) { const settings = config.components; if (!settings) { if (pkg.components?.length) throw Error("Declared components require checked component build settings"); @@ -108,7 +108,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); } const bindingPath = compiler.artifactPath(settings.bindingOutput), - bindings = compiler.generateComponentClientBindings(plan, pkg.revision), + bindings = contract.definition.component, assets = compiler.componentAssetDeclarations(); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), bindings); @@ -126,10 +126,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec for (const root of settings.contracts ?? []) { const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8")); if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion"); - const contracts = plan.definition.workspace.interfaceImports.filter( + const contracts = contract.definition.moduleInterfaces.filter( (iface) => - iface.revisionId === companion.interfaceRevisionId || - iface.application?.definitionId === companion.interfaceRevisionId, + iface.revisionId === companion.interfaceRevisionId || iface.definitionId === companion.interfaceRevisionId, ); if (!contracts.length) continue; for (const [memberId, entry] of Object.entries(companion.members)) { @@ -139,12 +138,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec `import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`, ); for (const iface of contracts) { - if ( - !(iface.template?.members ?? iface.members).some( - (member) => member.kind === "module" && member.id === memberId, - ) - ) - throw Error("Props companion names an unknown module member"); + if (!iface.members.includes(memberId)) throw Error("Props companion names an unknown module member"); const key = JSON.stringify([iface.revisionId, memberId]); if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key); seenProps.add(key); diff --git a/implementation-source.nix b/implementation-source.nix new file mode 100644 index 0000000..672db0a --- /dev/null +++ b/implementation-source.nix @@ -0,0 +1,30 @@ +# QX and recursive pins enter through the checked contract and build recipe. +# The compiler receives authored implementation files, not resource provenance. +{ lib, src }: +builtins.path { + path = src; + name = "camino-implementation-source"; + filter = + path: type: + let + name = builtins.baseNameOf path; + in + if type == "directory" then + !(builtins.elem name [ + ".git" + ".jj" + "node_modules" + ]) + else + !(builtins.elem name [ + "flake.nix" + "flake.lock" + "quixos.lock" + "quixos.check.json" + "quixos.react.json" + "yarn-project.nix" + "components.nix" + "contracts.nix" + ]) + && !(lib.hasSuffix ".qx" name); +} diff --git a/package-context.nix b/package-context.nix new file mode 100644 index 0000000..36310f3 --- /dev/null +++ b/package-context.nix @@ -0,0 +1,2 @@ +# The selected protocol owns semantic projection; builders receive its result. +args@{ protocol, ... }: import "${protocol}/share/package-context.nix" args diff --git a/react-contracts.nix b/react-contracts.nix index d9c5bab..a037d3c 100644 --- a/react-contracts.nix +++ b/react-contracts.nix @@ -8,8 +8,23 @@ let config = pkgs.writeText "camino-react-contracts.json" ( builtins.toJSON { - source = src; - interface = context.interface; + source = import ./implementation-source.nix { + inherit (pkgs) lib; + inherit src; + }; + interface = builtins.toFile "camino-interface-contract.json" ( + builtins.unsafeDiscardStringContext ( + builtins.toJSON ( + let + resource = builtins.fromJSON (builtins.readFile context.interface); + in + { + kind = resource.kind; + revision = builtins.removeAttrs resource.revision [ "source" ]; + } + ) + ) + ); inherit members dependencies; reactSdk = context.reactSdk or null; node = "${pkgs.nodejs_24}/bin/node"; diff --git a/react-package.nix b/react-package.nix index e76ad74..4a99309 100644 --- a/react-package.nix +++ b/react-package.nix @@ -10,7 +10,7 @@ let group = import ./artifacts.nix { inherit pkgs src; inherit (context) - plan + buildContract core sharedRuntime typescript diff --git a/typescript-package.nix b/typescript-package.nix index 950dcbb..09fcaae 100644 --- a/typescript-package.nix +++ b/typescript-package.nix @@ -15,7 +15,7 @@ let group = import ./artifacts.nix { inherit pkgs src; inherit (context) - plan + buildContract core sharedRuntime typescript diff --git a/typescript-service.nix b/typescript-service.nix index 61ceea6..587b60a 100644 --- a/typescript-service.nix +++ b/typescript-service.nix @@ -11,7 +11,7 @@ let group = import ./artifacts.nix { inherit pkgs src service; inherit (context) - plan + buildContract core sharedRuntime typescript From 1f7aa801301995649ce1d8cfab32e4fb61f9fb07 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Thu, 1 Oct 2026 01:37:46 -0700 Subject: [PATCH 8/8] Instrument and improve incremental workspace checks Profile source capture, Nix evaluation/realization, resource/query checking, and implementation builds without putting timings into immutable outputs. Keep modular repositories; bound independent capture work, snapshot jj once per observation, and retain final concurrent-edit and Git completeness checks. Compose exact resource/closure/frontend/toolchain checks as memoized Nix derivations. Preserve whole-workspace validation and the full compiler path when no checked dependency is supplied. Share canonical Nixpkgs by content, index workspace build contexts once, and narrow lock/driver/compiler inputs with conservative complete-source fallback. Batch React witness checks only when distinct source closures cannot share ambient declarations; otherwise run the original independent TypeScript checks. Seed local authoring toolchains with exact canonical fetch identities when they match the selected inputs; retain store-path inputs for overrides. Add generic correctness checks rather than permanent Reader timing/content fixtures. Verify actual Reader edits and rejection/recovery in a private workbench, preserving the original user's workbench and runtime. Query checking currently caches at resource/closure boundaries, not individual query semantic dependency boundaries. Template helper pins and reproducible fixture snapshots follow in a separate publication change. --- PORTABLE_ARTIFACTS.md | 10 +++++ artifacts.nix | 20 +++++++-- assemble-package.nix | 9 +++- build-artifacts.mjs | 73 ++++++++++++++++-------------- build-components.mjs | 92 ++++++++++++++++++-------------------- build-profile.mjs | 12 +++++ build-tools.nix | 13 ++++++ component-type-batch.mjs | 49 ++++++++++++++++++++ component-types.mjs | 51 +++++++++++++++++++++ component-types.test.mjs | 59 ++++++++++++++++++++++++ package-set.test.nix | 57 +++++++++++++++++++++++ quixos-package-helpers.nix | 34 ++++++++++++++ react-contracts.nix | 3 +- 13 files changed, 393 insertions(+), 89 deletions(-) create mode 100644 build-profile.mjs create mode 100644 build-tools.nix create mode 100644 component-type-batch.mjs create mode 100644 component-types.mjs create mode 100644 component-types.test.mjs create mode 100644 package-set.test.nix diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index 55a35a3..b285675 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -31,6 +31,16 @@ excluded from the implementation source tree. The `plan` argument on the low-lev helper is a composition convenience for fixtures; it is projected before building. Package recipes receive the contract directly and can be checked without a workspace. +Inside `checkedArtifacts = context: ...`, the package helpers expose +`camino.packageSet { inherit nixpkgs system context; }` as the usual package set +selection. It shares the workspace composition's evaluated Nixpkgs when the +source content, system, and default import arguments match. Pass ordinary Nixpkgs +arguments such as `config` or `overlays` to use a separate import. A direct +`import nixpkgs { inherit system; }` also works, with additional evaluation cost. +The implementation groups depend on the complete execution compiler bundle and +JavaScript build drivers by content; unrelated checker CLI and publication +metadata changes do not enter those inputs. + The handler entry exports `registry = createRegistry(handlers)` from the generated bindings. One registry contains many exports. The builder always supplies a server implementation for the generated process service. A service-only package instead diff --git a/artifacts.nix b/artifacts.nix index dabb747..f5ca613 100644 --- a/artifacts.nix +++ b/artifacts.nix @@ -34,7 +34,13 @@ group ? false, }: let - compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; + buildTools = import ./build-tools.nix; + # The standalone bundle is the complete code dependency of this driver. + # Changes to unrelated checker CLIs must not invalidate every package group. + compiler = builtins.path { + path = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; + name = "execution-world.mjs"; + }; config = pkgs.writeText "camino-artifact-build.json" ( builtins.toJSON { inherit @@ -92,9 +98,15 @@ let serviceAdapter = ./portable-service.mjs; } ); - compiled = pkgs.runCommand "camino-compiled-artifacts" { passthru.buildConfig = config; } '' - ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} - ''; + compiled = + pkgs.runCommand "camino-compiled-artifacts" + { + passthru.buildConfig = config; + QUIXOS_TRACE_PROFILE = "1"; + } + '' + ${pkgs.nodejs_24}/bin/node ${buildTools}/build-artifacts.mjs ${config} + ''; in if group then compiled diff --git a/assemble-package.nix b/assemble-package.nix index 87e4e5d..f4f4835 100644 --- a/assemble-package.nix +++ b/assemble-package.nix @@ -4,7 +4,12 @@ artifacts, }: let + buildTools = import ./build-tools.nix; groups = map (artifact: artifact.caminoGroup or artifact) artifacts; + compiler = builtins.path { + path = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; + name = "execution-world.mjs"; + }; config = pkgs.writeText "camino-package-assembly.json" ( builtins.toJSON { inherit groups; @@ -14,7 +19,7 @@ let packageRevisionId sharedRuntime ; - compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; + inherit compiler; node = "${pkgs.nodejs_24}/bin/node"; esbuild = "${pkgs.esbuild}/bin/esbuild"; shell = "${pkgs.runtimeShell}"; @@ -30,5 +35,5 @@ pkgs.runCommand "camino-package" }; } '' - ${pkgs.nodejs_24}/bin/node ${./.}/assemble-package.mjs ${config} + ${pkgs.nodejs_24}/bin/node ${buildTools}/assemble-package.mjs ${config} '' diff --git a/build-artifacts.mjs b/build-artifacts.mjs index f1bc3c1..040e3b3 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -1,4 +1,5 @@ import { installDependencies } from "./install-dependencies.mjs"; +import { measureBuild } from "./build-profile.mjs"; import { buildService } from "./build-service.mjs"; import { buildComponents } from "./build-components.mjs"; /** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */ @@ -102,46 +103,50 @@ for (const { target, mode, settings } of builds) { source, ); const entry = relative(settings.entries?.[target] ?? settings.entry); - execFileSync( - config.node, - [ - config.tsc, - "--noEmit", - "--strict", - "--skipLibCheck", - "false", - "--target", - "es2024", - "--lib", - "es2024,dom,esnext.disposable", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - entry, - ], - { cwd: work, stdio: "inherit" }, + measureBuild("portable-typecheck", { package: pkg.revision, target, mode }, () => + execFileSync( + config.node, + [ + config.tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "es2024", + "--lib", + "es2024,dom,esnext.disposable", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + entry, + ], + { cwd: work, stdio: "inherit" }, + ), ); const bundlePath = mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`; await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true }); const coreImport = target === "browser" ? coreURL : coreServer; const metadata = path.join(work, "bundle-meta.json"); - execFileSync( - config.esbuild, - [ - entry, - "--bundle", - `--platform=${target === "browser" ? "browser" : "node"}`, - "--format=esm", - "--minify-whitespace", - "--target=es2024", - `--outfile=${path.join(output, bundlePath)}`, - `--metafile=${metadata}`, - `--alias:@quixos/camino-replica-core=${coreImport}`, - `--external:${coreImport}`, - ], - { cwd: work, stdio: "inherit" }, + measureBuild("portable-bundle", { package: pkg.revision, target, mode }, () => + execFileSync( + config.esbuild, + [ + entry, + "--bundle", + `--platform=${target === "browser" ? "browser" : "node"}`, + "--format=esm", + "--minify-whitespace", + "--target=es2024", + `--outfile=${path.join(output, bundlePath)}`, + `--metafile=${metadata}`, + `--alias:@quixos/camino-replica-core=${coreImport}`, + `--external:${coreImport}`, + ], + { cwd: work, stdio: "inherit" }, + ), ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")); const built = Object.values(meta.outputs).find((o) => o.entryPoint); diff --git a/build-components.mjs b/build-components.mjs index d87a825..535594f 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -1,4 +1,6 @@ import { installDependencies } from "./install-dependencies.mjs"; +import { checkComponentTypes } from "./component-types.mjs"; +import { measureBuild } from "./build-profile.mjs"; import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; @@ -152,16 +154,12 @@ export async function buildComponents({ config, compiler, contract, pkg, output, propsImports.join("\n") + `\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`, ); - const components = []; - const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs"))); - const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js"))); - const checkedControls = new Set(); + const witnesses = []; for (const [id, entry] of entries) { const declaration = declarations.find((component) => component.id === id), source = compiler.artifactPath(entry.entry); if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name"); - const key = digest(Buffer.from(id)).slice(0, 24), - directory = "share/quixos/components/" + key; + const key = digest(Buffer.from(id)).slice(0, 24); const witness = path.join(work, "component-check-" + key + ".tsx"); const module = pkg.modules.find((module) => module.id === id); await fs.writeFile( @@ -171,30 +169,26 @@ export async function buildComponents({ config, compiler, contract, pkg, output, ? `import type {ComponentProps} from "react";declare const props:Pick,"subject"|"input">;const mount = ;\n` : ""), ); - execFileSync( - config.node, - [ - config.tsc, - "--noEmit", - "--strict", - "--skipLibCheck", - "false", - "--target", - "es2024", - "--lib", - "es2024,dom,dom.iterable,esnext.disposable", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - "--jsx", - "react-jsx", - path.basename(witness), - assetPath, - propsPath, - ], - { cwd: work, stdio: "inherit" }, - ); + witnesses.push(path.basename(witness)); + } + checkComponentTypes({ + node: config.node, + tsc: config.tsc, + work, + witnesses, + assets: assetPath, + props: propsPath, + packageRevision: pkg.revision, + }); + const components = []; + const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs"))); + const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js"))); + const checkedControls = new Set(); + for (const [id, entry] of entries) { + const declaration = declarations.find((component) => component.id === id), + source = compiler.artifactPath(entry.entry), + key = digest(Buffer.from(id)).slice(0, 24), + directory = "share/quixos/components/" + key; const metadata = path.join(work, "component-meta-" + key + ".json"); const facade = "component-entry-" + key + ".ts"; await fs.writeFile( @@ -202,24 +196,26 @@ export async function buildComponents({ config, compiler, contract, pkg, output, `export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`, ); await fs.mkdir(path.join(output, directory), { recursive: true }); - execFileSync( - config.esbuild, - [ - facade, - "--bundle", - "--platform=browser", - "--format=esm", - "--target=es2024", - "--jsx=automatic", - '--define:process.env.NODE_ENV="production"', - "--metafile=" + metadata, - "--outfile=" + path.join(output, directory, "module.mjs"), - ...sharedAliases, - ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( - (extension) => "--loader:." + extension + "=dataurl", - ), - ], - { cwd: work, stdio: "inherit" }, + measureBuild("component-bundle", { package: pkg.revision, component: id }, () => + execFileSync( + config.esbuild, + [ + facade, + "--bundle", + "--platform=browser", + "--format=esm", + "--target=es2024", + "--jsx=automatic", + '--define:process.env.NODE_ENV="production"', + "--metafile=" + metadata, + "--outfile=" + path.join(output, directory, "module.mjs"), + ...sharedAliases, + ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( + (extension) => "--loader:." + extension + "=dataurl", + ), + ], + { cwd: work, stdio: "inherit" }, + ), ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")), outputs = Object.entries(meta.outputs); diff --git a/build-profile.mjs b/build-profile.mjs new file mode 100644 index 0000000..4391102 --- /dev/null +++ b/build-profile.mjs @@ -0,0 +1,12 @@ +/** Timing is log data, never part of an artifact or its witness. */ +export function measureBuild(phase, details, action) { + const started = performance.now(); + try { + return action(); + } finally { + if (process.env.QUIXOS_TRACE_PROFILE === "1") + console.error( + `[qx-build-profile] ${JSON.stringify({ phase, ms: Math.round(performance.now() - started), ...details })}`, + ); + } +} diff --git a/build-tools.nix b/build-tools.nix new file mode 100644 index 0000000..c80795d --- /dev/null +++ b/build-tools.nix @@ -0,0 +1,13 @@ +# Standalone build drivers import sibling JavaScript modules. Their code is an +# input; Nix helper APIs and test sources do not belong to that dependency. +builtins.path { + path = ./.; + name = "camino-build-tools"; + filter = + filename: type: + let + name = builtins.baseNameOf filename; + in + type == "directory" + || (builtins.match ".*\\.mjs" name != null && builtins.match ".*\\.test\\.mjs" name == null); +} diff --git a/component-type-batch.mjs b/component-type-batch.mjs new file mode 100644 index 0000000..4ce801a --- /dev/null +++ b/component-type-batch.mjs @@ -0,0 +1,49 @@ +import path from "node:path"; +import { createRequire } from "node:module"; + +const [tsc, count, ...args] = process.argv.slice(2); +const ts = createRequire(import.meta.url)(path.resolve(path.dirname(tsc), "../lib/typescript.js")); +const parsed = ts.parseCommandLine(args); +const witnesses = parsed.fileNames.slice(0, Number(count)); +const shared = parsed.fileNames.slice(Number(count)); +const host = ts.createCompilerHost(parsed.options, true); +// These programs only discover source closures; they do not bind or check. +// Sharing immutable parsed source files avoids reading/parsing the same libs +// for every witness. Only the final combined program performs typechecking. +const sources = new Map(); +const getSourceFile = host.getSourceFile; +host.getSourceFile = (filename, ...options) => { + if (!sources.has(filename)) sources.set(filename, getSourceFile(filename, ...options)); + return sources.get(filename); +}; +for (const method of ["fileExists", "directoryExists", "getDirectories", "readFile", "realpath"]) { + if (!host[method]) continue; + const original = host[method], + cache = new Map(); + host[method] = (filename) => { + if (!cache.has(filename)) cache.set(filename, original(filename)); + return cache.get(filename); + }; +} +const closures = witnesses.map( + (witness) => new Set(ts.createProgram([witness, ...shared], parsed.options, host).getSourceFiles()), +); +const common = new Set([...closures[0]].filter((source) => closures.every((closure) => closure.has(source)))); +const ambient = (node) => + ts.isModuleDeclaration(node) || ts.isNamespaceExportDeclaration(node) || ts.forEachChild(node, ambient); +// A file that is visible to just some witnesses must not contribute globals +// or ambient module declarations to the others. Otherwise a combined success +// could hide an independent failure. Script files are conservatively unsafe. +if ( + closures.some((closure) => + [...closure].some((source) => !common.has(source) && (!ts.isExternalModule(source) || ambient(source))), + ) +) + process.exit(2); + +const program = ts.createProgram(parsed.fileNames, parsed.options, host); +const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; +if (diagnostics.length) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, host)); + process.exit(1); +} diff --git a/component-types.mjs b/component-types.mjs new file mode 100644 index 0000000..8da07ce --- /dev/null +++ b/component-types.mjs @@ -0,0 +1,51 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { measureBuild } from "./build-profile.mjs"; + +// Component witnesses share bindings and usually the same source graph. +// Check a bounded batch once when distinct source closures cannot supply +// ambient declarations to one another. Otherwise use the original checks. +export function checkComponentTypes({ node, tsc, work, witnesses, assets, props, packageRevision }) { + const checkTypes = (files, combined = false) => + execFileSync( + node, + [ + ...(combined ? [fileURLToPath(new URL("./component-type-batch.mjs", import.meta.url))] : []), + tsc, + ...(combined ? [String(files.length)] : []), + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "es2024", + "--lib", + "es2024,dom,dom.iterable,esnext.disposable", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--jsx", + "react-jsx", + ...files, + assets, + props, + ], + { cwd: work, stdio: combined ? "pipe" : "inherit" }, + ); + measureBuild("component-typecheck", { package: packageRevision, components: witnesses.length }, () => { + for (let start = 0; start < witnesses.length; start += 1000) { + const batch = witnesses.slice(start, start + 1000); + if (batch.length === 1) checkTypes(batch); + else { + try { + checkTypes(batch, true); + } catch (error) { + if (!(error.status > 0) && error.code !== "ENOBUFS") throw error; + console.error("Component batch requires independent witness checks"); + for (const witness of batch) checkTypes([witness]); + } + } + } + }); +} diff --git a/component-types.test.mjs b/component-types.test.mjs new file mode 100644 index 0000000..3bfce73 --- /dev/null +++ b/component-types.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { checkComponentTypes } from "./component-types.mjs"; + +const tsc = process.env.QUIXOS_TYPESCRIPT; +if (!tsc) throw Error("Pass the selected TypeScript compiler path"); +async function fixture(context, left, right) { + const work = await mkdtemp(path.join(os.tmpdir(), "component-types-")); + context.after(() => rm(work, { recursive: true, force: true })); + await Promise.all( + Object.entries({ "left.ts": left, "right.ts": right, "assets.d.ts": "", "props.d.ts": "" }).map(([file, source]) => + writeFile(path.join(work, file), source), + ), + ); + return { + node: process.execPath, + tsc, + work, + witnesses: ["left.ts", "right.ts"], + assets: "assets.d.ts", + props: "props.d.ts", + packageRevision: "test", + }; +} +test("shared component typechecking accepts all valid witnesses", async (context) => { + checkComponentTypes(await fixture(context, "export const left: string = 'ok';", "export const right: number = 1;")); +}); +test("independently valid ambient declarations use the full individual check fallback", async (context) => { + checkComponentTypes( + await fixture( + context, + "export {}; declare global { interface Application { value: string; } }", + "export {}; declare global { interface Application { value: number; } }", + ), + ); +}); +test("a type error in any witness still rejects the artifact", async (context) => { + const input = await fixture(context, "export const left: string = 'ok';", "export const right: string = 1;"); + assert.throws(() => checkComponentTypes(input)); +}); +test("one witness's globals cannot make an independently invalid witness pass", async (context) => { + const input = await fixture( + context, + "export {}; declare global { interface Application { value: string; } }", + "export const right: Application = {value: 'ok'};", + ); + assert.throws(() => checkComponentTypes(input)); +}); +test("an ambient module from another source closure cannot resolve a missing import", async (context) => { + const input = await fixture( + context, + "declare module 'missing-component-package' { export const value: string; }", + "import {value} from 'missing-component-package'; export const right = value;", + ); + assert.throws(() => checkComponentTypes(input)); +}); diff --git a/package-set.test.nix b/package-set.test.nix new file mode 100644 index 0000000..034e57b --- /dev/null +++ b/package-set.test.nix @@ -0,0 +1,57 @@ +{ + pkgs, + nixpkgs, + system, +}: +let + packageSet = (import ./quixos-package-helpers.nix).packageSet; + shared = { + marker = "shared"; + }; + environment = { + inherit system; + nixpkgsSource = toString nixpkgs; + pkgs = shared; + narHash = nixpkgs.narHash; + }; + standard = { + inherit nixpkgs system; + context.buildEnvironment = environment; + }; + fallback = packageSet { inherit nixpkgs system; }; +in +assert packageSet standard == shared; +assert + packageSet ( + standard + // { + context.buildEnvironment = environment // { + nixpkgsSource = "same-content-under-another-name"; + }; + } + ) == shared; +assert + (packageSet ( + standard + // { + context.buildEnvironment = environment // { + system = "another-system"; + }; + } + )).path == fallback.path; +assert + (packageSet ( + standard + // { + context.buildEnvironment = environment // { + nixpkgsSource = "another-source"; + narHash = "another-hash"; + }; + } + )).path == fallback.path; +assert + (packageSet (standard // { overlays = [ (_: _: { marker = "overlay"; }) ]; })).marker == "overlay"; +assert (packageSet (standard // { config.allowBroken = true; })).config.allowBroken; +pkgs.runCommand "camino-package-set-check" { } '' + touch "$out" +'' diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index 356a7d4..8f3ecef 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -847,6 +847,40 @@ let in { mkCaminoReactPackage = import ./react-package.nix; + # Share the composition's package set only for the same exact Nixpkgs source, + # system and default import arguments. Config/overlay/cross-build choices use + # ordinary Nixpkgs evaluation. No package implementation is substituted. + packageSet = + args@{ + nixpkgs, + system, + context ? { }, + ... + }: + let + authoredArgs = builtins.removeAttrs args [ + "nixpkgs" + "context" + ]; + importArgs = { + config = { }; + overlays = [ ]; + } + // authoredArgs; + environment = context.buildEnvironment or null; + in + if + builtins.attrNames authoredArgs == [ "system" ] + && environment != null + && environment.system == system + && ( + environment.nixpkgsSource == toString nixpkgs + || ((nixpkgs.narHash or null) != null && nixpkgs.narHash == (environment.narHash or null)) + ) + then + environment.pkgs + else + import nixpkgs importArgs; mkCaminoReactContracts = import ./react-contracts.nix; mkCaminoYarnDependencies = import ./yarn-dependencies.nix; mkCaminoYarnShell = import ./yarn-shell.nix; diff --git a/react-contracts.nix b/react-contracts.nix index a037d3c..fcc1e40 100644 --- a/react-contracts.nix +++ b/react-contracts.nix @@ -6,6 +6,7 @@ dependencies ? null, }: let + buildTools = import ./build-tools.nix; config = pkgs.writeText "camino-react-contracts.json" ( builtins.toJSON { source = import ./implementation-source.nix { @@ -33,5 +34,5 @@ let ); in pkgs.runCommand "camino-react-contracts" { } '' - ${pkgs.nodejs_24}/bin/node ${./.}/build-react-contracts.mjs ${config} + ${pkgs.nodejs_24}/bin/node ${buildTools}/build-react-contracts.mjs ${config} ''