@peripheries/engine 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/check-template-routes.mjs +1 -1
- package/src/commands/test-visual.mjs +1 -1
- package/src/harness/app.mjs +2 -2
- package/src/harness/changed.mjs +1 -1
- package/src/harness/plugin.mjs +2 -2
- package/src/harness/public-assets.mjs +1 -0
- package/src/harness/server.mjs +1 -1
- package/src/index.d.mts +5 -1
- package/src/lib/config.mjs +1 -1
- package/src/template/cli.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{existsSync,readFileSync,readdirSync}from"node:fs";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{loadConfig}from"../lib/config.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const GATED_ORGS=new Set(ENGINE_CONFIG.checks.gated.orgTemplates);const REQUIRED_PREFIX="/portal/project/{projectId}";const ALLOWED_PATH_PARAMS=new Set(["projectId"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);const repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const orgsRoot=join(repoRoot,"src","org_templates");const args=process.argv.slice(2);const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const only=args.find(a=>!a.startsWith("--"));let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let exportSkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){exportSkipReason="jsdoc-parser.mjs exports no readExportsFromIndex"}else{exportedNames=new Map(ENGINE_CONFIG.lineages.flatMap(l=>existsSync(join(repoRoot,l.entry))?parser.readExportsFromIndex(repoRoot,l.entry).map(e=>[e.name,e.source]):[]));hasComponentJSDoc=typeof parser.hasComponentJSDoc==="function"?parser.hasComponentJSDoc:null;resolveComponentFilePath=typeof parser.resolveComponentFilePath==="function"?parser.resolveComponentFilePath:null}}catch(err){exportSkipReason=`could not load the engine's jsdoc parser \u2014 ${err.message}`}const SHARED=await loadSharedLineages(ENGINE_CONFIG);const sharedNames=SHARED.names;const sharedLabel=SHARED.specifiers.length?` or shared lineage (${SHARED.specifiers.join(", ")})`:"";let sharedRefCount=0;const hostScreens=new Map(Object.entries(ENGINE_CONFIG.hostScreens??{}));let hostRefCount=0;const GENERATED_CONTRACT=join(repoRoot,"src","types","host-contract.generated.ts");function generatedContractKeys(source,interfaceName){const open=source.indexOf(`export interface ${interfaceName} {`);if(open===-1)return null;const keys=new Set;let depth=0;for(let i=source.indexOf("{",open);i<source.length;i++){const ch=source[i];if(ch==="{"){depth+=1;continue}if(ch==="}"){depth-=1;if(depth===0)break;continue}if(depth===1&&ch==='"'){const end=source.indexOf('"',i+1);if(end===-1)break;const key=source.slice(i+1,end);if(source.slice(end+1,end+2)===":")keys.add(key);i=end}}return keys}let contractQueries=null;let contractMutations=null;let contractNote=null;if(existsSync(GENERATED_CONTRACT)){const source=readFileSync(GENERATED_CONTRACT,"utf8");contractQueries=generatedContractKeys(source,"Queries");contractMutations=generatedContractKeys(source,"Mutations");if(!contractQueries||!contractMutations){contractNote="host-contract.generated.ts has no Queries/Mutations interface";contractQueries=null;contractMutations=null}}else{contractNote="src/types/host-contract.generated.ts is absent (the host contract is retired)"}function parsePlaceholder(segment){if(segment.startsWith("[")&&segment.endsWith("]"))return segment.slice(1,-1);if(segment.startsWith("{")&&segment.endsWith("}"))return segment.slice(1,-1);return null}function placeholdersIn(path){return path.split("/").filter(Boolean).map(parsePlaceholder).filter(name=>name!==null)}function getRouteParam(params,name){if(!params)return void 0;if(Array.isArray(params)){const prefix=`${name}=`;const hit=params.find(p=>typeof p==="string"&&p.startsWith(prefix));return hit?hit.slice(prefix.length):void 0}return typeof params[name]==="string"?params[name]:void 0}function pageTypeEntries(templateDir){const pagesDir=join(templateDir,"pages");const out=[];if(!existsSync(pagesDir))return out;for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const file=join(pagesDir,entry.name,"page.json");if(!existsSync(file))continue;try{const page=JSON.parse(readFileSync(file,"utf8"));if(page.page_type)out.push([page.page_type,entry.name])}catch{}}return out}function declaredPageTypes(templateDir){const pagesDir=join(templateDir,"pages");const found=new Map;if(!existsSync(pagesDir))return found;for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const file=join(pagesDir,entry.name,"page.json");if(!existsSync(file))continue;try{const page=JSON.parse(readFileSync(file,"utf8"));if(page.page_type)found.set(page.page_type,entry.name)}catch{}}return found}const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let routeCount=0;let templateCount=0;let orgCount=0;let componentRefCount=0;function flattenRoutes(routes){const out=[];const visit=r=>{out.push(r);(Array.isArray(r?.children)?r.children:[]).forEach(visit)};(routes??[]).forEach(visit);return out}function routeKinds(route){return["component","project","page","redirect"].filter(k=>route?.[k]!==void 0)}function framedKinds(kinds,framed){return kinds.includes("component")&&kinds.includes(framed)?kinds.filter(k=>k!=="component"):kinds}const ROLE=/^[a-z0-9][a-z0-9_-]{0,62}$/;const INSTANCES=new Set(["per_client","per_org","shared","chosen"]);function checkComponentName(where,name,problems){if(typeof name!=="string"||name===""){problems.push(`${where} declares no component name.`);return}componentRefCount+=1;if(name.startsWith("/")||name.startsWith("@"))return;if(!exportedNames)return;if(!exportedNames.has(name)){if(sharedNames.has(name)){sharedRefCount+=1;return}if(hostScreens.has(name)){hostRefCount+=1;return}problems.push(`${where} component "${name}" is not exported from any lineage barrel${sharedLabel}, so register-components.mjs writes no component_registry row and the host renders "Component '${name}' not found in authorized registries".`);return}if(!hasComponentJSDoc||!resolveComponentFilePath)return;const filePath=resolveComponentFilePath(repoRoot,exportedNames.get(name));if(!filePath){problems.push(`${where} component "${name}" is exported from src/index.ts but its source ("${exportedNames.get(name)}") does not resolve \u2014 it cannot be registered.`);return}if(!hasComponentJSDoc(filePath,name)){problems.push(`${where} component "${name}" carries no \`@component ${name}\` JSDoc block, so register-components.mjs skips it and the host finds no registry row.`)}}const RETIRED="retired host-contract binding; replace with a recipe descriptor";const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";function checkDeclarations(where,node,problems){const queries=node?.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}const params=Array.isArray(decl?.params)?decl.params:[];for(const param of params){if(typeof param!=="string"||!param.startsWith("@params."))continue;const root=param.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;problems.push(`${where} data.queries.${friendly} reads ${param}, but "${root}" is not a key the host puts into ParameterContext. There is no context_parameters allowlist at org level, so it resolves to nothing and the query runs with a missing argument.`)}}for(const[friendly,decl]of Object.entries(node?.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];const allTemplates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name):[];const pageTypeOwners=new Map;for(const slug of allTemplates){for(const[type,dir]of pageTypeEntries(join(templatesRoot,slug))){const owner=pageTypeOwners.get(type);if(!owner){pageTypeOwners.set(type,{slug,at:`${slug}/pages/${dir}`});continue}if(only&&slug!==only&&owner.slug!==only)continue;errors.push(`src/project_templates/${slug}/pages/${dir}/page.json: page_type "${type}" is also ${owner.at}'s; instances find a page template by page type, so the two would collide.`)}}for(const slug of templates){const templateDir=join(templatesRoot,slug);const templateFile=join(templateDir,"template.json");if(!existsSync(templateFile))continue;templateCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.layout??{};if(Array.isArray(layout.routes)){const pageTypes2=declaredPageTypes(templateDir);const processes=Array.isArray(template.processes)?template.processes:[];const roles=new Map;for(const[i,entry]of processes.entries()){if(!entry||typeof entry.role!=="string"||!ROLE.test(entry.role)){problems.push(`${rel}: processes[${i}].role must be a lowercase slug.`);continue}if(roles.has(entry.role))problems.push(`${rel}: processes[${i}] repeats role "${entry.role}".`);if(typeof entry.template!=="string"||!entry.template)problems.push(`${rel}: processes[${i}] ("${entry.role}") names no process template.`);if(entry.count!==void 0&&entry.count!=="one"&&entry.count!=="many")problems.push(`${rel}: processes[${i}].count must be "one" or "many".`);roles.set(entry.role,entry)}if(template.slug!==void 0&&(typeof template.slug!=="string"||!/^[a-z0-9][a-z0-9-]{0,62}$/.test(template.slug))){problems.push(`${rel}: slug "${template.slug}" must be a lowercase slug \u2014 org-template routes open the template by it.`)}const bySlug2=new Map;const seenPaths2=new Map;for(const route of flattenRoutes(layout.routes)){routeCount+=1;const path=route.path??route.route;const id=route.slug||path||route.label||"<unnamed>";if(typeof path!=="string"){problems.push(`${rel}: route "${id}" has no path ("" is the project's root).`);continue}if(path.startsWith("/"))problems.push(`${rel}: route "${id}" path "${path}" must be relative to the project (no leading "/").`);const kinds=framedKinds(routeKinds(route).filter(k=>k!=="project"),"page");if(kinds.length!==1)problems.push(`${rel}: route "${id}" declares ${kinds.length?kinds.join(" and "):"no kind"} \u2014 exactly one of component, page or redirect (a component may frame a page).`);if(route.page&&!route.page.id){const role=route.page.process;if(role!==void 0&&!roles.has(role))problems.push(`${rel}: route "${id}" names process role "${role}", which is not in processes.`);if(!route.page.page_type)problems.push(`${rel}: route "${id}" page names no page_type.`);else if(pageTypes2.size>0&&!pageTypes2.has(route.page.page_type)){warnings.push(`${rel}: route "${id}" page_type "${route.page.page_type}" matches no page.json in ${slug}/pages/; it renders only if another org's library release or the agency's process template provides that page type.`)}if(role&&roles.get(role)?.count==="many"&&!placeholdersIn(path).includes(role)){problems.push(`${rel}: route "${id}" shows a page of the "many" role "${role}" but its path has no {${role}} \u2014 the URL must name the process.`)}}if(route.component&&route.component!=="ProjectPageByType"){componentRefCount+=1;checkComponentName(`${rel}: route "${id}"`,route.component,problems)}if(route.slug){if(bySlug2.has(route.slug))problems.push(`${rel}: duplicate slug "${route.slug}".`);else bySlug2.set(route.slug,route)}if(seenPaths2.has(path))problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths2.get(path)}").`);else seenPaths2.set(path,id)}if(layout.home_slug&&!bySlug2.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug.`)}continue}const routes=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no page is reachable.`);continue}const pageTypes=declaredPageTypes(templateDir);const bySlug=new Map;const seenPaths=new Map;const isAppLayout=layout.layout_type==="app";for(const route of routes){routeCount+=1;const id=route.slug||route.path||route.label||"<unnamed>";const path=route.path||route.route;if(!route.slug){if(isAppLayout){problems.push(`${rel}: route "${id}" has no slug. layout_type is 'app', so the platform routes at /v/{slug} and without it the route is unreachable there even though the portal path resolves.`)}}else if(bySlug.has(route.slug)){problems.push(`${rel}: duplicate slug "${route.slug}".`)}else{bySlug.set(route.slug,route);if(!isAppLayout){warnings.push(`${rel}: route "${id}" declares slug "${route.slug}", but layout_type is ${JSON.stringify(layout.layout_type??null)}, not 'app' \u2014 so routesBySlug() is never consulted and the slug is inert. Harmless, but it implies a /v/{slug} surface that is not active.`)}}if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${id}" path "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use {param}.`)}const stray=placeholdersIn(path).filter(p=>!ALLOWED_PATH_PARAMS.has(p));if(stray.length>0){problems.push(`${rel}: route "${id}" path "${path}" declares placeholder(s) ${stray.map(s=>`{${s}}`).join(", ")} \u2014 detail record ids travel as QUERY PARAMETERS (?${stray[0]}=<graph_node_id>), not path segments. The detail page traverses the same subtree as its list and selects the node matching @params.${stray[0]}.`)}if(path!==REQUIRED_PREFIX&&!path.startsWith(`${REQUIRED_PREFIX}/`)){problems.push(`${rel}: route "${id}" path "${path}" must start with "${REQUIRED_PREFIX}" \u2014 findRouteInLayout matches the raw request path and NavigationRenderer rewrites exactly the "/portal/project" prefix, so this can never match.`)}if(route.component==="ProjectPageByType"){if(Array.isArray(route.params)){problems.push(`${rel}: route "${id}" declares params as an array. Use the object form { "pageType": "..." }.`)}const pageType=getRouteParam(route.params,"pageType");if(!pageType){problems.push(`${rel}: route "${id}" targets ProjectPageByType with no params.pageType \u2014 it renders the "Page Type Required" card.`)}else if(pageTypes.size>0&&!pageTypes.has(pageType)){problems.push(`${rel}: route "${id}" pageType "${pageType}" matches no page.json in ${slug}/pages/ \u2014 declared: ${[...pageTypes.keys()].sort().join(", ")||"(none)"}.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id)}}if(layout.home_slug&&!bySlug.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug \u2014 known: ${[...bySlug.keys()].join(", ")||"(none)"}. The /v/{home_slug} redirect would 404.`)}if(layout.layout_type==="app"&&!layout.home_slug){problems.push(`${rel}: layout_type is "app" but there is no home_slug \u2014 a bare project URL has nowhere to redirect to.`)}const navItems=layout.sidebar?.navigation;if(Array.isArray(navItems)){for(const item of navItems){const navSlug=typeof item==="string"?item:item?.slug;if(!navSlug){problems.push(`${rel}: sidebar.navigation has an entry with no slug.`);continue}const route=bySlug.get(navSlug);if(!route){problems.push(`${rel}: sidebar.navigation references slug "${navSlug}" which is not in whitelisted_routes \u2014 the nav item would render but 404.`)}else if(route.show_in_nav===false){problems.push(`${rel}: sidebar.navigation references "${navSlug}" but that route sets show_in_nav: false \u2014 contradictory. Drill-in routes stay routable and out of nav.`)}}}const ctxParams=layout.context_parameters;if(ctxParams!==void 0&&!Array.isArray(ctxParams)){problems.push(`${rel}: layout.context_parameters must be an array of strings.`)}else if(Array.isArray(ctxParams)&&ctxParams.some(p=>typeof p!=="string")){problems.push(`${rel}: layout.context_parameters must contain only strings.`)}const switcher=layout.sidebar?.context_switcher;if(switcher?.enabled){if(!switcher.param){problems.push(`${rel}: context_switcher is enabled but declares no \`param\`.`)}else if(!Array.isArray(ctxParams)||!ctxParams.includes(switcher.param)){problems.push(`${rel}: context_switcher writes ?${switcher.param}= but "${switcher.param}" is not in layout.context_parameters \u2014 pickAllowedContextParams would refuse to merge it and every context would resolve to the same page.`)}if(!switcher.source?.kind){problems.push(`${rel}: context_switcher.source.kind is required (a resolver id, never a UUID).`)}}const create=switcher?.create;if(create&&create.enabled!==false){if(!create.template_process_id&&!create.template_name){problems.push(`${rel}: context_switcher.create declares neither template_process_id nor template_name, so there is no process template to clone and the create button always fails.`)}if(create.job?.mode==="code"){problems.push(`${rel}: context_switcher.create.job.mode is 'code' \u2014 code-mode jobs go to the per-repo sprite bridge, not the director machine that runs plan-builder. Setup would never run.`)}const fields=Array.isArray(create.fields)?create.fields:[];const keys=new Set;for(const field of fields){if(!field?.key){problems.push(`${rel}: context_switcher.create has a field with no \`key\`.`);continue}if(keys.has(field.key)){problems.push(`${rel}: context_switcher.create declares field "${field.key}" twice.`)}keys.add(field.key);if(field.target&&!["binding","definition","both"].includes(field.target)){problems.push(`${rel}: context_switcher.create field "${field.key}" has target "${field.target}"; must be binding | definition | both.`)}if(field.type&&!["text","textarea"].includes(field.type)){problems.push(`${rel}: context_switcher.create field "${field.key}" has type "${field.type}"; the modal renders text | textarea only.`)}}if(create.name_from&&!keys.has(create.name_from)){problems.push(`${rel}: context_switcher.create.name_from is "${create.name_from}" but no field declares that key \u2014 the record would be created unnamed and the slug would be empty.`)}if(!create.name_from&&fields.length>0){warnings.push(`${rel}: context_switcher.create declares fields but no name_from, so every record is created with the RPC's default name and no slug binding.`)}const labelFrom=switcher.source?.label_from;if(labelFrom?.startsWith("metadata.bindings.")){const bindingKey=labelFrom.slice("metadata.bindings.".length);const writesIt=keys.has(bindingKey)&&fields.some(f=>f.key===bindingKey&&(f.target??"binding")!=="definition");if(!writesIt&&bindingKey!==(create.slug_key??"product_slug")){warnings.push(`${rel}: source.label_from reads bindings.${bindingKey}, but create writes no such binding \u2014 new records would fall back to the process name in the switcher.`)}}}const routedTypes=new Set(routes.map(r=>getRouteParam(r.params,"pageType")).filter(Boolean));for(const[pageType,dir]of pageTypes){if(!routedTypes.has(pageType)){warnings.push(`${rel}: page "${dir}" (page_type "${pageType}") has no route \u2014 unreachable except by direct pageId URL.`)}}}const orgs=existsSync(orgsRoot)?readdirSync(orgsRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];for(const slug of orgs){const templateFile=join(orgsRoot,slug,"template.json");if(!existsSync(templateFile))continue;orgCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_ORGS.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.portal_layout??{};const routes=flattenRoutes(layout.routes??layout.whitelisted_routes);if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: portal_layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no portal page is reachable.`);continue}const seenPaths=new Map;const routeByPath=new Map;const projectPrefixes=[];const redirects=[];for(const route of routes){routeCount+=1;const path=route.path||route.route;const id=path||route.label||"<unnamed>";if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use [param].`)}for(const placeholder of placeholdersIn(path)){if(placeholder.startsWith("...")){warnings.push(`${rel}: route "${path}" uses catch-all "[${placeholder}]", but matchesParameterizedPath compares segment counts \u2014 it matches exactly ONE segment, so deeper paths never resolve through findRouteInLayout.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id);routeByPath.set(path,route)}const kinds=framedKinds(routeKinds(route),"project");if(kinds.length>1)problems.push(`${rel}: route "${path}" declares ${routeKinds(route).join(" and ")} \u2014 exactly one kind (a component may frame a project).`);if(route.project){if(route.component!==void 0)checkComponentName(`${rel}: route "${path}"`,route.component,problems);if(!route.project.template)problems.push(`${rel}: route "${path}" project names no template slug.`);if(!INSTANCES.has(route.project.instance))problems.push(`${rel}: route "${path}" project.instance must be per_client, per_org, shared or chosen.`);projectPrefixes.push(path)}else if(route.redirect){redirects.push([`route "${path}" redirect`,route.redirect])}else if(route.page){if(!route.page.id)problems.push(`${rel}: route "${path}" page in an org template names a page by id.`)}else{checkComponentName(`${rel}: route "${path}"`,route.component,problems)}checkDeclarations(`${rel}: route "${path}"`,route,problems);if(route.right_sidebar?.component){checkComponentName(`${rel}: route "${path}" right_sidebar`,route.right_sidebar.component,problems)}}const resolvesToRoute=target=>{if(seenPaths.has(target))return true;if(projectPrefixes.some(p=>target.startsWith(`${p}/`)))return true;const targetSegments=target.split("/").filter(Boolean);for(const path of seenPaths.keys()){const routeSegments=path.split("/").filter(Boolean);if(routeSegments.length!==targetSegments.length)continue;const matches=routeSegments.every((segment,i)=>parsePlaceholder(segment)!==null||segment===targetSegments[i]);if(matches)return true}return false};const navPaths=[];if(layout.base_route?.path)navPaths.push([`base_route.path`,layout.base_route.path]);for(const[i,item]of(layout.tab_bar?.items??[]).entries()){if(item?.path)navPaths.push([`tab_bar.items[${i}].path`,item.path]);for(const[j,active]of(item?.active_paths??[]).entries()){navPaths.push([`tab_bar.items[${i}].active_paths[${j}]`,active])}}for(const[i,item]of(layout.more_menu?.items??[]).entries()){if(item?.path)navPaths.push([`more_menu.items[${i}].path`,item.path])}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(item?.path)navPaths.push([`sidebar.navigation[${i}].path`,item.path])}navPaths.push(...redirects);for(const[where,path]of navPaths){if(!resolvesToRoute(path)){problems.push(`${rel}: ${where} is "${path}" but no whitelisted_route matches it \u2014 the nav entry renders and then resolves to nothing.`)}}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(!item?.path||!item?.component)continue;const route=routeByPath.get(item.path);if(route&&route.component&&route.component!==item.component){problems.push(`${rel}: sidebar.navigation[${i}] claims component "${item.component}" for "${item.path}" but the whitelisted route declares "${route.component}" \u2014 nav and route disagree.`)}}const chrome=[["base_route",layout.base_route?.component],["app_bar",layout.app_bar?.component],["tab_bar",layout.tab_bar?.component],["more_menu",layout.more_menu?.component],["chat_rail",layout.chat_rail?.component],["right_sidebar",layout.right_sidebar?.component],["sidebar",layout.sidebar?.component],["assistant_overlay.component",layout.assistant_overlay?.component],["assistant_overlay.pill",layout.assistant_overlay?.pill]];for(const[where,name]of chrome){if(name===void 0||name===null)continue;checkComponentName(`${rel}: portal_layout.${where}`,name,problems)}if(layout.right_sidebar){checkDeclarations(`${rel}: portal_layout.right_sidebar`,layout.right_sidebar,problems)}}if(exportSkipReason){console.warn(`[check-template-routes] component-name checks SKIPPED \u2014 ${exportSkipReason}.
|
|
2
|
+
import{existsSync,readFileSync,readdirSync}from"node:fs";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{loadConfig}from"../lib/config.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const GATED_ORGS=new Set(ENGINE_CONFIG.checks.gated.orgTemplates);const REQUIRED_PREFIX="/portal/project/{projectId}";const ALLOWED_PATH_PARAMS=new Set(["projectId"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);const repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const orgsRoot=join(repoRoot,"src","org_templates");const args=process.argv.slice(2);const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const only=args.find(a=>!a.startsWith("--"));let exportedNames=null;const exportsOfLineage=new Map;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let exportSkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){exportSkipReason="jsdoc-parser.mjs exports no readExportsFromIndex"}else{exportedNames=new Map(ENGINE_CONFIG.lineages.flatMap(l=>existsSync(join(repoRoot,l.entry))?parser.readExportsFromIndex(repoRoot,l.entry).map(e=>[e.name,e.source]):[]));for(const l of ENGINE_CONFIG.lineages){if(existsSync(join(repoRoot,l.entry)))exportsOfLineage.set(l.name,new Set(parser.readExportsFromIndex(repoRoot,l.entry).map(e=>e.name)))}hasComponentJSDoc=typeof parser.hasComponentJSDoc==="function"?parser.hasComponentJSDoc:null;resolveComponentFilePath=typeof parser.resolveComponentFilePath==="function"?parser.resolveComponentFilePath:null}}catch(err){exportSkipReason=`could not load the engine's jsdoc parser \u2014 ${err.message}`}const SHARED=await loadSharedLineages(ENGINE_CONFIG);const sharedNames=SHARED.names;const sharedLabel=SHARED.specifiers.length?` or shared lineage (${SHARED.specifiers.join(", ")})`:"";let sharedRefCount=0;const hostScreens=new Map(Object.entries(ENGINE_CONFIG.hostScreens??{}));let hostRefCount=0;const GENERATED_CONTRACT=join(repoRoot,"src","types","host-contract.generated.ts");function generatedContractKeys(source,interfaceName){const open=source.indexOf(`export interface ${interfaceName} {`);if(open===-1)return null;const keys=new Set;let depth=0;for(let i=source.indexOf("{",open);i<source.length;i++){const ch=source[i];if(ch==="{"){depth+=1;continue}if(ch==="}"){depth-=1;if(depth===0)break;continue}if(depth===1&&ch==='"'){const end=source.indexOf('"',i+1);if(end===-1)break;const key=source.slice(i+1,end);if(source.slice(end+1,end+2)===":")keys.add(key);i=end}}return keys}let contractQueries=null;let contractMutations=null;let contractNote=null;if(existsSync(GENERATED_CONTRACT)){const source=readFileSync(GENERATED_CONTRACT,"utf8");contractQueries=generatedContractKeys(source,"Queries");contractMutations=generatedContractKeys(source,"Mutations");if(!contractQueries||!contractMutations){contractNote="host-contract.generated.ts has no Queries/Mutations interface";contractQueries=null;contractMutations=null}}else{contractNote="src/types/host-contract.generated.ts is absent (the host contract is retired)"}function parsePlaceholder(segment){if(segment.startsWith("[")&&segment.endsWith("]"))return segment.slice(1,-1);if(segment.startsWith("{")&&segment.endsWith("}"))return segment.slice(1,-1);return null}function placeholdersIn(path){return path.split("/").filter(Boolean).map(parsePlaceholder).filter(name=>name!==null)}function getRouteParam(params,name){if(!params)return void 0;if(Array.isArray(params)){const prefix=`${name}=`;const hit=params.find(p=>typeof p==="string"&&p.startsWith(prefix));return hit?hit.slice(prefix.length):void 0}return typeof params[name]==="string"?params[name]:void 0}function pageTypeEntries(templateDir){const pagesDir=join(templateDir,"pages");const out=[];if(!existsSync(pagesDir))return out;for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const file=join(pagesDir,entry.name,"page.json");if(!existsSync(file))continue;try{const page=JSON.parse(readFileSync(file,"utf8"));if(page.page_type)out.push([page.page_type,entry.name])}catch{}}return out}function declaredPageTypes(templateDir){const pagesDir=join(templateDir,"pages");const found=new Map;if(!existsSync(pagesDir))return found;for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const file=join(pagesDir,entry.name,"page.json");if(!existsSync(file))continue;try{const page=JSON.parse(readFileSync(file,"utf8"));if(page.page_type)found.set(page.page_type,entry.name)}catch{}}return found}const REGIONS=new Set(["sidebar","content","rail"]);const SLOTS=new Set(["header","body","footer"]);const HOST_BUILTINS=new Set(["host:Wordmark","host:Navigation","host:UserIdentity","host:Copyright","host:Header","host:ChatPanel","host:PageRail","host:ChatBar","host:TabBar","host:AppBar","host:MoreMenu","host:FormContents","host:FormHeader","host:FormFooter","host:FormAppBar","host:FormStepBar","host:FormAnswers"]);const RESERVED_SEGMENTS=new Set(["access-denied","api","assets","auth","doc","documents","form","platform","portal","project"]);const PUBLIC_PROPS_MAX=8*1024;function externalNames(){const out=new Set;for(const[specifier,file]of Object.entries(ENGINE_CONFIG.externalComponents??{})){const path=join(repoRoot,file);try{for(const n of JSON.parse(readFileSync(path,"utf8")).components??[])out.add(n)}catch(err){errors.push(`engine config externalComponents["${specifier}"]: ${relative(repoRoot,path)} cannot be read as { "components": [...] } \u2014 ${err.message}`)}}return out}function sharedFor(lineageName){const lineage=ENGINE_CONFIG.lineages.find(l=>l.name===lineageName);const specifiers=new Set([...ENGINE_CONFIG.shared??[],...lineage?.shared??[]]);const names=new Set;for(const l of SHARED.lineages)if(specifiers.has(l.specifier))for(const n of l.names)names.add(n);return names}function pathProblems(path,{root=true}={}){const out=[];if(root&&!path.startsWith("/"))out.push("has no leading slash");if(path!=="/"&&path.endsWith("/"))out.push("ends with a slash (the host strips it before matching, so it would never match)");if(path.split("/").some(seg=>seg.startsWith(":")))out.push("uses :param, which the matcher treats as a literal segment (use [param])");if(path.replace(/\[[^\]]*\]|\{[^}]*\}/g,"")!==path.replace(/\[[^\]]*\]|\{[^}]*\}/g,"").toLowerCase())out.push("has uppercase letters outside its [placeholders] (the host lowercases the request path)");return out}function checkPublicLayout(rel,layout,problems,{client,prefix}){if(!layout||typeof layout!=="object")return;const routes=flattenRoutes(layout.routes??layout.whitelisted_routes);if(layout.base_route&&typeof layout.base_route==="object"&&!routes.some(r=>(r.path||r.route)===layout.base_route.path))routes.unshift(layout.base_route);const own=exportsOfLineage.get("public");const sharedPublic=sharedFor("public");const main=exportsOfLineage.get("main");const seen=new Map;if(client&&prefix!==void 0&&RESERVED_SEGMENTS.has(prefix))problems.push(`${rel}: the client's path prefix "/${prefix}" is one of the app's own paths (${[...RESERVED_SEGMENTS].join(", ")}).`);for(const route of routes){const path=route.path||route.route;if(!path){problems.push(`${rel}: public_layout route "${route.label??"<unnamed>"}" has no path.`);continue}routeCount+=1;const where=`${rel}: public_layout route "${path}"`;for(const p of pathProblems(path))problems.push(`${where} ${p}.`);const key=path.toLowerCase();if(seen.has(key))problems.push(`${where} is declared twice (the host matches case-insensitively).`);seen.set(key,true);const first=path.split("/").filter(Boolean)[0];if(!client&&first&&RESERVED_SEGMENTS.has(first))problems.push(`${where} starts with "/${first}", one of the app's own paths: the app would answer it, not this page.`);const name=route.component;if(typeof name!=="string"||!name)problems.push(`${where} names no component.`);else if(own||exportsOfLineage.size){componentRefCount+=1;if(own?.has(name)||sharedPublic.has(name)){if(!own?.has(name))sharedRefCount+=1}else if(hostScreens.has(name))hostRefCount+=1;else if(main?.has(name))problems.push(`${where} component "${name}" is exported only by the main lineage: an anonymous page loads the public lineage, never main, so it would render nothing.`);else problems.push(`${where} component "${name}" is not exported from the public lineage (src/index.public.ts)${sharedPublic.size?" or a shared lineage it imports":""}.`)}const params=placeholdersIn(path).filter(n=>!n.startsWith("..."));if(params.length>0&&route.data!==void 0){if(!Array.isArray(route.data))problems.push(`${where} has dynamic segments but its data is not an array of rows.`);else{route.data.forEach((row,i)=>{if(!row||typeof row!=="object")return problems.push(`${where} data[${i}] is not an object.`);for(const param of params){if(typeof row[param]!=="string"||row[param]==="")problems.push(`${where} data[${i}] has no "${param}": the host matches a row by the names in the path's brackets, so this row is never served.`)}})}}const propsBytes=Buffer.byteLength(JSON.stringify(route.props??{}));const rowBytes=Array.isArray(route.data)?Math.max(0,...route.data.map(r=>Buffer.byteLength(JSON.stringify(r??{})))):0;if(propsBytes+rowBytes>PUBLIC_PROPS_MAX)problems.push(`${where} props are ${propsBytes+rowBytes} bytes with the largest data row; the host drops a public route over ${PUBLIC_PROPS_MAX}.`)}}function checkPlatformLayout(rel,layout,problems){if(!layout||typeof layout!=="object")return;const routes=flattenRoutes(layout.routes??layout.whitelisted_routes);const manage=flattenRoutes(layout.project_manage?.routes);const main=exportsOfLineage.get("main")??new Set;const names=new Set([...main,...sharedFor("main"),...externalNames()]);const resolves=name=>names.has(name)||hostScreens.has(name);const count=name=>{componentRefCount+=1;if(hostScreens.has(name)&&!main.has(name))hostRefCount+=1;else if(!main.has(name))sharedRefCount+=1};const checkName=(where,name)=>{if(typeof name!=="string"||!name)return problems.push(`${where} names no component.`);if(name.startsWith("host:")){componentRefCount+=1;if(!HOST_BUILTINS.has(name))problems.push(`${where} names "${name}", which is not a host built-in (${[...HOST_BUILTINS].join(", ")}).`);return}if(exportsOfLineage.size===0&&names.size===0)return;if(resolves(name))count(name);else{componentRefCount+=1;problems.push(`${where} component "${name}" is not exported from the main lineage, a shared lineage${ENGINE_CONFIG.externalComponents&&Object.keys(ENGINE_CONFIG.externalComponents).length?", an externalComponents file":""} or hostScreens.`)}};const paths=new Map;for(const route of routes){const path=route.path||route.route;routeCount+=1;if(route.redirect!==void 0&&typeof route.redirect==="string"&&route.redirect!==""&&!routes.some(r=>(r.path||r.route)===route.redirect)){problems.push(`${rel}: platform_layout route "${path}" redirects to "${route.redirect}", which is no route of the layout.`)}if(!path){problems.push(`${rel}: platform_layout route "${route.label??"<unnamed>"}" has no path.`);continue}const where=`${rel}: platform_layout route "${path}"`;for(const p of pathProblems(path))problems.push(`${where} ${p}.`);if(!path.startsWith("/platform"))problems.push(`${where} is outside /platform: the platform shell serves only paths under it.`);if(paths.has(path))problems.push(`${where} is declared twice.`);paths.set(path,true);const kinds=framedKinds(routeKinds(route),"project");if(kinds.length===0)problems.push(`${where} declares none of component, project, page or redirect: it renders nothing.`);else if(kinds.length>1)problems.push(`${where} declares more than one of ${kinds.join(", ")}; a route has one kind (a component may frame a project).`);if(route.component!==void 0)checkName(where,route.component)}for(const route of manage){routeCount+=1;const where=`${rel}: platform_layout project_manage route "${route.path??route.label??"<unnamed>"}"`;if(route.component!==void 0)checkName(where,route.component);else if(routeKinds(route).length===0)problems.push(`${where} declares no component, project, page or redirect.`)}for(const[id,region]of Object.entries(layout.regions??{})){if(!REGIONS.has(id)){problems.push(`${rel}: platform_layout.regions.${id} is not a region of the shell (${[...REGIONS].join(", ")}): it is ignored.`);continue}for(const[slotName,slot]of Object.entries(region??{})){if(SLOTS.has(slotName)&&slot&&typeof slot==="object"&&slot.component!==void 0)checkName(`${rel}: platform_layout.regions.${id}.${slotName}`,slot.component)}}for(const key of["header","footer"]){if(layout[key]&&typeof layout[key]==="object"&&layout[key].component===void 0&&!layout.regions?.content){warnings.push(`${rel}: platform_layout.${key} is a legacy block: the shell draws only a host:${key==="header"?"Header":"Copyright"} band from it and ignores the rest. Say it with regions.content.${key}.`)}}}const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let routeCount=0;let templateCount=0;let orgCount=0;let componentRefCount=0;function flattenRoutes(routes){const out=[];const visit=r=>{out.push(r);(Array.isArray(r?.children)?r.children:[]).forEach(visit)};(routes??[]).forEach(visit);return out}function routeKinds(route){return["component","project","page","redirect"].filter(k=>route?.[k]!==void 0)}function framedKinds(kinds,framed){return kinds.includes("component")&&kinds.includes(framed)?kinds.filter(k=>k!=="component"):kinds}const ROLE=/^[a-z0-9][a-z0-9_-]{0,62}$/;const INSTANCES=new Set(["per_client","per_org","shared","chosen"]);function checkComponentName(where,name,problems){if(typeof name!=="string"||name===""){problems.push(`${where} declares no component name.`);return}componentRefCount+=1;if(name.startsWith("/")||name.startsWith("@"))return;if(!exportedNames)return;if(!exportedNames.has(name)){if(sharedNames.has(name)){sharedRefCount+=1;return}if(hostScreens.has(name)){hostRefCount+=1;return}problems.push(`${where} component "${name}" is not exported from any lineage barrel${sharedLabel}, so register-components.mjs writes no component_registry row and the host renders "Component '${name}' not found in authorized registries".`);return}if(!hasComponentJSDoc||!resolveComponentFilePath)return;const filePath=resolveComponentFilePath(repoRoot,exportedNames.get(name));if(!filePath){problems.push(`${where} component "${name}" is exported from src/index.ts but its source ("${exportedNames.get(name)}") does not resolve \u2014 it cannot be registered.`);return}if(!hasComponentJSDoc(filePath,name)){problems.push(`${where} component "${name}" carries no \`@component ${name}\` JSDoc block, so register-components.mjs skips it and the host finds no registry row.`)}}const RETIRED="retired host-contract binding; replace with a recipe descriptor";const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";function checkDeclarations(where,node,problems){const queries=node?.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}const params=Array.isArray(decl?.params)?decl.params:[];for(const param of params){if(typeof param!=="string"||!param.startsWith("@params."))continue;const root=param.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;problems.push(`${where} data.queries.${friendly} reads ${param}, but "${root}" is not a key the host puts into ParameterContext. There is no context_parameters allowlist at org level, so it resolves to nothing and the query runs with a missing argument.`)}}for(const[friendly,decl]of Object.entries(node?.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];const allTemplates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name):[];const pageTypeOwners=new Map;for(const slug of allTemplates){for(const[type,dir]of pageTypeEntries(join(templatesRoot,slug))){const owner=pageTypeOwners.get(type);if(!owner){pageTypeOwners.set(type,{slug,at:`${slug}/pages/${dir}`});continue}if(only&&slug!==only&&owner.slug!==only)continue;errors.push(`src/project_templates/${slug}/pages/${dir}/page.json: page_type "${type}" is also ${owner.at}'s; instances find a page template by page type, so the two would collide.`)}}for(const slug of templates){const templateDir=join(templatesRoot,slug);const templateFile=join(templateDir,"template.json");if(!existsSync(templateFile))continue;templateCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.layout??{};if(Array.isArray(layout.routes)){const pageTypes2=declaredPageTypes(templateDir);const processes=Array.isArray(template.processes)?template.processes:[];const roles=new Map;for(const[i,entry]of processes.entries()){if(!entry||typeof entry.role!=="string"||!ROLE.test(entry.role)){problems.push(`${rel}: processes[${i}].role must be a lowercase slug.`);continue}if(roles.has(entry.role))problems.push(`${rel}: processes[${i}] repeats role "${entry.role}".`);if(typeof entry.template!=="string"||!entry.template)problems.push(`${rel}: processes[${i}] ("${entry.role}") names no process template.`);if(entry.count!==void 0&&entry.count!=="one"&&entry.count!=="many")problems.push(`${rel}: processes[${i}].count must be "one" or "many".`);roles.set(entry.role,entry)}if(template.slug!==void 0&&(typeof template.slug!=="string"||!/^[a-z0-9][a-z0-9-]{0,62}$/.test(template.slug))){problems.push(`${rel}: slug "${template.slug}" must be a lowercase slug \u2014 org-template routes open the template by it.`)}const bySlug2=new Map;const seenPaths2=new Map;for(const route of flattenRoutes(layout.routes)){routeCount+=1;const path=route.path??route.route;const id=route.slug||path||route.label||"<unnamed>";if(typeof path!=="string"){problems.push(`${rel}: route "${id}" has no path ("" is the project's root).`);continue}if(path.startsWith("/"))problems.push(`${rel}: route "${id}" path "${path}" must be relative to the project (no leading "/").`);const kinds=framedKinds(routeKinds(route).filter(k=>k!=="project"),"page");if(kinds.length!==1)problems.push(`${rel}: route "${id}" declares ${kinds.length?kinds.join(" and "):"no kind"} \u2014 exactly one of component, page or redirect (a component may frame a page).`);if(route.page&&!route.page.id){const role=route.page.process;if(role!==void 0&&!roles.has(role))problems.push(`${rel}: route "${id}" names process role "${role}", which is not in processes.`);if(!route.page.page_type)problems.push(`${rel}: route "${id}" page names no page_type.`);else if(pageTypes2.size>0&&!pageTypes2.has(route.page.page_type)){warnings.push(`${rel}: route "${id}" page_type "${route.page.page_type}" matches no page.json in ${slug}/pages/; it renders only if another org's library release or the agency's process template provides that page type.`)}if(role&&roles.get(role)?.count==="many"&&!placeholdersIn(path).includes(role)){problems.push(`${rel}: route "${id}" shows a page of the "many" role "${role}" but its path has no {${role}} \u2014 the URL must name the process.`)}}if(route.component&&route.component!=="ProjectPageByType"){componentRefCount+=1;checkComponentName(`${rel}: route "${id}"`,route.component,problems)}if(route.slug){if(bySlug2.has(route.slug))problems.push(`${rel}: duplicate slug "${route.slug}".`);else bySlug2.set(route.slug,route)}if(seenPaths2.has(path))problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths2.get(path)}").`);else seenPaths2.set(path,id)}if(layout.home_slug&&!bySlug2.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug.`)}continue}const routes=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no page is reachable.`);continue}const pageTypes=declaredPageTypes(templateDir);const bySlug=new Map;const seenPaths=new Map;const isAppLayout=layout.layout_type==="app";for(const route of routes){routeCount+=1;const id=route.slug||route.path||route.label||"<unnamed>";const path=route.path||route.route;if(!route.slug){if(isAppLayout){problems.push(`${rel}: route "${id}" has no slug. layout_type is 'app', so the platform routes at /v/{slug} and without it the route is unreachable there even though the portal path resolves.`)}}else if(bySlug.has(route.slug)){problems.push(`${rel}: duplicate slug "${route.slug}".`)}else{bySlug.set(route.slug,route);if(!isAppLayout){warnings.push(`${rel}: route "${id}" declares slug "${route.slug}", but layout_type is ${JSON.stringify(layout.layout_type??null)}, not 'app' \u2014 so routesBySlug() is never consulted and the slug is inert. Harmless, but it implies a /v/{slug} surface that is not active.`)}}if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${id}" path "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use {param}.`)}const stray=placeholdersIn(path).filter(p=>!ALLOWED_PATH_PARAMS.has(p));if(stray.length>0){problems.push(`${rel}: route "${id}" path "${path}" declares placeholder(s) ${stray.map(s=>`{${s}}`).join(", ")} \u2014 detail record ids travel as QUERY PARAMETERS (?${stray[0]}=<graph_node_id>), not path segments. The detail page traverses the same subtree as its list and selects the node matching @params.${stray[0]}.`)}if(path!==REQUIRED_PREFIX&&!path.startsWith(`${REQUIRED_PREFIX}/`)){problems.push(`${rel}: route "${id}" path "${path}" must start with "${REQUIRED_PREFIX}" \u2014 findRouteInLayout matches the raw request path and NavigationRenderer rewrites exactly the "/portal/project" prefix, so this can never match.`)}if(route.component==="ProjectPageByType"){if(Array.isArray(route.params)){problems.push(`${rel}: route "${id}" declares params as an array. Use the object form { "pageType": "..." }.`)}const pageType=getRouteParam(route.params,"pageType");if(!pageType){problems.push(`${rel}: route "${id}" targets ProjectPageByType with no params.pageType \u2014 it renders the "Page Type Required" card.`)}else if(pageTypes.size>0&&!pageTypes.has(pageType)){problems.push(`${rel}: route "${id}" pageType "${pageType}" matches no page.json in ${slug}/pages/ \u2014 declared: ${[...pageTypes.keys()].sort().join(", ")||"(none)"}.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id)}}if(layout.home_slug&&!bySlug.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug \u2014 known: ${[...bySlug.keys()].join(", ")||"(none)"}. The /v/{home_slug} redirect would 404.`)}if(layout.layout_type==="app"&&!layout.home_slug){problems.push(`${rel}: layout_type is "app" but there is no home_slug \u2014 a bare project URL has nowhere to redirect to.`)}const navItems=layout.sidebar?.navigation;if(Array.isArray(navItems)){for(const item of navItems){const navSlug=typeof item==="string"?item:item?.slug;if(!navSlug){problems.push(`${rel}: sidebar.navigation has an entry with no slug.`);continue}const route=bySlug.get(navSlug);if(!route){problems.push(`${rel}: sidebar.navigation references slug "${navSlug}" which is not in whitelisted_routes \u2014 the nav item would render but 404.`)}else if(route.show_in_nav===false){problems.push(`${rel}: sidebar.navigation references "${navSlug}" but that route sets show_in_nav: false \u2014 contradictory. Drill-in routes stay routable and out of nav.`)}}}const ctxParams=layout.context_parameters;if(ctxParams!==void 0&&!Array.isArray(ctxParams)){problems.push(`${rel}: layout.context_parameters must be an array of strings.`)}else if(Array.isArray(ctxParams)&&ctxParams.some(p=>typeof p!=="string")){problems.push(`${rel}: layout.context_parameters must contain only strings.`)}const switcher=layout.sidebar?.context_switcher;if(switcher?.enabled){if(!switcher.param){problems.push(`${rel}: context_switcher is enabled but declares no \`param\`.`)}else if(!Array.isArray(ctxParams)||!ctxParams.includes(switcher.param)){problems.push(`${rel}: context_switcher writes ?${switcher.param}= but "${switcher.param}" is not in layout.context_parameters \u2014 pickAllowedContextParams would refuse to merge it and every context would resolve to the same page.`)}if(!switcher.source?.kind){problems.push(`${rel}: context_switcher.source.kind is required (a resolver id, never a UUID).`)}}const create=switcher?.create;if(create&&create.enabled!==false){if(!create.template_process_id&&!create.template_name){problems.push(`${rel}: context_switcher.create declares neither template_process_id nor template_name, so there is no process template to clone and the create button always fails.`)}if(create.job?.mode==="code"){problems.push(`${rel}: context_switcher.create.job.mode is 'code' \u2014 code-mode jobs go to the per-repo sprite bridge, not the director machine that runs plan-builder. Setup would never run.`)}const fields=Array.isArray(create.fields)?create.fields:[];const keys=new Set;for(const field of fields){if(!field?.key){problems.push(`${rel}: context_switcher.create has a field with no \`key\`.`);continue}if(keys.has(field.key)){problems.push(`${rel}: context_switcher.create declares field "${field.key}" twice.`)}keys.add(field.key);if(field.target&&!["binding","definition","both"].includes(field.target)){problems.push(`${rel}: context_switcher.create field "${field.key}" has target "${field.target}"; must be binding | definition | both.`)}if(field.type&&!["text","textarea"].includes(field.type)){problems.push(`${rel}: context_switcher.create field "${field.key}" has type "${field.type}"; the modal renders text | textarea only.`)}}if(create.name_from&&!keys.has(create.name_from)){problems.push(`${rel}: context_switcher.create.name_from is "${create.name_from}" but no field declares that key \u2014 the record would be created unnamed and the slug would be empty.`)}if(!create.name_from&&fields.length>0){warnings.push(`${rel}: context_switcher.create declares fields but no name_from, so every record is created with the RPC's default name and no slug binding.`)}const labelFrom=switcher.source?.label_from;if(labelFrom?.startsWith("metadata.bindings.")){const bindingKey=labelFrom.slice("metadata.bindings.".length);const writesIt=keys.has(bindingKey)&&fields.some(f=>f.key===bindingKey&&(f.target??"binding")!=="definition");if(!writesIt&&bindingKey!==(create.slug_key??"product_slug")){warnings.push(`${rel}: source.label_from reads bindings.${bindingKey}, but create writes no such binding \u2014 new records would fall back to the process name in the switcher.`)}}}const routedTypes=new Set(routes.map(r=>getRouteParam(r.params,"pageType")).filter(Boolean));for(const[pageType,dir]of pageTypes){if(!routedTypes.has(pageType)){warnings.push(`${rel}: page "${dir}" (page_type "${pageType}") has no route \u2014 unreachable except by direct pageId URL.`)}}}const orgs=existsSync(orgsRoot)?readdirSync(orgsRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];for(const slug of orgs){const templateFile=join(orgsRoot,slug,"template.json");if(!existsSync(templateFile))continue;orgCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_ORGS.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}if(template.public_layout){let prefix;try{prefix=JSON.parse(readFileSync(join(repoRoot,"template","config","public.json"),"utf8")).path_prefix}catch{prefix=void 0}checkPublicLayout(rel,template.public_layout,problems,{client:typeof prefix==="string",prefix})}if(template.platform_layout)checkPlatformLayout(rel,template.platform_layout,problems);const layout=template.portal_layout??{};const routes=flattenRoutes(layout.routes??layout.whitelisted_routes);if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: portal_layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no portal page is reachable.`);continue}const seenPaths=new Map;const routeByPath=new Map;const projectPrefixes=[];const redirects=[];for(const route of routes){routeCount+=1;const path=route.path||route.route;const id=path||route.label||"<unnamed>";if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use [param].`)}for(const placeholder of placeholdersIn(path)){if(placeholder.startsWith("...")){warnings.push(`${rel}: route "${path}" uses catch-all "[${placeholder}]", but matchesParameterizedPath compares segment counts \u2014 it matches exactly ONE segment, so deeper paths never resolve through findRouteInLayout.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id);routeByPath.set(path,route)}const kinds=framedKinds(routeKinds(route),"project");if(kinds.length>1)problems.push(`${rel}: route "${path}" declares ${routeKinds(route).join(" and ")} \u2014 exactly one kind (a component may frame a project).`);if(route.project){if(route.component!==void 0)checkComponentName(`${rel}: route "${path}"`,route.component,problems);if(!route.project.template)problems.push(`${rel}: route "${path}" project names no template slug.`);if(!INSTANCES.has(route.project.instance))problems.push(`${rel}: route "${path}" project.instance must be per_client, per_org, shared or chosen.`);projectPrefixes.push(path)}else if(route.redirect){redirects.push([`route "${path}" redirect`,route.redirect])}else if(route.page){if(!route.page.id)problems.push(`${rel}: route "${path}" page in an org template names a page by id.`)}else{checkComponentName(`${rel}: route "${path}"`,route.component,problems)}checkDeclarations(`${rel}: route "${path}"`,route,problems);if(route.right_sidebar?.component){checkComponentName(`${rel}: route "${path}" right_sidebar`,route.right_sidebar.component,problems)}}const resolvesToRoute=target=>{if(seenPaths.has(target))return true;if(projectPrefixes.some(p=>target.startsWith(`${p}/`)))return true;const targetSegments=target.split("/").filter(Boolean);for(const path of seenPaths.keys()){const routeSegments=path.split("/").filter(Boolean);if(routeSegments.length!==targetSegments.length)continue;const matches=routeSegments.every((segment,i)=>parsePlaceholder(segment)!==null||segment===targetSegments[i]);if(matches)return true}return false};const navPaths=[];if(layout.base_route?.path)navPaths.push([`base_route.path`,layout.base_route.path]);for(const[i,item]of(layout.tab_bar?.items??[]).entries()){if(item?.path)navPaths.push([`tab_bar.items[${i}].path`,item.path]);for(const[j,active]of(item?.active_paths??[]).entries()){navPaths.push([`tab_bar.items[${i}].active_paths[${j}]`,active])}}for(const[i,item]of(layout.more_menu?.items??[]).entries()){if(item?.path)navPaths.push([`more_menu.items[${i}].path`,item.path])}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(item?.path)navPaths.push([`sidebar.navigation[${i}].path`,item.path])}navPaths.push(...redirects);for(const[where,path]of navPaths){if(!resolvesToRoute(path)){problems.push(`${rel}: ${where} is "${path}" but no whitelisted_route matches it \u2014 the nav entry renders and then resolves to nothing.`)}}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(!item?.path||!item?.component)continue;const route=routeByPath.get(item.path);if(route&&route.component&&route.component!==item.component){problems.push(`${rel}: sidebar.navigation[${i}] claims component "${item.component}" for "${item.path}" but the whitelisted route declares "${route.component}" \u2014 nav and route disagree.`)}}const chrome=[["base_route",layout.base_route?.component],["app_bar",layout.app_bar?.component],["tab_bar",layout.tab_bar?.component],["more_menu",layout.more_menu?.component],["chat_rail",layout.chat_rail?.component],["right_sidebar",layout.right_sidebar?.component],["sidebar",layout.sidebar?.component],["assistant_overlay.component",layout.assistant_overlay?.component],["assistant_overlay.pill",layout.assistant_overlay?.pill]];for(const[where,name]of chrome){if(name===void 0||name===null)continue;checkComponentName(`${rel}: portal_layout.${where}`,name,problems)}if(layout.right_sidebar){checkDeclarations(`${rel}: portal_layout.right_sidebar`,layout.right_sidebar,problems)}}if(exportSkipReason){console.warn(`[check-template-routes] component-name checks SKIPPED \u2014 ${exportSkipReason}.
|
|
3
3
|
`)}if(contractNote){console.warn(`[check-template-routes] note: ${contractNote}; registry bindings are warnings only.
|
|
4
4
|
`)}const notGated=[...templates.filter(s=>!gateAll&&!GATED_TEMPLATES.has(s)),...orgs.filter(s=>!gateAll&&!GATED_ORGS.has(s))];if(warnings.length>0){console.warn(`[check-template-routes] ${warnings.length} warning(s):
|
|
5
5
|
`);for(const w of warnings)console.warn(` \xB7 ${w}`);if(notGated.length>0){console.warn(`
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{mkdir,rm,writeFile}from"node:fs/promises";import{createRequire}from"node:module";import{cpus}from"node:os";import{join,relative,resolve}from"node:path";import{pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{newDeterministicContext,prepare,settle}from"../harness/determinism.mjs";import{buildAndServeHarness,createHarnessServer}from"../harness/server.mjs";import{selectChanged}from"../harness/changed.mjs";const argv=process.argv.slice(2);const config=await loadConfig();const{harness}=config;const OUTPUT=resolve(REPO_ROOT,harness.output);const only=parseFlagValue(argv,"only");const[shardIndex,shardTotal]=(parseFlagValue(argv,"shard")??"1/1").split("/").map(Number);if(!(shardTotal>=1&&shardIndex>=1&&shardIndex<=shardTotal))throw new Error("[test-visual] --shard expects <i>/<n>");const base=parseFlagValue(argv,"changed");const listOnly=argv.includes("--list");const{fixtureFiles,pageFiles}=await sourceFiles();let selection=null;if(base){const s=selection=await selectChanged({root:REPO_ROOT,base,config,fixtureFiles,pageFiles,pageKey});if(s.all)console.log(`[test-visual] --changed ${base}: running everything \u2014 ${s.all}`);else console.log(`[test-visual] --changed ${base}: ${s.changed.length} changed file(s) \u2192 ${s.fixtures.size} fixture file(s), ${s.pages.size} page(s)`);if(!s.all&&!s.fixtures.size&&!s.pages.size){console.log("[test-visual] nothing the changes affect renders here; nothing to do");if(!listOnly){await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,mode:"changed",base,selected:[],reports:[],skipped:[]},null,2)+"\n")}process.exit(0)}if(listOnly){if(!s.all){for(const[file,why]of s.fixtures)console.log(` fixture ${relative(REPO_ROOT,file)} \u2014 ${why}`);for(const[key,why]of s.pages)console.log(` page ${key} \u2014 ${why}`)}process.exit(0)}}const reasonOf=t=>!selection?void 0:selection.all?`everything: ${selection.all}`:t.kind==="page"?selection.pages.get(t.key):selection.fixtures.get(t.file);const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const playwright=await import(pathToFileURL(fromRepo.resolve("playwright")).href);const chromium=playwright.chromium??playwright.default.chromium;const skipped=[];const{server}=await createHarnessServer();const served=await buildAndServeHarness();const origin=served.origin;const HARNESS_PATH=served.path;let exitCode=0;try{const list=await listTargets(server);const targets=list.filter(t=>(!only||t.label.startsWith(`${only} `))&&t.index%shardTotal===shardIndex-1&&(!selection||selection.all||reasonOf(t)));await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});console.log(`[test-visual] ${targets.length} target(s) (of ${list.length}) \xD7 ${harness.widths.length} width(s) + a ${harness.container}px container${skipped.length?`; ${skipped.length}
|
|
2
|
+
import{mkdir,rm,writeFile}from"node:fs/promises";import{createRequire}from"node:module";import{cpus}from"node:os";import{join,relative,resolve}from"node:path";import{pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{newDeterministicContext,prepare,settle}from"../harness/determinism.mjs";import{buildAndServeHarness,createHarnessServer}from"../harness/server.mjs";import{selectChanged}from"../harness/changed.mjs";import{PUBLIC_ASSETS_URL}from"../harness/public-assets.mjs";const argv=process.argv.slice(2);const config=await loadConfig();const{harness}=config;const OUTPUT=resolve(REPO_ROOT,harness.output);const only=parseFlagValue(argv,"only");const[shardIndex,shardTotal]=(parseFlagValue(argv,"shard")??"1/1").split("/").map(Number);if(!(shardTotal>=1&&shardIndex>=1&&shardIndex<=shardTotal))throw new Error("[test-visual] --shard expects <i>/<n>");const base=parseFlagValue(argv,"changed");const listOnly=argv.includes("--list");const{fixtureFiles,pageFiles}=await sourceFiles();let selection=null;if(base){const s=selection=await selectChanged({root:REPO_ROOT,base,config,fixtureFiles,pageFiles,pageKey});if(s.all)console.log(`[test-visual] --changed ${base}: running everything \u2014 ${s.all}`);else console.log(`[test-visual] --changed ${base}: ${s.changed.length} changed file(s) \u2192 ${s.fixtures.size} fixture file(s), ${s.pages.size} page(s)`);if(!s.all&&!s.fixtures.size&&!s.pages.size){console.log("[test-visual] nothing the changes affect renders here; nothing to do");if(!listOnly){await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,mode:"changed",base,selected:[],reports:[],skipped:[]},null,2)+"\n")}process.exit(0)}if(listOnly){if(!s.all){for(const[file,why]of s.fixtures)console.log(` fixture ${relative(REPO_ROOT,file)} \u2014 ${why}`);for(const[key,why]of s.pages)console.log(` page ${key} \u2014 ${why}`)}process.exit(0)}}const reasonOf=t=>!selection?void 0:selection.all?`everything: ${selection.all}`:t.kind==="page"?selection.pages.get(t.key):selection.fixtures.get(t.file);const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const playwright=await import(pathToFileURL(fromRepo.resolve("playwright")).href);const chromium=playwright.chromium??playwright.default.chromium;const skipped=[];const{server}=await createHarnessServer();const served=await buildAndServeHarness();const origin=served.origin;const HARNESS_PATH=served.path;let exitCode=0;try{const list=await listTargets(server);const targets=list.filter(t=>(!only||t.label.startsWith(`${only} `))&&t.index%shardTotal===shardIndex-1&&(!selection||selection.all||reasonOf(t)));await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});console.log(`[test-visual] ${targets.length} target(s) (of ${list.length}) \xD7 ${harness.widths.length} width(s) + a ${harness.container}px container${skipped.length?`; ${skipped.length} skipped`:""}`);const browser=await chromium.launch();const concurrency=Math.max(1,Number(process.env.ENGINE_VISUAL_CONCURRENCY)||Math.min(6,Math.max(2,cpus().length-1)));const reports=new Array(targets.length);let next=0;await Promise.all(Array.from({length:Math.min(concurrency,targets.length)},async()=>{while(next<targets.length){const i=next++;const t=targets[i];const renders=[...harness.widths.map(w=>({width:w,container:null})),{width:Math.max(...harness.widths),container:harness.container}];const report={target:t.label,kind:t.kind,...t.designIntent?{designIntent:t.designIntent}:{},widths:[]};for(const r of renders)report.widths.push(await renderOnce(browser,t,r));reports[i]=report;const bad=report.widths.filter(w=>w.errors.length);const over=report.widths.filter(w=>w.overflow);console.log(` ${bad.length?"\u2717":over.length?"\u25B3":"\u2713"} ${t.label}${bad.length?` \u2014 ${bad[0].errors[0].split("\n")[0].slice(0,160)}`:""}${over.length?` (overflow at ${over.map(w=>w.container?`${w.container}px container`:`${w.width}px`).join(", ")})`:""}`)}}));await browser.close();const failed=reports.filter(r=>r.widths.some(w=>w.errors.length));const overflowing=reports.filter(r=>r.widths.some(w=>w.overflow));const changed=selection?{mode:"changed",base,selected:targets.map(t=>({target:t.label,reason:reasonOf(t)}))}:{};await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,...changed,reports,skipped},null,2)+"\n");console.log(`[test-visual] ${reports.length-failed.length}/${reports.length} rendered clean; ${overflowing.length} overflow; report \u2192 ${relative(REPO_ROOT,join(OUTPUT,"report.json"))}`);if(failed.length||harness.overflow==="error"&&overflowing.length)exitCode=1}finally{await server.close();await served.close()}process.exit(exitCode);async function sourceFiles(){const{readdir}=await import("node:fs/promises");const walk=async dir=>{const out=[];for(const e of await readdir(dir,{withFileTypes:true}).catch(()=>[])){const p=join(dir,e.name);if(e.isDirectory())out.push(...await walk(p));else out.push(p)}return out};const fixtureFiles2=(await walk(resolve(REPO_ROOT,"src"))).filter(f=>/\.fixtures\.tsx?$/.test(f)&&!relative(REPO_ROOT,f).includes("node_modules/")).sort();const pageFiles2=[...(await walk(resolve(REPO_ROOT,"src/pages"))).filter(f=>f.endsWith("/page.json")&&f.split("/").length-resolve(REPO_ROOT,"src/pages").split("/").length===2),...(await walk(resolve(REPO_ROOT,"src/project_templates"))).filter(f=>/\/pages\/[^/]+\/page\.json$/.test(f))].sort();return{fixtureFiles:fixtureFiles2,pageFiles:pageFiles2}}function pageKey(file){const m=file.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??file.match(/\/pages\/([^/]+)\/page\.json$/);return m.slice(1).join("/")}async function listTargets(vite){const targets=[];for(const file of fixtureFiles){const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");if(selection&&!selection.all&&!selection.fixtures.has(file))continue;const mod=await vite.ssrLoadModule(file);for(const s of mod.scenarios??[]){targets.push({kind:"fixture",file,label:`${name} \xB7 ${s.name}`,query:`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`,designIntent:s.designIntent})}}for(const file of pageFiles){const key=pageKey(file);targets.push({kind:"page",key,label:`page ${key}`,query:`page=${encodeURIComponent(key)}`})}return targets.map((t,index)=>({...t,index}))}async function renderOnce(browser,target,{width,container}){const context=await newDeterministicContext(browser,{width,height:900});const page=await context.newPage();await prepare(page);const consoleErrors=[];const pageErrors=[];const failedRequests=[];page.on("console",m=>m.type()==="error"&&consoleErrors.push(m.text()));page.on("pageerror",e=>pageErrors.push(e.stack||e.message));page.on("response",r=>r.url().startsWith(`${origin}${PUBLIC_ASSETS_URL}`)&&r.status()>=400&&failedRequests.push(`${decodeURIComponent(new URL(r.url()).pathname.slice(PUBLIC_ASSETS_URL.length))} is not in ${harness.publicAssets} (assets.publicUrl)`));const url=`${origin}${HARNESS_PATH}?${target.query}${container?`&container=${container}`:""}`;const result={width,...container?{container}:{},errors:[],diagnostics:[],overflow:false};try{await page.goto(url,{waitUntil:"load",timeout:3e4});await settle(page,{timeout:15e3});const harnessError=await page.evaluate(()=>window.__ENGINE_HARNESS_ERROR??null);if(harnessError)result.errors.push(harnessError);result.diagnostics=await page.evaluate(()=>window.__ENGINE_HARNESS_DIAGNOSTICS??[]);const missing=await page.$$eval("[data-missing-component]",els=>els.map(e=>e.getAttribute("data-missing-component")));for(const name of new Set(missing))result.errors.push(`${name} isn't exported by any barrel or shared lineage`);result.overflow=await page.evaluate(inContainer=>{const el=inContainer?document.querySelector("[data-engine-container]"):document.scrollingElement;return!!el&&el.scrollWidth>el.clientWidth+1},Boolean(container));result.aria=await page.locator("#root").ariaSnapshot().catch(()=>null)}catch(err){result.errors.push(err instanceof Error?err.message:String(err))}const dir=join(OUTPUT,target.kind,target.label.replace(/[^A-Za-z0-9._-]+/g,"_"));await mkdir(dir,{recursive:true});const shot=join(dir,`${container?`c${container}`:width}.png`);await page.screenshot({path:shot,fullPage:true}).then(()=>result.screenshot=relative(REPO_ROOT,shot),()=>{});const attributeWarning=/Unknown event handler property|React does not recognize the|Invalid DOM property|Invalid value for prop|for a non-boolean attribute/;result.warnings=consoleErrors.filter(m=>attributeWarning.test(m));result.errors.push(...failedRequests,...pageErrors,...consoleErrors.filter(m=>!attributeWarning.test(m)));for(const d of result.diagnostics)if(d.severity==="error")result.errors.push(`${d.code}: ${d.message}`);await context.close();return result}
|
package/src/harness/app.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{Fragment,jsx,jsxs}from"react/jsx-runtime";import"@peripheries/renderer/styles.css";import{pageRuntimeFromValues,projectCellActions}from"@peripheries/renderer/core";import{PageRenderer}from"@peripheries/renderer/react";import{getStackStore}from"@peripheries/renderer/runtime";import{FIXTURE_BASE_PROPS}from"@peripheries/renderer/testing";import{Component,isValidElement,useEffect,useMemo}from"react";import{createRoot}from"react-dom/client";const isComponent=v=>typeof v==="function"||typeof v==="object"&&v!==null&&"$$typeof"in v;function sharedBundle({specifier,module}){const components=Object.fromEntries(Object.entries(module).filter(([name,v])=>/^[A-Z]/.test(name)&&isComponent(v)));return{id:specifier,label:specifier,specifier,components,contracts:{}}}const fixtureName=path=>path.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const pageKey=path=>{const m=path.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??path.match(/\/pages\/([^/]+)\/page\.json$/);return m?m.slice(1).join("/"):path};function isJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isJson);if(typeof v!=="object"||isValidElement(v))return false;const proto=Object.getPrototypeOf(v);return(proto===Object.prototype||proto===null)&&Object.values(v).every(isJson)}class Boundary extends Component{state={error:null};static getDerivedStateFromError(error){return{error}}componentDidCatch(error,info){window.__ENGINE_HARNESS_ERROR=`${error.message}
|
|
2
|
-
${info.componentStack??""}`}render(){return this.state.error?jsx("pre",{role:"alert",children:this.state.error.message}):this.props.children}}function Ready({children}){useEffect(()=>{const id=requestAnimationFrame(()=>requestAnimationFrame(()=>window.__ENGINE_HARNESS_READY=true));return()=>cancelAnimationFrame(id)},[]);return jsx(Fragment,{children})}function RenderedPage({row,values,actions,baseProps,components,shared,Wrapper,statusOverride}){const runtime=useMemo(()=>{const rt=pageRuntimeFromValues(row,{values});if(statusOverride&&(statusOverride.isLoading||statusOverride.error)){const error=statusOverride.error?new Error(statusOverride.error):null;for(const cell of rt.cells)rt.cellData[cell.id]??={static:{},declared:{}};for(const own of Object.values(rt.cellData)){const keys=Object.keys(own.static).length?Object.keys(own.static):["fixture"];for(const k of keys)own.static[k]={data:void 0,isLoading:Boolean(statusOverride.isLoading),error}}}return rt},[row,values,statusOverride]);const bundles=useMemo(()=>{const bundle={id:"harness",label:"harness",components,contracts:{}};return{scope:[bundle,...shared],forCell:()=>null}},[components,shared]);const environment=useMemo(()=>{const pageActions=Object.fromEntries(Object.keys(actions??{}).map(k=>[k,async input=>(console.info(`[harness] action ${k}`,input),{ok:true})]));window.__ENGINE_HARNESS_DIAGNOSTICS=[];return{core:{},navigate:()=>{},store:getStackStore(),actionsFor:cell=>cell.actions?projectCellActions(cell.actions,pageActions):Object.keys(pageActions).length?pageActions:void 0,onCellError:(cellId,error)=>console.error(`[harness] cell "${cellId}" failed:`,error),onDiagnostic:d=>{window.__ENGINE_HARNESS_DIAGNOSTICS.push({code:d.code,severity:d.severity,message:d.message,...d.cellId?{cellId:d.cellId}:{}});if(d.severity==="error")console.error(`[harness] ${d.code}: ${d.message}`)}}},[actions]);const page=jsx(PageRenderer,{runtime,bundles,params:{},baseProps,context:{environment:"harness"},environment});return Wrapper?jsx(Wrapper,{baseProps,children:page}):page}function pageRow(id,json){return{id,name:json.name,org_id:null,project_id:null,process_id:null,graph_node_id:null,page_type:json.page_type??null,status:"published",is_template:json.is_template??false,content:json.content??{cells:[]},layout:json.layout??{type:"stack",gap:16},metadata:json.metadata??null}}function Index({fixtures,pages}){useEffect(()=>void(window.__ENGINE_HARNESS_READY=true),[]);const link=q=>`?${q}`;return jsxs("main",{style:{fontFamily:"system-ui, sans-serif",padding:24,maxWidth:960},children:[jsx("h1",{children:"Harness"}),jsx("h2",{children:"Fixtures"}),fixtures.map(([name,scenarios])=>jsxs("section",{children:[jsx("h3",{children:name}),jsx("ul",{children:scenarios.map(s=>jsxs("li",{children:[jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`),children:s.name})," \xB7 ",jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}&container=320`),children:"in 320px"})]},s.name))})]},name)),jsx("h2",{children:"Pages"}),jsx("ul",{children:pages.map(p=>jsx("li",{children:jsx("a",{href:link(`page=${encodeURIComponent(p)}`),children:p})},p))})]})}function mountHarness(sources){const q=new URLSearchParams(window.location.search);const components=Object.assign({},...Object.values(sources.barrels));const shared=(sources.shared??[]).map(sharedBundle);const fixtures=Object.entries(sources.fixtures).map(([path,mod])=>[fixtureName(path),[...mod.scenarios??[]]]).sort(([a],[b])=>a.localeCompare(b));const pages=Object.fromEntries(Object.entries(sources.pages).map(([path,json])=>[pageKey(path),{json,mock:sources.mocks[path.replace(/page\.json$/,"mock.json")]}]));let body;const fixture=q.get("fixture");const page=q.get("page");if(fixture){const scenario=fixtures.find(([n])=>n===fixture)?.[1].find(s=>s.name===q.get("scenario"));if(!scenario){window.__ENGINE_HARNESS_ERROR=`no scenario "${q.get("scenario")}" for ${fixture}`;body=null}else if(!isJson(scenario.props??{})){const Direct=components[fixture]??shared.find(b=>b.components[fixture])?.components[fixture];if(!Direct)window.__ENGINE_HARNESS_ERROR=`${fixture} is not exported from any barrel or shared lineage`;const baseProps={...FIXTURE_BASE_PROPS,...scenario.baseProps};const element=Direct?jsx("div",{className:"cell","data-engine-direct":"",children:jsx(Direct,{...scenario.props,baseProps})}):null;body=sources.Wrapper?jsx(sources.Wrapper,{baseProps,children:element}):element}else{const cell={id:"fixture",component:fixture,props:scenario.props??{},...scenario.data?{data:{static:scenario.data}}:{},...scenario.selections?{selections:scenario.selections}:{},...scenario.viewState?{viewState:scenario.viewState}:{},...scenario.live?{live:scenario.live}:{}};body=jsx(RenderedPage,{row:pageRow(`${fixture}/${scenario.name}`,{name:`${fixture} \xB7 ${scenario.name}`,content:{cells:[cell]}}),values:{},actions:scenario.actions,baseProps:{...FIXTURE_BASE_PROPS,...scenario.baseProps},components,shared,Wrapper:sources.Wrapper,statusOverride:scenario.status})}}else if(page){const entry=pages[page];if(!entry){window.__ENGINE_HARNESS_ERROR=`no page "${page}"`;body=null}else{const values={...entry.mock?.data};body=jsx(RenderedPage,{row:pageRow(page,entry.json),values,actions:entry.mock?.actions,baseProps:{...FIXTURE_BASE_PROPS,...entry.mock?.baseProps},components,shared,Wrapper:sources.Wrapper})}}else{body=jsx(Index,{fixtures,pages:Object.keys(pages).sort()})}const container=Number(q.get("container"))||null;const framed=container?jsx("div",{"data-engine-container":"",style:{width:container,containerType:"inline-size",outline:"1px dashed #bbb"},children:body}):body;createRoot(document.getElementById("root")).render(jsx(Boundary,{children:jsx(Ready,{children:framed})}))}export{mountHarness};
|
|
1
|
+
import{Fragment,jsx,jsxs}from"react/jsx-runtime";import"@peripheries/renderer/styles.css";import{pageRuntimeFromValues,projectCellActions}from"@peripheries/renderer/core";import{PageRenderer}from"@peripheries/renderer/react";import{getStackStore}from"@peripheries/renderer/runtime";import{FIXTURE_BASE_PROPS}from"@peripheries/renderer/testing";import{Component,isValidElement,useEffect,useMemo}from"react";import{createRoot}from"react-dom/client";const isComponent=v=>typeof v==="function"||typeof v==="object"&&v!==null&&"$$typeof"in v;function sharedBundle({specifier,module}){const components=Object.fromEntries(Object.entries(module).filter(([name,v])=>/^[A-Z]/.test(name)&&isComponent(v)));return{id:specifier,label:specifier,specifier,components,contracts:{}}}const fixtureName=path=>path.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const pageKey=path=>{const m=path.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??path.match(/\/pages\/([^/]+)\/page\.json$/);return m?m.slice(1).join("/"):path};function isJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isJson);if(typeof v!=="object"||isValidElement(v))return false;const proto=Object.getPrototypeOf(v);return(proto===Object.prototype||proto===null)&&Object.values(v).every(isJson)}const PUBLIC_PROPS_MAX=8192;const PUBLIC_ASSETS_URL="/__public-assets/";const publicAction=name=>async input=>(console.info(`[harness] action ${name}`,input),{success:true});function publicBaseProps(over){const host="www.example.test";const base={site:{host,pathPrefix:null,url:["https",host].join("://")},pathname:"/",params:{},actions:{submitRequest:publicAction("submitRequest"),submitClientRequest:publicAction("submitClientRequest")},assets:{publicUrl:path=>encodeURI(`${PUBLIC_ASSETS_URL}${String(path).replace(/^\/+/,"")}`)}};const part=k=>({...base[k],...over?.[k]??{}});return{...base,...over,site:part("site"),params:part("params"),actions:part("actions"),assets:part("assets")}}class Boundary extends Component{state={error:null};static getDerivedStateFromError(error){return{error}}componentDidCatch(error,info){window.__ENGINE_HARNESS_ERROR=`${error.message}
|
|
2
|
+
${info.componentStack??""}`}render(){return this.state.error?jsx("pre",{role:"alert",children:this.state.error.message}):this.props.children}}function Ready({children}){useEffect(()=>{const id=requestAnimationFrame(()=>requestAnimationFrame(()=>window.__ENGINE_HARNESS_READY=true));return()=>cancelAnimationFrame(id)},[]);return jsx(Fragment,{children})}function RenderedPage({row,values,actions,baseProps,components,shared,Wrapper,statusOverride}){const runtime=useMemo(()=>{const rt=pageRuntimeFromValues(row,{values});if(statusOverride&&(statusOverride.isLoading||statusOverride.error)){const error=statusOverride.error?new Error(statusOverride.error):null;for(const cell of rt.cells)rt.cellData[cell.id]??={static:{},declared:{}};for(const own of Object.values(rt.cellData)){const keys=Object.keys(own.static).length?Object.keys(own.static):["fixture"];for(const k of keys)own.static[k]={data:void 0,isLoading:Boolean(statusOverride.isLoading),error}}}return rt},[row,values,statusOverride]);const bundles=useMemo(()=>{const bundle={id:"harness",label:"harness",components,contracts:{}};return{scope:[bundle,...shared],forCell:()=>null}},[components,shared]);const environment=useMemo(()=>{const pageActions=Object.fromEntries(Object.keys(actions??{}).map(k=>[k,async input=>(console.info(`[harness] action ${k}`,input),{ok:true})]));window.__ENGINE_HARNESS_DIAGNOSTICS=[];return{core:{},navigate:()=>{},store:getStackStore(),actionsFor:cell=>cell.actions?projectCellActions(cell.actions,pageActions):Object.keys(pageActions).length?pageActions:void 0,onCellError:(cellId,error)=>console.error(`[harness] cell "${cellId}" failed:`,error),onDiagnostic:d=>{window.__ENGINE_HARNESS_DIAGNOSTICS.push({code:d.code,severity:d.severity,message:d.message,...d.cellId?{cellId:d.cellId}:{}});if(d.severity==="error")console.error(`[harness] ${d.code}: ${d.message}`)}}},[actions]);const page=jsx(PageRenderer,{runtime,bundles,params:{},baseProps,context:{environment:"harness"},environment});return Wrapper?jsx(Wrapper,{baseProps,children:page}):page}function pageRow(id,json){return{id,name:json.name,org_id:null,project_id:null,process_id:null,graph_node_id:null,page_type:json.page_type??null,status:"published",is_template:json.is_template??false,content:json.content??{cells:[]},layout:json.layout??{type:"stack",gap:16},metadata:json.metadata??null}}function Index({fixtures,pages}){useEffect(()=>void(window.__ENGINE_HARNESS_READY=true),[]);const link=q=>`?${q}`;return jsxs("main",{style:{fontFamily:"system-ui, sans-serif",padding:24,maxWidth:960},children:[jsx("h1",{children:"Harness"}),jsx("h2",{children:"Fixtures"}),fixtures.map(([name,scenarios])=>jsxs("section",{children:[jsx("h3",{children:name}),jsx("ul",{children:scenarios.map(s=>jsxs("li",{children:[jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`),children:s.name})," \xB7 ",jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}&container=320`),children:"in 320px"})]},s.name))})]},name)),jsx("h2",{children:"Pages"}),jsx("ul",{children:pages.map(p=>jsx("li",{children:jsx("a",{href:link(`page=${encodeURIComponent(p)}`),children:p})},p))})]})}function mountHarness(sources){const q=new URLSearchParams(window.location.search);const components=Object.assign({},...Object.values(sources.barrels));const shared=(sources.shared??[]).map(sharedBundle);const fixtures=Object.entries(sources.fixtures).map(([path,mod])=>[fixtureName(path),[...mod.scenarios??[]]]).sort(([a],[b])=>a.localeCompare(b));const pages=Object.fromEntries(Object.entries(sources.pages).map(([path,json])=>[pageKey(path),{json,mock:sources.mocks[path.replace(/page\.json$/,"mock.json")]}]));let body;const fixture=q.get("fixture");const page=q.get("page");if(fixture){const scenario=fixtures.find(([n])=>n===fixture)?.[1].find(s=>s.name===q.get("scenario"));if(!scenario){window.__ENGINE_HARNESS_ERROR=`no scenario "${q.get("scenario")}" for ${fixture}`;body=null}else if(scenario.surface==="public-page"){const Page=components[fixture]??shared.find(b=>b.components[fixture])?.components[fixture];const props=scenario.props??{};if(!Page)window.__ENGINE_HARNESS_ERROR=`${fixture} is not exported from any barrel or shared lineage`;else if(!isJson(props))window.__ENGINE_HARNESS_ERROR=`${fixture} \xB7 ${scenario.name}: a public route's props are JSON (no elements, dates or functions)`;else if(JSON.stringify(props).length>PUBLIC_PROPS_MAX)window.__ENGINE_HARNESS_ERROR=`${fixture} \xB7 ${scenario.name}: a public route's props are over ${PUBLIC_PROPS_MAX} bytes`;const baseProps=publicBaseProps(scenario.baseProps);const element=Page?jsx(Page,{...props,baseProps}):null;body=sources.PublicWrapper?jsx(sources.PublicWrapper,{baseProps,children:element}):element}else if(!isJson(scenario.props??{})){const Direct=components[fixture]??shared.find(b=>b.components[fixture])?.components[fixture];if(!Direct)window.__ENGINE_HARNESS_ERROR=`${fixture} is not exported from any barrel or shared lineage`;const baseProps={...FIXTURE_BASE_PROPS,...scenario.baseProps};const element=Direct?jsx("div",{className:"cell","data-engine-direct":"",children:jsx(Direct,{...scenario.props,baseProps})}):null;body=sources.Wrapper?jsx(sources.Wrapper,{baseProps,children:element}):element}else{const cell={id:"fixture",component:fixture,props:scenario.props??{},...scenario.data?{data:{static:scenario.data}}:{},...scenario.selections?{selections:scenario.selections}:{},...scenario.viewState?{viewState:scenario.viewState}:{},...scenario.live?{live:scenario.live}:{}};body=jsx(RenderedPage,{row:pageRow(`${fixture}/${scenario.name}`,{name:`${fixture} \xB7 ${scenario.name}`,content:{cells:[cell]}}),values:{},actions:scenario.actions,baseProps:{...FIXTURE_BASE_PROPS,...scenario.baseProps},components,shared,Wrapper:sources.Wrapper,statusOverride:scenario.status})}}else if(page){const entry=pages[page];if(!entry){window.__ENGINE_HARNESS_ERROR=`no page "${page}"`;body=null}else{const values={...entry.mock?.data};body=jsx(RenderedPage,{row:pageRow(page,entry.json),values,actions:entry.mock?.actions,baseProps:{...FIXTURE_BASE_PROPS,...entry.mock?.baseProps},components,shared,Wrapper:sources.Wrapper})}}else{body=jsx(Index,{fixtures,pages:Object.keys(pages).sort()})}const container=Number(q.get("container"))||null;const framed=container?jsx("div",{"data-engine-container":"",style:{width:container,containerType:"inline-size",outline:"1px dashed #bbb"},children:body}):body;createRoot(document.getElementById("root")).render(jsx(Boundary,{children:jsx(Ready,{children:framed})}))}export{mountHarness};
|
package/src/harness/changed.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{execFileSync}from"node:child_process";import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve,sep}from"node:path";import{compilerOptions,loadTypeScript}from"../lib/shared-lineages.mjs";const SOURCE=/\.(?:[cm]?[jt]sx?)$/;const EXTENSIONS=[".ts",".tsx",".mts",".js",".jsx",".mjs"];const git=(root,args)=>execFileSync("git",["-C",root,...args],{encoding:"utf8",stdio:["ignore","pipe","pipe"]});const lines=s=>s.split("\n").map(l=>l.trim()).filter(Boolean);function changedFiles(root,base){try{git(root,["rev-parse","--verify","--quiet",`${base}^{commit}`]);git(root,["merge-base",base,"HEAD"])}catch{return{error:`can't resolve "${base}" here (a shallow clone? fetch it: git fetch origin develop, or check out with fetch-depth: 0)`}}const files=new Set([...lines(git(root,["diff","--name-only","--relative",`${base}...HEAD`])),...lines(git(root,["diff","--name-only","--relative","HEAD"])),...lines(git(root,["ls-files","--others","--exclude-standard"]))]);return{files:[...files].sort()}}function globalReason(path,{root,barrels}){const name=path.split("/").pop();if(name==="package.json"||/^(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/.test(name))return`${path} (dependencies)`;if(/^engine\.config\.[cm]?[jt]s$/.test(path))return`${path} (the engine config)`;if(/^tsconfig[^/]*\.json$/.test(path)||/^vite[^/]*\.[cm]?[jt]s$/.test(path))return`${path} (build config)`;if(path.startsWith(".github/"))return`${path} (CI)`;if(path.startsWith("test/harness/"))return`${path} (the harness)`;if(path.startsWith("src/theme/")||path.startsWith("tokens/")||path.includes("/tokens/"))return`${path} (theme or tokens)`;if(path.split("/").includes("_shared"))return`${path} (a _shared helper)`;if(barrels.has(path))return`${path} (a barrel)`;if(path.endsWith(".css")){const stem=join(root,path.slice(0,-".css".length));if(!EXTENSIONS.some(e=>existsSync(stem+e)))return`${path} (CSS that isn't one component's)`}if(path.startsWith("src/")&&(SOURCE.test(path)||path.endsWith(".css"))&&!existsSync(join(root,path)))return`${path} (a source file was deleted)`;return null}async function importGraph(root,config){const ts=await loadTypeScript();const options=ts?compilerOptions(ts,root):null;const aliases=[["@",resolve(root,"src")],...Object.entries(config.harness.alias??{}).map(([k,v])=>[k,resolve(root,v)])];const asFile=p=>{if(existsSync(p)&&statSync(p).isFile())return p;for(const e of EXTENSIONS)if(existsSync(p+e))return p+e;const bare=p.replace(/\.[cm]?jsx?$/,"");if(bare!==p){for(const e of EXTENSIONS)if(existsSync(bare+e))return bare+e}for(const e of EXTENSIONS)if(existsSync(join(p,"index"+e)))return join(p,"index"+e);return null};const resolveImport=(spec,from)=>{if(spec.startsWith("."))return asFile(resolve(dirname(from),spec));for(const[find,to]of aliases){if(spec===find)return asFile(to);if(find==="@"&&spec.startsWith("@/"))return asFile(join(to,spec.slice(2)))}if(!ts)return null;const r=ts.resolveModuleName(spec,from,options,ts.sys).resolvedModule;return r?.resolvedFileName&&!r.isExternalLibraryImport&&!r.resolvedFileName.includes(`${sep}node_modules${sep}`)?r.resolvedFileName:null};const specsOf=(file,text)=>{if(file.endsWith(".css"))return[...text.matchAll(/@import\s+(?:url\()?\s*['"]([^'"]+)['"]/g)].map(m=>m[1]);if(ts)return ts.preProcessFile(text,true,true).importedFiles.map(f=>f.fileName);return[...text.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g)].map(m=>m[1])};const edges=new Map;const depsOf=file=>{if(!edges.has(file)){let text="";try{text=readFileSync(file,"utf8")}catch{}const deps=new Set;if(SOURCE.test(file)||file.endsWith(".css"))for(const s of specsOf(file,text)){const to=resolveImport(s,file);if(to&&to.startsWith(root+sep))deps.add(to)}edges.set(file,deps)}return edges.get(file)};const closure=file=>{const seen=new Set;const stack=[file];while(stack.length){const f=stack.pop();if(seen.has(f))continue;seen.add(f);for(const d of depsOf(f))stack.push(d)}return seen};return{closure,resolveImport}}function exportMap(files,resolveImport){const names=new Map;const stars=new Set;for(const file of files){const text=readFileSync(file,"utf8");for(const m of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)){const to=resolveImport(m[2],file);if(!to)continue;for(const part of m[1].split(",")){const name=part.trim().split(/\s+as\s+/).pop()?.trim();if(name&&!names.has(name))names.set(name,to)}}for(const m of text.matchAll(/export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)/g))if(!names.has(m[1]))names.set(m[1],file);for(const m of text.matchAll(/export\s*\*\s*(?:as\s+\w+\s*)?from\s*['"]([^'"]+)['"]/g)){const to=resolveImport(m[1],file);if(to)stars.add(to)}}return{names,stars}}function componentsOf(json){const out=new Set;const visit=v=>{if(Array.isArray(v))v.forEach(visit);else if(v&&typeof v==="object"){if(typeof v.component==="string")out.add(v.component);Object.values(v).forEach(visit)}};visit(json);return out}async function selectChanged({root,base,config,fixtureFiles,pageFiles,pageKey}){const found=changedFiles(root,base);const result={base,changed:found.files??[],all:null,fixtures:new Map,pages:new Map};if(found.error)return{...result,all:found.error};const barrelFiles=[...new Set([...config.lineages.map(l=>l.entry),...existsSync(join(root,"src"))?readdirSync(join(root,"src")).filter(f=>/^index[^/]*\.[cm]?tsx?$/.test(f)&&!/\.test\./.test(f)).map(f=>`src/${f}`):[]])];const barrels=new Set(barrelFiles);for(const path of result.changed){const why=globalReason(path,{root,barrels});if(why)return{...result,all:why}}const graph=await importGraph(root,config);const changedAbs=new Set(result.changed.map(p=>resolve(root,p)));const hit=file=>{for(const f of graph.closure(file))if(changedAbs.has(f))return relative(root,f);return null};
|
|
1
|
+
import{execFileSync}from"node:child_process";import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve,sep}from"node:path";import{compilerOptions,loadTypeScript}from"../lib/shared-lineages.mjs";const SOURCE=/\.(?:[cm]?[jt]sx?)$/;const EXTENSIONS=[".ts",".tsx",".mts",".js",".jsx",".mjs"];const git=(root,args)=>execFileSync("git",["-C",root,...args],{encoding:"utf8",stdio:["ignore","pipe","pipe"]});const lines=s=>s.split("\n").map(l=>l.trim()).filter(Boolean);function changedFiles(root,base){try{git(root,["rev-parse","--verify","--quiet",`${base}^{commit}`]);git(root,["merge-base",base,"HEAD"])}catch{return{error:`can't resolve "${base}" here (a shallow clone? fetch it: git fetch origin develop, or check out with fetch-depth: 0)`}}const files=new Set([...lines(git(root,["diff","--name-only","--relative",`${base}...HEAD`])),...lines(git(root,["diff","--name-only","--relative","HEAD"])),...lines(git(root,["ls-files","--others","--exclude-standard"]))]);return{files:[...files].sort()}}function globalReason(path,{root,barrels}){const name=path.split("/").pop();if(name==="package.json"||/^(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/.test(name))return`${path} (dependencies)`;if(/^engine\.config\.[cm]?[jt]s$/.test(path))return`${path} (the engine config)`;if(/^tsconfig[^/]*\.json$/.test(path)||/^vite[^/]*\.[cm]?[jt]s$/.test(path))return`${path} (build config)`;if(path.startsWith(".github/"))return`${path} (CI)`;if(path.startsWith("test/harness/"))return`${path} (the harness)`;if(path.startsWith("src/theme/")||path.startsWith("tokens/")||path.includes("/tokens/"))return`${path} (theme or tokens)`;if(path.split("/").includes("_shared"))return`${path} (a _shared helper)`;if(barrels.has(path))return`${path} (a barrel)`;if(path.endsWith(".css")){const stem=join(root,path.slice(0,-".css".length));if(!EXTENSIONS.some(e=>existsSync(stem+e)))return`${path} (CSS that isn't one component's)`}if(path.startsWith("src/")&&(SOURCE.test(path)||path.endsWith(".css"))&&!existsSync(join(root,path)))return`${path} (a source file was deleted)`;return null}async function importGraph(root,config){const ts=await loadTypeScript();const options=ts?compilerOptions(ts,root):null;const aliases=[["@",resolve(root,"src")],...Object.entries(config.harness.alias??{}).map(([k,v])=>[k,resolve(root,v)])];const asFile=p=>{if(existsSync(p)&&statSync(p).isFile())return p;for(const e of EXTENSIONS)if(existsSync(p+e))return p+e;const bare=p.replace(/\.[cm]?jsx?$/,"");if(bare!==p){for(const e of EXTENSIONS)if(existsSync(bare+e))return bare+e}for(const e of EXTENSIONS)if(existsSync(join(p,"index"+e)))return join(p,"index"+e);return null};const resolveImport=(spec,from)=>{if(spec.startsWith("."))return asFile(resolve(dirname(from),spec));for(const[find,to]of aliases){if(spec===find)return asFile(to);if(find==="@"&&spec.startsWith("@/"))return asFile(join(to,spec.slice(2)))}if(!ts)return null;const r=ts.resolveModuleName(spec,from,options,ts.sys).resolvedModule;return r?.resolvedFileName&&!r.isExternalLibraryImport&&!r.resolvedFileName.includes(`${sep}node_modules${sep}`)?r.resolvedFileName:null};const specsOf=(file,text)=>{if(file.endsWith(".css"))return[...text.matchAll(/@import\s+(?:url\()?\s*['"]([^'"]+)['"]/g)].map(m=>m[1]);if(ts)return ts.preProcessFile(text,true,true).importedFiles.map(f=>f.fileName);return[...text.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g)].map(m=>m[1])};const edges=new Map;const depsOf=file=>{if(!edges.has(file)){let text="";try{text=readFileSync(file,"utf8")}catch{}const deps=new Set;if(SOURCE.test(file)||file.endsWith(".css"))for(const s of specsOf(file,text)){const to=resolveImport(s,file);if(to&&to.startsWith(root+sep))deps.add(to)}edges.set(file,deps)}return edges.get(file)};const closure=file=>{const seen=new Set;const stack=[file];while(stack.length){const f=stack.pop();if(seen.has(f))continue;seen.add(f);for(const d of depsOf(f))stack.push(d)}return seen};return{closure,resolveImport}}function exportMap(files,resolveImport){const names=new Map;const stars=new Set;for(const file of files){const text=readFileSync(file,"utf8");for(const m of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)){const to=resolveImport(m[2],file);if(!to)continue;for(const part of m[1].split(",")){const name=part.trim().split(/\s+as\s+/).pop()?.trim();if(name&&!names.has(name))names.set(name,to)}}for(const m of text.matchAll(/export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)/g))if(!names.has(m[1]))names.set(m[1],file);for(const m of text.matchAll(/export\s*\*\s*(?:as\s+\w+\s*)?from\s*['"]([^'"]+)['"]/g)){const to=resolveImport(m[1],file);if(to)stars.add(to)}}return{names,stars}}function componentsOf(json){const out=new Set;const visit=v=>{if(Array.isArray(v))v.forEach(visit);else if(v&&typeof v==="object"){if(typeof v.component==="string")out.add(v.component);Object.values(v).forEach(visit)}};visit(json);return out}async function selectChanged({root,base,config,fixtureFiles,pageFiles,pageKey}){const found=changedFiles(root,base);const result={base,changed:found.files??[],all:null,fixtures:new Map,pages:new Map};if(found.error)return{...result,all:found.error};const barrelFiles=[...new Set([...config.lineages.map(l=>l.entry),...existsSync(join(root,"src"))?readdirSync(join(root,"src")).filter(f=>/^index[^/]*\.[cm]?tsx?$/.test(f)&&!/\.test\./.test(f)).map(f=>`src/${f}`):[]])];const barrels=new Set(barrelFiles);for(const path of result.changed){const why=globalReason(path,{root,barrels});if(why)return{...result,all:why};const assets=config.harness.publicAssets.replace(/\/+$/,"");if(path.startsWith(`${assets}/`))return{...result,all:`${path} (public assets: a public page may name it)`}}const graph=await importGraph(root,config);const changedAbs=new Set(result.changed.map(p=>resolve(root,p)));const hit=file=>{for(const f of graph.closure(file))if(changedAbs.has(f))return relative(root,f);return null};for(const configured of[config.harness.wrapper,config.harness.publicWrapper]){if(!configured)continue;const why=hit(resolve(root,configured.split("#")[0]));if(why)return{...result,all:`${why} (the harness wrapper imports it)`}}const local=Object.values(config.harness.alias??{}).map(p=>resolve(root,p));const{names,stars}=exportMap([...barrelFiles.map(f=>resolve(root,f)),...local].filter(f=>existsSync(f)),graph.resolveImport);const componentHit=new Map;const componentChanged=name=>{if(!componentHit.has(name)){let why=null;if(names.has(name))why=hit(names.get(name));else for(const s of stars)if(why=hit(s))break;componentHit.set(name,why)}return componentHit.get(name)};for(const file of fixtureFiles){const own=hit(file);const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const beside=EXTENSIONS.map(e=>join(dirname(file),name+e)).find(f=>existsSync(f));const why=own??componentChanged(name)??(beside&&!names.has(name)?hit(beside):null);if(why)result.fixtures.set(file,why===relative(root,file)?`its fixtures changed (${why})`:`${why} changed`)}for(const file of pageFiles){const key=pageKey(file);const own=["page.json","mock.json"].map(f=>relative(root,join(dirname(file),f))).find(p=>result.changed.includes(p));if(own){result.pages.set(key,`${own} changed`);continue}let json;try{json=JSON.parse(readFileSync(file,"utf8"))}catch{result.pages.set(key,"its page.json does not parse");continue}for(const name of componentsOf(json)){const why=componentChanged(name);if(why){result.pages.set(key,`names ${name} (${why} changed)`);break}}}return result}export{changedFiles,selectChanged};
|
package/src/harness/plugin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{LAYER_ORDER}from"../lib/layers.mjs";const HERE=dirname(fileURLToPath(import.meta.url));const APP=["app.tsx","app.mjs"].map(f=>resolve(HERE,f)).find(f=>existsSync(f));const ENTRY="virtual:engine-harness/entry";const HARNESS_PATH="/__engine/harness.html";const HTML=`<!doctype html>
|
|
1
|
+
import{createReadStream,existsSync}from"node:fs";import{dirname,extname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{LAYER_ORDER}from"../lib/layers.mjs";import{CONTENT_TYPES,publicAssetFile}from"./public-assets.mjs";const HERE=dirname(fileURLToPath(import.meta.url));const APP=["app.tsx","app.mjs"].map(f=>resolve(HERE,f)).find(f=>existsSync(f));const ENTRY="virtual:engine-harness/entry";const HARNESS_PATH="/__engine/harness.html";const HTML=`<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
@@ -11,4 +11,4 @@ import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fi
|
|
|
11
11
|
<div id="root"></div>
|
|
12
12
|
<script type="module" src="/@id/${ENTRY}"><\/script>
|
|
13
13
|
</body>
|
|
14
|
-
</html>`;function entryCode({wrapperPath=null,wrapperExport="default",appPath="/@fs"+APP,fsPrefix="/@fs",shared=[]}={}){return[`import { mountHarness } from ${JSON.stringify(appPath)};`,wrapperPath?`import { ${wrapperExport} as Wrapper } from ${JSON.stringify(fsPrefix+wrapperPath)};`:"const Wrapper = undefined;",...shared.flatMap((s,i)=>[`import * as __shared${i} from ${JSON.stringify(s.specifier)};`,...s.css?[`import ${JSON.stringify(fsPrefix+s.css)};`]:[]]),`const shared = [${shared.map((s,i)=>`{ specifier: ${JSON.stringify(s.specifier)}, module: __shared${i} }`).join(", ")}];`,`const fixtures = import.meta.glob('/src/**/*.fixtures.{ts,tsx}', { eager: true });`,`const barrels = import.meta.glob(['/src/index.ts', '/src/index.*.ts', '!/src/index.*.test.ts'], { eager: true });`,`const pages = import.meta.glob(['/src/pages/*/page.json', '/src/project_templates/*/pages/*/page.json'], { eager: true, import: 'default' });`,`const mocks = import.meta.glob(['/src/pages/*/mock.json', '/src/project_templates/*/pages/*/mock.json'], { eager: true, import: 'default' });`,`mountHarness({ fixtures, barrels, pages, mocks, Wrapper, shared });`].join("\n")}function engineHarness({root,wrapper=null,shared=[]}){const[wrapperFile,wrapperExport="default"]=wrapper?wrapper.split("#"):[];const wrapperPath=wrapperFile?resolve(root,wrapperFile):null;return{name:"engine-harness",resolveId(id){return id===ENTRY?`\0${ENTRY}`:void 0},load(id){return id===`\0${ENTRY}`?entryCode({wrapperPath,wrapperExport,shared}):void 0},configureServer(server){server.middlewares.use(async(req,res,next)=>{if(!req.url||!req.url.startsWith(HARNESS_PATH))return next();const html=await server.transformIndexHtml(req.url,HTML);res.setHeader("content-type","text/html; charset=utf-8");res.end(html)})}}}export{APP,HARNESS_PATH,HTML,engineHarness,entryCode};
|
|
14
|
+
</html>`;function entryCode({wrapperPath=null,wrapperExport="default",publicWrapperPath=null,publicWrapperExport="default",appPath="/@fs"+APP,fsPrefix="/@fs",shared=[]}={}){return[`import { mountHarness } from ${JSON.stringify(appPath)};`,wrapperPath?`import { ${wrapperExport} as Wrapper } from ${JSON.stringify(fsPrefix+wrapperPath)};`:"const Wrapper = undefined;",publicWrapperPath?`import { ${publicWrapperExport} as PublicWrapper } from ${JSON.stringify(fsPrefix+publicWrapperPath)};`:"const PublicWrapper = undefined;",...shared.flatMap((s,i)=>[`import * as __shared${i} from ${JSON.stringify(s.specifier)};`,...s.css?[`import ${JSON.stringify(fsPrefix+s.css)};`]:[]]),`const shared = [${shared.map((s,i)=>`{ specifier: ${JSON.stringify(s.specifier)}, module: __shared${i} }`).join(", ")}];`,`const fixtures = import.meta.glob('/src/**/*.fixtures.{ts,tsx}', { eager: true });`,`const barrels = import.meta.glob(['/src/index.ts', '/src/index.*.ts', '!/src/index.*.test.ts'], { eager: true });`,`const pages = import.meta.glob(['/src/pages/*/page.json', '/src/project_templates/*/pages/*/page.json'], { eager: true, import: 'default' });`,`const mocks = import.meta.glob(['/src/pages/*/mock.json', '/src/project_templates/*/pages/*/mock.json'], { eager: true, import: 'default' });`,`mountHarness({ fixtures, barrels, pages, mocks, Wrapper, PublicWrapper, shared });`].join("\n")}function engineHarness({root,wrapper=null,publicWrapper=null,publicAssets=null,shared=[]}){const[wrapperFile,wrapperExport="default"]=wrapper?wrapper.split("#"):[];const wrapperPath=wrapperFile?resolve(root,wrapperFile):null;const[publicFile,publicWrapperExport="default"]=publicWrapper?publicWrapper.split("#"):[];const publicWrapperPath=publicFile?resolve(root,publicFile):null;const assetsDir=publicAssets?resolve(root,publicAssets):null;return{name:"engine-harness",resolveId(id){return id===ENTRY?`\0${ENTRY}`:void 0},load(id){return id===`\0${ENTRY}`?entryCode({wrapperPath,wrapperExport,publicWrapperPath,publicWrapperExport,shared}):void 0},configureServer(server){server.middlewares.use((req,res,next)=>{const file=assetsDir&&publicAssetFile(assetsDir,req.url);if(!file)return next();res.setHeader("content-type",CONTENT_TYPES[extname(file)]??"application/octet-stream");createReadStream(file).pipe(res)});server.middlewares.use(async(req,res,next)=>{if(!req.url||!req.url.startsWith(HARNESS_PATH))return next();const html=await server.transformIndexHtml(req.url,HTML);res.setHeader("content-type","text/html; charset=utf-8");res.end(html)})}}}export{APP,HARNESS_PATH,HTML,engineHarness,entryCode};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync,statSync}from"node:fs";import{extname,join,normalize}from"node:path";const PUBLIC_ASSETS_URL="/__public-assets/";const CONTENT_TYPES={".html":"text/html; charset=utf-8",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".webp":"image/webp",".gif":"image/gif",".ico":"image/x-icon",".avif":"image/avif",".woff2":"font/woff2",".woff":"font/woff",".mp4":"video/mp4",".webm":"video/webm",".pdf":"application/pdf"};function publicAssetFile(dir,url){const pathname=(url??"").split("?")[0];if(!pathname.startsWith(PUBLIC_ASSETS_URL))return null;let rel;try{rel=normalize(decodeURIComponent(pathname.slice(PUBLIC_ASSETS_URL.length)))}catch{return null}if(!rel||rel.startsWith("..")||rel.includes("\0"))return null;const file=join(dir,rel);return file.startsWith(join(dir,"/"))&&existsSync(file)&&!statSync(file).isDirectory()?file:null}export{CONTENT_TYPES,PUBLIC_ASSETS_URL,publicAssetFile};
|
package/src/harness/server.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createReadStream,existsSync,mkdirSync,realpathSync,statSync,writeFileSync}from"node:fs";import{createServer as createHttpServer}from"node:http";import{createRequire}from"node:module";import{dirname,extname,join,normalize,resolve}from"node:path";import{fileURLToPath,pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{engineHarness}from"./plugin.mjs";const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const importFromRepo=name=>import(pathToFileURL(fromRepo.resolve(name)).href);const escape=s=>s.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&");async function harnessShared(config){const loaded=await loadSharedLineages(config);const shared=[];const alias=[{find:"@",replacement:resolve(REPO_ROOT,"src")},...Object.entries(config.harness.alias).map(([find,to])=>({find,replacement:resolve(REPO_ROOT,to)}))];const dirs=[];for(const specifier of loaded.specifiers){const aliased=specifier in config.harness.alias;const found=loaded.lineages.find(l=>l.specifier===specifier);if(!aliased&&!found)throw new Error(`[harness] ${loaded.error??unresolvedMessage(specifier)}`);if(!aliased&&!found.external&&!/\.d\.[cm]?ts$/.test(found.file)){alias.push({find:new RegExp(`^${escape(specifier)}$`),replacement:found.file})}if(!aliased&&found.packageDir)dirs.push(found.packageDir,realpathSync(found.packageDir));shared.push({specifier,css:!aliased&&found.external?found.css:null})}return{shared,alias,dirs}}async function createHarnessServer({port=0,open=false}={}){const config=await loadConfig();const{shared,alias,dirs}=await harnessShared(config);const{createServer}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const server=await createServer({configFile:false,root:REPO_ROOT,logLevel:"warn",plugins:[react(),engineHarness({root:REPO_ROOT,wrapper:config.harness.wrapper,shared})],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org)},optimizeDeps:{include:["react","react-dom/client","@peripheries/renderer/core","@peripheries/renderer/react","@peripheries/renderer/runtime","@peripheries/renderer/testing"]},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias},server:{port,strictPort:Boolean(port),open,fs:{allow:[REPO_ROOT,dirname(fileURLToPath(import.meta.url)),...dirs]}}});await server.listen();const address=server.httpServer?.address();if(!address||typeof address==="string")throw new Error("[harness] the Vite server did not bind a port");return{server,origin:`http://localhost:${address.port}`}}async function buildAndServeHarness(){const config=await loadConfig();const{shared,alias}=await harnessShared(config);const{build}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const{entryCode,HTML,APP}=await import("./plugin.mjs");const cache=resolve(REPO_ROOT,"node_modules/.cache/engine-harness");const outDir=resolve(cache,"dist");mkdirSync(cache,{recursive:true});const[wrapperFile,wrapperExport="default"]=config.harness.wrapper?config.harness.wrapper.split("#"):[];writeFileSync(resolve(cache,"entry.mjs"),entryCode({wrapperPath:wrapperFile?resolve(REPO_ROOT,wrapperFile):null,wrapperExport,appPath:APP,fsPrefix:"",shared}));writeFileSync(resolve(cache,"harness.html"),HTML.replace(/<script type="module" src="[^"]*"><\/script>/,'<script type="module" src="./entry.mjs"><\/script>'));await build({configFile:false,root:REPO_ROOT,mode:"development",logLevel:"warn",plugins:[react()],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org),"process.env.NODE_ENV":JSON.stringify("development")},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias},build:{outDir,emptyOutDir:true,minify:false,sourcemap:false,rollupOptions:{input:resolve(cache,"harness.html")}}});const
|
|
1
|
+
import{createReadStream,existsSync,mkdirSync,realpathSync,statSync,writeFileSync}from"node:fs";import{createServer as createHttpServer}from"node:http";import{createRequire}from"node:module";import{dirname,extname,join,normalize,resolve}from"node:path";import{fileURLToPath,pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{engineHarness}from"./plugin.mjs";import{CONTENT_TYPES,publicAssetFile}from"./public-assets.mjs";const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const importFromRepo=name=>import(pathToFileURL(fromRepo.resolve(name)).href);const escape=s=>s.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&");async function harnessShared(config){const loaded=await loadSharedLineages(config);const shared=[];const alias=[{find:"@",replacement:resolve(REPO_ROOT,"src")},...Object.entries(config.harness.alias).map(([find,to])=>({find,replacement:resolve(REPO_ROOT,to)}))];const dirs=[];for(const specifier of loaded.specifiers){const aliased=specifier in config.harness.alias;const found=loaded.lineages.find(l=>l.specifier===specifier);if(!aliased&&!found)throw new Error(`[harness] ${loaded.error??unresolvedMessage(specifier)}`);if(!aliased&&!found.external&&!/\.d\.[cm]?ts$/.test(found.file)){alias.push({find:new RegExp(`^${escape(specifier)}$`),replacement:found.file})}if(!aliased&&found.packageDir)dirs.push(found.packageDir,realpathSync(found.packageDir));shared.push({specifier,css:!aliased&&found.external?found.css:null})}return{shared,alias,dirs}}async function createHarnessServer({port=0,open=false}={}){const config=await loadConfig();const{shared,alias,dirs}=await harnessShared(config);const{createServer}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const server=await createServer({configFile:false,root:REPO_ROOT,logLevel:"warn",plugins:[react(),engineHarness({root:REPO_ROOT,wrapper:config.harness.wrapper,publicWrapper:config.harness.publicWrapper,publicAssets:config.harness.publicAssets,shared})],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org)},optimizeDeps:{include:["react","react-dom/client","@peripheries/renderer/core","@peripheries/renderer/react","@peripheries/renderer/runtime","@peripheries/renderer/testing"]},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias},server:{port,strictPort:Boolean(port),open,fs:{allow:[REPO_ROOT,dirname(fileURLToPath(import.meta.url)),...dirs]}}});await server.listen();const address=server.httpServer?.address();if(!address||typeof address==="string")throw new Error("[harness] the Vite server did not bind a port");return{server,origin:`http://localhost:${address.port}`}}async function buildAndServeHarness(){const config=await loadConfig();const{shared,alias}=await harnessShared(config);const{build}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const{entryCode,HTML,APP}=await import("./plugin.mjs");const cache=resolve(REPO_ROOT,"node_modules/.cache/engine-harness");const outDir=resolve(cache,"dist");mkdirSync(cache,{recursive:true});const[wrapperFile,wrapperExport="default"]=config.harness.wrapper?config.harness.wrapper.split("#"):[];const[publicFile,publicWrapperExport="default"]=config.harness.publicWrapper?config.harness.publicWrapper.split("#"):[];const assetsDir=resolve(REPO_ROOT,config.harness.publicAssets);writeFileSync(resolve(cache,"entry.mjs"),entryCode({wrapperPath:wrapperFile?resolve(REPO_ROOT,wrapperFile):null,wrapperExport,publicWrapperPath:publicFile?resolve(REPO_ROOT,publicFile):null,publicWrapperExport,appPath:APP,fsPrefix:"",shared}));writeFileSync(resolve(cache,"harness.html"),HTML.replace(/<script type="module" src="[^"]*"><\/script>/,'<script type="module" src="./entry.mjs"><\/script>'));await build({configFile:false,root:REPO_ROOT,mode:"development",logLevel:"warn",plugins:[react()],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org),"process.env.NODE_ENV":JSON.stringify("development")},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias},build:{outDir,emptyOutDir:true,minify:false,sourcemap:false,rollupOptions:{input:resolve(cache,"harness.html")}}});const server=createHttpServer((req,res)=>{const asset=publicAssetFile(assetsDir,req.url);if(asset){res.setHeader("content-type",CONTENT_TYPES[extname(asset)]??"application/octet-stream");return createReadStream(asset).pipe(res)}const path=normalize(decodeURIComponent((req.url??"/").split("?")[0])).replace(/^(\.\.[/\\])+/,"");const file=join(outDir,path);if(!file.startsWith(outDir)||!existsSync(file)||statSync(file).isDirectory()){res.statusCode=404;return res.end("not found")}res.setHeader("content-type",CONTENT_TYPES[extname(file)]??"application/octet-stream");createReadStream(file).pipe(res)});await new Promise(r=>server.listen(0,"127.0.0.1",r));const{port}=server.address();return{origin:`http://127.0.0.1:${port}`,path:"/node_modules/.cache/engine-harness/harness.html",close:()=>new Promise(r=>server.close(r))}}export{buildAndServeHarness,createHarnessServer,harnessShared};
|
package/src/index.d.mts
CHANGED
|
@@ -21,9 +21,12 @@ export type EngineConfig = {
|
|
|
21
21
|
shared?: string[];
|
|
22
22
|
shadows?: Record<string, string>;
|
|
23
23
|
hostScreens?: Record<string, string>;
|
|
24
|
+
externalComponents?: Record<string, string>;
|
|
24
25
|
audit?: { allowNames?: Record<string, string> };
|
|
25
26
|
harness?: {
|
|
26
27
|
wrapper?: string;
|
|
28
|
+
publicWrapper?: string;
|
|
29
|
+
publicAssets?: string;
|
|
27
30
|
widths?: number[];
|
|
28
31
|
container?: number;
|
|
29
32
|
overflow?: 'warn' | 'error';
|
|
@@ -79,7 +82,8 @@ export type LoadedConfig = {
|
|
|
79
82
|
};
|
|
80
83
|
shadows: Record<string, string>;
|
|
81
84
|
hostScreens: Record<string, string>;
|
|
82
|
-
|
|
85
|
+
externalComponents: Record<string, string>;
|
|
86
|
+
harness: { wrapper: string | null; publicWrapper: string | null; publicAssets: string; widths: number[]; container: number; overflow: 'warn' | 'error'; output: string; alias: Record<string, string> };
|
|
83
87
|
assets: { maxAssetBytes: number; maxTotalBytes: number };
|
|
84
88
|
audit: { allowNames: Record<string, string> };
|
|
85
89
|
};
|
package/src/lib/config.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{DEFAULT_ASSET_LIMITS}from"./assets.mjs";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","check-tokens","check-css-scope","check-inline-styles","check-shadowing","check-theme","check-inline-assets","check-page-reads","knowledge"];function budgetVerdict(name,count,budget){if(budget===void 0||budget===null)return{ok:true,message:null};if(count>budget)return{ok:false,message:`${count} finding(s), over the budget of ${budget}: the count may only fall (checks.budgets['${name}'])`};if(count<budget)return{ok:true,message:`${count} finding(s), under the budget of ${budget}: lower checks.budgets['${name}'] to ${count}`};return{ok:true,message:`${count} finding(s), at the budget`}}const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:k==="shared"?`${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});const mainAt=lineages.findIndex(l=>l.name===MAIN_LINEAGE);if(mainAt>0)throw new Error(`[engine] ${origin}: main must be the first lineage`);if(mainAt===-1&&!lineages.every(l=>l.kind==="shared")){throw new Error(`[engine] ${origin}: lineages must start with "main", unless every lineage is shared`)}for(const l of lineages){if(l.kind==="shared"&&!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(l.specifier??"")){throw new Error(`[engine] ${origin}: shared lineage "${l.name}" needs a specifier like "@stack/${l.name}"`)}}const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]},budgets:raw.checks?.budgets??{},propsSchema:{families:raw.checks?.propsSchema?.families??null,names:raw.checks?.propsSchema?.names??[],floor:raw.checks?.propsSchema?.floor??1,propless:raw.checks?.propsSchema?.propless??[]}};const shadows=raw.shadows??{};const hostScreens=raw.hostScreens??{};if(hostScreens===null||typeof hostScreens!=="object"||Array.isArray(hostScreens))throw new Error(`[engine] ${origin}: hostScreens must be an object of name \u2192 reason`);for(const[name,reason]of Object.entries(hostScreens)){if(!/^[A-Za-z][A-Za-z0-9_]*$/.test(name))throw new Error(`[engine] ${origin}: hostScreens["${name}"] is not a component name`);if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: hostScreens["${name}"] needs a reason`)}const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};const assets={maxAssetBytes:raw.assets?.maxAssetBytes??DEFAULT_ASSET_LIMITS.maxAssetBytes,maxTotalBytes:raw.assets?.maxTotalBytes??DEFAULT_ASSET_LIMITS.maxTotalBytes};for(const[k,v]of Object.entries(assets)){if(!Number.isInteger(v)||v<=0)throw new Error(`[engine] ${origin}: assets.${k} must be a positive number of bytes`)}const audit={allowNames:raw.audit?.allowNames??{}};for(const[name,reason]of Object.entries(audit.allowNames)){if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: audit.allowNames["${name}"] needs a reason`)}return{org:raw.org,lineages,checks,shared,shadows,hostScreens,harness,assets,audit}}const normalizeForTest=raw=>normalize(raw,"test");let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,budgetVerdict,defineConfig,lineageConfig,lineageDefaults,loadConfig,normalizeForTest};
|
|
1
|
+
import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{DEFAULT_ASSET_LIMITS}from"./assets.mjs";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","check-tokens","check-css-scope","check-inline-styles","check-shadowing","check-theme","check-inline-assets","check-page-reads","knowledge"];function budgetVerdict(name,count,budget){if(budget===void 0||budget===null)return{ok:true,message:null};if(count>budget)return{ok:false,message:`${count} finding(s), over the budget of ${budget}: the count may only fall (checks.budgets['${name}'])`};if(count<budget)return{ok:true,message:`${count} finding(s), under the budget of ${budget}: lower checks.budgets['${name}'] to ${count}`};return{ok:true,message:`${count} finding(s), at the budget`}}const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:k==="shared"?`${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});const mainAt=lineages.findIndex(l=>l.name===MAIN_LINEAGE);if(mainAt>0)throw new Error(`[engine] ${origin}: main must be the first lineage`);if(mainAt===-1&&!lineages.every(l=>l.kind==="shared")){throw new Error(`[engine] ${origin}: lineages must start with "main", unless every lineage is shared`)}for(const l of lineages){if(l.kind==="shared"&&!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(l.specifier??"")){throw new Error(`[engine] ${origin}: shared lineage "${l.name}" needs a specifier like "@stack/${l.name}"`)}}const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]},budgets:raw.checks?.budgets??{},propsSchema:{families:raw.checks?.propsSchema?.families??null,names:raw.checks?.propsSchema?.names??[],floor:raw.checks?.propsSchema?.floor??1,propless:raw.checks?.propsSchema?.propless??[]}};const shadows=raw.shadows??{};const hostScreens=raw.hostScreens??{};if(hostScreens===null||typeof hostScreens!=="object"||Array.isArray(hostScreens))throw new Error(`[engine] ${origin}: hostScreens must be an object of name \u2192 reason`);for(const[name,reason]of Object.entries(hostScreens)){if(!/^[A-Za-z][A-Za-z0-9_]*$/.test(name))throw new Error(`[engine] ${origin}: hostScreens["${name}"] is not a component name`);if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: hostScreens["${name}"] needs a reason`)}const externalComponents=raw.externalComponents??{};if(externalComponents===null||typeof externalComponents!=="object"||Array.isArray(externalComponents))throw new Error(`[engine] ${origin}: externalComponents must be an object of specifier \u2192 file`);for(const[specifier,file]of Object.entries(externalComponents)){if(!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(specifier))throw new Error(`[engine] ${origin}: externalComponents["${specifier}"] is not a specifier like "@stack/platform"`);if(typeof file!=="string"||!file.trim())throw new Error(`[engine] ${origin}: externalComponents["${specifier}"] needs the path of a JSON file of names`)}const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,publicWrapper:raw.harness?.publicWrapper??null,publicAssets:raw.harness?.publicAssets??"template/data/storage/public-assets",widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};const assets={maxAssetBytes:raw.assets?.maxAssetBytes??DEFAULT_ASSET_LIMITS.maxAssetBytes,maxTotalBytes:raw.assets?.maxTotalBytes??DEFAULT_ASSET_LIMITS.maxTotalBytes};for(const[k,v]of Object.entries(assets)){if(!Number.isInteger(v)||v<=0)throw new Error(`[engine] ${origin}: assets.${k} must be a positive number of bytes`)}const audit={allowNames:raw.audit?.allowNames??{}};for(const[name,reason]of Object.entries(audit.allowNames)){if(typeof reason!=="string"||!reason.trim())throw new Error(`[engine] ${origin}: audit.allowNames["${name}"] needs a reason`)}return{org:raw.org,lineages,checks,shared,shadows,hostScreens,externalComponents,harness,assets,audit}}const normalizeForTest=raw=>normalize(raw,"test");let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,budgetVerdict,defineConfig,lineageConfig,lineageDefaults,loadConfig,normalizeForTest};
|
package/src/template/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import{execSync}from"node:child_process";function parseArgs(argv){const flags=ne
|
|
|
5
5
|
npm run template -- sync --layer dev-users --env ${this.options.targetEnv}`});skipped++;continue}const purged=new Set;if(this.options.replaceExisting){const typeIds=new Set;for(const entry of def.entries){const t=await this.resolveDataTypeId(entry.data_type);if(t)typeIds.add(t.id)}if(typeIds.size>0){const{data:stale,error:staleError}=await this.options.supabase.from("client_data").select("id, name").eq("client_id",clientId).in("data_type_id",[...typeIds]);if(staleError)throw new Error(`client_data lookup failed: ${staleError.message}`);for(const row of stale??[]){if(!this.options.dryRun){const{error:deleteError}=await this.options.supabase.from("client_data").delete().eq("id",row.id);if(deleteError)throw new Error(`client_data delete failed: ${deleteError.message}`)}purged.add(row.id);updated++;entities.push({entity:row.name,action:"updated",id:row.id,reason:this.options.dryRun?"dry run \u2014 would DELETE (replace-existing)":"deleted (replace-existing)"})}}}for(const entry of def.entries){const dataType=await this.resolveDataTypeId(entry.data_type);if(!dataType){entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"skipped",id:null,reason:`no client_data_type named "${entry.data_type}". That row belongs to the data-types LAYER, not this one, and this layer deliberately does not create it:
|
|
6
6
|
npm run template -- sync --layer data-types --env ${this.options.targetEnv}`});skipped++;continue}const hasJson=entry.json_content!==void 0;const hasText=entry.content!==void 0;if(hasJson===hasText){throw new Error(`${entry.data_type} / ${entry.name}: supply exactly one of json_content or content (got ${hasJson?"both":"neither"}).`)}if(dataType.format==="json"&&!hasJson){throw new Error(`${entry.data_type} / ${entry.name}: data type declares data_format "json" but the fixture supplies \`content\`. A JSON reader looks at json_content and would find null.`)}if(dataType.format&&dataType.format!=="json"&&!hasText){throw new Error(`${entry.data_type} / ${entry.name}: data type declares data_format "${dataType.format}" but the fixture supplies \`json_content\`.`)}const{data:existingRows,error:existingError}=await this.options.supabase.from("client_data").select("id").eq("client_id",clientId).eq("data_type_id",dataType.id).eq("name",entry.name).limit(1);if(existingError)throw new Error(`client_data lookup failed: ${existingError.message}`);const found=(existingRows??[])[0];const existing=found&&!purged.has(found.id)?found:void 0;if(existing){entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"skipped",id:existing.id,reason:"artefact already exists \u2014 client_data is append-only history, never rewritten"});skipped++;continue}if(this.options.dryRun){entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"created",id:null,reason:"dry run"});created++;continue}const{data:inserted,error:insertError}=await this.options.supabase.from("client_data").insert({client_id:clientId,data_type_id:dataType.id,org_id:this.options.orgId,name:entry.name,description:entry.description??null,json_content:entry.json_content??null,content:entry.content??null}).select("id").single();if(insertError)throw new Error(`client_data insert failed: ${insertError.message}`);created++;entities.push({entity:`${entry.data_type} / ${entry.name}`,action:"created",id:inserted.id})}}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created,updated,skipped,entities,errors,duration_ms:Date.now()-started}}};import{existsSync as existsSync5,readdirSync as readdirSync3,readFileSync as readFileSync3}from"node:fs";import{join as join6}from"node:path";import{createClient as createClient3}from"@supabase/supabase-js";function answerValue(type,raw){if(type==="number"){const n=typeof raw==="number"?raw:Number(raw);if(Number.isNaN(n))throw new Error(`a number question, but "${raw}" isn't a number`);return{v:1,value:n}}if(type==="multiselect")return{v:1,value:String(raw).split(",").map(x=>x.trim()).filter(Boolean)};return{v:1,value:String(raw)}}async function seedWorkbooks(port,member,templates){const out=[];for(const wb of member.workbooks){const entity=`${member.client} / ${wb.template_name}`;const t=templates.get(wb.template_name);if(!t)throw new Error(`${entity}: no released form template is named "${wb.template_name}" (names are exact)`);const existing=await port.existing(member.client,t.templateKey);if(existing){out.push({entity,action:"skipped",id:existing,reason:"the client already has this workbook; fixtures never overwrite a member\u2019s answers"});continue}const form=await port.instantiate(member.client,member.project,t.templateKey);for(const a of wb.answers??[]){const node=form.questions[a.field_name];const type=t.types.get(a.field_name);if(!node||!type)throw new Error(`${entity}: the form has no question "${a.field_name}"`);await port.answer(member.client,node,a.field_name,answerValue(type,a.value))}const submitted=wb.completed===true||wb.status==="submitted"||wb.status==="completed";if(submitted)await port.submit(member.client,form.structureNodeId);if(wb.completed===true||wb.status==="completed")await port.complete(form.structureNodeId);out.push({entity,action:"created",id:form.structureNodeId,reason:`${wb.answers?.length??0} answer(s)${submitted?", submitted":""}${wb.completed?", completed":""}`})}return out}var DevWorkbookManager=class extends LayerManager{get shape(){return{fields:{client:{type:"string",required:true,minLength:1},project:{type:"string"},workbooks:{type:"array",required:true}},ignored:["_comment"]}}naturalKeyOf(def){return def.client}naturalKeyFilter(def){return{org_id:this.options.orgId,client:def.client}}toRow(){throw new Error("DevWorkbookManager writes via syncAll()")}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const clientId=await this.resolveClientId(def.client);if(clientId)out.set(def.client,clientId)}return out}async resolveClientId(reference){const bySlug=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("data->>slug",reference).limit(2);if(bySlug.error)throw new Error(`client lookup failed: ${bySlug.error.message}`);const slugRows=bySlug.data??[];if(slugRows.length>1){throw new Error(`${slugRows.length} clients have data->>slug="${reference}" \u2014 ambiguous.`)}if(slugRows[0])return slugRows[0].id;const byName=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("name",reference).limit(2);if(byName.error)throw new Error(`client lookup failed: ${byName.error.message}`);const nameRows=byName.data??[];if(nameRows.length>1){throw new Error(`${nameRows.length} clients named "${reference}" \u2014 ambiguous.`)}return nameRows[0]?.id??null}async resolveProjectId(name){const{data,error}=await this.options.supabase.from("project").select("id").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`project lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1)throw new Error(`${rows.length} projects named "${name}" \u2014 ambiguous.`);return rows[0]?.id??null}async syncAll(){const started=Date.now();const entities=[];const errors=[];const finish=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:0,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});if(this.options.targetEnv==="prod"){entities.push({entity:"dev-workbooks",action:"skipped",id:null,reason:"dev fixtures never run against prod"});return finish()}if(this.options.replaceExisting){errors.push({entity:"dev-workbooks",error:"--replace-existing is retired: forms are pages now, and a member\u2019s form is never deleted by a fixture"});return finish()}try{const formProcess=loadTemplatesConfig().form_template_process;if(!formProcess)throw new Error("templates.json names no form_template_process: dev workbooks are instantiated from its library release");const users=devUsers();const staffEmail=users.find(u=>u.type==="advisor"&&u.org_memberships?.[0]===this.options.orgSlug&&(u.org_role==="editor"||u.org_role==="owner"))?.email;if(!staffEmail)throw new Error(`seed/dev-users has no advisor of ${this.options.orgSlug} with an org_role (editor or owner): dev staff instantiates the workbooks, which takes edit forms`);const password=devPassword(this.options.targetEnv);if(this.options.dryRun){for(const file of this.files())entities.push({entity:this.loadFile(file).client,action:"skipped",id:null,reason:"dry run"});return finish()}const staff=await devSession(staffEmail,password);const members=new Map;const memberSession=async client=>{const email=users.find(u=>u.type==="client"&&u.client_record?.slug===client)?.email;if(!email)throw new Error(`seed/dev-users has no client "${client}" with a login: its answers are saved in the member's own session`);if(!members.has(client))members.set(client,await devSession(email,password));return members.get(client)};const port=realWorkbookPort({staff,memberSession,orgId:this.options.orgId,formProcess,resolveClient:ref2=>this.resolveClientId(ref2)});const templates=templateIndex();for(const file of this.files()){const def=this.loadFile(file);for(const r of await seedWorkbooks(port,def,templates))entities.push(r)}}catch(error){errors.push({entity:"dev-workbooks",error:error instanceof Error?error.message:String(error)})}return finish()}};function devUsers(){const dir=join6(SEED_DIR,"dev-users");if(!existsSync5(dir))return[];return readdirSync3(dir).filter(f=>f.endsWith(".json")).map(f=>JSON.parse(readFileSync3(join6(dir,f),"utf8")))}async function devSession(email,password){const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_PUBLISHABLE_KEY;if(!url||!key)throw new Error("SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are needed: dev workbooks are written as dev users, never with the secret key");const db=createClient3(url,key,{auth:{persistSession:false,autoRefreshToken:false}});const{error}=await db.auth.signInWithPassword({email,password});if(error)throw new Error(`signing in as ${email} failed: ${error.message} (seed dev-users first)`);return db}function templateIndex(){const out=new Map;const dir=join6(DATA_DIR,"form-templates");if(!existsSync5(dir))return out;for(const f of readdirSync3(dir).filter(x=>x.endsWith(".json"))){const t=JSON.parse(readFileSync3(join6(dir,f),"utf8"));if(!t.template_id)continue;const types=new Map;for(const p of t.pages??[])for(const s of p.sections??[]){for(const fl of s.fields??[])types.set(fl.fieldId,fl.fieldType);if(s.sectionType==="upload"&&s.uploadConfig?.fieldId)types.set(s.uploadConfig.fieldId,"file")}out.set(t.template_name,{templateKey:t.template_id,types})}return out}var must2=(what,r)=>{if(r.error)throw new Error(`${what}: ${r.error.message}${r.error.details?` (${r.error.details})`:""}`);return r.data};function realWorkbookPort(deps){const clientIds=new Map;const clientId=async ref2=>{if(!clientIds.has(ref2)){const id=await deps.resolveClient(ref2);if(!id)throw new Error(`no client matches "${ref2}": seed dev-users first`);clientIds.set(ref2,id)}return clientIds.get(ref2)};const projectOf=async(client,project)=>{if(project){const rows2=must2("project",await deps.staff.from("project").select("id").eq("org_id",deps.orgId).eq("name",project).limit(2));if(rows2.length!==1)throw new Error(`${rows2.length?"more than one project":"no project"} named "${project}"`);return rows2[0].id}const rows=must2("client_project",await deps.staff.from("client_project").select("project_id").eq("client_id",await clientId(client)).limit(2));if(rows.length!==1)throw new Error(`client "${client}" is in ${rows.length} projects; name one in the fixture`);return rows[0].project_id};return{existing:async(client,templateKey)=>{const rows=must2("form_status",await deps.staff.from("form_status").select("structure_node_id").eq("client_id",await clientId(client)).eq("template_key",templateKey).limit(1));return rows[0]?.structure_node_id??null},instantiate:async(client,project,templateKey)=>{const projectId=await projectOf(client,project);const procs=must2("process",await deps.staff.from("process").select("id").eq("project_id",projectId).eq("is_project_process",true).eq("archived",false).eq("is_template",false).limit(2));if(procs.length!==1)throw new Error(`project ${projectId} has ${procs.length} project processes; open it with project_open first`);const out=must2("instantiate_process",await deps.staff.rpc("instantiate_process",{p_template_process_id:deps.formProcess,p_org_id:deps.orgId,p_project_id:projectId,p_template_key:templateKey,p_parent_binding:{process_id:procs[0].id,key:"workbook_forms"}}));if(out?.success===false||!out?.form)throw new Error(`instantiate_process (${templateKey}): ${out?.message??"no form returned"}`);return{structureNodeId:out.form.structure_node_id,questions:out.form.questions}},answer:async(client,node,key,value)=>{must2("answer_save",await(await deps.memberSession(client)).rpc("answer_save",{p_question_node:node,p_key:key,p_value:value}))},submit:async(client,structure)=>{must2("form_submit",await(await deps.memberSession(client)).rpc("form_submit",{p_structure_node:structure}))},complete:async structure=>{must2("form_review",await deps.staff.rpc("form_review",{p_structure_node:structure,p_status:"completed"}))}}}var CUTOVER_OWNED_COLUMNS=["remote_version","previous_version","remote_url","remote_storage_bucket"];var LAYOUT_SYNC_OWNED=["name","description"];var OrgManager=class extends LayerManager{get shape(){return{fields:{slug:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},advisor:{type:"boolean"},advisor_org_slug:{type:"string"},bundle_name:{type:"string"},asset_bucket:{type:"string"},description:{type:"string"}},ignored:["_comment"]}}naturalKeyOf(def){return def.slug}naturalKeyFilter(def){return{metadata_org_slug:def.slug}}toRow(){throw new Error("OrgManager writes via syncAll()")}files(){return["org.json"]}async fetchDbKeys(){const config=loadOrgConfig();const org=await findOrgBySlug(this.options.supabase,config.slug);return org?new Map([[config.slug,org.id]]):new Map}async syncAll(){const started=Date.now();const entities=[];const errors=[];const config=loadOrgConfig();const dryRun=this.options.dryRun;try{const existingOrg=await findOrgBySlug(this.options.supabase,config.slug);let advisorOrgId=null;if(!config.advisor&&config.advisor_org_slug){const advisorOrg=await findOrgBySlug(this.options.supabase,config.advisor_org_slug);if(advisorOrg){advisorOrgId=advisorOrg.id;this.mappings.set("org",config.advisor_org_slug,advisorOrg.id)}else{entities.push({entity:`advisor:${config.advisor_org_slug}`,action:"skipped",id:null,reason:`advisor org "${config.advisor_org_slug}" not found in ${this.options.targetEnv} \u2014 seed the advisor org repo first, then re-run. Advisor-scoped lookups stay unresolved until then.`})}}const orgMetadata={...existingOrg?{}:{seeded:true,created_at:new Date().toISOString()},org_slug:config.slug,organization_type:config.advisor?"advisor":"client",...config.advisor_org_slug?{advisor_org_slug:config.advisor_org_slug}:{},...advisorOrgId?{advisor_org_id:advisorOrgId}:{}};let orgId;if(existingOrg){orgId=existingOrg.id;if(!dryRun){const{data:current}=await this.options.supabase.from("organization").select("metadata").eq("id",orgId).single();const merged={...current?.metadata??{},...orgMetadata};const{error}=await this.options.supabase.from("organization").update({advisor:config.advisor,metadata:merged}).eq("id",orgId);if(error)throw new Error(`organization update failed: ${error.message}`)}entities.push({entity:`organization:${config.slug}`,action:"updated",id:orgId})}else if(dryRun){orgId="(new)";entities.push({entity:`organization:${config.slug}`,action:"created",id:orgId})}else{const{data,error}=await this.options.supabase.from("organization").insert([{name:config.name,advisor:config.advisor,created_by:"00000000-0000-0000-0000-000000000000",metadata:orgMetadata}]).select("id").single();if(error)throw new Error(`organization insert failed: ${error.message}`);orgId=data.id;entities.push({entity:`organization:${config.slug}`,action:"created",id:orgId})}if(!dryRun&&orgId!=="(new)")this.mappings.set("org",config.slug,orgId);const{data:templateRows,error:templateError}=await this.options.supabase.from("org_template").select("id, metadata").eq("org_id",orgId==="(new)"?"00000000-0000-0000-0000-000000000000":orgId).eq("is_archived",false).limit(2);if(templateError)throw new Error(`org_template lookup failed: ${templateError.message}`);const templates=templateRows??[];if(templates.length>1){throw new Error(`${templates.length} non-archived org_template rows for org ${orgId} \u2014 ambiguous. Fix the DB first.`)}const existingTemplate=templates[0];if(existingTemplate){const patch={advisor_org_id:advisorOrgId,is_published:true,is_archived:false,metadata:{...existingTemplate.metadata??{},org_slug:config.slug,organization_type:config.advisor?"advisor":"client",...config.advisor_org_slug?{advisor_org_slug:config.advisor_org_slug}:{}},updated_at:new Date().toISOString()};const leaked=[...CUTOVER_OWNED_COLUMNS,...LAYOUT_SYNC_OWNED,"remote_name","remote_enabled"].filter(c=>c in patch);if(leaked.length){throw new Error(`BUG: org layer patch contains non-owned column(s): ${leaked.join(", ")}`)}if(!dryRun){const{error}=await this.options.supabase.from("org_template").update(patch).eq("id",existingTemplate.id);if(error)throw new Error(`org_template update failed: ${error.message}`)}entities.push({entity:"org_template",action:"updated",id:existingTemplate.id});if(!dryRun)this.mappings.set("org_template",config.slug,existingTemplate.id)}else if(dryRun){entities.push({entity:"org_template",action:"created",id:"(new)"})}else{const{data,error}=await this.options.supabase.from("org_template").insert([{org_id:orgId,advisor_org_id:advisorOrgId,name:config.name,description:config.description??null,is_published:true,is_archived:false,remote_name:config.slug,remote_enabled:true,metadata:{org_slug:config.slug,organization_type:config.advisor?"advisor":"client",...config.advisor_org_slug?{advisor_org_slug:config.advisor_org_slug}:{},created_by:"template-cli"}}]).select("id").single();if(error)throw new Error(`org_template insert failed: ${error.message}`);const templateId=data.id;entities.push({entity:"org_template",action:"created",id:templateId});this.mappings.set("org_template",config.slug,templateId)}}catch(error){errors.push({entity:"org",error:error instanceof Error?error.message:String(error)})}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};import{existsSync as existsSync6,readFileSync as readFileSync4}from"node:fs";import{relative}from"node:path";var COLUMNS="id, org_id, org_slug, bundle_name, version, status, is_live, uploaded_at";async function resolveBundle(supabase,orgId,orgSlug,options={}){const bundleName=options.bundleName??"main";const version=options.version??null;if(version){const{data,error}=await supabase.from("component_bundle").select(COLUMNS).eq("org_id",orgId).eq("bundle_name",bundleName).eq("version",version).limit(2);if(error)throw new Error(`component_bundle lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} bundles match org=${orgSlug} bundle=${bundleName} version=${version} \u2014 ambiguous.`)}const row2=rows[0];if(!row2){throw new Error(`no component_bundle for org=${orgSlug} bundle=${bundleName} version=${version}.`)}return{...row2,resolved_by:"pin"}}const{data:liveRows,error:liveError}=await supabase.from("component_bundle").select(COLUMNS).eq("org_id",orgId).eq("bundle_name",bundleName).eq("is_live",true).limit(2);if(liveError)throw new Error(`component_bundle lookup failed: ${liveError.message}`);const live=liveRows??[];if(live.length>1){throw new Error(`${live.length} bundles in lineage "${bundleName}" (org=${orgSlug}) have is_live=true \u2014 exactly one row may be live. Re-run cutover from whichever repo owns this org's deploy pipeline (this one if it has scripts/cutover.mjs):
|
|
7
7
|
npm run deploy:cutover -- --org ${orgSlug} --version <v> --<env>`)}if(live[0]){return{...live[0],resolved_by:"is_live"}}const{data:latestRows,error:latestError}=await supabase.from("component_bundle").select(COLUMNS).eq("org_id",orgId).eq("bundle_name",bundleName).order("uploaded_at",{ascending:false}).limit(1);if(latestError)throw new Error(`component_bundle lookup failed: ${latestError.message}`);const latest=(latestRows??[])[0];if(!latest){throw new Error(`no component_bundle found for org=${orgSlug} bundle=${bundleName}. Run \`npm run deploy:upload\` first.`)}const row=latest;console.warn(`[template] \u26A0 no is_live bundle in lineage "${bundleName}" (org=${orgSlug}) \u2014 falling back to the most recently uploaded bundle (${row.version}). The host serves ONLY the is_live row, so this metadata may describe components the host is not running. Pin with --version, or promote one first from THIS repo:
|
|
8
|
-
npm run deploy:cutover -- --org ${orgSlug} --version ${row.version} --<env>`);return{...latest,resolved_by:"latest-uploaded"}}async function lineageExists(supabase,orgId,bundleName){const{data,error}=await supabase.from("component_bundle_lineage").select("bundle_name").eq("org_id",orgId).eq("bundle_name",bundleName).limit(1);if(error)throw new Error(`component_bundle_lineage lookup failed: ${error.message}`);return(data??[]).length>0}async function resolveBundleIfAny(supabase,orgId,orgSlug,options={}){try{return await resolveBundle(supabase,orgId,orgSlug,options)}catch(error){const message=error instanceof Error?error.message:String(error);if(!options.version&&message.startsWith("no component_bundle found for org="))return null;throw error}}var HOST_NATIVE_COMPONENTS=new Set(["ProjectPageByType","ProjectPageById","ProjectPageRenderer","IndividualPageViewer","CellRenderer","CellsRenderer"]);var REGISTRY_COLUMNS="component_name, description, props_schema, delivery_mode, exposed_export, is_active";async function fetchRegistry(supabase,bundleId,componentNames){if(!bundleId||componentNames.length===0)return new Map;const{data,error}=await supabase.from("component_registry").select(REGISTRY_COLUMNS).eq("bundle_id",bundleId).in("component_name",componentNames);if(error)throw new Error(`component_registry lookup failed: ${error.message}`);const rows=data??[];return new Map(rows.map(row=>[row.component_name,row]))}function missingBundleComponents(componentNames,registry){return componentNames.filter(n=>!registry.has(n)&&!HOST_NATIVE_COMPONENTS.has(n))}var SHELL_BLOCK_KEYS=["tab_bar","app_bar","chat_rail","more_menu"];function collectFromLayout(layout,out){if(!layout||typeof layout!=="object")return;const l=layout;if(l.base_route?.component)out.add(l.base_route.component);const visit=route=>{if(route?.component)out.add(route.component);if(route?.right_sidebar?.component)out.add(route.right_sidebar.component);route?.children?.forEach(visit)};[...l.whitelisted_routes??[],...l.routes??[]].forEach(visit);for(const region of Object.values(l.regions??{})){for(const band of["header","body","footer"]){const name=region?.[band]?.component;if(name&&!name.startsWith("host:"))out.add(name)}}for(const nav of l.sidebar?.navigation??[]){if(nav?.component)out.add(nav.component)}if(l.right_sidebar?.component)out.add(l.right_sidebar.component);for(const key of SHELL_BLOCK_KEYS){const block=l[key];if(block?.component)out.add(block.component)}}function collectComponentNames(payload){const out=new Set;collectFromLayout(payload.layout,out);for(const tier of["public","portal","platform","agency","aux"]){collectFromLayout(payload[`${tier}_layout`],out)}return[...out]}function extractRegistryMeta(row){const props=row.props_schema??{};return{description:row.description??null,declared_data:props.data??null,declared_actions:props.actions??null,declared_props:props.properties??null,contract_version:props.contract_version??null,delivery_mode:row.delivery_mode??null,exposed_export:row.exposed_export??null}}function metaFor(component,registry){if(!component)return null;const row=registry.get(component);return row?extractRegistryMeta(row):null}function inlineRouteRegistryMeta(routes,registry){if(!Array.isArray(routes))return routes;return routes.map(route=>{const children=Array.isArray(route?.children)?{children:inlineRouteRegistryMeta(route.children,registry)}:{};if(!route?.component)return{...route,...children};const next={...route,...children,registry_meta:metaFor(route.component,registry)};const sidebar=route.right_sidebar;if(sidebar?.component){next.right_sidebar={...sidebar,registry_meta:metaFor(sidebar.component,registry)}}return next})}function inlineNavRegistryMeta(navItems,registry){if(!Array.isArray(navItems))return navItems;return navItems.map(item=>item?.component?{...item,registry_meta:metaFor(item.component,registry)}:item)}function annotateLayout(layout,registry){if(!layout||typeof layout!=="object")return layout;const l=layout;return{...l,...l.sidebar?{sidebar:{...l.sidebar,navigation:inlineNavRegistryMeta(l.sidebar.navigation,registry)}}:{},...l.whitelisted_routes?{whitelisted_routes:inlineRouteRegistryMeta(l.whitelisted_routes,registry)}:{},...l.routes?{routes:inlineRouteRegistryMeta(l.routes,registry)}:{}}}function buildTemplateMeta(input){const{sourcePath,bundle,componentNames,registry,generator}=input;return{source:sourcePath,generated_at:new Date().toISOString(),generator,bundle_id:bundle.id,bundle_version:bundle.version,bundle_org_slug:bundle.org_slug,bundle_status:bundle.status,bundle_resolved_by:bundle.resolved_by,component_count:componentNames.length,missing_components:missingBundleComponents(componentNames,registry),host_native_components:componentNames.filter(n=>HOST_NATIVE_COMPONENTS.has(n))}}function stripHarnessOnly(value){if(Array.isArray(value))return value.map(v=>stripHarnessOnly(v));if(value&&typeof value==="object"){const out={};for(const[key,v]of Object.entries(value)){if(key.startsWith("_harness_")||key.startsWith("_mock"))continue;out[key]=stripHarnessOnly(v)}return out}return value}var GENERATOR="template/cli sync --layer org-template-layout";var LAYOUT_COLUMNS=["public_layout","portal_layout","platform_layout","agency_layout","aux_layout"];var CUTOVER_OWNED_COLUMNS2=["remote_name","remote_version","previous_version","remote_url","remote_storage_bucket","remote_enabled"];var OrgTemplateLayoutManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"}},allowUnknown:true}}naturalKeyOf(def){return def.name}naturalKeyFilter(){return{metadata_org_slug:loadOrgConfig().slug}}toRow(){throw new Error("OrgTemplateLayoutManager writes via syncAll()")}source(){const config=loadTemplatesConfig().org_template;if(!config)return null;return{slug:config.slug,bundleName:config.bundle_name??"main",layoutBundles:config.layout_bundles??{},path:orgTemplatePath(config.slug)}}bundleFor(column){const source=this.source();return source?.layoutBundles[column]??source?.bundleName??"main"}files(){const source=this.source();return source&&existsSync6(source.path)?[`src/org_templates/${source.slug}/template.json`]:[]}read(path){const parsed=JSON.parse(readFileSync4(path,"utf8"));const file=parsed;if(!file||typeof file.name!=="string"){throw new Error(`${path}: template.json must have at least { "name": "..." }`)}return file}fileKeys(){const source=this.source();if(!source||!existsSync6(source.path))return[];try{return[this.read(source.path).name]}catch{return[]}}async fetchDbKeys(){const source=this.source();if(!source||!existsSync6(source.path))return new Map;const{data,error}=await this.options.supabase.from("org_template").select("id, name").eq("org_id",this.options.orgId).eq("is_archived",false).limit(2);if(error)throw new Error(`org_template lookup failed: ${error.message}`);const rows=data??[];return new Map(rows.map(r=>[r.name,r.id]))}validateAll(){const errors=[];const warnings=[];const source=this.source();if(!source){return{layer:this.def.name,filesChecked:0,errors,warnings}}const label=`src/org_templates/${source.slug}/template.json`;if(!existsSync6(source.path)){errors.push({file:label,message:`declared in config/templates.json but not found on disk`});return{layer:this.def.name,filesChecked:0,errors,warnings}}let file;try{file=this.read(source.path)}catch(error){errors.push({file:label,message:error instanceof Error?error.message:String(error)});return{layer:this.def.name,filesChecked:1,errors,warnings}}const declared=LAYOUT_COLUMNS.filter(c=>file[c]!==void 0);if(declared.length===0){warnings.push({file:label,message:"declares no *_layout column \u2014 nothing to sync"})}const droppable=Object.keys(file).filter(k=>!["name","description","is_published","is_archived","metadata"].includes(k)&&!LAYOUT_COLUMNS.includes(k));for(const key of droppable){warnings.push({file:label,message:`"${key}" has no org_template column and is not written (harness-only or dead config)`})}if(collectComponentNames(file).length===0){warnings.push({file:label,message:"no component references found in any layout"})}return{layer:this.def.name,filesChecked:1,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];const source=this.source();const done=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});if(!source){entities.push({entity:"org-template-layout",action:"skipped",id:null,reason:"no org_template declared in config/templates.json"});return done()}try{const file=this.read(source.path);const cleaned=stripHarnessOnly(file);const presentColumns=LAYOUT_COLUMNS.filter(c=>cleaned[c]!==void 0);const neededLineages=[...new Set([source.bundleName,...presentColumns.map(c=>this.bundleFor(c))])].sort();const bundles=new Map;for(const lineage of neededLineages){const resolved=await resolveBundleIfAny(this.options.supabase,this.options.orgId,this.options.orgSlug,{version:lineage===source.bundleName?this.options.versionPin??null:null,bundleName:lineage});if(!resolved){entities.push({entity:`org_template:${cleaned.name}`,action:"skipped",id:null,reason:`no component_bundle in lineage "${lineage}" yet \u2014 layouts inline registry metadata from the bundle the host serves. Deploy (upload \u2192 register \u2192 host cutover), then re-run this layer.`});return done()}bundles.set(lineage,resolved)}const bundle=bundles.get(source.bundleName);const componentNames=collectComponentNames(cleaned);const registries=new Map;for(const lineage of neededLineages){const names=new Set;for(const column of presentColumns){if(this.bundleFor(column)!==lineage)continue;for(const n of collectComponentNames({[column]:cleaned[column]}))names.add(n)}const target=bundles.get(lineage);registries.set(lineage,await fetchRegistry(this.options.supabase,target.id,[...names]));const missing=missingBundleComponents([...names],registries.get(lineage));if(missing.length){console.warn(`[template] \u26A0 ${missing.length} component(s) not in ${lineage} bundle ${target.version}: ${missing.join(", ")}. Routes referencing them land with registry_meta=null.`)}}const registry=new Map;for(const r of registries.values())for(const[k,v]of r)registry.set(k,v);const{data:rows,error:fetchError}=await this.options.supabase.from("org_template").select("id, name, metadata").eq("org_id",this.options.orgId).eq("is_archived",false).limit(2);if(fetchError)throw new Error(`org_template lookup failed: ${fetchError.message}`);const existing=rows??[];if(existing.length>1){throw new Error(`${existing.length} non-archived org_template rows for this org \u2014 ambiguous. Fix the DB first.`)}const row=existing[0];if(!row){throw new Error(`no org_template row for "${this.options.orgSlug}". Run \`sync --layer org\` first \u2014 that layer owns row creation, this one owns the layout columns.`)}const patch={name:cleaned.name,updated_at:new Date().toISOString()};if(cleaned.description!==void 0)patch.description=cleaned.description;if(cleaned.is_published!==void 0)patch.is_published=cleaned.is_published;if(cleaned.is_archived!==void 0)patch.is_archived=cleaned.is_archived;for(const column of presentColumns){patch[column]=annotateLayout(cleaned[column],registries.get(this.bundleFor(column)))}patch.metadata={...row.metadata??{},...cleaned.metadata??{},org_slug:row.metadata?.org_slug??cleaned.metadata?.org_slug??this.options.orgSlug,bundle_refs:cleaned.metadata?.bundle_refs??[bundle.org_slug],template_meta:{...buildTemplateMeta({sourcePath:relative(REPO_ROOT,source.path),bundle,componentNames,registry,generator:GENERATOR}),layout_bundles:Object.fromEntries(presentColumns.map(column=>{const lineage=this.bundleFor(column);return[column,{bundle_name:lineage,version:bundles.get(lineage).version}]}))}};const leaked=CUTOVER_OWNED_COLUMNS2.filter(c=>c in patch);if(leaked.length){throw new Error(`BUG: cutover-owned column(s) in patch: ${leaked.join(", ")}`)}const untouched=LAYOUT_COLUMNS.filter(c=>!(c in patch));if(untouched.length){console.log(` layout columns preserved (absent from template.json): ${untouched.join(", ")}`)}if(!this.options.dryRun){const{error}=await this.options.supabase.from("org_template").update(patch).eq("id",row.id);if(error)throw new Error(`org_template update failed: ${error.message}`);this.mappings.set("org_template",this.options.orgSlug,row.id)}entities.push({entity:`org_template:${cleaned.name} (bundle ${bundle.version} via ${bundle.resolved_by})`,action:"updated",id:row.id})}catch(error){errors.push({entity:`src/org_templates/${source.slug}/template.json`,error:error instanceof Error?error.message:String(error)})}return done()}async healthChecks(){const source=this.source();if(!source||!existsSync6(source.path))return[];const{data,error}=await this.options.supabase.from("org_template").select("metadata").eq("org_id",this.options.orgId).eq("is_archived",false).limit(1);if(error){return[{name:"layout-bundle",severity:"warn",message:`lookup failed: ${error.message}`}]}const meta=(data??[])[0]?.metadata;const templateMeta=meta?.template_meta;if(!templateMeta){return[{name:"layout-bundle",severity:"warn",message:"org_template has no metadata.template_meta \u2014 layouts have never been synced from src/"}]}let live;try{live=await resolveBundle(this.options.supabase,this.options.orgId,this.options.orgSlug,{bundleName:source.bundleName})}catch(error2){return[{name:"layout-bundle",severity:"warn",message:error2 instanceof Error?error2.message:String(error2)}]}const recorded=templateMeta.bundle_version;if(recorded&&live.resolved_by==="is_live"&&recorded!==live.version){return[{name:"layout-bundle",severity:"warn",message:`layouts were annotated from bundle ${recorded}, but the host serves ${live.version}. Re-run \`sync --layer org-template-layout\` so registry_meta describes what is running.`}]}return[{name:"layout-bundle",severity:"ok",message:`layouts annotated from ${recorded??"(unknown)"}; host serves ${live.version} (${live.resolved_by})`}]}};import{existsSync as existsSync7,readFileSync as readFileSync5}from"node:fs";import{join as join7}from"node:path";var ICON_KEYS=["192","512","maskable","180"];var PATH=/^[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/;var IMAGE_KEYS={favicon:{accepts:/\.(png|svg|ico)$/i,what:"a PNG, SVG or ICO"},og_image:{accepts:/\.(png|jpe?g)$/i,what:"a PNG or JPEG"}};function storageOrigin(projectUrl){try{return projectUrl?new URL(projectUrl).origin:null}catch{return null}}function resolveImageRef(value,origin,options,accepts){if(typeof value!=="string"||!value.trim())return{problem:"must be a string"};const v=value.trim();let url=null;if(v.startsWith("storage:")){const object=v.slice("storage:".length);if(!/^[a-z0-9][a-z0-9_-]*\/[^/]/.test(object)||!PATH.test(object)||object.includes(".."))return{problem:`"${v}" is not storage:<bucket>/<path>`};if(origin)url=`${origin}/storage/v1/object/public/${object}`;else if(options.shapeOnly)url=v;else return{problem:"a storage: reference needs the project URL, which this run does not have"}}else if(v.startsWith("/")){if(v.startsWith("//")||!PATH.test(v)||v.includes(".."))return{problem:`"${v}" is not a path on the host`};url=v}else{try{const u=new URL(v);if(!origin&&!options.shapeOnly)return{problem:"a URL needs the project URL, which this run does not have"};if(!u.pathname.startsWith("/storage/v1/")||origin&&u.origin!==origin)return{problem:`"${v}" is not in this project's storage (${origin??u.origin}/storage/v1/\u2026)`};url=u.href}catch{return{problem:`"${v}" is not a storage: reference, a /path or a URL`}}}const file=url.startsWith("/")||url.startsWith("storage:")?url.split(/[?#]/)[0]:new URL(url).pathname;if(!accepts.test(file))return{problem:`"${v}" is not ${accepts.what}`};return{url}}function resolveBrandingIcons(branding,projectUrl,options={}){const problems=[];const origin=storageOrigin(projectUrl);let out=branding;const icons=branding?.icons;if(icons!==void 0){if(!icons||typeof icons!=="object"||Array.isArray(icons)){problems.push({key:"icons",message:"branding.icons must be an object of icon names to values"})}else{const resolved={};for(const[key,value]of Object.entries(icons)){if(!ICON_KEYS.includes(key)){problems.push({key,message:`"${key}" is not an icon name (${ICON_KEYS.join(", ")})`});continue}const r=resolveImageRef(value,origin,options,{test:f=>/\.png$/i.test(f),what:"a PNG"});if("url"in r)resolved[key]=r.url;else problems.push({key,message:r.problem})}out={...out,icons:resolved}}}for(const[key,spec]of Object.entries(IMAGE_KEYS)){const value=branding?.[key];if(value===void 0)continue;const r=resolveImageRef(value,origin,options,{test:f=>spec.accepts.test(f),what:spec.what});if("url"in r)out={...out,[key]:r.url};else{problems.push({key,message:r.problem});const{[key]:_dropped,...rest}=out??{};out=rest}}return{branding:out,problems}}var CONFIG_FILE=join7(CONFIG_DIR,"public.json");var DOMAIN_SHAPE={fields:{domain_pattern:{type:"string",required:true,minLength:1},is_wildcard:{type:"boolean",default:false},priority:{type:"number",default:100},is_active:{type:"boolean",default:true}},ignored:["_comment"]};var OrgTemplatePublicManager=class extends LayerManager{get shape(){return DOMAIN_SHAPE}naturalKeyOf(def){return def.domain_pattern}naturalKeyFilter(){throw new Error("OrgTemplatePublicManager writes via syncAll()")}toRow(){throw new Error("OrgTemplatePublicManager writes via syncAll()")}config(){if(!existsSync7(CONFIG_FILE))return null;const parsed=JSON.parse(readFileSync5(CONFIG_FILE,"utf8"));const outer=validateShape(parsed,{fields:{advisor_org_slug:{type:"string",required:true,minLength:1},domains:{type:"array",required:true},path_prefix:{type:"string",minLength:1},branding:{type:"object"},metadata:{type:"object"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/public.json is invalid:
|
|
8
|
+
npm run deploy:cutover -- --org ${orgSlug} --version ${row.version} --<env>`);return{...latest,resolved_by:"latest-uploaded"}}async function lineageExists(supabase,orgId,bundleName){const{data,error}=await supabase.from("component_bundle_lineage").select("bundle_name").eq("org_id",orgId).eq("bundle_name",bundleName).limit(1);if(error)throw new Error(`component_bundle_lineage lookup failed: ${error.message}`);return(data??[]).length>0}async function resolveBundleIfAny(supabase,orgId,orgSlug,options={}){try{return await resolveBundle(supabase,orgId,orgSlug,options)}catch(error){const message=error instanceof Error?error.message:String(error);if(!options.version&&message.startsWith("no component_bundle found for org="))return null;throw error}}var HOST_NATIVE_COMPONENTS=new Set(["ProjectPageByType","ProjectPageById","ProjectPageRenderer","IndividualPageViewer","CellRenderer","CellsRenderer"]);var REGISTRY_COLUMNS="component_name, description, props_schema, delivery_mode, exposed_export, is_active";async function fetchRegistry(supabase,bundleId,componentNames){if(!bundleId||componentNames.length===0)return new Map;const{data,error}=await supabase.from("component_registry").select(REGISTRY_COLUMNS).eq("bundle_id",bundleId).in("component_name",componentNames);if(error)throw new Error(`component_registry lookup failed: ${error.message}`);const rows=data??[];return new Map(rows.map(row=>[row.component_name,row]))}function missingBundleComponents(componentNames,registry){return componentNames.filter(n=>!registry.has(n)&&!HOST_NATIVE_COMPONENTS.has(n))}var SHELL_BLOCK_KEYS=["tab_bar","app_bar","chat_rail","more_menu"];function collectFromLayout(layout,out){if(!layout||typeof layout!=="object")return;const l=layout;if(l.base_route?.component)out.add(l.base_route.component);const visit=route=>{if(route?.component)out.add(route.component);if(route?.right_sidebar?.component)out.add(route.right_sidebar.component);route?.children?.forEach(visit)};[...l.whitelisted_routes??[],...l.routes??[]].forEach(visit);for(const region of Object.values(l.regions??{})){for(const band of["header","body","footer"]){const name=region?.[band]?.component;if(name&&!name.startsWith("host:"))out.add(name)}}for(const nav of l.sidebar?.navigation??[]){if(nav?.component)out.add(nav.component)}if(l.right_sidebar?.component)out.add(l.right_sidebar.component);for(const key of SHELL_BLOCK_KEYS){const block=l[key];if(block?.component)out.add(block.component)}}function collectComponentNames(payload){const out=new Set;collectFromLayout(payload.layout,out);for(const tier of["public","portal","platform","agency","aux"]){collectFromLayout(payload[`${tier}_layout`],out)}return[...out]}function extractRegistryMeta(row){const props=row.props_schema??{};return{description:row.description??null,declared_data:props.data??null,declared_actions:props.actions??null,declared_props:props.properties??null,contract_version:props.contract_version??null,delivery_mode:row.delivery_mode??null,exposed_export:row.exposed_export??null}}function metaFor(component,registry){if(!component)return null;const row=registry.get(component);return row?extractRegistryMeta(row):null}function inlineRouteRegistryMeta(routes,registry){if(!Array.isArray(routes))return routes;return routes.map(route=>{const children=Array.isArray(route?.children)?{children:inlineRouteRegistryMeta(route.children,registry)}:{};if(!route?.component)return{...route,...children};const next={...route,...children,registry_meta:metaFor(route.component,registry)};const sidebar=route.right_sidebar;if(sidebar?.component){next.right_sidebar={...sidebar,registry_meta:metaFor(sidebar.component,registry)}}return next})}function inlineNavRegistryMeta(navItems,registry){if(!Array.isArray(navItems))return navItems;return navItems.map(item=>item?.component?{...item,registry_meta:metaFor(item.component,registry)}:item)}function annotateLayout(layout,registry){if(!layout||typeof layout!=="object")return layout;const l=layout;return{...l,...l.sidebar?{sidebar:{...l.sidebar,navigation:inlineNavRegistryMeta(l.sidebar.navigation,registry)}}:{},...l.whitelisted_routes?{whitelisted_routes:inlineRouteRegistryMeta(l.whitelisted_routes,registry)}:{},...l.routes?{routes:inlineRouteRegistryMeta(l.routes,registry)}:{}}}function buildTemplateMeta(input){const{sourcePath,bundle,componentNames,registry,generator}=input;return{source:sourcePath,generated_at:new Date().toISOString(),generator,bundle_id:bundle.id,bundle_version:bundle.version,bundle_org_slug:bundle.org_slug,bundle_status:bundle.status,bundle_resolved_by:bundle.resolved_by,component_count:componentNames.length,missing_components:missingBundleComponents(componentNames,registry),host_native_components:componentNames.filter(n=>HOST_NATIVE_COMPONENTS.has(n))}}function stripHarnessOnly(value){if(Array.isArray(value))return value.map(v=>stripHarnessOnly(v));if(value&&typeof value==="object"){const out={};for(const[key,v]of Object.entries(value)){if(key.startsWith("_harness_")||key.startsWith("_mock"))continue;out[key]=stripHarnessOnly(v)}return out}return value}var GENERATOR="template/cli sync --layer org-template-layout";var LAYOUT_COLUMNS=["public_layout","portal_layout","platform_layout","agency_layout","aux_layout"];var CUTOVER_OWNED_COLUMNS2=["remote_name","remote_version","previous_version","remote_url","remote_storage_bucket","remote_enabled"];var OrgTemplateLayoutManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"}},allowUnknown:true}}naturalKeyOf(def){return def.name}naturalKeyFilter(){return{metadata_org_slug:loadOrgConfig().slug}}toRow(){throw new Error("OrgTemplateLayoutManager writes via syncAll()")}source(){const config=loadTemplatesConfig().org_template;if(!config)return null;return{slug:config.slug,bundleName:config.bundle_name??"main",layoutBundles:config.layout_bundles??{},path:orgTemplatePath(config.slug)}}bundleFor(column){const source=this.source();return source?.layoutBundles[column]??source?.bundleName??"main"}files(){const source=this.source();return source&&existsSync6(source.path)?[`src/org_templates/${source.slug}/template.json`]:[]}read(path){const parsed=JSON.parse(readFileSync4(path,"utf8"));const file=parsed;if(!file||typeof file.name!=="string"){throw new Error(`${path}: template.json must have at least { "name": "..." }`)}return file}fileKeys(){const source=this.source();if(!source||!existsSync6(source.path))return[];try{return[this.read(source.path).name]}catch{return[]}}async fetchDbKeys(){const source=this.source();if(!source||!existsSync6(source.path))return new Map;const{data,error}=await this.options.supabase.from("org_template").select("id, name").eq("org_id",this.options.orgId).eq("is_archived",false).limit(2);if(error)throw new Error(`org_template lookup failed: ${error.message}`);const rows=data??[];return new Map(rows.map(r=>[r.name,r.id]))}validateAll(){const errors=[];const warnings=[];const source=this.source();if(!source){return{layer:this.def.name,filesChecked:0,errors,warnings}}const label=`src/org_templates/${source.slug}/template.json`;if(!existsSync6(source.path)){errors.push({file:label,message:`declared in config/templates.json but not found on disk`});return{layer:this.def.name,filesChecked:0,errors,warnings}}let file;try{file=this.read(source.path)}catch(error){errors.push({file:label,message:error instanceof Error?error.message:String(error)});return{layer:this.def.name,filesChecked:1,errors,warnings}}const declared=LAYOUT_COLUMNS.filter(c=>file[c]!==void 0);if(declared.length===0){warnings.push({file:label,message:"declares no *_layout column \u2014 nothing to sync"})}const droppable=Object.keys(file).filter(k=>!["name","description","is_published","is_archived","metadata"].includes(k)&&!LAYOUT_COLUMNS.includes(k));for(const key of droppable){warnings.push({file:label,message:`"${key}" has no org_template column and is not written (harness-only or dead config)`})}if(collectComponentNames(file).length===0){warnings.push({file:label,message:"no component references found in any layout"})}return{layer:this.def.name,filesChecked:1,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];const source=this.source();const done=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});if(!source){entities.push({entity:"org-template-layout",action:"skipped",id:null,reason:"no org_template declared in config/templates.json"});return done()}try{const file=this.read(source.path);const cleaned=stripHarnessOnly(file);const presentColumns=LAYOUT_COLUMNS.filter(c=>cleaned[c]!==void 0);const neededLineages=[...new Set([source.bundleName,...presentColumns.map(c=>this.bundleFor(c))])].sort();const bundles=new Map;for(const lineage of neededLineages){const resolved=await resolveBundleIfAny(this.options.supabase,this.options.orgId,this.options.orgSlug,{version:lineage===source.bundleName?this.options.versionPin??null:null,bundleName:lineage});if(!resolved){entities.push({entity:`org_template:${cleaned.name}`,action:"skipped",id:null,reason:`no component_bundle in lineage "${lineage}" yet \u2014 layouts inline registry metadata from the bundle the host serves. Deploy (upload \u2192 register \u2192 host cutover), then re-run this layer.`});return done()}bundles.set(lineage,resolved)}const bundle=bundles.get(source.bundleName);const componentNames=collectComponentNames(cleaned);const registries=new Map;for(const lineage of neededLineages){const names=new Set;for(const column of presentColumns){if(this.bundleFor(column)!==lineage)continue;for(const n of collectComponentNames({[column]:cleaned[column]}))names.add(n)}const target=bundles.get(lineage);registries.set(lineage,await fetchRegistry(this.options.supabase,target.id,[...names]));const missing=missingBundleComponents([...names],registries.get(lineage));if(missing.length){console.warn(`[template] \u26A0 ${missing.length} component(s) not in ${lineage} bundle ${target.version}: ${missing.join(", ")}. Routes referencing them land with registry_meta=null.`)}}const registry=new Map;for(const r of registries.values())for(const[k,v]of r)registry.set(k,v);const{data:rows,error:fetchError}=await this.options.supabase.from("org_template").select("id, name, metadata").eq("org_id",this.options.orgId).eq("is_archived",false).limit(2);if(fetchError)throw new Error(`org_template lookup failed: ${fetchError.message}`);const existing=rows??[];if(existing.length>1){throw new Error(`${existing.length} non-archived org_template rows for this org \u2014 ambiguous. Fix the DB first.`)}const row=existing[0];if(!row){throw new Error(`no org_template row for "${this.options.orgSlug}". Run \`sync --layer org\` first \u2014 that layer owns row creation, this one owns the layout columns.`)}const patch={name:cleaned.name,updated_at:new Date().toISOString()};if(cleaned.description!==void 0)patch.description=cleaned.description;if(cleaned.is_published!==void 0)patch.is_published=cleaned.is_published;if(cleaned.is_archived!==void 0)patch.is_archived=cleaned.is_archived;for(const column of presentColumns){patch[column]=annotateLayout(cleaned[column],registries.get(this.bundleFor(column)))}patch.metadata={...row.metadata??{},...cleaned.metadata??{},org_slug:row.metadata?.org_slug??cleaned.metadata?.org_slug??this.options.orgSlug,bundle_refs:cleaned.metadata?.bundle_refs??[bundle.org_slug],template_meta:{...buildTemplateMeta({sourcePath:relative(REPO_ROOT,source.path),bundle,componentNames,registry,generator:GENERATOR}),layout_bundles:Object.fromEntries(presentColumns.map(column=>{const lineage=this.bundleFor(column);return[column,{bundle_name:lineage,version:bundles.get(lineage).version}]}))}};const leaked=CUTOVER_OWNED_COLUMNS2.filter(c=>c in patch);if(leaked.length){throw new Error(`BUG: cutover-owned column(s) in patch: ${leaked.join(", ")}`)}const untouched=LAYOUT_COLUMNS.filter(c=>!(c in patch));if(untouched.length){console.log(` layout columns preserved (absent from template.json): ${untouched.join(", ")}`)}if(!this.options.dryRun){const{error}=await this.options.supabase.from("org_template").update(patch).eq("id",row.id);if(error)throw new Error(`org_template update failed: ${error.message}`);this.mappings.set("org_template",this.options.orgSlug,row.id)}entities.push({entity:`org_template:${cleaned.name} (bundle ${bundle.version} via ${bundle.resolved_by})`,action:"updated",id:row.id})}catch(error){errors.push({entity:`src/org_templates/${source.slug}/template.json`,error:error instanceof Error?error.message:String(error)})}return done()}async healthChecks(){const source=this.source();if(!source||!existsSync6(source.path))return[];const{data,error}=await this.options.supabase.from("org_template").select("metadata").eq("org_id",this.options.orgId).eq("is_archived",false).limit(1);if(error){return[{name:"layout-bundle",severity:"warn",message:`lookup failed: ${error.message}`}]}const meta=(data??[])[0]?.metadata;const templateMeta=meta?.template_meta;if(!templateMeta){return[{name:"layout-bundle",severity:"warn",message:"org_template has no metadata.template_meta \u2014 layouts have never been synced from src/"}]}let live;try{live=await resolveBundle(this.options.supabase,this.options.orgId,this.options.orgSlug,{bundleName:source.bundleName})}catch(error2){return[{name:"layout-bundle",severity:"warn",message:error2 instanceof Error?error2.message:String(error2)}]}const recorded=templateMeta.bundle_version;if(recorded&&live.resolved_by==="is_live"&&recorded!==live.version){return[{name:"layout-bundle",severity:"warn",message:`layouts were annotated from bundle ${recorded}, but the host serves ${live.version}. Re-run \`sync --layer org-template-layout\` so registry_meta describes what is running.`}]}return[{name:"layout-bundle",severity:"ok",message:`layouts annotated from ${recorded??"(unknown)"}; host serves ${live.version} (${live.resolved_by})`}]}};import{existsSync as existsSync7,readFileSync as readFileSync5}from"node:fs";import{join as join7}from"node:path";var ICON_KEYS=["192","512","maskable","180"];var PATH=/^[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/;var IMAGE_KEYS={favicon:{accepts:/\.(png|svg|ico)$/i,what:"a PNG, SVG or ICO"},og_image:{accepts:/\.(png|jpe?g)$/i,what:"a PNG or JPEG"},signin_image:{accepts:/\.(png|jpe?g|webp)$/i,what:"a PNG, JPEG or WEBP"}};function storageOrigin(projectUrl){try{return projectUrl?new URL(projectUrl).origin:null}catch{return null}}function resolveImageRef(value,origin,options,accepts){if(typeof value!=="string"||!value.trim())return{problem:"must be a string"};const v=value.trim();let url=null;if(v.startsWith("storage:")){const object=v.slice("storage:".length);if(!/^[a-z0-9][a-z0-9_-]*\/[^/]/.test(object)||!PATH.test(object)||object.includes(".."))return{problem:`"${v}" is not storage:<bucket>/<path>`};if(origin)url=`${origin}/storage/v1/object/public/${object}`;else if(options.shapeOnly)url=v;else return{problem:"a storage: reference needs the project URL, which this run does not have"}}else if(v.startsWith("/")){if(v.startsWith("//")||!PATH.test(v)||v.includes(".."))return{problem:`"${v}" is not a path on the host`};url=v}else{try{const u=new URL(v);if(!origin&&!options.shapeOnly)return{problem:"a URL needs the project URL, which this run does not have"};if(!u.pathname.startsWith("/storage/v1/")||origin&&u.origin!==origin)return{problem:`"${v}" is not in this project's storage (${origin??u.origin}/storage/v1/\u2026)`};url=u.href}catch{return{problem:`"${v}" is not a storage: reference, a /path or a URL`}}}const file=url.startsWith("/")||url.startsWith("storage:")?url.split(/[?#]/)[0]:new URL(url).pathname;if(!accepts.test(file))return{problem:`"${v}" is not ${accepts.what}`};return{url}}function resolveBrandingIcons(branding,projectUrl,options={}){const problems=[];const origin=storageOrigin(projectUrl);let out=branding;const icons=branding?.icons;if(icons!==void 0){if(!icons||typeof icons!=="object"||Array.isArray(icons)){problems.push({key:"icons",message:"branding.icons must be an object of icon names to values"})}else{const resolved={};for(const[key,value]of Object.entries(icons)){if(!ICON_KEYS.includes(key)){problems.push({key,message:`"${key}" is not an icon name (${ICON_KEYS.join(", ")})`});continue}const r=resolveImageRef(value,origin,options,{test:f=>/\.png$/i.test(f),what:"a PNG"});if("url"in r)resolved[key]=r.url;else problems.push({key,message:r.problem})}out={...out,icons:resolved}}}for(const[key,spec]of Object.entries(IMAGE_KEYS)){const value=branding?.[key];if(value===void 0)continue;const r=resolveImageRef(value,origin,options,{test:f=>spec.accepts.test(f),what:spec.what});if("url"in r)out={...out,[key]:r.url};else{problems.push({key,message:r.problem});const{[key]:_dropped,...rest}=out??{};out=rest}}return{branding:out,problems}}var CONFIG_FILE=join7(CONFIG_DIR,"public.json");var DOMAIN_SHAPE={fields:{domain_pattern:{type:"string",required:true,minLength:1},is_wildcard:{type:"boolean",default:false},priority:{type:"number",default:100},is_active:{type:"boolean",default:true}},ignored:["_comment"]};var OrgTemplatePublicManager=class extends LayerManager{get shape(){return DOMAIN_SHAPE}naturalKeyOf(def){return def.domain_pattern}naturalKeyFilter(){throw new Error("OrgTemplatePublicManager writes via syncAll()")}toRow(){throw new Error("OrgTemplatePublicManager writes via syncAll()")}config(){if(!existsSync7(CONFIG_FILE))return null;const parsed=JSON.parse(readFileSync5(CONFIG_FILE,"utf8"));const outer=validateShape(parsed,{fields:{advisor_org_slug:{type:"string",required:true,minLength:1},domains:{type:"array",required:true},path_prefix:{type:"string",minLength:1},branding:{type:"object"},metadata:{type:"object"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/public.json is invalid:
|
|
9
9
|
- ${outer.errors.join("\n - ")}`)}const domains=[];outer.value.domains.forEach((raw,index)=>{const result=validateShape(raw,DOMAIN_SHAPE);if(!result.ok||!result.value){throw new Error(`public.json domains[${index}] is invalid: ${result.errors.join("; ")}`)}domains.push(result.value)});const full=parsed;return{advisor_org_slug:outer.value.advisor_org_slug,...full.path_prefix!==void 0?{path_prefix:full.path_prefix}:{},domains,branding:full.branding,metadata:full.metadata}}publicLayout(){const configured=loadTemplatesConfig().org_template;if(!configured)return null;const path=orgTemplatePath(configured.slug);if(!existsSync7(path))return null;const parsed=JSON.parse(readFileSync5(path,"utf8"));return parsed.public_layout??null}files(){const config=this.config();if(!config)return[];const filter=this.options.nameFilter;const domains=config.domains.map(d=>d.domain_pattern);return filter?domains.filter(d=>d===filter):domains}fileKeys(){return this.files()}projectUrl(){return this.options.supabase?.supabaseUrl}async advisorOrgId(slug){if(slug===loadOrgConfig().slug)return this.options.orgId;const org=await findOrgBySlug(this.options.supabase,slug);if(!org){throw new Error(`public.json advisor_org_slug "${slug}" resolves to no organization. Seed that org first.`)}return org.id}clientProblems(config){if(config.path_prefix===void 0)return[];const org=loadOrgConfig();const out=[];if(org.advisor)out.push("path_prefix is for a client org; this org is an advisor org (its site rows have no prefix)");if(config.path_prefix!==org.slug)out.push(`path_prefix "${config.path_prefix}" must be this org's own slug ("${org.slug}"), which only the platform assigns`);if(!/^[a-z0-9][a-z0-9-]*$/.test(config.path_prefix))out.push(`path_prefix "${config.path_prefix}" is not a lowercase slug segment`);if(org.advisor_org_slug!==config.advisor_org_slug){out.push(`public.json names advisor "${config.advisor_org_slug}" but config/org.json's advisor_org_slug is "${org.advisor_org_slug??"unset"}": a client's public row is accepted only under the advisor its org template names`)}for(const d of config.domains){if(d.is_wildcard===true||d.domain_pattern.includes("*"))out.push(`domain "${d.domain_pattern}" is a wildcard; a client's row is on one of the advisor's exact domains`)}return out}validateAll(){const errors=[];const warnings=[];let config;try{config=this.config()}catch(error){errors.push({file:"config/public.json",message:error instanceof Error?error.message:String(error)});return{layer:this.def.name,filesChecked:1,errors,warnings}}if(!config)return{layer:this.def.name,filesChecked:0,errors,warnings};const seen=new Set;for(const domain of config.domains){if(seen.has(domain.domain_pattern)){errors.push({file:"config/public.json",message:`domain_pattern "${domain.domain_pattern}" is declared twice`})}seen.add(domain.domain_pattern);if(domain.domain_pattern.includes("*")&&domain.is_wildcard!==true){warnings.push({file:"config/public.json",message:`"${domain.domain_pattern}" contains * but is_wildcard is false \u2014 it will only ever match literally`})}}for(const message of this.clientProblems(config))errors.push({file:"config/public.json",message});const icons=resolveBrandingIcons(config.branding,this.projectUrl(),{shapeOnly:true});for(const p of icons.problems){errors.push({file:"config/public.json",message:`branding.icons.${p.key}: ${p.message}`})}const declared=Object.keys(config.branding?.icons??{});if(declared.length>0&&!["192","512","maskable","180"].every(k=>declared.includes(k))){warnings.push({file:"config/public.json",message:"branding.icons does not name all of 192, 512, maskable and 180 \u2014 the host uses its neutral default for each one missing"})}if(!this.publicLayout()){warnings.push({file:"config/public.json",message:"the configured org template declares no public_layout \u2014 every domain row would be written with a null layout, and the anonymous path renders nothing"})}return{layer:this.def.name,filesChecked:1,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];const done=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});let config;try{config=this.config()}catch(error){errors.push({entity:"config/public.json",error:error instanceof Error?error.message:String(error)});return done()}if(!config){entities.push({entity:"org-template-public",action:"skipped",id:null,reason:"no template/config/public.json \u2014 this org publishes no public site"});return done()}const layout=this.publicLayout();const selected=new Set(this.files());let advisorOrgId;try{advisorOrgId=await this.advisorOrgId(config.advisor_org_slug)}catch(error){errors.push({entity:"advisor_org_slug",error:error instanceof Error?error.message:String(error)});return done()}const client=config.path_prefix!==void 0;if(client){const problems=this.clientProblems(config);if(problems.length>0){for(const message of problems)errors.push({entity:"config/public.json",error:message});return done()}}for(const domain of config.domains){if(!selected.has(domain.domain_pattern))continue;try{if(client){const site=await this.advisorSite(advisorOrgId,domain.domain_pattern);if(site)throw new Error(site)}const outcome=await upsertByNaturalKey(this.options.supabase,"org_template_public",{advisor_org_id:advisorOrgId,domain_pattern:domain.domain_pattern,client_org_id:client?this.options.orgId:null},{advisor_org_id:advisorOrgId,client_org_id:client?this.options.orgId:null,path_prefix:client?config.path_prefix:null,domain_pattern:domain.domain_pattern,is_wildcard:client?false:domain.is_wildcard??false,priority:domain.priority??100,is_active:domain.is_active??true,public_layout:layout,branding:resolveBrandingIcons(config.branding,this.projectUrl()).branding??null,metadata:config.metadata??null,updated_at:new Date().toISOString()},{dryRun:this.options.dryRun});entities.push({entity:domain.domain_pattern,action:outcome.action,id:outcome.id})}catch(error){errors.push({entity:domain.domain_pattern,error:error instanceof Error?error.message:String(error)})}}return done()}async fetchDbKeys(){const config=this.config();if(!config)return new Map;const advisorOrgId=await this.advisorOrgId(config.advisor_org_slug);const rows=this.options.supabase.from("org_template_public").select("id, domain_pattern").eq("advisor_org_id",advisorOrgId);const{data,error}=await(config.path_prefix!==void 0?rows.eq("client_org_id",this.options.orgId):rows.is("client_org_id",null));if(error)throw new Error(`org_template_public: ${error.message}`);const keys=new Map;for(const row of data??[]){keys.set(row.domain_pattern,row.id)}return keys}async advisorSite(advisorOrgId,domain){const{data,error}=await this.options.supabase.from("org_template_public").select("id, is_wildcard, is_active, verified_at").eq("advisor_org_id",advisorOrgId).eq("domain_pattern",domain).is("client_org_id",null).limit(2);if(error)throw new Error(`org_template_public: ${error.message}`);const site=(data??[])[0];if(!site)return`the advisor has no site row for ${domain}: a client's row rides on one of the advisor's own domains (sync the advisor's public.json first)`;if(site.is_wildcard)return`the advisor's row for ${domain} is a wildcard; a client's row needs an exact domain`;if(!site.is_active)return`the advisor's row for ${domain} is inactive`;if(!site.verified_at)return`the advisor's row for ${domain} is not verified yet (the platform verifies a domain, not the engine)`;return null}async healthChecks(){const config=this.config();if(!config)return[];const live=config.domains.filter(d=>d.is_active!==false);const exact=live.filter(d=>d.is_wildcard!==true);if(config.path_prefix!==void 0){return[{name:"public-client-rows",severity:live.length>0?"ok":"fail",message:live.length>0?`${live.length} client row(s) at /${config.path_prefix} on the advisor's domain(s)`:`every domain in public.json is is_active:false \u2014 /${config.path_prefix} resolves nowhere`}]}if(live.length===0){return[{name:"public-domains",severity:"fail",message:"every domain in public.json is is_active:false \u2014 the public site resolves nowhere"}]}if(exact.length===0){return[{name:"public-domains",severity:"warn",message:`${live.length} active domain(s), all wildcards \u2014 preview URLs resolve but the production hostname does not`}]}return[{name:"public-domains",severity:"ok",message:`${exact.length} exact + ${live.length-exact.length} wildcard domain(s) active`}]}};import{readFileSync as readFileSync6}from"node:fs";var PREFS=["push","suggest","ignore"];var FRAME_PATHS=["layout","profile","chrome"];var NEVER=["answer","submission"];var isPref=v=>typeof v==="string"&&PREFS.includes(v);function markerProblems(marker,layers){if(!marker||typeof marker!=="object"||Array.isArray(marker))return["update must be an object of layer \u2192 preference"];const out=[];for(const[layer,value]of Object.entries(marker)){if(NEVER.includes(layer)){out.push(`${layer} is never offered, so it takes no preference`);continue}if(!layers.includes(layer))out.push(`${layer} isn't a registered layer`);const check2=(pref,where,path)=>{if(pref==="note")return out.push(`${where}: note is reserved until agents read notes`);if(!isPref(pref))return out.push(`${where}: ${JSON.stringify(pref)} isn't push, suggest or ignore`);if(pref==="push"&&layer==="organisation")out.push(`${where}: the organisation rules are never pushed`);if(pref==="push"&&layer==="data"&&!(path&&FRAME_PATHS.includes(path)))out.push(`${where}: data is pushed only on its frame paths (${FRAME_PATHS.join(", ")})`)};if(typeof value==="string")check2(value,layer);else if(value&&typeof value==="object"&&!Array.isArray(value)){const split=value;const extra=Object.keys(split).filter(k=>k!=="default"&&k!=="paths");if(extra.length)out.push(`${layer}: a split takes default and paths only (not ${extra.join(", ")})`);check2(split.default,`${layer}.default`);if(!split.paths||typeof split.paths!=="object"||Array.isArray(split.paths))out.push(`${layer}.paths must be an object of top-level key \u2192 preference`);else for(const[path,pref]of Object.entries(split.paths))check2(pref,`${layer}.paths.${path}`,path)}else out.push(`${layer}: ${JSON.stringify(value)} isn't a preference or a split`)}return out}function pageTemplateProblems(pages,layers){const out=[];const seen=new Map;for(const p of pages){if(!p.pageType)out.push(`${p.label}: a page template needs a page_type (instances find it by page type)`);if(p.audience!==void 0&&p.audience!=="client"&&p.audience!=="staff")out.push(`${p.label}: audience is "client" or "staff", not ${JSON.stringify(p.audience)}`);const other=seen.get(p.pageType);if(other)out.push(`${p.label}: page_type "${p.pageType}" is also ${other}'s`);seen.set(p.pageType,p.label);for(const[where,marker]of Object.entries(p.update??{})){if(marker)for(const m of markerProblems(marker,layers))out.push(`${p.label}: ${where} update: ${m}`)}}return out}function artifactData(p){const page={...p.page?.blueprint!==void 0?{blueprint:p.page.blueprint}:{},...p.page?.editing!==void 0?{editing:p.page.editing}:{}};return{cells:p.document.cells,...p.document.data!==void 0?{data:p.document.data}:{},...p.document.layout!==void 0?{layout:p.document.layout}:{},...Object.keys(page).length?{page}:{}}}async function writePageTemplates(api,processId,pages){const anchors=await api.anchors(processId);const out=[];for(const p of pages){const existing=anchors.get(p.pageType);const anchorId=existing?.anchorId??await api.pageCreate({processId,name:p.name,pageType:p.pageType});await api.pageUpdate({anchorId,...existing&&existing.name!==p.name?{name:p.name}:{},model:{content_mode:null,shell:null,...p.model}});const artifactId=await api.pageArtifact(anchorId);await api.writeDocument({artifactId,data:artifactData(p),fixture:!!p.fixture});await api.setUpdate(anchorId,p.update?.anchor??null);await api.setUpdate(artifactId,p.update?.artifact??null);await api.setAudience(anchorId,p.audience??null);out.push({label:p.label,pageType:p.pageType,anchorId,artifactId,created:!existing})}return out}function pageTemplateOf(source,file,orgSlug){const{editing,shell,...layout}=file.layout??{};const content=file.content;return{label:source.label,name:file.name,pageType:file.page_type??"",document:{cells:content.cells??[],...content.data!==void 0?{data:content.data}:{},...Object.keys(layout).length?{layout}:{}},page:{...file.blueprint!==void 0?{blueprint:file.blueprint}:{},...editing!==void 0?{editing}:{}},model:{bundle_refs:[source.bundleName==="main"?orgSlug:{org_slug:orgSlug,bundle:source.bundleName}],...typeof file.metadata?.content_mode==="string"?{content_mode:file.metadata.content_mode}:{},...shell!==void 0?{shell}:{}},...file.update?{update:file.update}:{},...file.fixture?{fixture:true}:{},...file.audience!==void 0?{audience:file.audience}:{}}}var PageTemplateManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},status:{type:"string",default:"active"},page_type:{type:"string"},content:{type:"object",required:true},layout:{type:"object"},metadata:{type:"object"}},allowUnknown:true}}naturalKeyOf(def){return def.page_type??def.name}naturalKeyFilter(def){return{page_type:def.page_type??def.name}}toRow(){throw new Error("PageTemplateManager writes via syncAll()")}sources(){const filter=this.options.nameFilter;const out=[];for(const template of loadTemplatesConfig().project_templates){if(template.skip_pages)continue;for(const page of projectTemplatePages(template.slug)){const label=`${template.slug}/${page.page}`;if(filter&&filter!==page.page&&filter!==label&&filter!==template.slug)continue;out.push({label,projectSlug:template.slug,bundleName:template.bundle_name??"main",path:page.path,processId:template.page_template_process??null})}}return out}files(){return this.sources().map(s=>`src/project_templates/${s.projectSlug}/pages/${s.label.split("/")[1]}/page.json`)}read(path){const parsed=JSON.parse(readFileSync6(path,"utf8"));const file=parsed;if(!file?.name||!Array.isArray(file.content?.cells)){throw new Error(`${path}: page.json needs { name, content: { cells: [...] } }`)}return file}fileKeys(){const keys=[];for(const source of this.sources()){try{const file=this.read(source.path);keys.push(file.page_type??file.name)}catch{}}return keys}async fetchDbKeys(){const processId=process.env[PROCESS_ENV];if(!processId||!process.env.PERIPHERIES_TEMPLATE_TOKEN)return new Map;const db=this.mandateClient(processId);const anchors=await templateApi(db).anchors(processId);return new Map([...anchors].map(([type,a])=>[type,a.anchorId]))}mandateClient(processId){const{token}=templateToken(processId);const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_PUBLISHABLE_KEY;if(!url||!key)throw new Error("SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are needed to release templates (never the secret key).");return templateClient(url,key,token)}validateAll(){const errors=[];const warnings=[];const byPageType=new Map;let checked=0;for(const source of this.sources()){const label=`${source.label}/page.json`;let file;try{file=this.read(source.path)}catch(error){errors.push({file:label,message:error instanceof Error?error.message:String(error)});continue}checked++;if(!source.processId){errors.push({file:label,message:`project template "${source.projectSlug}" names no page_template_process in templates.json; its pages have nowhere to be released from`})}if(!file.page_type){errors.push({file:label,message:"no page_type: instances find a page template by page type, so it must have one"})}else{const previous=byPageType.get(file.page_type);if(previous){errors.push({file:label,message:`page_type "${file.page_type}" is also declared by ${previous}`})}else{byPageType.set(file.page_type,label)}}if(file.audience===void 0){warnings.push({file:label,message:`no audience: the page stays staff-only and is never a source for other templates' instances. Say "client" for a page clients get, "staff" for a working page`})}else if(file.audience!=="client"&&file.audience!=="staff"){errors.push({file:label,message:`audience is "client" or "staff", not ${JSON.stringify(file.audience)}`})}const cells=file.content.cells??[];if(cells.length===0){warnings.push({file:label,message:"no cells \u2014 the page renders empty"})}if(!file.layout){warnings.push({file:label,message:"no layout \u2014 one is derived from cell order on write"})}}return{layer:this.def.name,filesChecked:checked,errors,warnings}}async syncAll(){const started=Date.now();const entities=this.sources().map(s=>({entity:s.label,action:"skipped",id:null,reason:s.processId?"released only by `engine template release` under the with-template-token wrapper":`project template "${s.projectSlug}" names no page_template_process`}));return{layer:this.def.name,created:0,updated:0,skipped:entities.length,entities,errors:[],duration_ms:Date.now()-started}}async release(){const started=Date.now();const entities=[];const errors=[];const finish=()=>({layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started});const processId=process.env[PROCESS_ENV]?.toLowerCase()??null;const sources=this.sources().filter(s=>s.processId);for(const s of this.sources().filter(x=>!x.processId)){entities.push({entity:s.label,action:"skipped",id:null,reason:`project template "${s.projectSlug}" names no page_template_process`})}const mine=processId?sources.filter(s=>s.processId===processId):[];if(!processId){errors.push({entity:"page-templates",error:`${PROCESS_ENV} isn't set; run under the with-template-token wrapper`});return finish()}for(const s of sources.filter(x=>x.processId!==processId)){entities.push({entity:s.label,action:"skipped",id:null,reason:`another process (${s.processId}); released in its own wrapped run`})}if(!mine.length){errors.push({entity:"page-templates",error:`no project template in templates.json names page_template_process ${processId}`});return finish()}let pages;try{pages=mine.map(s=>pageTemplateOf(s,stripHarnessOnly(this.read(s.path)),this.options.orgSlug))}catch(error){errors.push({entity:"page-templates",error:error instanceof Error?error.message:String(error)});return finish()}try{const{deadline}=templateToken(processId);const db=this.mandateClient(processId);const{data:layerRows,error:layerError}=await db.from("system_layer").select("system_type");if(layerError)throw new Error(`system_layer: ${layerError.message}`);const layers=(layerRows??[]).map(r=>r.system_type.replace(/-system$/,""));const problems=pageTemplateProblems(pages,layers);if(problems.length){for(const p of problems)errors.push({entity:"page-templates",error:p});return finish()}if(this.options.dryRun){for(const p of pages)entities.push({entity:`${p.label} \u2192 ${p.pageType}`,action:"skipped",id:null,reason:"dry run"});return finish()}const api=templateApi(db);const written=await writePageTemplates(api,processId,pages);for(const w of written)entities.push({entity:`${w.label} \u2192 ${w.pageType}`,action:w.created?"created":"updated",id:w.anchorId});const root=await api.scopeRoot(processId);const release=await releaseWith(db,root,{deadline,log:line=>console.log(`[template] ${line}`)});entities.push({entity:`release ${release.number} on library`,action:"created",id:release.releaseId})}catch(error){errors.push({entity:"page-templates",error:error instanceof Error?error.message:String(error)})}return finish()}async healthChecks(){const missing=[...new Set(this.sources().filter(s=>!s.processId).map(s=>s.projectSlug))];return[missing.length?{name:"page-template-process",severity:"fail",message:`${missing.length} project template(s) with pages name no page_template_process: ${missing.join(", ")}`}:{name:"page-template-process",severity:"ok",message:"every project template with pages names its page-template process"}]}};import{existsSync as existsSync8,readFileSync as readFileSync7}from"node:fs";import{join as join8,relative as relative2}from"node:path";var GENERATOR2="template/cli sync --layer project-templates";var ProjectTemplateManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},project_type:{type:"string"},slug:{type:"string",minLength:1},processes:{type:"array"},audience:{type:"enum",values:["client","internal"]},layout:{type:"object"},metadata:{type:"object"}},allowUnknown:true}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(){throw new Error("ProjectTemplateManager writes via syncAll()")}sources(){const filter=this.options.nameFilter;return loadTemplatesConfig().project_templates.filter(t=>!filter||t.slug===filter).map(t=>({slug:t.slug,bundleName:t.bundle_name??"main",path:join8(projectTemplateDir(t.slug),"template.json")}))}files(){return this.sources().filter(s=>existsSync8(s.path)).map(s=>`src/project_templates/${s.slug}/template.json`)}read(path){const parsed=JSON.parse(readFileSync7(path,"utf8"));const file=parsed;if(!file||typeof file.name!=="string"){throw new Error(`${path}: template.json must have at least { "name": "..." }`)}return file}fileKeys(){const keys=[];for(const source of this.sources()){if(!existsSync8(source.path))continue;try{keys.push(this.read(source.path).name)}catch{}}return keys}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("project_template").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`project_template lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){out.set(row.name,row.id)}return out}validateAll(){const errors=[];const warnings=[];let checked=0;for(const source of this.sources()){const label=`src/project_templates/${source.slug}/template.json`;if(!existsSync8(source.path)){errors.push({file:label,message:"declared in config/templates.json but not on disk"});continue}checked++;let file;try{file=this.read(source.path)}catch(error){errors.push({file:label,message:error instanceof Error?error.message:String(error)});continue}if(!file.project_type){warnings.push({file:label,message:"no project_type \u2014 page-type routing needs one (host doc 07/08)"})}if(!file.layout){warnings.push({file:label,message:"no layout \u2014 the project renders no routes"})}if(collectComponentNames(file).length===0){warnings.push({file:label,message:"layout references no components"})}}return{layer:this.def.name,filesChecked:checked,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];for(const source of this.sources()){const label=`src/project_templates/${source.slug}/template.json`;try{if(!existsSync8(source.path)){throw new Error("declared in config/templates.json but not on disk")}const cleaned=stripHarnessOnly(this.read(source.path));const bundle=await resolveBundleIfAny(this.options.supabase,this.options.orgId,this.options.orgSlug,{version:this.options.versionPin??null,bundleName:source.bundleName});if(!bundle){entities.push({entity:cleaned.name,action:"skipped",id:null,reason:`no component_bundle in lineage "${source.bundleName}" yet \u2014 routes inline registry metadata from it, and project_template.bundle_name has an FK to component_bundle_lineage, which host cutover creates. Deploy the bundle, then re-run.`});continue}const componentNames=collectComponentNames(cleaned);const registry=await fetchRegistry(this.options.supabase,bundle.id,componentNames);const missing=missingBundleComponents(componentNames,registry);if(missing.length){console.warn(`[template] \u26A0 ${source.slug}: ${missing.length} component(s) not in bundle ${bundle.version}: ${missing.join(", ")}`)}const{data:existingRows,error:readError}=await this.options.supabase.from("project_template").select("id, metadata, bundle_name").eq("org_id",this.options.orgId).eq("name",cleaned.name).limit(2);if(readError)throw new Error(`project_template lookup failed: ${readError.message}`);const existing=existingRows??[];if(existing.length>1){throw new Error(`${existing.length} project_template rows named "${cleaned.name}" for this org \u2014 ambiguous, refusing to write. Ids: ${existing.map(r=>r.id).join(", ")}.`)}const layout=cleaned.layout?{...cleaned.layout,whitelisted_routes:inlineRouteRegistryMeta(cleaned.layout.whitelisted_routes,registry),...cleaned.layout.routes?{routes:inlineRouteRegistryMeta(cleaned.layout.routes,registry)}:{}}:null;const row={org_id:this.options.orgId,name:cleaned.name,description:cleaned.description??null,project_type:cleaned.project_type??null,...cleaned.slug!==void 0?{slug:cleaned.slug}:{},...cleaned.processes!==void 0?{processes:cleaned.processes}:{},...cleaned.audience!==void 0?{audience:cleaned.audience}:{},bundle_name:await lineageExists(this.options.supabase,this.options.orgId,source.bundleName)?source.bundleName:existing[0]?.bundle_name??null,layout,metadata:{...existing[0]?.metadata??{},...cleaned.metadata??{},bundle_refs:cleaned.metadata?.bundle_refs??[bundle.org_slug],template_meta:buildTemplateMeta({sourcePath:relative2(REPO_ROOT,source.path),bundle,componentNames,registry,generator:GENERATOR2})},updated_at:new Date().toISOString()};const outcome=await upsertByNaturalKey(this.options.supabase,"project_template",{org_id:this.options.orgId,name:cleaned.name},row,{dryRun:this.options.dryRun});if(!this.options.dryRun){this.mappings.set("project_template",cleaned.name,outcome.id)}entities.push({entity:`${cleaned.name} (${source.bundleName}/${bundle.version} via ${bundle.resolved_by})`,action:outcome.action,id:outcome.id})}catch(error){errors.push({entity:label,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}async healthChecks(){const checks=[];for(const source of this.sources()){if(!existsSync8(source.path))continue;let file;try{file=this.read(source.path)}catch{continue}const routes=file.layout?.whitelisted_routes??[];const referenced=[...new Set(routes.map(r=>r.params?.pageType).filter(t=>Boolean(t)))];if(referenced.length===0)continue;const{data,error}=await this.options.supabase.from("page_ids").select("page_type").eq("org_id",this.options.orgId).is("project_id",null).in("page_type",referenced);if(error){checks.push({name:"route-page-types",severity:"warn",message:`page-type lookup failed: ${error.message}`});continue}const present=new Set((data??[]).map(r=>r.page_type));const absent=referenced.filter(t=>!present.has(t));checks.push(absent.length===0?{name:"route-page-types",severity:"ok",message:`${source.slug}: all ${referenced.length} routed page type(s) have a template`}:{name:"route-page-types",severity:"warn",message:`${source.slug}: ${absent.length} routed page type(s) have no page template in this org \u2014 they render only if an advisor's or the platform's library provides them`,detail:absent})}return checks}};var ProjectManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},status:{type:"string",default:"active"},project_template_name:{type:"string",required:true,minLength:1},clients:{type:"array",default:[]},metadata:{type:"object"}},ignored:["_comment"]}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(){throw new Error("ProjectManager writes via syncAll()")}async anyBundleDeployed(){const{data,error}=await this.options.supabase.from("component_bundle").select("id").eq("org_id",this.options.orgId).limit(1);if(error)throw new Error(`component_bundle lookup failed: ${error.message}`);return(data??[]).length>0}async resolveTemplateId(name){const{data,error}=await this.options.supabase.from("project_template").select("id").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`project_template lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} project_template rows named "${name}" \u2014 ambiguous.`)}if(!rows[0]){if(!await this.anyBundleDeployed())return null;throw new Error(`no project_template named "${name}" for this org, and this org HAS deployed bundles \u2014 so this is a missing or misnamed template, not an undeployed environment. That row is owned by the project-templates LAYER, not this one:
|
|
10
10
|
npm run template -- sync --layer project-templates --env ${this.options.targetEnv}`)}return rows[0].id}async resolveClientId(reference){const bySlug=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("data->>slug",reference).limit(2);if(bySlug.error)throw new Error(`client lookup failed: ${bySlug.error.message}`);const slugRows=bySlug.data??[];if(slugRows.length>1){throw new Error(`${slugRows.length} clients have data->>slug="${reference}" \u2014 ambiguous.`)}if(slugRows[0])return slugRows[0].id;const byName=await this.options.supabase.from("client").select("id").eq("org_id",this.options.orgId).eq("name",reference).limit(2);if(byName.error)throw new Error(`client lookup failed: ${byName.error.message}`);const nameRows=byName.data??[];if(nameRows.length>1){throw new Error(`${nameRows.length} clients named "${reference}" \u2014 ambiguous.`)}return nameRows[0]?.id??null}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("project").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`project lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){if(row.name)out.set(row.name,row.id)}return out}async syncAll(){const started=Date.now();const entities=[];const errors=[];if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"projects",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}for(const file of this.files()){try{const def=this.loadFile(file);const templateId=await this.resolveTemplateId(def.project_template_name);if(templateId===null){entities.push({entity:def.name,action:"skipped",id:null,reason:`project_template "${def.project_template_name}" does not exist yet \u2014 this org has no deployed bundle, so the project-templates layer skipped too. Deploy (upload \u2192 register \u2192 host cutover), re-run project-templates, then re-run this layer.`});continue}const outcome=await upsertByNaturalKey(this.options.supabase,"project",{org_id:this.options.orgId,name:def.name},{org_id:this.options.orgId,name:def.name,description:def.description??null,status:def.status,project_template_id:templateId,metadata:def.metadata??{}},{dryRun:this.options.dryRun});let linked=0;const unresolved=[];for(const reference of def.clients){const clientId=await this.resolveClientId(reference);if(!clientId){unresolved.push(reference);continue}if(this.options.dryRun){linked++;continue}const{data:existing}=await this.options.supabase.from("client_project").select("id").eq("client_id",clientId).eq("project_id",outcome.id).maybeSingle();if(!existing){const{error}=await this.options.supabase.from("client_project").insert({client_id:clientId,project_id:outcome.id,org_id:this.options.orgId});if(error)throw new Error(`client_project insert failed: ${error.message}`)}linked++}if(unresolved.length){console.warn(`[template] \u26A0 project "${def.name}": could not resolve client(s) ${unresolved.join(", ")} \u2014 seed dev-users first. The project exists but those clients cannot open it.`)}if(!this.options.dryRun)this.mappings.set("project",def.name,outcome.id);entities.push({entity:`${def.name} (${linked}/${def.clients.length} client link(s))`,action:outcome.action,id:outcome.id})}catch(error){errors.push({entity:file,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};import{existsSync as existsSync9,readFileSync as readFileSync8,statSync}from"node:fs";import{extname,join as join9,resolve as resolve3}from"node:path";var MIME_TYPES={".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".webp":"image/webp",".svg":"image/svg+xml",".mp4":"video/mp4",".webm":"video/webm",".pdf":"application/pdf",".json":"application/json",".txt":"text/plain",".csv":"text/csv"};var ASSET_SHAPE={fields:{bucket:{type:"string",required:true,minLength:1},entity_type:{type:"any"},root:{type:"enum",values:["layer","repo"],default:"layer"},local_path:{type:"string",required:true,minLength:1},storage_path:{type:"string",required:true,minLength:1},advisor_slug:{type:"string"},description:{type:"string"}}};var StorageAssetManager=class extends LayerManager{get shape(){return{fields:{org_slug:{type:"string",required:true,minLength:1},assets:{type:"array",required:true}},ignored:["_comment"]}}naturalKeyOf(def){return def.org_slug}naturalKeyFilter(){return{}}toRow(){throw new Error("StorageAssetManager uploads files; it writes no table row")}files(){return existsSync9(join9(DATA_DIR,"storage","manifest.json"))?["manifest.json"]:[]}manifest(){return this.loadFile("manifest.json")}assets(){const errors=[];const out=[];const manifest=this.manifest();manifest.assets.forEach((raw,index)=>{const result=validateShape(raw,ASSET_SHAPE);if(!result.ok||!result.value){errors.push(`assets[${index}]: ${result.errors.join("; ")}`);return}out.push(result.value)});return{assets:out,errors}}localFile(asset){return asset.root==="repo"?resolve3(REPO_ROOT,asset.local_path):resolve3(DATA_DIR,"storage",asset.local_path)}resolvePath(asset){if(asset.entity_type==="advisor"){if(!asset.advisor_slug){throw new Error(`advisor_slug required for entity_type=advisor (${asset.storage_path})`)}const advisorId=this.mappings.getId("advisor",asset.advisor_slug);if(!advisorId){throw new Error(`cannot resolve advisor_id for slug "${asset.advisor_slug}" \u2014 seed dev-users first`)}return`${this.options.orgId}/advisors/${advisorId}/${asset.storage_path}`}if(asset.entity_type==="org"||asset.entity_type==="advisor_org"){return`${this.options.orgId}/${asset.storage_path}`}return asset.storage_path}mimeType(filePath){return MIME_TYPES[extname(filePath).toLowerCase()]??"application/octet-stream"}validateAll(){const errors=[];const warnings=[];if(this.files().length===0){return{layer:this.def.name,filesChecked:0,errors,warnings}}let assets=[];try{const parsed=this.assets();assets=parsed.assets;for(const message of parsed.errors)errors.push({file:"manifest.json",message})}catch(error){errors.push({file:"manifest.json",message:error instanceof Error?error.message:String(error)});return{layer:this.def.name,filesChecked:1,errors,warnings}}const destinations=new Map;for(const asset of assets){const local=this.localFile(asset);if(!existsSync9(local)){errors.push({file:"manifest.json",message:`file not found: ${asset.local_path}`});continue}if(statSync(local).size===0){warnings.push({file:"manifest.json",message:`empty file: ${asset.local_path}`})}const destination=`${asset.bucket}:${asset.storage_path}`;const previous=destinations.get(destination);if(previous){errors.push({file:"manifest.json",message:`${destination} is written by both "${previous}" and "${asset.local_path}"`})}else{destinations.set(destination,asset.local_path)}}return{layer:this.def.name,filesChecked:1,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];if(this.files().length===0){return{layer:this.def.name,created:0,updated:0,skipped:0,entities,errors,duration_ms:Date.now()-started}}let assets;try{const parsed=this.assets();assets=parsed.assets;for(const message of parsed.errors)errors.push({entity:"manifest.json",error:message})}catch(error){return{layer:this.def.name,created:0,updated:0,skipped:0,entities,errors:[{entity:"manifest.json",error:error instanceof Error?error.message:String(error)}],duration_ms:Date.now()-started}}const existing=await this.listBucketObjects(assets);for(const asset of assets){const key=`${asset.bucket}:${asset.storage_path}`;try{const local=this.localFile(asset);if(!existsSync9(local)){errors.push({entity:key,error:`file not found: ${asset.local_path}`});continue}const storagePath=this.resolvePath(asset);const alreadyThere=existing.has(`${asset.bucket}:${storagePath}`);if(this.options.dryRun){entities.push({entity:key,action:alreadyThere?"updated":"created",id:storagePath});continue}const{error}=await this.options.supabase.storage.from(asset.bucket).upload(storagePath,readFileSync8(local),{contentType:this.mimeType(local),upsert:true,cacheControl:"3600"});if(error){errors.push({entity:key,error:error.message});continue}this.mappings.set("storage_asset",key,storagePath);entities.push({entity:key,action:alreadyThere?"updated":"created",id:storagePath})}catch(error){errors.push({entity:key,error:error instanceof Error?error.message:String(error)})}}return{layer:this.def.name,created:entities.filter(e=>e.action==="created").length,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}async listBucketObjects(assets){const folders=new Set;for(const asset of assets){let resolved;try{resolved=this.resolvePath(asset)}catch{continue}const slash=resolved.lastIndexOf("/");folders.add(`${asset.bucket}:${slash===-1?"":resolved.slice(0,slash)}`)}const found=new Set;for(const folder of folders){const separator=folder.indexOf(":");const bucket=folder.slice(0,separator);const prefix=folder.slice(separator+1);const{data,error}=await this.options.supabase.storage.from(bucket).list(prefix,{limit:1e3});if(error)continue;for(const entry of data??[]){if(entry.name)found.add(`${bucket}:${prefix?`${prefix}/${entry.name}`:entry.name}`)}}return found}async fetchDbKeys(){if(this.files().length===0)return new Map;const{assets}=this.assets();const existing=await this.listBucketObjects(assets);const out=new Map;for(const entry of existing)out.set(entry,entry);return out}async status(){const base={layer:this.def.name,kind:this.def.kind,implemented:this.def.implemented,files:0,dbRows:null,mappedIds:this.mappingEntities().reduce((sum,e)=>sum+this.mappings.count(e),0),missingInDb:[],missingInFiles:[],unmappedInDb:[]};if(this.files().length===0)return base;try{const{assets}=this.assets();base.files=assets.length;const declared=new Map;for(const asset of assets)declared.set(`${asset.bucket}:${this.resolvePath(asset)}`,asset.storage_path);const present=await this.listBucketObjects(assets);base.dbRows=[...declared.keys()].filter(d=>present.has(d)).length;base.missingInDb=[...declared.entries()].filter(([full])=>!present.has(full)).map(([,short])=>short).sort();base.missingInFiles=[...present].filter(p=>!declared.has(p)).sort()}catch(error){base.error=error instanceof Error?error.message:String(error)}return base}async healthChecks(){if(this.files().length===0)return[];const{assets}=this.assets();const declared=new Set;for(const asset of assets){try{declared.add(`${asset.bucket}:${this.resolvePath(asset)}`)}catch{}}const present=await this.listBucketObjects(assets);const missing=[...declared].filter(d=>!present.has(d)).sort();const extra=[...present].filter(p=>!declared.has(p)).sort();const checks=[missing.length===0?{name:"storage-declared",severity:"ok",message:`all ${declared.size} declared asset(s) present in the bucket`}:{name:"storage-declared",severity:"warn",message:`${missing.length} declared asset(s) not in the bucket \u2014 run sync`,detail:missing}];if(extra.length>0){checks.push({name:"storage-unmanaged",severity:"warn",message:`${extra.length} object(s) in the manifest's folders are declared by no asset. Uploaded out of band, or left from an uploader that used different filenames. This layer never deletes.`,detail:extra})}return checks}};var LAYER_ORDER=["org","org-template-layout","org-template-public","event-types","data-types","feed-producers","agent-templates","form-templates","project-templates","page-templates","storage","dev-users","projects","dev-workbooks","dev-client-data"];var LAYERS={org:{name:"org",kind:"data",table:"organization + org_template",naturalKey:"metadata->>org_slug; org_template (org_id, is_archived=false)",mappingEntity:"org",sourceDir:"config",envs:["local","develop","prod"],implemented:true,notes:"Owns identity columns only: org_id, advisor_org_id, is_published/is_archived, metadata.org_slug. name/description are written on INSERT only \u2014 the layout sync owns them after that."},"org-template-layout":{name:"org-template-layout",kind:"data",table:"org_template.*_layout",naturalKey:"metadata->>org_slug (read-modify-write)",mappingEntity:"org_template",sourceDir:null,envs:["local","develop","prod"],implemented:true,notes:"Source stays in src/org_templates/<slug>/template.json (D-T3); config/templates.json names which slug and which bundle lineage. Owns the *_layout columns PRESENT in the file \u2014 an absent one is left untouched. Never creates the row; `sync --layer org` does."},"org-template-public":{name:"org-template-public",kind:"data",table:"org_template_public",naturalKey:"(advisor_org_id, domain_pattern, client_org_id)",mappingEntity:"org_template_public",sourceDir:null,envs:["local","develop","prod"],implemented:true,notes:"Advisor orgs only \u2014 the anonymous hostname\u2192layout lookup. Reads config/public.json for domains/branding and takes public_layout from src/org_templates/<slug>/template.json, so the layout is single-sourced rather than duplicated as the host did."},"event-types":{name:"event-types",kind:"data",table:"event_type",naturalKey:"(org_id, name) \u2014 NOT unique-constrained in the DB",mappingEntity:"event_type",sourceDir:"data/shared/event-types",envs:["local","develop","prod"],implemented:true,notes:"SHARED data (its hashes ship with the engine): org-scoped rows, identical in every org. data/event-types/ holds org-specific additions and is not hashed. category/display_order are in the files but are not columns \u2014 deliberately not written."},"data-types":{name:"data-types",kind:"data",table:"client_data_type",naturalKey:"(org_id, name) \u2014 NOT unique-constrained in the DB",mappingEntity:"client_data_type",sourceDir:"data/data-types",envs:["local","develop","prod"],implemented:true},"feed-producers":{name:"feed-producers",kind:"data",table:"feed_producer",naturalKey:"(org_id, key) \u2014 UNIQUE",mappingEntity:"feed_producer",sourceDir:"data/feed-producers",envs:["local","develop","prod"],implemented:true},"agent-templates":{name:"agent-templates",kind:"data",table:"agent",naturalKey:"(org_id, name, is_template=true)",mappingEntity:"agent",sourceDir:"data/agent-templates",envs:["local","develop","prod"],implemented:true,notes:"Org-scoped rows only \u2014 core (org_id IS NULL) agents stay in BEA (D-T9)."},"form-templates":{name:"form-templates",kind:"data",table:"form SCGs (form_template_write)",naturalKey:"(form_template_process, template_key); questions by key",mappingEntity:null,sourceDir:"data/form-templates",envs:["local","develop","prod"],implemented:true,notes:"Every published data/form-templates/*.json, released by `engine template release` as the org's form-template process's mandate (templates.json form_template_process): form_template_write per form, then a release on library. No client_form rows; seed and sync skip this layer."},"project-templates":{name:"project-templates",kind:"data",table:"project_template",naturalKey:"(org_id, name) (read-modify-write)",mappingEntity:"project_template",sourceDir:null,envs:["local","develop","prod"],implemented:true,notes:"Source stays in src/project_templates/<slug>/template.json (D-T3). Writes bundle_name, which the .mjs uploader never did, and resolves registry metadata against the is_live bundle."},"page-templates":{name:"page-templates",kind:"data",table:"node (page anchors, via page_create / page_update)",naturalKey:"(page_template_process, page_type)",mappingEntity:null,sourceDir:null,envs:["local","develop","prod"],implemented:true,notes:"Every pages/*/page.json of the project template whose page_template_process the with-template-token wrapper names, released by `engine template release` as that process's mandate: page anchors and artifacts, then a release on library. No project_page rows; seed and sync skip this layer."},storage:{name:"storage",kind:"data",table:"storage.objects",naturalKey:"bucket + resolved path (org_id prefix from slug, never hardcoded)",mappingEntity:"storage_asset",sourceDir:"data/storage",envs:["local","develop","prod"],implemented:true,notes:'ONE manifest replaces three disagreeing uploaders. Branding SVGs are single-homed in assets/branding/ via root:"repo" \u2014 never copied into template/data/.'},"dev-users":{name:"dev-users",kind:"seed",table:"auth.users + org_member + advisor/client",naturalKey:"email; (user_id, org_id); (advisor_id, org_id)",mappingEntity:"client",mappingEntities:["advisor"],sourceDir:"seed/dev-users",envs:["local","develop"],implemented:true,notes:"Advisors live in the OTA repo, client users here (D-T4). org_member and advisor have NO unique constraint on (user_id, org_id) \u2014 duplicates break role resolution, so doctor checks."},projects:{name:"projects",kind:"seed",table:"project + client_project",naturalKey:"(org_id, name)",mappingEntity:"project",sourceDir:"seed/projects",envs:["local","develop"],implemented:true,notes:"NEW content for block-plans, not a port \u2014 the legacy fixtures resolved template rows no file recreates (D-T10). The project_template row is owned by the project-templates layer."},"dev-workbooks":{name:"dev-workbooks",kind:"seed",table:"client_form + client_input",naturalKey:"(client_id, template_id); client_input (form_id, field_id)",mappingEntity:null,sourceDir:"seed/dev-workbooks",envs:["local","develop"],implemented:true,notes:"CHECK-THEN-INSERT ONLY \u2014 these rows carry member progress, so an existing workbook is never overwritten (the converger objection in HOST-025/026). Definitions belong to form-templates; this layer only records that a member started one. client_input is APPEND-ONLY with no unique constraint on (form_id, field_id), so existence is checked here, not by the database."},"dev-client-data":{name:"dev-client-data",kind:"seed",table:"client_data",naturalKey:"(client_id, data_type_id, name)",mappingEntity:null,sourceDir:"seed/dev-client-data",envs:["local","develop"],implemented:true,notes:`CHECK-THEN-INSERT ONLY \u2014 client_data is append-only artefact history (the host inserts a new "Client Profile" row per save rather than updating), so a converger here would either revert a member's current profile or delete the history it versions. client_data_type rows belong to the data-types layer and are resolved by exact name, never created.`}};var FACTORIES={org:(options,mappings)=>new OrgManager(LAYERS.org,options,mappings),"org-template-layout":(options,mappings)=>new OrgTemplateLayoutManager(LAYERS["org-template-layout"],options,mappings),"project-templates":(options,mappings)=>new ProjectTemplateManager(LAYERS["project-templates"],options,mappings),"page-templates":(options,mappings)=>new PageTemplateManager(LAYERS["page-templates"],options,mappings),"org-template-public":(options,mappings)=>new OrgTemplatePublicManager(LAYERS["org-template-public"],options,mappings),"event-types":(options,mappings)=>new EventTypeManager(LAYERS["event-types"],options,mappings),"data-types":(options,mappings)=>new DataTypeManager(LAYERS["data-types"],options,mappings),"feed-producers":(options,mappings)=>new FeedProducerManager(LAYERS["feed-producers"],options,mappings),"agent-templates":(options,mappings)=>new AgentTemplateManager(LAYERS["agent-templates"],options,mappings),"form-templates":(options,mappings)=>new FormTemplateManager(LAYERS["form-templates"],options,mappings),storage:(options,mappings)=>new StorageAssetManager(LAYERS.storage,options,mappings),"dev-users":(options,mappings)=>new DevUserManager(LAYERS["dev-users"],options,mappings),projects:(options,mappings)=>new ProjectManager(LAYERS.projects,options,mappings),"dev-workbooks":(options,mappings)=>new DevWorkbookManager(LAYERS["dev-workbooks"],options,mappings),"dev-client-data":(options,mappings)=>new DevClientDataManager(LAYERS["dev-client-data"],options,mappings)};function isLayerName(value){return value in LAYERS}function assertLayerName(value){if(!isLayerName(value)){throw new Error(`unknown layer "${value}". Known layers: ${LAYER_ORDER.join(", ")}. Run \`npm run template -- list\` to see which are ported.`)}return value}function createManager(layer,options,mappings){const factory=FACTORIES[layer];return factory?factory(options,mappings):null}function resolveLayers(owned,options){const ordered=LAYER_ORDER.filter(l=>owned.includes(l));const selected=options.layer?ordered.filter(l=>l===options.layer):ordered;return selected.filter(l=>LAYERS[l].envs.includes(options.targetEnv))}import{existsSync as existsSync10,mkdirSync,readFileSync as readFileSync9,renameSync,writeFileSync}from"node:fs";import{join as join10}from"node:path";var MAPPING_ENTITIES=["org","org_template","org_template_public","client_data_type","event_type","feed_producer","agent","form_template","form_page","form_section","form_field","shared_field","project_template","project_page","project","client","client_form","advisor","storage_asset","process","system","skill"];var ENTITY_SET=new Set(MAPPING_ENTITIES);function isMappingEntity(value){return ENTITY_SET.has(value)}function assertMappingEntity(value){if(!isMappingEntity(value)){throw new Error(`"${value}" is not a canonical mapping entity. Add it to MAPPING_ENTITIES in template/lib/entities.ts rather than inventing a spelling \u2014 two spellings of one entity means two mapping files, and the second one starts empty.`)}return value}function mappingFileName(entity,targetEnv){return`${entity}.mapping.${targetEnv}.json`}var META_KEYS=new Set(["_comment","_generated"]);var MappingStore=class{constructor(mappingsDir,targetEnv,now=()=>new Date().toISOString()){this.mappingsDir=mappingsDir;this.targetEnv=targetEnv;this.now=now}mappingsDir;targetEnv;now;cache=new Map;dirty=new Set;load(entity){const cached=this.cache.get(entity);if(cached)return cached;const path=join10(this.mappingsDir,mappingFileName(entity,this.targetEnv));const data={};if(existsSync10(path)){try{const parsed=JSON.parse(readFileSync9(path,"utf8"));for(const[key,value]of Object.entries(parsed)){if(!META_KEYS.has(key))data[key]=value}}catch(error){throw new Error(`mapping file ${path} is not valid JSON \u2014 fix or delete it before seeding. (${error instanceof Error?error.message:String(error)})`)}}this.cache.set(entity,data);return data}get(entityName,naturalKey){const entity=assertMappingEntity(entityName);return this.load(entity)[naturalKey]}getId(entityName,naturalKey){const value=this.get(entityName,naturalKey);return typeof value==="string"?value:void 0}set(entityName,naturalKey,value){const entity=assertMappingEntity(entityName);if(value===void 0||value===null){throw new Error(`refusing to map ${entity}."${naturalKey}" to ${String(value)}`)}const mapping=this.load(entity);if(mapping[naturalKey]===value)return;mapping[naturalKey]=value;this.dirty.add(entity)}keys(entityName){const entity=assertMappingEntity(entityName);return Object.keys(this.load(entity)).sort()}count(entityName){return this.keys(entityName).length}hasChanges(){return this.dirty.size>0}changedEntities(){return[...this.dirty].sort()}flushedEntities(){return[...this.lastFlushed].sort()}lastFlushed=[];flush(){this.lastFlushed=[...this.dirty].sort();if(this.dirty.size===0)return[];if(!existsSync10(this.mappingsDir))mkdirSync(this.mappingsDir,{recursive:true});const written=[];for(const entity of this.dirty){const mapping=this.cache.get(entity)??{};const ordered={};for(const key of Object.keys(mapping).sort())ordered[key]=mapping[key];const body={_comment:"Auto-generated by template/cli (seed | sync | pull). Commit this file.",_generated:this.now(),...ordered};const path=join10(this.mappingsDir,mappingFileName(entity,this.targetEnv));const temp=`${path}.tmp`;writeFileSync(temp,`${JSON.stringify(body,null,2)}
|
|
11
11
|
`,"utf8");renameSync(temp,path);written.push(path)}this.dirty.clear();return written}};function loadLayersConfig(){const path=join11(CONFIG_DIR,"layers.json");if(!existsSync11(path))return{layers:[...LAYER_ORDER],bundleRefFloor:1};const parsed=JSON.parse(readFileSync10(path,"utf8"));const result=validateShape(parsed,{fields:{layers:{type:"array",required:true},bundle_ref_floor:{type:"number",required:false}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`template/config/layers.json is invalid:
|