@peripheries/engine 0.17.2 → 0.19.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peripheries/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Build, check, knowledge and deploy for peripheries component repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@supabase/supabase-js": "2.112.0",
|
|
37
|
+
"ajv": "8.20.0",
|
|
37
38
|
"semver": "7.8.5",
|
|
38
39
|
"tsx": "4.23.5"
|
|
39
40
|
},
|
|
@@ -41,11 +42,11 @@
|
|
|
41
42
|
"@peripheries/renderer": ">=0.6.2 <1",
|
|
42
43
|
"@peripheries/runtime": ">=0.6.2 <1",
|
|
43
44
|
"@vitejs/plugin-react": ">=6 <7",
|
|
44
|
-
"typescript": ">=5.6 <6",
|
|
45
|
-
"vite": ">=8.2 <9",
|
|
46
45
|
"playwright": ">=1.60 <2",
|
|
47
46
|
"react": ">=19.2 <20",
|
|
48
|
-
"react-dom": ">=19.2 <20"
|
|
47
|
+
"react-dom": ">=19.2 <20",
|
|
48
|
+
"typescript": ">=5.6 <6",
|
|
49
|
+
"vite": ">=8.2 <9"
|
|
49
50
|
},
|
|
50
51
|
"peerDependenciesMeta": {
|
|
51
52
|
"typescript": {
|
|
@@ -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 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 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}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):[];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/.`)}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;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 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}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}.
|
|
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,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{readFileSync,existsSync,readdirSync}from"node:fs";import{join,dirname,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 repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const args=process.argv.slice(2);const only=args.find(a=>!a.startsWith("--"));const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const STATE_SYSTEMS=new Set(["eng_data","eng_process","eng_model"]);const MAX_NODES_CEILING=500;const DEFAULT_MAX_NODES=50;const RESERVED_KEYS=new Set(["_traversal","params"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let a1SkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){a1SkipReason="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){a1SkipReason=`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 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 collectTokens(value,out=[]){if(typeof value==="string"){if(value.startsWith("@"))out.push(value)}else if(Array.isArray(value)){for(const v of value)collectTokens(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectTokens(v,out)}return out}function collectInterpolations(value,out=[]){if(typeof value==="string"){for(const match of value.matchAll(/\$\{(?:context\.)?([^}]+)\}/g)){out.push({raw:match[0],path:match[1]})}}else if(Array.isArray(value)){for(const v of value)collectInterpolations(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectInterpolations(v,out)}return out}function pageFilesFor(templateSlug){const pagesDir=join(templatesRoot,templateSlug,"pages");const shipped=[];const stale=[];if(!existsSync(pagesDir))return{shipped,stale};for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const dir=join(pagesDir,entry.name);for(const file of readdirSync(dir)){if(file==="page.json")shipped.push(join(dir,file));else if(/^page\..+\.json$/.test(file))stale.push(join(dir,file))}}return{shipped,stale}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let fileCount=0;let staleCount=0;let cellCount=0;let documentPageCount=0;let tokenCount=0;let patternBCells=0;function checkComponentName(where,name,problems){if(!name){problems.push(`${where} has no component.`);return}if(name.startsWith("/")||name.startsWith("@"))return;if(!exportedNames)return;if(!exportedNames.has(name)){if(sharedNames.has(name)){sharedRefCount+=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 file ("${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 W5_SEEN=new Map;const RETIRED="retired host-contract binding; replace with a recipe descriptor";function checkRegistryIds(where,label,cell,problems){const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";const queries=cell.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id===""){if(decl&&typeof decl==="object"&&decl.graph_node_id)continue;const key=`${label}::${friendly}`;const seen=W5_SEEN.get(key);if(seen){seen.count+=1;continue}const record={count:1,index:warnings.length};W5_SEEN.set(key,record);warnings.push(`${label}: data.queries.${friendly} declares neither \`registry\` nor \`graph_node_id\` (keys: ${Object.keys(decl??{}).join(", ")||"none"}), so getStaticDataFromDeclarations resolves it to null. Harmless only because these cells bind through the traversal binding_key "${friendly}" instead.`);continue}warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}for(const[friendly,decl]of Object.entries(cell.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id==="")continue;warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}function auditPage(file,slug,tag,allowedParams,problems){const rel=relative(repoRoot,file);const label=tag?`${rel} [${tag}]`:rel;let page;try{page=JSON.parse(readFileSync(file,"utf8"))}catch(err){problems.push(`${label}: invalid JSON \u2014 ${err.message}`);return}const declared=new Set(Object.keys(page.metadata?.binding_sources??{}));const cells=page.content?.cells??[];const pageLayout=page.layout??{};let structureChildren=null;let documentPages=0;if(pageLayout.type==="document"){documentPages+=1;structureChildren=new Set;const structure=Array.isArray(pageLayout.structure)?pageLayout.structure:null;if(!structure){problems.push(`${label}: layout.type is "document" but there is no layout.structure \u2014 StructureRenderer renders nothing.`)}else{const visit=node=>{if(!node)return;if(typeof node==="string"){structureChildren.add(node);return}if(node.cellId)structureChildren.add(node.cellId);if(node.id&&!node.cellId){problems.push(`${label}: structure node uses "id": "${node.id}" \u2014 the renderer reads "cellId" and ignores "id", so this node resolves to nothing and renders blank. Rename the key.`)}for(const child of node.children??[])visit(child)};for(const node of structure)visit(node)}}documentPageCount+=documentPages;const seenIds=new Set;for(const cell of cells){cellCount+=1;const where=`${label}: cell "${cell.id}"`;if(cell.id===void 0||cell.id===null||cell.id===""){problems.push(`${label}: a cell has no id.`)}else if(seenIds.has(cell.id)){problems.push(`${label}: duplicate cell id "${cell.id}".`)}else{seenIds.add(cell.id)}checkComponentName(where,cell.component,problems);checkRegistryIds(where,label,cell,problems);if(structureChildren&&cell.id&&!structureChildren.has(cell.id)){problems.push(`${where} is not claimed by any layout.structure[].children, so StructureRenderer clears it and the cell vanishes with no error.`)}const span=cell.layout?.span;if(pageLayout.type==="grid"&&span!==void 0){const columns=pageLayout.columns??12;if(!Number.isInteger(span)||span<1||span>columns){warnings.push(`${where} layout.span is ${JSON.stringify(span)} on a ${columns}-column grid \u2014 expected an integer 1..${columns}.`)}}const tokens=collectTokens(cell.props??{});tokenCount+=tokens.length;const traversal=cell.traversal;const keys=tokens.map(t=>t.slice(1).split(".")[0]);const usesPatternB=keys.includes("_traversal");if(usesPatternB)patternBCells+=1;const dataTokens=tokens.filter(t=>!t.startsWith("@params."));if(dataTokens.length>0&&!traversal){problems.push(`${where} has ${dataTokens.length} @token(s) but no traversal block \u2014 usePageCellTraversalData skips it and every token resolves to nothing.`)}if(tokens.length===0&&traversal){problems.push(`${where} declares traversal but has no @tokens \u2014 a BFS per render feeding nothing.`)}for(const token of tokens){if(!token.startsWith("@params."))continue;const param=token.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(param))continue;if(allowedParams?.has(param))continue;problems.push(`${where} reads ${token} but "${param}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. pickAllowedContextParams drops an undeclared query parameter, so this resolves to nothing and the cell renders its empty state.`)}for(const{raw,path}of collectInterpolations(cell.props??{})){const root=path.split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;if(allowedParams?.has(root))continue;problems.push(`${where} interpolates ${raw} but "${root}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. On a miss the host leaves the literal "${raw}" in the prop, which reads as a real value downstream.`)}for(const[i,key]of keys.entries()){if(RESERVED_KEYS.has(key))continue;if(!declared.has(key)){problems.push(`${where} token ${tokens[i]} \u2192 key "${key}" is not in metadata.binding_sources, so no producer is documented for it.`)}}if(!traversal)continue;if(!Array.isArray(traversal.edgeTypes)||traversal.edgeTypes.length===0){problems.push(`${where} traversal has no edgeTypes. There is no RPC default \u2014 the walk returns nothing.`)}if(traversal.include_state!==void 0){if(!Array.isArray(traversal.include_state)){problems.push(`${where} traversal.include_state must be an array.`)}else{for(const system of traversal.include_state){if(!STATE_SYSTEMS.has(system)){problems.push(`${where} traversal.include_state contains ${JSON.stringify(system)} \u2014 expected one of ${[...STATE_SYSTEMS].join(" | ")}. resolveStateSystems drops it and the cell renders no state for that system.`)}}}}if(usesPatternB&&!traversal.anchor){problems.push(`${where} consumes @_traversal but declares no traversal.anchor. Auto-wire matches @<binding_key> tokens in props to create the data_flow source\u2192cell edge, and a Pattern B cell has none \u2014 without an anchor the edge must be hand-wired per instance.`)}if(traversal.maxNodes!==void 0){const n=traversal.maxNodes;if(!Number.isInteger(n)||n<=0){problems.push(`${where} traversal.maxNodes must be a positive integer, got ${JSON.stringify(n)}.`)}else if(n>MAX_NODES_CEILING){problems.push(`${where} traversal.maxNodes ${n} exceeds the host ceiling of ${MAX_NODES_CEILING}; it would be clamped.`)}}if(usesPatternB&&!traversal.include_state){warnings.push(`${where} consumes @_traversal.nodes with no include_state \u2014 nodes arrive with content only (no status, no criteria). Correct for a label-only view; otherwise add include_state.`)}if(usesPatternB&&traversal.maxNodes===void 0&&(traversal.maxDepth??3)>1){warnings.push(`${where} walks to depth ${traversal.maxDepth??3} at the default cap of ${DEFAULT_MAX_NODES} nodes, so it may truncate silently. Set traversal.maxNodes.`)}}}for(const slug of templates){const templateFile=join(templatesRoot,slug,"template.json");let allowedParams=null;if(existsSync(templateFile)){try{const template=JSON.parse(readFileSync(templateFile,"utf8"));const declaredParams=template.layout?.context_parameters;if(Array.isArray(declaredParams))allowedParams=new Set(declaredParams)}catch{}}const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;const{shipped,stale}=pageFilesFor(slug);for(const file of stale){staleCount+=1;problems.push(`${relative(repoRoot,file)} exists beside page.json. page.json is the single source \u2014 the page-templates layer reads it and nothing in scripts/, src/ or test/ reads this file, so it is edited and never shipped. Merge it into page.json and delete it.`)}for(const file of shipped){fileCount+=1;auditPage(file,slug,null,allowedParams,problems)}for(const file of stale){auditPage(file,slug,"stale",allowedParams,warnings)}}for(const rec of W5_SEEN.values()){if(rec.count>1)warnings[rec.index]+=` Repeated on ${rec.count} cells.`}if(a1SkipReason){console.warn(`[check-wired-bindings] A1 SKIPPED \u2014 ${a1SkipReason}. Component names unchecked.
|
|
2
|
+
import{readFileSync,existsSync,readdirSync}from"node:fs";import{join,dirname,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 repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const args=process.argv.slice(2);const only=args.find(a=>!a.startsWith("--"));const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const STATE_SYSTEMS=new Set(["eng_data","eng_process","eng_model"]);const MAX_NODES_CEILING=500;const DEFAULT_MAX_NODES=50;const RESERVED_KEYS=new Set(["_traversal","params"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let a1SkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){a1SkipReason="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){a1SkipReason=`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 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 collectTokens(value,out=[]){if(typeof value==="string"){if(value.startsWith("@"))out.push(value)}else if(Array.isArray(value)){for(const v of value)collectTokens(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectTokens(v,out)}return out}function collectInterpolations(value,out=[]){if(typeof value==="string"){for(const match of value.matchAll(/\$\{(?:context\.)?([^}]+)\}/g)){out.push({raw:match[0],path:match[1]})}}else if(Array.isArray(value)){for(const v of value)collectInterpolations(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectInterpolations(v,out)}return out}function pageFilesFor(templateSlug){const pagesDir=join(templatesRoot,templateSlug,"pages");const shipped=[];const stale=[];if(!existsSync(pagesDir))return{shipped,stale};for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const dir=join(pagesDir,entry.name);for(const file of readdirSync(dir)){if(file==="page.json")shipped.push(join(dir,file));else if(/^page\..+\.json$/.test(file))stale.push(join(dir,file))}}return{shipped,stale}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let fileCount=0;let staleCount=0;let cellCount=0;let documentPageCount=0;let tokenCount=0;let patternBCells=0;function checkComponentName(where,name,problems){if(!name){problems.push(`${where} has no component.`);return}if(name.startsWith("/")||name.startsWith("@"))return;if(!exportedNames)return;if(!exportedNames.has(name)){if(sharedNames.has(name)){sharedRefCount+=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 file ("${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 W5_SEEN=new Map;const RETIRED="retired host-contract binding; replace with a recipe descriptor";function checkRegistryIds(where,label,cell,problems){const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";const queries=cell.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id===""){if(decl&&typeof decl==="object"&&decl.graph_node_id)continue;if(decl&&typeof decl==="object"&&"descriptor"in decl)continue;const key=`${label}::${friendly}`;const seen=W5_SEEN.get(key);if(seen){seen.count+=1;continue}const record={count:1,index:warnings.length};W5_SEEN.set(key,record);warnings.push(`${label}: data.queries.${friendly} declares neither \`registry\` nor \`graph_node_id\` (keys: ${Object.keys(decl??{}).join(", ")||"none"}), so getStaticDataFromDeclarations resolves it to null. Harmless only because these cells bind through the traversal binding_key "${friendly}" instead.`);continue}warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}for(const[friendly,decl]of Object.entries(cell.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id==="")continue;warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}function declaredQueryRoots(where,cell,page,problems,report="cell"){const roots=new Set;const scopes=[["page",page.content?.data?.queries],["cell",cell.data?.queries]];for(const[scope,queries]of scopes){if(!queries||typeof queries!=="object")continue;for(const[name,decl]of Object.entries(queries)){if(!decl||typeof decl!=="object")continue;if("descriptor"in decl){const d=decl.descriptor;const ok=d&&typeof d==="object"&&typeof d.kind==="string"&&d.kind!==""&&typeof d.document==="string"&&d.document.trim()!=="";if(ok)roots.add(name);else if(scope===report){problems.push(`${where} data.queries.${name}.descriptor needs a string \`kind\` and \`document\`; the host resolves a malformed descriptor to nothing.`)}continue}if(typeof decl.graph_node_id==="string"&&decl.graph_node_id)roots.add(name)}}return roots}function auditPage(file,slug,tag,allowedParams,problems){const rel=relative(repoRoot,file);const label=tag?`${rel} [${tag}]`:rel;let page;try{page=JSON.parse(readFileSync(file,"utf8"))}catch(err){problems.push(`${label}: invalid JSON \u2014 ${err.message}`);return}const declared=new Set(Object.keys(page.metadata?.binding_sources??{}));declaredQueryRoots(label,{},{content:{data:{queries:page.content?.data?.queries}}},problems,"page");const cells=page.content?.cells??[];const pageLayout=page.layout??{};let structureChildren=null;let documentPages=0;if(pageLayout.type==="document"){documentPages+=1;structureChildren=new Set;const structure=Array.isArray(pageLayout.structure)?pageLayout.structure:null;if(!structure){problems.push(`${label}: layout.type is "document" but there is no layout.structure \u2014 StructureRenderer renders nothing.`)}else{const visit=node=>{if(!node)return;if(typeof node==="string"){structureChildren.add(node);return}if(node.cellId)structureChildren.add(node.cellId);if(node.id&&!node.cellId){problems.push(`${label}: structure node uses "id": "${node.id}" \u2014 the renderer reads "cellId" and ignores "id", so this node resolves to nothing and renders blank. Rename the key.`)}for(const child of node.children??[])visit(child)};for(const node of structure)visit(node)}}documentPageCount+=documentPages;const seenIds=new Set;for(const cell of cells){cellCount+=1;const where=`${label}: cell "${cell.id}"`;if(cell.id===void 0||cell.id===null||cell.id===""){problems.push(`${label}: a cell has no id.`)}else if(seenIds.has(cell.id)){problems.push(`${label}: duplicate cell id "${cell.id}".`)}else{seenIds.add(cell.id)}checkComponentName(where,cell.component,problems);checkRegistryIds(where,label,cell,problems);if(structureChildren&&cell.id&&!structureChildren.has(cell.id)){problems.push(`${where} is not claimed by any layout.structure[].children, so StructureRenderer clears it and the cell vanishes with no error.`)}const span=cell.layout?.span;if(pageLayout.type==="grid"&&span!==void 0){const columns=pageLayout.columns??12;if(!Number.isInteger(span)||span<1||span>columns){warnings.push(`${where} layout.span is ${JSON.stringify(span)} on a ${columns}-column grid \u2014 expected an integer 1..${columns}.`)}}const tokens=collectTokens(cell.props??{});tokenCount+=tokens.length;const traversal=cell.traversal;const keys=tokens.map(t=>t.slice(1).split(".")[0]);const usesPatternB=keys.includes("_traversal");if(usesPatternB)patternBCells+=1;const local=declaredQueryRoots(where,cell,page,problems);const dataTokens=tokens.filter(t=>!t.startsWith("@params.")&&!local.has(t.slice(1).split(".")[0]));if(dataTokens.length>0&&!traversal){problems.push(`${where} has ${dataTokens.length} @token(s) but no traversal block \u2014 usePageCellTraversalData skips it and every token resolves to nothing.`)}if(tokens.length===0&&traversal){problems.push(`${where} declares traversal but has no @tokens \u2014 a BFS per render feeding nothing.`)}for(const token of tokens){if(!token.startsWith("@params."))continue;const param=token.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(param))continue;if(allowedParams?.has(param))continue;problems.push(`${where} reads ${token} but "${param}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. pickAllowedContextParams drops an undeclared query parameter, so this resolves to nothing and the cell renders its empty state.`)}for(const{raw,path}of collectInterpolations(cell.props??{})){const root=path.split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;if(allowedParams?.has(root))continue;problems.push(`${where} interpolates ${raw} but "${root}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. On a miss the host leaves the literal "${raw}" in the prop, which reads as a real value downstream.`)}for(const[i,key]of keys.entries()){if(RESERVED_KEYS.has(key)||local.has(key))continue;if(!declared.has(key)){problems.push(`${where} token ${tokens[i]} \u2192 key "${key}" is not in metadata.binding_sources, so no producer is documented for it.`)}}if(!traversal)continue;if(!Array.isArray(traversal.edgeTypes)||traversal.edgeTypes.length===0){problems.push(`${where} traversal has no edgeTypes. There is no RPC default \u2014 the walk returns nothing.`)}if(traversal.include_state!==void 0){if(!Array.isArray(traversal.include_state)){problems.push(`${where} traversal.include_state must be an array.`)}else{for(const system of traversal.include_state){if(!STATE_SYSTEMS.has(system)){problems.push(`${where} traversal.include_state contains ${JSON.stringify(system)} \u2014 expected one of ${[...STATE_SYSTEMS].join(" | ")}. resolveStateSystems drops it and the cell renders no state for that system.`)}}}}if(usesPatternB&&!traversal.anchor){problems.push(`${where} consumes @_traversal but declares no traversal.anchor. Auto-wire matches @<binding_key> tokens in props to create the data_flow source\u2192cell edge, and a Pattern B cell has none \u2014 without an anchor the edge must be hand-wired per instance.`)}if(traversal.maxNodes!==void 0){const n=traversal.maxNodes;if(!Number.isInteger(n)||n<=0){problems.push(`${where} traversal.maxNodes must be a positive integer, got ${JSON.stringify(n)}.`)}else if(n>MAX_NODES_CEILING){problems.push(`${where} traversal.maxNodes ${n} exceeds the host ceiling of ${MAX_NODES_CEILING}; it would be clamped.`)}}if(usesPatternB&&!traversal.include_state){warnings.push(`${where} consumes @_traversal.nodes with no include_state \u2014 nodes arrive with content only (no status, no criteria). Correct for a label-only view; otherwise add include_state.`)}if(usesPatternB&&traversal.maxNodes===void 0&&(traversal.maxDepth??3)>1){warnings.push(`${where} walks to depth ${traversal.maxDepth??3} at the default cap of ${DEFAULT_MAX_NODES} nodes, so it may truncate silently. Set traversal.maxNodes.`)}}}for(const slug of templates){const templateFile=join(templatesRoot,slug,"template.json");let allowedParams=null;if(existsSync(templateFile)){try{const template=JSON.parse(readFileSync(templateFile,"utf8"));const declaredParams=template.layout?.context_parameters;if(Array.isArray(declaredParams))allowedParams=new Set(declaredParams)}catch{}}const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;const{shipped,stale}=pageFilesFor(slug);for(const file of stale){staleCount+=1;problems.push(`${relative(repoRoot,file)} exists beside page.json. page.json is the single source \u2014 the page-templates layer reads it and nothing in scripts/, src/ or test/ reads this file, so it is edited and never shipped. Merge it into page.json and delete it.`)}for(const file of shipped){fileCount+=1;auditPage(file,slug,null,allowedParams,problems)}for(const file of stale){auditPage(file,slug,"stale",allowedParams,warnings)}}for(const rec of W5_SEEN.values()){if(rec.count>1)warnings[rec.index]+=` Repeated on ${rec.count} cells.`}if(a1SkipReason){console.warn(`[check-wired-bindings] A1 SKIPPED \u2014 ${a1SkipReason}. Component names unchecked.
|
|
3
3
|
`)}if(contractNote){console.warn(`[check-wired-bindings] note: ${contractNote}; registry bindings are warnings only.
|
|
4
4
|
`)}if(warnings.length>0){console.warn(`[check-wired-bindings] ${warnings.length} warning(s):
|
|
5
5
|
`);for(const w of warnings)console.warn(` \xB7 ${w}`);console.warn("")}const notGated=templates.filter(s=>!gateAll&&!GATED_TEMPLATES.has(s));if(notGated.length>0){console.warn(` Not gated: ${notGated.join(", ")} \u2014 reported but not enforced. Run with --all-gated to enforce.
|
package/src/template/cli.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{execSync}from"node:child_process";function parseArgs(argv){const flags=new Set;const values=new Map;const positional=[];for(let i=0;i<argv.length;i++){const token=argv[i];if(!token.startsWith("--")){positional.push(token);continue}const eq=token.indexOf("=");if(eq!==-1){values.set(token.slice(2,eq),token.slice(eq+1));continue}const key=token.slice(2);const next=argv[i+1];if(next!==void 0&&!next.startsWith("--")){values.set(key,next);i++}else{flags.add(key)}}return{command:positional[0]??"",targetEnv:resolveTargetEnv(flags,values),layer:values.get("layer")??null,name:values.get("name")??null,check:values.get("check")??null,version:values.get("version")??null,dryRun:flags.has("dry-run"),offline:flags.has("offline"),json:flags.has("json"),overwrite:flags.has("overwrite"),confirmProd:values.get("confirm-prod")??null,force:flags.has("force"),flags,values}}function resolveTargetEnv(flags,values){const fromValue=values.get("env")??null;const aliases=[];if(flags.has("develop"))aliases.push("develop");if(flags.has("prod"))aliases.push("prod");if(aliases.length>1){throw new Error("Pass at most one of --develop / --prod.")}if(fromValue!==null){if(fromValue!=="local"&&fromValue!=="develop"&&fromValue!=="prod"){throw new Error(`--env must be local | develop | prod (got "${fromValue}").`)}if(aliases.length===1&&aliases[0]!==fromValue){throw new Error(`--env ${fromValue} conflicts with --${aliases[0]}. Pass one.`)}return fromValue}return aliases[0]??"local"}function assertWriteAllowed(args,options){if(args.flags.has("replace-existing")){if(args.targetEnv==="prod"){throw new Error("--replace-existing is never allowed against prod.")}if(args.layer!=="dev-workbooks"&&args.layer!=="dev-client-data"||!args.name){throw new Error("--replace-existing requires --layer dev-workbooks|dev-client-data and --name <fixture> \u2014 it deletes a member's seeded rows, so it is never run across every client at once.")}}if(!options.requiresWrite||args.dryRun||args.offline)return;if(args.targetEnv==="local")return;if(!args.overwrite){throw new Error(`Writing to ${args.targetEnv} requires --overwrite. Run with --dry-run first and read the report.`)}if(args.targetEnv==="prod"){if(args.confirmProd!==options.orgSlug){throw new Error(`Writing to prod requires --confirm-prod ${options.orgSlug}. This is a deliberate speed bump: prod org config is live client-facing data.`)}if(!args.force&&isGitTreeDirty(options.repoRoot)){throw new Error("Refusing to write to prod from a dirty git tree \u2014 the files being uploaded would not match any commit, so the write could not be reproduced or reviewed. Commit first, or pass --force if you know exactly why.")}}}function isGitTreeDirty(repoRoot){try{const out=execSync("git status --porcelain",{cwd:repoRoot,encoding:"utf8",stdio:["ignore","pipe","ignore"]});return out.trim().length>0}catch{return false}}import{existsSync as existsSync11,readFileSync as
|
|
3
|
-
- ${result.errors.join("\n - ")}`)}if(!result.value.advisor&&!result.value.advisor_org_slug){console.warn("[template] config/org.json: client org has no advisor_org_slug \u2014 the org layer will not link an advisor org.")}cachedOrgConfig=result.value;return result.value}var cachedTemplatesConfig=null;function loadTemplatesConfig(){if(cachedTemplatesConfig)return cachedTemplatesConfig;const path=join(CONFIG_DIR,"templates.json");if(!existsSync2(path)){cachedTemplatesConfig={org_template:null,project_templates:[]};return cachedTemplatesConfig}const parsed=JSON.parse(readFileSync2(path,"utf8"));const outer=validateShape(parsed,{fields:{org_template:{type:"any"},project_templates:{type:"array",default:[]}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/templates.json is invalid:
|
|
4
|
-
- ${outer.errors.join("\n - ")}`)}let orgTemplate=null;if(outer.value.org_template){const result=validateShape(outer.value.org_template,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},layout_bundles:{type:"object"}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json org_template is invalid: ${result.errors.join("; ")}`)}orgTemplate=result.value}const projectTemplates=[];(outer.value.project_templates??[]).forEach((raw,index)=>{const result=validateShape(raw,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},skip_pages:{type:"boolean",default:false}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json project_templates[${index}] is invalid: ${result.errors.join("; ")}`)}projectTemplates.push(result.value)});cachedTemplatesConfig={org_template:orgTemplate,project_templates:projectTemplates};return cachedTemplatesConfig}function orgTemplatePath(slug){return join(REPO_ROOT,"src","org_templates",slug,"template.json")}function projectTemplateDir(slug){return join(REPO_ROOT,"src","project_templates",slug)}function projectTemplatePages(slug){const pagesDir=join(projectTemplateDir(slug),"pages");if(!existsSync2(pagesDir))return[];return readdirSync(pagesDir,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>({page:e.name,path:join(pagesDir,e.name,"page.json")})).filter(p=>existsSync2(p.path)).sort((a,b)=>a.page.localeCompare(b.page))}function layerDir(sourceDir){return sourceDir?join(TEMPLATE_ROOT,sourceDir):null}function listJsonFiles(dir){if(!dir||!existsSync2(dir))return[];return readdirSync(dir).filter(f=>f.endsWith(".json")).sort()}function readJsonFile(path){try{return JSON.parse(readFileSync2(path,"utf8"))}catch(error){throw new Error(`${path}: not valid JSON \u2014 ${error instanceof Error?error.message:String(error)}`)}}import{join as join2}from"node:path";var LIST_KEY="bundle_refs";var SINGLE_KEY="bundle_ref";function describe(value){if(value===null)return"null";if(Array.isArray(value))return"an array";return typeof value}function refError(ref,path,orgSlug){if(typeof ref==="string"){if(ref===orgSlug)return null;return`${path}: bare-string bundle ref "${ref}" is an ORG SLUG, and it is not this org's ("${orgSlug}") \u2014 did you mean { "bundle": "${ref}" }? A bare string resolves to that org's main bundle, so a lineage name here silently falls through to main and the page renders with the wrong components. For a deliberate cross-org ref, name the lineage explicitly: { "bundle": "<lineage>", "org_slug": "${ref}" }.`}if(typeof ref!=="object"||ref===null||Array.isArray(ref)){return`${path}: a bundle ref must be a string or an object, got ${describe(ref)}`}const obj=ref;if("bundle"in obj){if(typeof obj.bundle!=="string"||obj.bundle.length===0){return`${path}: { "bundle": \u2026 } must name a lineage as a non-empty string, got ${describe(obj.bundle)}`}if("org_slug"in obj&&typeof obj.org_slug!=="string"){return`${path}: "org_slug" must be a string when present, got ${describe(obj.org_slug)}`}return null}if("bundle_id"in obj){if(typeof obj.bundle_id!=="string"||obj.bundle_id.length===0){return`${path}: { "bundle_id": \u2026 } must be a non-empty string`}return null}return`${path}: object bundle ref has neither "bundle" nor "bundle_id" (keys: ${Object.keys(obj).join(", ")||"none"}) \u2014 the union is string | { bundle_id } | { bundle, org_slug? }`}function bundleRefErrors(def,orgSlug){const errors=[];let visited=0;const walk=(node,path)=>{if(Array.isArray(node)){node.forEach((item,i)=>walk(item,`${path}[${i}]`));return}if(typeof node!=="object"||node===null)return;for(const[key,value]of Object.entries(node)){const here=path?`${path}.${key}`:key;if(key===LIST_KEY){if(!Array.isArray(value)){errors.push(`${here}: "${LIST_KEY}" must be an array, got ${describe(value)}`);continue}value.forEach((ref,i)=>{visited+=1;const error=refError(ref,`${here}[${i}]`,orgSlug);if(error)errors.push(error)});continue}if(key===SINGLE_KEY){if(value===void 0||value===null)continue;visited+=1;const error=refError(value,here,orgSlug);if(error)errors.push(error);continue}walk(value,here)}};walk(def,"");return{errors,visited}}function describeKey(naturalKey){return Object.entries(naturalKey).map(([k,v])=>`${k}=${v===null?"NULL":JSON.stringify(v)}`).join(", ")}async function upsertByNaturalKey(supabase,table,naturalKey,record,options={}){let query=supabase.from(table).select("id");for(const[key,value]of Object.entries(naturalKey)){query=value===null?query.is(key,null):query.eq(key,value)}const{data:existing,error:selectError}=await query.limit(2);if(selectError){throw new Error(`lookup failed on ${table} (${describeKey(naturalKey)}): ${selectError.message}`)}const rows=existing??[];if(rows.length>1){throw new Error(`${table} has at least ${rows.length} rows matching ${describeKey(naturalKey)} \u2014 ambiguous, refusing to write. Ids: ${rows.map(r=>r.id).join(", ")}. ${table==="client_data_type"?"This table has no unique constraint on its natural key, so duplicates are possible. ":""}Resolve the duplicate in the database first (see \`template/cli doctor\`).`)}const current=rows[0];if(current){if(options.dryRun)return{id:current.id,action:"updated"};const{data:data2,error:error2}=await supabase.from(table).update(record).eq("id",current.id).select("id").single();if(error2)throw new Error(`update failed on ${table} id=${current.id}: ${error2.message}`);return{id:data2.id,action:"updated"}}if(options.dryRun)return{id:"(new)",action:"created"};const{data,error}=await supabase.from(table).insert([record]).select("id").single();if(error){throw new Error(`insert failed on ${table} (${describeKey(naturalKey)}): ${error.message}`)}return{id:data.id,action:"created"}}var LayerManager=class{constructor(def,options,mappings){this.def=def;this.options=options;this.mappings=mappings}def;options;mappings;mappingEntities(){const primary=this.def.mappingEntity?[this.def.mappingEntity]:[];return[...primary,...this.def.mappingEntities??[]]}dir(){return layerDir(this.def.sourceDir)}duplicateDbKeys=new Set;bundleRefsVisited=0;files(){const files=listJsonFiles(this.dir());const filter=this.options.nameFilter;if(!filter)return files;return files.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}fileKeys(){const keys=[];for(const file of this.files()){try{keys.push(this.naturalKeyOf(this.loadFile(file)))}catch{}}return keys}loadFile(fileName){const dir=this.dir();if(!dir)throw new Error(`layer ${this.def.name} has no source directory`);const raw=readJsonFile(join2(dir,fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value){throw new Error(result.errors.join("; "))}return result.value}extraIssues(_def){return{errors:[],warnings:[]}}validateAll(){this.bundleRefsVisited=0;const errors=[];const warnings=[];const files=this.files();const seen=new Map;for(const file of files){try{const def=this.loadFile(file);const refs=bundleRefErrors(def,this.options.orgSlug);this.bundleRefsVisited+=refs.visited;for(const message of refs.errors)errors.push({file,message});const extra=this.extraIssues(def);for(const message of extra.errors)errors.push({file,message});for(const message of extra.warnings)warnings.push({file,message});const key=this.naturalKeyOf(def);const previous=seen.get(key);if(previous){errors.push({file,message:`natural key "${key}" already declared by ${previous} \u2014 two files would fight over one row`})}else{seen.set(key,file)}}catch(error){errors.push({file,message:error instanceof Error?error.message:String(error)})}}if(files.length===0&&this.def.sourceDir&&!this.options.nameFilter){errors.push({file:this.dir()??"(layer)",message:`this layer declares sourceDir "${this.def.sourceDir}" and found NO files there. Every check in this layer runs per file, so zero files is a green run that verified nothing \u2014 a moved or unchecked-out directory, not a clean one.`})}return{layer:this.def.name,filesChecked:files.length,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];for(const file of this.files()){try{const def=this.loadFile(file);const key=this.naturalKeyOf(def);const outcome=await upsertByNaturalKey(this.options.supabase,this.def.table,this.naturalKeyFilter(def),this.toRow(def),{dryRun:this.options.dryRun});if(this.def.mappingEntity&&!this.options.dryRun){this.mappings.set(this.def.mappingEntity,key,outcome.id)}entities.push({entity:key,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}}async status(){const files=this.files();const base={layer:this.def.name,kind:this.def.kind,implemented:this.def.implemented,files:files.length,dbRows:null,mappedIds:this.mappingEntities().reduce((sum,e)=>sum+this.mappings.count(e),0),missingInDb:[],missingInFiles:[],unmappedInDb:[]};if(!this.def.implemented)return base;const fileKeys=new Set(this.fileKeys());try{const dbKeys=await this.fetchDbKeys();base.dbRows=dbKeys.size;base.missingInDb=[...fileKeys].filter(k=>!dbKeys.has(k)).sort();base.missingInFiles=[...dbKeys.keys()].filter(k=>!fileKeys.has(k)).sort();if(this.def.mappingEntity){const mapped=new Set(this.mappings.keys(this.def.mappingEntity));base.unmappedInDb=[...dbKeys.keys()].filter(k=>fileKeys.has(k)&&!mapped.has(k)).sort()}}catch(error){base.error=error instanceof Error?error.message:String(error)}return base}async healthChecks(){return[]}async orphans(){return[]}async pull(){const started=Date.now();const entities=[];const errors=[];if(!this.def.implemented||!this.def.mappingEntity){return{layer:this.def.name,created:0,updated:0,skipped:0,entities,errors,duration_ms:Date.now()-started}}try{const dbKeys=await this.fetchDbKeys();for(const key of this.fileKeys()){const id=dbKeys.get(key);if(!id){entities.push({entity:key,action:"skipped",id:null,reason:"not in database"});continue}if(this.duplicateDbKeys.has(key)){entities.push({entity:key,action:"skipped",id:null,reason:"ambiguous \u2014 more than one row shares this natural key; resolve the duplicate before mapping it (see `doctor`)"});continue}const before=this.mappings.getId(this.def.mappingEntity,key);this.mappings.set(this.def.mappingEntity,key,id);entities.push({entity:key,action:before===id?"skipped":"updated",id})}}catch(error){errors.push({entity:this.def.name,error:error instanceof Error?error.message:String(error)})}return{layer:this.def.name,created:0,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};var CONTRACT=["src/types/host-contract.generated.ts","src/types/host-contract.generated.js"].map(f=>join3(REPO_ROOT,f)).find(f=>existsSync3(f));var AGENT_TOOL_NAMES=CONTRACT?(await import(pathToFileURL(CONTRACT).href)).AGENT_TOOL_NAMES??[]:[];var AgentTemplateManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},is_template:{type:"boolean",default:true},prompt:{type:"any",required:true},tools:{type:"any",required:true},model:{type:"any"},output_schema:{type:"any"},skills:{type:"any"},metadata:{type:"object"}}}}extraIssues(def){const errors=[];const warnings=[];const tools=def.tools;if(tools!==void 0){const shape=tools;if(typeof tools!=="object"||tools===null||Array.isArray(tools)){errors.push(`"tools" must be an object carrying a toolNames array \u2014 the host keeps platform-agent's ADVISOR tools for any other shape, silently`)}else if(!Array.isArray(shape?.toolNames)){errors.push(`"tools.toolNames" must be an array \u2014 without it the host keeps platform-agent's ADVISOR tools (createEvent, updateEvent, submitProcessJob), silently, and this row's own tools are never applied`)}else if(shape.toolNames.some(t=>typeof t!=="string"||t.trim()==="")){errors.push(`"tools.toolNames" must contain non-empty strings \u2014 the host matches them by exact name against its tool registry, so a blank entry silently selects nothing`)}else if(AGENT_TOOL_NAMES.length>0){const known=new Set(AGENT_TOOL_NAMES);const names=shape.toolNames;const unknown=names.filter(t=>!known.has(t));if(unknown.length>0&&unknown.length<names.length){errors.push(`"tools.toolNames" names ${unknown.length} tool(s) of ${names.length} that the host does not have: ${unknown.join(", ")}. The runtime matches by exact name and drops the rest silently, so this agent would run with fewer tools than it declares. Valid names are in AGENT_TOOL_NAMES (host contract 2.15.0+)`)}else if(unknown.length===names.length){warnings.push(`none of this agent's ${names.length} tool name(s) are in the host's chat-agent registry, which reads as a BEA or other non-host runtime rather than a typo \u2014 every name would have to be wrong for a typo to look like this. Not checkable here: only the host chat runtime publishes its tool names (AGENT_TOOL_NAMES, contract 2.15.0+)`)}}}const prompt=def.prompt;if(prompt!==void 0){const shape=prompt;if(typeof prompt!=="object"||prompt===null||Array.isArray(prompt)){errors.push(`"prompt" must be an object with a "system" string \u2014 the host keeps platform-agent's ADVISOR system prompt for any other shape`)}else if(typeof shape?.system!=="string"||shape.system.trim()===""){errors.push(`"prompt.system" must be a non-empty string \u2014 without it the host keeps platform-agent's ADVISOR system prompt ("assistant for financial advisors")`)}}return{errors,warnings}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name,is_template:true}}toRow(def){const row={org_id:this.options.orgId,name:def.name,is_template:true,archived:false,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.prompt!==void 0)row.prompt=def.prompt;if(def.tools!==void 0)row.tools=def.tools;if(def.model!==void 0)row.model=def.model;if(def.output_schema!==void 0)row.output_schema=def.output_schema;if(def.skills!==void 0)row.skills=def.skills;if(def.metadata!==void 0)row.metadata=def.metadata;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("agent").select("id, name").eq("org_id",this.options.orgId).eq("is_template",true).eq("archived",false);if(error)throw new Error(`agent lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){if(row.name)out.set(row.name,row.id)}return out}};var DataTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},data_format:{type:"enum",values:["text","json","file"],default:"text"},schema:{type:"any"}},ignored:["org_id","org_name"]}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,data_format:def.data_format,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.schema!==void 0)row.schema=def.schema;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("client_data_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){out.set(row.name,row.id)}return out}};import{existsSync as existsSync4,readdirSync as readdirSync2}from"node:fs";import{join as join4}from"node:path";var ORG_OVERRIDE_DIR=join4(DATA_DIR,"event-types");var DECLARATIONS=["kind","visible_to","creatable_by","data_schema","retention","requires_consent","hooks","components","calendar_export","access_logged"];var EventTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},icon:{type:"string"},color:{type:"string"},category:{type:"string"},display_order:{type:"number"},metadata:{type:"object"},kind:{type:"enum",values:["scheduled","record"]},visible_to:{type:"array"},creatable_by:{type:"array"},data_schema:{type:"object"},retention:{type:"string"},requires_consent:{type:"boolean"},hooks:{type:"array"},components:{type:"object"},calendar_export:{type:"enum",values:["none","busy","details"]},access_logged:{type:"boolean"}}}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.icon!==void 0)row.icon=def.icon;if(def.color!==void 0)row.color=def.color;if(def.metadata!==void 0)row.metadata=def.metadata;for(const key of DECLARATIONS)if(def[key]!==void 0)row[key]=def[key];return row}orgFiles(){if(!existsSync4(ORG_OVERRIDE_DIR))return[];return readdirSync2(ORG_OVERRIDE_DIR).filter(f=>f.endsWith(".json")).sort()}resolvePath(fileName){const orgPath=join4(ORG_OVERRIDE_DIR,fileName);return existsSync4(orgPath)?orgPath:join4(this.dir()??"",fileName)}files(){const merged=new Set([...super.files(),...this.orgFiles()]);const all=[...merged].sort();const filter=this.options.nameFilter;if(!filter)return all;return all.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}loadFile(fileName){const raw=readJsonFile(this.resolvePath(fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value)throw new Error(result.errors.join("; "));return result.value}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("event_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`event_type: ${error.message}`);const keys=new Map;for(const row of data??[]){keys.set(row.name,row.id)}return keys}};var FeedProducerManager=class extends LayerManager{get shape(){return{fields:{key:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},description:{type:"string"},kind:{type:"enum",values:["process","system","manual","user"],default:"process"},is_default:{type:"boolean",default:false},config:{type:"object",default:{}},status:{type:"enum",values:["active","paused","retired"],default:"active"},metadata:{type:"object"},source_table:{type:"string"},source_id:{type:"string"}}}}naturalKeyOf(def){return def.key}naturalKeyFilter(def){return{org_id:this.options.orgId,key:def.key}}toRow(def){const row={org_id:this.options.orgId,key:def.key,name:def.name,kind:def.kind,is_default:def.is_default,config:def.config,status:def.status,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.metadata!==void 0)row.metadata=def.metadata;if(def.source_table!==void 0)row.source_table=def.source_table;if(def.source_id!==void 0)row.source_id=def.source_id;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("feed_producer").select("id, key").eq("org_id",this.options.orgId);if(error){throw new Error(`feed_producer lookup failed: ${error.message}. This layer needs the 20260804000000_feed_system migration.`)}const out=new Map;for(const row of data??[]){out.set(row.key,row.id)}return out}};import{existsSync as existsSync5}from"node:fs";import{join as join5}from"node:path";function decodeJsonbValue(value){let current=value;for(let depth=0;depth<4&&typeof current==="string";depth++){const trimmed=current.trim();if(!trimmed.startsWith("{")&&!trimmed.startsWith("[")&&!trimmed.startsWith('"'))break;try{const parsed=JSON.parse(trimmed);if(parsed===current)break;current=parsed}catch{break}}return current!==null&&typeof current==="object"?current:value}var RETIRED_SECTION_CONFIG_KEYS={nextButtonText:'authored 10x across 8 files and consumed by nothing \u2014 DocumentSectionRenderer and UploadSectionRenderer render no forward control; advancement is FormNavigationButtons at form scope. Removed 2026-08-27. The requirement it encoded is recorded as a rule ("the forward control names its destination"), not as literals, in the host plan 02 \xA711 \u2014 reviving the key re-creates the literals and loses the rule.'};function retiredSectionConfigKeyErrors(def){const out=[];let visited=0;const pages=Array.isArray(def.pages)?def.pages:[];pages.forEach((page,pi)=>{const sections=Array.isArray(page?.sections)?page.sections:[];sections.forEach((section,si)=>{for(const[configKey,config]of Object.entries(section??{})){if(!configKey.endsWith("Config")||!config||typeof config!=="object")continue;visited+=1;for(const key of Object.keys(config)){const reason=RETIRED_SECTION_CONFIG_KEYS[key];if(reason){out.push(`retired key "${key}" at pages[${pi}].sections[${si}].${configKey} \u2014 ${reason}`)}}}})});return{errors:out,visited}}var SECTION_CONFIG_KEY={artifact:"artifactSectionConfig",document:"documentConfig",upload:"uploadConfig",review:"reviewConfig","advisor-review":"advisorReviewConfig",status:"statusConfig",cell:"cellConfig"};var FormTemplateManager=class extends LayerManager{get shape(){return{fields:{template_name:{type:"string",required:true,minLength:1},name:{type:"string"},description:{type:"string"},template_type:{type:"enum",values:["form","workbook"],default:"form"},is_published:{type:"boolean",default:false},is_archived:{type:"boolean",default:false},metadata:{type:"object"},shell:{type:"object"},pages:{type:"array",required:true}},ignored:["template_id","org_id","version","profile_block_id","profile_block_name"]}}validateAll(){const result=super.validateAll();let visited=0;for(const file of this.files()){try{visited+=retiredSectionConfigKeyErrors(super.loadFile(file)).visited}catch{}}if(visited===0){result.errors.push({file:"(layer)",message:"the retired-key walker visited zero section config objects across every form template \u2014 its traversal assumptions no longer match the file shape, so the retired-key check is passing vacuously. Fix the walker before trusting a green run."})}return result}loadFile(fileName){const def=super.loadFile(fileName);const{errors}=retiredSectionConfigKeyErrors(def);if(errors.length)throw new Error(errors.join("; "));return def}naturalKeyOf(def){return def.name??def.template_name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:this.naturalKeyOf(def)}}toRow(){throw new Error("FormTemplateManager writes via syncAll()")}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("client_form_template").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`client_form_template lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){out.set(row.name,row.id)}return out}sharedFieldsPath(){return join5(DATA_DIR,"shared-fields.json")}async syncSharedFields(entities,errors){const path=this.sharedFieldsPath();const map=new Map;if(!existsSync5(path))return map;try{const parsed=readJsonFile(path);const result=validateShape(parsed,{fields:{template_name:{type:"string",required:true,minLength:1},description:{type:"string"},shared_fields:{type:"array",required:true}},ignored:["template_id","org_id"]});if(!result.ok||!result.value){throw new Error(result.errors.join("; "))}const config=result.value;const outcome=await upsertByNaturalKey(this.options.supabase,"client_form_template",{org_id:this.options.orgId,name:config.template_name},{org_id:this.options.orgId,name:config.template_name,description:config.description??null,layout:{},updated_at:new Date().toISOString()},{dryRun:this.options.dryRun});const templateId=outcome.id;if(!this.options.dryRun){this.mappings.set("form_template",config.template_name,templateId)}let fieldCount=0;for(const field of config.shared_fields){const fieldOutcome=await this.upsertField(templateId,{fieldId:field.canonical_slug,label:field.label,fieldType:field.field_type,description:field.description??null,required:field.required??false,options:field.options??null,validation:decodeJsonbValue(field.validation)??null,placeholder:field.placeholder??null,pageId:null,sectionId:null,orderIndex:0},void 0);map.set(field.canonical_slug,fieldOutcome.id);if(!this.options.dryRun){this.mappings.set("shared_field",field.canonical_slug,fieldOutcome.id)}fieldCount++}entities.push({entity:`${config.template_name} (${fieldCount} shared field(s))`,action:outcome.action,id:templateId})}catch(error){errors.push({entity:"shared-fields.json",error:error instanceof Error?error.message:String(error)})}return map}async upsertPage(templateId,page){return upsertByNaturalKey(this.options.supabase,"client_form_page",{template_id:templateId,page_id:page.pageId},{template_id:templateId,page_id:page.pageId,title:page.title,description:page.description??null,order_index:page.orderIndex??0,conditional_logic:decodeJsonbValue(page.conditionalLogic)??null,org_id:this.options.orgId,updated_at:new Date().toISOString()},{dryRun:this.options.dryRun})}async upsertSection(templateId,pageId,section){const sectionType=section.sectionType??"standard";const configKey=SECTION_CONFIG_KEY[sectionType];const content=configKey?section[configKey]??null:section.content??null;return upsertByNaturalKey(this.options.supabase,"client_form_section",{template_id:templateId,page_id:pageId,section_id:section.sectionId},{template_id:templateId,page_id:pageId,section_id:section.sectionId,title:section.title,description:section.description??null,order_index:section.orderIndex??0,conditional_logic:decodeJsonbValue(section.conditionalLogic)??null,section_type:sectionType,org_id:this.options.orgId,content,content_before:section.contentBefore??null,content_after:section.contentAfter??null,updated_at:new Date().toISOString()},{dryRun:this.options.dryRun})}async upsertField(templateId,field,sharedFields){if(field.shared===true&&sharedFields){const sharedId=sharedFields.get(field.fieldId);if(sharedId)return{id:sharedId,action:"reused"}}const outcome=await upsertByNaturalKey(this.options.supabase,"client_input_field",{template_id:templateId,name:field.fieldId},{template_id:templateId,name:field.fieldId,label:field.label,description:field.description??null,field_type:field.fieldType,options:typeof field.options==="string"?JSON.parse(field.options):field.options??null,validation:decodeJsonbValue(field.validation)??null,required:field.required??false,default_value:field.defaultValue??null,placeholder:field.placeholder??null,help_text:field.helpText??null,conditional_logic:decodeJsonbValue(field.conditionalLogic)??null,page_id:field.pageId??null,section_id:field.sectionId??null,order_index:field.orderIndex??0,org_id:this.options.orgId,content_before:field.contentBefore??null,content_after:field.contentAfter??null,updated_at:new Date().toISOString()},{dryRun:this.options.dryRun});return outcome}async syncTemplate(file,sharedFields,entities){const def=this.loadFile(file);const templateName=this.naturalKeyOf(def);const dryRun=this.options.dryRun;const templateOutcome=await upsertByNaturalKey(this.options.supabase,"client_form_template",{org_id:this.options.orgId,name:templateName},{org_id:this.options.orgId,name:templateName,description:def.description??null,layout:{placeholder:true},status:def.is_published?"published":"draft",is_active:!def.is_archived,template_type:def.template_type,workbook_config:def.workbook_config??null,updated_at:new Date().toISOString()},{dryRun});const templateId=templateOutcome.id;const sharedFieldPlacements=[];const layoutPages=[];let sectionCount=0;let fieldCount=0;let reusedCount=0;for(const page of def.pages){const pageOutcome=await this.upsertPage(templateId,page);layoutPages.push({id:pageOutcome.id,pageId:page.pageId,title:page.title,description:page.description??null,orderIndex:page.orderIndex??0});if(!dryRun){this.mappings.set("form_page",`${templateName}:${page.pageId}`,pageOutcome.id)}for(const section of page.sections??[]){const sectionOutcome=await this.upsertSection(templateId,page.pageId,section);sectionCount++;if(!dryRun){this.mappings.set("form_section",`${templateName}:${page.pageId}:${section.sectionId}`,sectionOutcome.id)}for(const field of section.fields??[]){const fieldOutcome=await this.upsertField(templateId,field,sharedFields);fieldCount++;if(fieldOutcome.action==="reused"){reusedCount++;sharedFieldPlacements.push({fieldId:field.fieldId,pageId:field.pageId??page.pageId,sectionId:field.sectionId??section.sectionId,orderIndex:field.orderIndex??0})}if(!dryRun){this.mappings.set("form_field",`${templateName}:${field.fieldId}`,fieldOutcome.id)}}}}if(!dryRun){const{error}=await this.options.supabase.from("client_form_template").update({layout:{pages:layoutPages,sharedFieldPlacements,metadata:def.metadata??null,...def.shell===void 0?{}:{shell:def.shell}},updated_at:new Date().toISOString()}).eq("id",templateId);if(error)throw new Error(`failed to finalize layout for ${templateName}: ${error.message}`);this.mappings.set("form_template",templateName,templateId)}const shared=reusedCount>0?`, ${reusedCount} shared`:"";entities.push({entity:`${templateName} (${def.pages.length}p/${sectionCount}s/${fieldCount}f${shared})`,action:templateOutcome.action,id:templateId})}async healthChecks(){const declared=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const name=this.naturalKeyOf(def);const fields=new Set;const pages=new Set;const sections=new Set;for(const page of def.pages){pages.add(page.pageId);for(const section of page.sections??[]){sections.add(`${page.pageId}:${section.sectionId}`);for(const field of section.fields??[])fields.add(field.fieldId)}}declared.set(name,{fields,pages,sections})}const dbTemplates=await this.fetchDbKeys();const orphanFields=[];const orphanPages=[];const orphanSections=[];for(const[templateName,sets]of declared){const templateId=dbTemplates.get(templateName);if(!templateId)continue;const{data:fields}=await this.options.supabase.from("client_input_field").select("name").eq("template_id",templateId);for(const row of fields??[]){if(!sets.fields.has(row.name))orphanFields.push(`${templateName}/${row.name}`)}const{data:pages}=await this.options.supabase.from("client_form_page").select("page_id").eq("template_id",templateId);for(const row of pages??[]){if(!sets.pages.has(row.page_id))orphanPages.push(`${templateName}/${row.page_id}`)}const{data:sections}=await this.options.supabase.from("client_form_section").select("page_id, section_id").eq("template_id",templateId);for(const row of sections??[]){if(!sets.sections.has(`${row.page_id}:${row.section_id}`)){orphanSections.push(`${templateName}/${row.section_id}`)}}}const total=orphanFields.length+orphanPages.length+orphanSections.length;return[total===0?{name:"form-orphans",severity:"ok",message:`no orphaned form rows across ${declared.size} managed template(s)`}:{name:"form-orphans",severity:"warn",message:`${orphanFields.length} field(s), ${orphanSections.length} section(s) and ${orphanPages.length} page(s) belong to a managed template but are declared by no file \u2014 a form renderer WILL still show them, which is not a theoretical risk: a deleted probe section stayed member-visible for a day because this check counted only two of the three levels. Re-declare them in the source file, or remove them with \`npm run template -- prune --confirm-prune\`, which has always covered all three.`,detail:[...orphanFields,...orphanSections,...orphanPages]}]}async orphans(){const out=[];const dbTemplates=await this.fetchDbKeys();for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const templateName=this.naturalKeyOf(def);const templateId=dbTemplates.get(templateName);if(!templateId)continue;const pages=new Set;const sections=new Set;const fields=new Set;for(const page of def.pages){pages.add(page.pageId);for(const section of page.sections??[]){sections.add(`${page.pageId}:${section.sectionId}`);for(const field of section.fields??[])fields.add(field.fieldId)}}const{data:dbFields}=await this.options.supabase.from("client_input_field").select("id, name").eq("template_id",templateId);for(const row of dbFields??[]){if(!fields.has(row.name)){out.push({table:"client_input_field",id:row.id,label:`${templateName}/${row.name}`})}}const{data:dbSections}=await this.options.supabase.from("client_form_section").select("id, page_id, section_id").eq("template_id",templateId);for(const row of dbSections??[]){if(!sections.has(`${row.page_id??""}:${row.section_id}`)){out.push({table:"client_form_section",id:row.id,label:`${templateName}/${row.page_id??"(no page)"}/${row.section_id}`})}}const{data:dbPages}=await this.options.supabase.from("client_form_page").select("id, page_id").eq("template_id",templateId);for(const row of dbPages??[]){if(!pages.has(row.page_id)){out.push({table:"client_form_page",id:row.id,label:`${templateName}/${row.page_id}`})}}}return out}async syncAll(){const started=Date.now();const entities=[];const errors=[];const sharedFields=await this.syncSharedFields(entities,errors);for(const file of this.files()){try{await this.syncTemplate(file,sharedFields,entities)}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}}};async function findOrgBySlug(supabase,orgSlug){const{data,error}=await supabase.from("organization").select("id, name, advisor, metadata").eq("metadata->>org_slug",orgSlug).limit(2);if(error){throw new Error(`organization lookup failed for org_slug="${orgSlug}": ${error.message}`)}const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} organization rows have metadata->>org_slug="${orgSlug}" \u2014 ambiguous. Ids: ${rows.map(r=>r.id).join(", ")}. Fix the database before seeding.`)}const row=rows[0];if(!row)return null;const metadata=row.metadata??{};return{id:row.id,name:row.name,advisor:row.advisor===true,advisorOrgId:metadata["advisor_org_id"]??null,advisorOrgSlug:metadata["advisor_org_slug"]??null}}var DevUserManager=class extends LayerManager{get shape(){return{fields:{type:{type:"enum",values:["advisor","client"],required:true},email:{type:"string",required:true,minLength:3},name:{type:"string",required:true,minLength:1},first_name:{type:"string",required:true,minLength:1},last_name:{type:"string",required:true,minLength:1},user_metadata:{type:"object"},org_memberships:{type:"array",required:true},advisor_orgs:{type:"array"},client_record:{type:"object"}}}}naturalKeyOf(def){return def.email}naturalKeyFilter(def){return{email:def.email}}toRow(){throw new Error("DevUserManager writes via syncAll()")}devPassword(){return process.env.DEV_USER_PASSWORD??"Password123!"}async findAuthUser(email){const perPage=200;for(let page=1;page<=50;page++){const{data,error}=await this.options.supabase.auth.admin.listUsers({page,perPage});if(error)throw new Error(`auth.admin.listUsers failed: ${error.message}`);const users=data?.users??[];const match=users.find(u=>u.email?.toLowerCase()===email.toLowerCase());if(match)return match.id;if(users.length<perPage)return null}return null}async ensureAuthUser(def){const existing=await this.findAuthUser(def.email);if(existing)return{id:existing,created:false};const{data,error}=await this.options.supabase.auth.admin.createUser({email:def.email,password:this.devPassword(),email_confirm:true,user_metadata:{name:def.name,role:def.type,...def.user_metadata??{}}});if(error)throw new Error(`auth.admin.createUser failed for ${def.email}: ${error.message}`);const id=data?.user?.id;if(!id)throw new Error(`no user id returned for ${def.email}`);return{id,created:true}}async orgIdFor(slug){if(slug===this.options.orgSlug)return this.options.orgId;const org=await findOrgBySlug(this.options.supabase,slug);return org?.id??null}async ensureOrgMember(userId,orgId,def){const{data,error}=await this.options.supabase.from("org_member").select("id").eq("user_id",userId).eq("org_id",orgId).limit(2);if(error)throw new Error(`org_member lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} org_member rows for this user+org \u2014 role resolution will throw. Run doctor.`)}if(rows.length===1){const{error:updateError}=await this.options.supabase.from("org_member").update({first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name}).eq("id",rows[0].id);if(updateError)throw new Error(`org_member update failed: ${updateError.message}`);return}const{error:insertError}=await this.options.supabase.from("org_member").insert({user_id:userId,org_id:orgId,first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name});if(insertError)throw new Error(`org_member insert failed: ${insertError.message}`)}async ensureAdvisor(userId,def){const primarySlug=def.org_memberships[0];if(!primarySlug)throw new Error(`advisor ${def.name} has no org_memberships`);const primaryOrgId=await this.orgIdFor(primarySlug);if(!primaryOrgId)throw new Error(`cannot resolve primary org "${primarySlug}"`);const{data,error}=await this.options.supabase.from("advisor").select("id").eq("user_id",userId).eq("org_id",primaryOrgId).limit(2);if(error)throw new Error(`advisor lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} advisor rows for this user+org \u2014 role resolution will throw. Run doctor.`)}let advisorId;if(rows.length===1){advisorId=rows[0].id;await this.options.supabase.from("advisor").update({name:def.name,email:def.email}).eq("id",advisorId)}else{const{data:inserted,error:insertError}=await this.options.supabase.from("advisor").insert({user_id:userId,org_id:primaryOrgId,name:def.name,email:def.email}).select("id").single();if(insertError)throw new Error(`advisor insert failed: ${insertError.message}`);advisorId=inserted.id}for(const slug of def.advisor_orgs??[]){const linkedOrgId=await this.orgIdFor(slug);if(!linkedOrgId){console.warn(`[template] \u26A0 advisor_orgs: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping the link. Seed that org, then re-run.`);continue}const{data:link}=await this.options.supabase.from("advisor_org").select("id").eq("advisor_id",advisorId).eq("org_id",linkedOrgId).maybeSingle();if(!link){const{error:linkError}=await this.options.supabase.from("advisor_org").insert({advisor_id:advisorId,org_id:linkedOrgId});if(linkError)throw new Error(`advisor_org insert failed: ${linkError.message}`)}}return advisorId}async ensureClient(userId,def){const record=def.client_record;if(!record)throw new Error(`client ${def.name} has no client_record`);const orgSlug=def.org_memberships[0]??this.options.orgSlug;const orgId=await this.orgIdFor(orgSlug);if(!orgId)throw new Error(`cannot resolve client org "${orgSlug}"`);const data={...record.metadata??{}};if(record.slug)data.slug=record.slug;const{data:found,error}=await this.options.supabase.from("client").select("id, data, user_id").eq("org_id",orgId).eq("name",record.name);if(error)throw new Error(`client lookup failed: ${error.message}`);let rows=found??[];if(rows.length>1){const mine=rows.filter(r=>r.user_id===userId);if(mine.length===1){console.warn(`[template] \u26A0 ${rows.length} client rows named "${record.name}" in this org; using the one already linked to ${def.email} (${mine[0].id}). Run doctor \u2014 the others are strays.`);rows=mine}else{throw new Error(`${rows.length} client rows named "${record.name}" in this org and ${mine.length} linked to ${def.email} \u2014 ambiguous, refusing to guess. Run doctor for the ids.`)}}if(rows[0]){const merged={...rows[0].data??{},...data};const{error:updateError}=await this.options.supabase.from("client").update({user_id:userId,data:merged,description:record.description??null}).eq("id",rows[0].id);if(updateError)throw new Error(`client update failed: ${updateError.message}`);return rows[0].id}const{data:inserted,error:insertError}=await this.options.supabase.from("client").insert({org_id:orgId,name:record.name,description:record.description??null,user_id:userId,data}).select("id").single();if(insertError)throw new Error(`client insert failed: ${insertError.message}`);return inserted.id}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const id=await this.findAuthUser(def.email);if(id)out.set(def.email,id)}return out}async healthChecks(){const{data,error}=await this.options.supabase.from("client").select("id, name, user_id").eq("org_id",this.options.orgId);if(error){return[{name:"dev-user-clients",severity:"warn",message:`lookup failed: ${error.message}`}]}const rows=data??[];const byName=new Map;for(const row of rows){byName.set(row.name,[...byName.get(row.name)??[],{id:row.id,user_id:row.user_id}])}const managed=new Set;for(const file of this.files()){try{const def=this.loadFile(file);if(def.client_record?.name)managed.add(def.client_record.name)}catch{}}const blocking=[];let unmanagedDuplicates=0;for(const[name,group]of byName){if(group.length<2)continue;if(!managed.has(name)){unmanagedDuplicates++;continue}const linked=group.filter(r=>r.user_id);if(linked.length===1)continue;blocking.push(`${name}: ${group.length} rows, ${linked.length} linked \u2014 ${group.map(r=>r.id).join(", ")}`)}const checks=[blocking.length===0?{name:"dev-user-clients",severity:"ok",message:`${managed.size} managed client(s), all resolvable`}:{name:"dev-user-clients",severity:"fail",message:`${blocking.length} managed client name(s) ambiguous \u2014 dev-users sync will refuse them`,detail:blocking}];if(unmanagedDuplicates>0){checks.push({name:"client-name-duplicates",severity:"warn",message:`${unmanagedDuplicates} client name(s) outside this layer appear more than once (bulk-imported member records; client has no unique constraint on (org_id, name)). Informational \u2014 a duplicate import or two members sharing a name.`})}return checks}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:"dev-users",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}const files=this.files();const ordered=[...files.filter(f=>f.startsWith("advisor-")),...files.filter(f=>!f.startsWith("advisor-"))];for(const file of ordered){try{const def=this.loadFile(file);if(this.options.dryRun){const existing=await this.findAuthUser(def.email);entities.push({entity:`${def.type}:${def.email}`,action:existing?"updated":"created",id:existing??"(new)"});continue}const user=await this.ensureAuthUser(def);for(const slug of def.type==="client"?[]:def.org_memberships){const orgId=await this.orgIdFor(slug);if(!orgId){console.warn(`[template] \u26A0 org_memberships: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping membership.`);continue}await this.ensureOrgMember(user.id,orgId,def)}if(def.type==="advisor"){const advisorId=await this.ensureAdvisor(user.id,def);this.mappings.set("advisor",def.email,advisorId)}else{const clientId=await this.ensureClient(user.id,def);this.mappings.set("client",def.email,clientId);const slug=def.client_record?.slug;if(slug)this.mappings.set("client",slug,clientId)}entities.push({entity:`${def.type}:${def.email}`,action:user.created?"created":"updated",id:user.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}}};var DevClientDataManager=class extends LayerManager{get shape(){return{fields:{client:{type:"string",required:true,minLength:1},entries:{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("DevClientDataManager 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 resolveDataTypeId(name){const{data,error}=await this.options.supabase.from("client_data_type").select("id, data_format").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} client_data_type rows named "${name}" in this org \u2014 ambiguous. That table has no unique constraint on (org_id, name); run doctor for the ids.`)}const row=rows[0];return row?{id:row.id,format:row.data_format}:null}async syncAll(){const started=Date.now();const entities=[];const errors=[];let created=0;let updated=0;let skipped=0;if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-client-data",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 clientId=await this.resolveClientId(def.client);if(!clientId){entities.push({entity:def.client,action:"skipped",id:null,reason:`no client matches "${def.client}" \u2014 seed dev-users first:
|
|
2
|
+
import{execSync}from"node:child_process";function parseArgs(argv){const flags=new Set;const values=new Map;const positional=[];for(let i=0;i<argv.length;i++){const token=argv[i];if(!token.startsWith("--")){positional.push(token);continue}const eq=token.indexOf("=");if(eq!==-1){values.set(token.slice(2,eq),token.slice(eq+1));continue}const key=token.slice(2);const next=argv[i+1];if(next!==void 0&&!next.startsWith("--")){values.set(key,next);i++}else{flags.add(key)}}return{command:positional[0]??"",targetEnv:resolveTargetEnv(flags,values),layer:values.get("layer")??null,name:values.get("name")??null,check:values.get("check")??null,version:values.get("version")??null,dryRun:flags.has("dry-run"),offline:flags.has("offline"),json:flags.has("json"),overwrite:flags.has("overwrite"),confirmProd:values.get("confirm-prod")??null,force:flags.has("force"),flags,values}}function resolveTargetEnv(flags,values){const fromValue=values.get("env")??null;const aliases=[];if(flags.has("develop"))aliases.push("develop");if(flags.has("prod"))aliases.push("prod");if(aliases.length>1){throw new Error("Pass at most one of --develop / --prod.")}if(fromValue!==null){if(fromValue!=="local"&&fromValue!=="develop"&&fromValue!=="prod"){throw new Error(`--env must be local | develop | prod (got "${fromValue}").`)}if(aliases.length===1&&aliases[0]!==fromValue){throw new Error(`--env ${fromValue} conflicts with --${aliases[0]}. Pass one.`)}return fromValue}return aliases[0]??"local"}function assertWriteAllowed(args,options){if(args.flags.has("replace-existing")){if(args.targetEnv==="prod"){throw new Error("--replace-existing is never allowed against prod.")}if(args.layer!=="dev-workbooks"&&args.layer!=="dev-client-data"||!args.name){throw new Error("--replace-existing requires --layer dev-workbooks|dev-client-data and --name <fixture> \u2014 it deletes a member's seeded rows, so it is never run across every client at once.")}}if(!options.requiresWrite||args.dryRun||args.offline)return;if(args.targetEnv==="local")return;if(!args.overwrite){throw new Error(`Writing to ${args.targetEnv} requires --overwrite. Run with --dry-run first and read the report.`)}if(args.targetEnv==="prod"){if(args.confirmProd!==options.orgSlug){throw new Error(`Writing to prod requires --confirm-prod ${options.orgSlug}. This is a deliberate speed bump: prod org config is live client-facing data.`)}if(!args.force&&isGitTreeDirty(options.repoRoot)){throw new Error("Refusing to write to prod from a dirty git tree \u2014 the files being uploaded would not match any commit, so the write could not be reproduced or reviewed. Commit first, or pass --force if you know exactly why.")}}}function isGitTreeDirty(repoRoot){try{const out=execSync("git status --porcelain",{cwd:repoRoot,encoding:"utf8",stdio:["ignore","pipe","ignore"]});return out.trim().length>0}catch{return false}}import{existsSync as existsSync11,readFileSync as readFileSync10}from"node:fs";import{join as join11}from"node:path";import{createClient}from"@supabase/supabase-js";import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";var ENV_FILE={local:".env.local",develop:".env.develop",prod:".env.prod"};function loadEnv(target,repoRoot){const primary=resolve(repoRoot,ENV_FILE[target]);const fallback=target==="local"?resolve(repoRoot,".env"):null;const fileToLoad=existsSync(primary)?primary:fallback&&existsSync(fallback)?fallback:null;if(!fileToLoad)return null;for(const rawLine of readFileSync(fileToLoad,"utf8").split("\n")){const line=rawLine.trim();if(!line||line.startsWith("#"))continue;const match=line.match(/^([^=]+)=(.*)$/);if(!match)continue;const key=match[1].trim();let value=match[2].trim();if(value.startsWith('"')&&value.endsWith('"')||value.startsWith("'")&&value.endsWith("'")){value=value.slice(1,-1)}if(process.env[key]===void 0)process.env[key]=value}return fileToLoad}function getSupabaseConfig(){const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_SECRET_KEY||process.env.SUPABASE_SERVICE_KEY||process.env.SUPABASE_SERVICE_ROLE_KEY;if(!url){throw new Error("SUPABASE_URL not set. Add it to the env file for this target, or pass --offline to work without a database.")}if(!key){throw new Error("SUPABASE_SECRET_KEY (or SUPABASE_SERVICE_KEY / SUPABASE_SERVICE_ROLE_KEY) not set. Template writes need the secret key \u2014 the publishable key cannot pass RLS on org config tables.")}return{url,key}}function createSupabaseClient(target,repoRoot){loadEnv(target,repoRoot);const{url,key}=getSupabaseConfig();return createClient(url,key,{auth:{persistSession:false,autoRefreshToken:false}})}import{existsSync as existsSync3}from"node:fs";import{join as join3}from"node:path";import{pathToFileURL}from"node:url";import{existsSync as existsSync2,readdirSync,readFileSync as readFileSync2}from"node:fs";import{join,resolve as resolve2}from"node:path";function typeName(value){if(value===null)return"null";if(Array.isArray(value))return"array";return typeof value}function checkField(key,spec,raw,errors){const present=raw!==void 0&&raw!==null;if(!present){if(spec.required){errors.push(`"${key}" is required`);return void 0}return"default"in spec?spec.default:void 0}switch(spec.type){case"string":if(typeof raw!=="string"){errors.push(`"${key}" must be a string (got ${typeName(raw)})`);return void 0}if(spec.minLength!==void 0&&raw.length<spec.minLength){errors.push(`"${key}" must be at least ${spec.minLength} character(s)`);return void 0}return raw;case"boolean":if(typeof raw!=="boolean"){errors.push(`"${key}" must be a boolean (got ${typeName(raw)})`);return void 0}return raw;case"number":if(typeof raw!=="number"||Number.isNaN(raw)){errors.push(`"${key}" must be a number (got ${typeName(raw)})`);return void 0}return raw;case"enum":if(typeof raw!=="string"||!spec.values.includes(raw)){errors.push(`"${key}" must be one of: ${spec.values.join(", ")} (got ${JSON.stringify(raw)})`);return void 0}return raw;case"object":if(typeName(raw)!=="object"){errors.push(`"${key}" must be an object (got ${typeName(raw)})`);return void 0}return raw;case"array":if(!Array.isArray(raw)){errors.push(`"${key}" must be an array (got ${typeName(raw)})`);return void 0}return raw;case"any":return raw}}function validateShape(raw,spec){const errors=[];if(typeName(raw)!=="object"){return{ok:false,value:null,errors:[`expected a JSON object, got ${typeName(raw)}`]}}const input=raw;const out={};for(const[key,fieldSpec]of Object.entries(spec.fields)){const value=checkField(key,fieldSpec,input[key],errors);if(value!==void 0)out[key]=value}const known=new Set([...Object.keys(spec.fields),...spec.ignored??[]]);for(const key of Object.keys(input)){if(spec.allowUnknown){if(!known.has(key))out[key]=input[key]}else if(!known.has(key)){errors.push(`unknown key "${key}" \u2014 not written to the database. Add it to the layer schema, or move this file out of the layer.`)}}return errors.length>0?{ok:false,value:null,errors}:{ok:true,value:out,errors:[]}}var REPO_ROOT=resolve2(process.env.ENGINE_ROOT??process.cwd());var TEMPLATE_ROOT=resolve2(REPO_ROOT,"template");var CONFIG_DIR=join(TEMPLATE_ROOT,"config");var DATA_DIR=join(TEMPLATE_ROOT,"data");var SEED_DIR=join(TEMPLATE_ROOT,"seed");var MAPPINGS_DIR=join(TEMPLATE_ROOT,"mappings");var cachedOrgConfig=null;function loadOrgConfig(){if(cachedOrgConfig)return cachedOrgConfig;const path=join(CONFIG_DIR,"org.json");if(!existsSync2(path)){throw new Error(`template/config/org.json not found at ${path}`)}const parsed=JSON.parse(readFileSync2(path,"utf8"));const result=validateShape(parsed,{fields:{slug:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},advisor:{type:"boolean",required:true},advisor_org_slug:{type:"string"},bundle_name:{type:"string",default:"main"},asset_bucket:{type:"string"},description:{type:"string"}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`template/config/org.json is invalid:
|
|
3
|
+
- ${result.errors.join("\n - ")}`)}if(!result.value.advisor&&!result.value.advisor_org_slug){console.warn("[template] config/org.json: client org has no advisor_org_slug \u2014 the org layer will not link an advisor org.")}cachedOrgConfig=result.value;return result.value}var cachedTemplatesConfig=null;function loadTemplatesConfig(){if(cachedTemplatesConfig)return cachedTemplatesConfig;const path=join(CONFIG_DIR,"templates.json");if(!existsSync2(path)){cachedTemplatesConfig={org_template:null,project_templates:[]};return cachedTemplatesConfig}const parsed=JSON.parse(readFileSync2(path,"utf8"));const outer=validateShape(parsed,{fields:{org_template:{type:"any"},project_templates:{type:"array",default:[]},form_template_process:{type:"string"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/templates.json is invalid:
|
|
4
|
+
- ${outer.errors.join("\n - ")}`)}let orgTemplate=null;if(outer.value.org_template){const result=validateShape(outer.value.org_template,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},layout_bundles:{type:"object"}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json org_template is invalid: ${result.errors.join("; ")}`)}orgTemplate=result.value}const projectTemplates=[];(outer.value.project_templates??[]).forEach((raw,index)=>{const result=validateShape(raw,{fields:{slug:{type:"string",required:true,minLength:1},bundle_name:{type:"string",default:"main"},skip_pages:{type:"boolean",default:false},page_template_process:{type:"string",minLength:36}},ignored:["_comment"]});if(!result.ok||!result.value){throw new Error(`templates.json project_templates[${index}] is invalid: ${result.errors.join("; ")}`)}const pageProcess=result.value.page_template_process;if(pageProcess!==void 0&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(pageProcess)){throw new Error(`templates.json project_templates[${index}].page_template_process must be a process id (a uuid)`)}projectTemplates.push(pageProcess===void 0?result.value:{...result.value,page_template_process:pageProcess.toLowerCase()})});const formProcess=outer.value.form_template_process;if(formProcess!==void 0&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(formProcess)){throw new Error("templates.json form_template_process must be a process id (a uuid)")}cachedTemplatesConfig={org_template:orgTemplate,project_templates:projectTemplates,...formProcess?{form_template_process:formProcess.toLowerCase()}:{}};return cachedTemplatesConfig}function orgTemplatePath(slug){return join(REPO_ROOT,"src","org_templates",slug,"template.json")}function projectTemplateDir(slug){return join(REPO_ROOT,"src","project_templates",slug)}function projectTemplatePages(slug){const pagesDir=join(projectTemplateDir(slug),"pages");if(!existsSync2(pagesDir))return[];return readdirSync(pagesDir,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>({page:e.name,path:join(pagesDir,e.name,"page.json")})).filter(p=>existsSync2(p.path)).sort((a,b)=>a.page.localeCompare(b.page))}function layerDir(sourceDir){return sourceDir?join(TEMPLATE_ROOT,sourceDir):null}function listJsonFiles(dir){if(!dir||!existsSync2(dir))return[];return readdirSync(dir).filter(f=>f.endsWith(".json")).sort()}function readJsonFile(path){try{return JSON.parse(readFileSync2(path,"utf8"))}catch(error){throw new Error(`${path}: not valid JSON \u2014 ${error instanceof Error?error.message:String(error)}`)}}import{join as join2}from"node:path";var LIST_KEY="bundle_refs";var SINGLE_KEY="bundle_ref";function describe(value){if(value===null)return"null";if(Array.isArray(value))return"an array";return typeof value}function refError(ref2,path,orgSlug){if(typeof ref2==="string"){if(ref2===orgSlug)return null;return`${path}: bare-string bundle ref "${ref2}" is an ORG SLUG, and it is not this org's ("${orgSlug}") \u2014 did you mean { "bundle": "${ref2}" }? A bare string resolves to that org's main bundle, so a lineage name here silently falls through to main and the page renders with the wrong components. For a deliberate cross-org ref, name the lineage explicitly: { "bundle": "<lineage>", "org_slug": "${ref2}" }.`}if(typeof ref2!=="object"||ref2===null||Array.isArray(ref2)){return`${path}: a bundle ref must be a string or an object, got ${describe(ref2)}`}const obj=ref2;if("bundle"in obj){if(typeof obj.bundle!=="string"||obj.bundle.length===0){return`${path}: { "bundle": \u2026 } must name a lineage as a non-empty string, got ${describe(obj.bundle)}`}if("org_slug"in obj&&typeof obj.org_slug!=="string"){return`${path}: "org_slug" must be a string when present, got ${describe(obj.org_slug)}`}return null}if("bundle_id"in obj){if(typeof obj.bundle_id!=="string"||obj.bundle_id.length===0){return`${path}: { "bundle_id": \u2026 } must be a non-empty string`}return null}return`${path}: object bundle ref has neither "bundle" nor "bundle_id" (keys: ${Object.keys(obj).join(", ")||"none"}) \u2014 the union is string | { bundle_id } | { bundle, org_slug? }`}function bundleRefErrors(def,orgSlug){const errors=[];let visited=0;const walk=(node,path)=>{if(Array.isArray(node)){node.forEach((item,i)=>walk(item,`${path}[${i}]`));return}if(typeof node!=="object"||node===null)return;for(const[key,value]of Object.entries(node)){const here=path?`${path}.${key}`:key;if(key===LIST_KEY){if(!Array.isArray(value)){errors.push(`${here}: "${LIST_KEY}" must be an array, got ${describe(value)}`);continue}value.forEach((ref2,i)=>{visited+=1;const error=refError(ref2,`${here}[${i}]`,orgSlug);if(error)errors.push(error)});continue}if(key===SINGLE_KEY){if(value===void 0||value===null)continue;visited+=1;const error=refError(value,here,orgSlug);if(error)errors.push(error);continue}walk(value,here)}};walk(def,"");return{errors,visited}}function describeKey(naturalKey){return Object.entries(naturalKey).map(([k,v])=>`${k}=${v===null?"NULL":JSON.stringify(v)}`).join(", ")}async function upsertByNaturalKey(supabase,table,naturalKey,record,options={}){let query=supabase.from(table).select("id");for(const[key,value]of Object.entries(naturalKey)){query=value===null?query.is(key,null):query.eq(key,value)}const{data:existing,error:selectError}=await query.limit(2);if(selectError){throw new Error(`lookup failed on ${table} (${describeKey(naturalKey)}): ${selectError.message}`)}const rows=existing??[];if(rows.length>1){throw new Error(`${table} has at least ${rows.length} rows matching ${describeKey(naturalKey)} \u2014 ambiguous, refusing to write. Ids: ${rows.map(r=>r.id).join(", ")}. ${table==="client_data_type"?"This table has no unique constraint on its natural key, so duplicates are possible. ":""}Resolve the duplicate in the database first (see \`template/cli doctor\`).`)}const current=rows[0];if(current){if(options.dryRun)return{id:current.id,action:"updated"};const{data:data2,error:error2}=await supabase.from(table).update(record).eq("id",current.id).select("id").single();if(error2)throw new Error(`update failed on ${table} id=${current.id}: ${error2.message}`);return{id:data2.id,action:"updated"}}if(options.dryRun)return{id:"(new)",action:"created"};const{data,error}=await supabase.from(table).insert([record]).select("id").single();if(error){throw new Error(`insert failed on ${table} (${describeKey(naturalKey)}): ${error.message}`)}return{id:data.id,action:"created"}}var LayerManager=class{constructor(def,options,mappings){this.def=def;this.options=options;this.mappings=mappings}def;options;mappings;mappingEntities(){const primary=this.def.mappingEntity?[this.def.mappingEntity]:[];return[...primary,...this.def.mappingEntities??[]]}dir(){return layerDir(this.def.sourceDir)}duplicateDbKeys=new Set;bundleRefsVisited=0;files(){const files=listJsonFiles(this.dir());const filter=this.options.nameFilter;if(!filter)return files;return files.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}fileKeys(){const keys=[];for(const file of this.files()){try{keys.push(this.naturalKeyOf(this.loadFile(file)))}catch{}}return keys}loadFile(fileName){const dir=this.dir();if(!dir)throw new Error(`layer ${this.def.name} has no source directory`);const raw=readJsonFile(join2(dir,fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value){throw new Error(result.errors.join("; "))}return result.value}extraIssues(_def){return{errors:[],warnings:[]}}validateAll(){this.bundleRefsVisited=0;const errors=[];const warnings=[];const files=this.files();const seen=new Map;for(const file of files){try{const def=this.loadFile(file);const refs=bundleRefErrors(def,this.options.orgSlug);this.bundleRefsVisited+=refs.visited;for(const message of refs.errors)errors.push({file,message});const extra=this.extraIssues(def);for(const message of extra.errors)errors.push({file,message});for(const message of extra.warnings)warnings.push({file,message});const key=this.naturalKeyOf(def);const previous=seen.get(key);if(previous){errors.push({file,message:`natural key "${key}" already declared by ${previous} \u2014 two files would fight over one row`})}else{seen.set(key,file)}}catch(error){errors.push({file,message:error instanceof Error?error.message:String(error)})}}if(files.length===0&&this.def.sourceDir&&!this.options.nameFilter){errors.push({file:this.dir()??"(layer)",message:`this layer declares sourceDir "${this.def.sourceDir}" and found NO files there. Every check in this layer runs per file, so zero files is a green run that verified nothing \u2014 a moved or unchecked-out directory, not a clean one.`})}return{layer:this.def.name,filesChecked:files.length,errors,warnings}}async syncAll(){const started=Date.now();const entities=[];const errors=[];for(const file of this.files()){try{const def=this.loadFile(file);const key=this.naturalKeyOf(def);const outcome=await upsertByNaturalKey(this.options.supabase,this.def.table,this.naturalKeyFilter(def),this.toRow(def),{dryRun:this.options.dryRun});if(this.def.mappingEntity&&!this.options.dryRun){this.mappings.set(this.def.mappingEntity,key,outcome.id)}entities.push({entity:key,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}}async status(){const files=this.files();const base={layer:this.def.name,kind:this.def.kind,implemented:this.def.implemented,files:files.length,dbRows:null,mappedIds:this.mappingEntities().reduce((sum,e)=>sum+this.mappings.count(e),0),missingInDb:[],missingInFiles:[],unmappedInDb:[]};if(!this.def.implemented)return base;const fileKeys=new Set(this.fileKeys());try{const dbKeys=await this.fetchDbKeys();base.dbRows=dbKeys.size;base.missingInDb=[...fileKeys].filter(k=>!dbKeys.has(k)).sort();base.missingInFiles=[...dbKeys.keys()].filter(k=>!fileKeys.has(k)).sort();if(this.def.mappingEntity){const mapped=new Set(this.mappings.keys(this.def.mappingEntity));base.unmappedInDb=[...dbKeys.keys()].filter(k=>fileKeys.has(k)&&!mapped.has(k)).sort()}}catch(error){base.error=error instanceof Error?error.message:String(error)}return base}async healthChecks(){return[]}async orphans(){return[]}async pull(){const started=Date.now();const entities=[];const errors=[];if(!this.def.implemented||!this.def.mappingEntity){return{layer:this.def.name,created:0,updated:0,skipped:0,entities,errors,duration_ms:Date.now()-started}}try{const dbKeys=await this.fetchDbKeys();for(const key of this.fileKeys()){const id=dbKeys.get(key);if(!id){entities.push({entity:key,action:"skipped",id:null,reason:"not in database"});continue}if(this.duplicateDbKeys.has(key)){entities.push({entity:key,action:"skipped",id:null,reason:"ambiguous \u2014 more than one row shares this natural key; resolve the duplicate before mapping it (see `doctor`)"});continue}const before=this.mappings.getId(this.def.mappingEntity,key);this.mappings.set(this.def.mappingEntity,key,id);entities.push({entity:key,action:before===id?"skipped":"updated",id})}}catch(error){errors.push({entity:this.def.name,error:error instanceof Error?error.message:String(error)})}return{layer:this.def.name,created:0,updated:entities.filter(e=>e.action==="updated").length,skipped:entities.filter(e=>e.action==="skipped").length,entities,errors,duration_ms:Date.now()-started}}};var CONTRACT=["src/types/host-contract.generated.ts","src/types/host-contract.generated.js"].map(f=>join3(REPO_ROOT,f)).find(f=>existsSync3(f));var AGENT_TOOL_NAMES=CONTRACT?(await import(pathToFileURL(CONTRACT).href)).AGENT_TOOL_NAMES??[]:[];var AgentTemplateManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},is_template:{type:"boolean",default:true},prompt:{type:"any",required:true},tools:{type:"any",required:true},model:{type:"any"},output_schema:{type:"any"},skills:{type:"any"},metadata:{type:"object"}}}}extraIssues(def){const errors=[];const warnings=[];const tools=def.tools;if(tools!==void 0){const shape=tools;if(typeof tools!=="object"||tools===null||Array.isArray(tools)){errors.push(`"tools" must be an object carrying a toolNames array \u2014 the host keeps platform-agent's ADVISOR tools for any other shape, silently`)}else if(!Array.isArray(shape?.toolNames)){errors.push(`"tools.toolNames" must be an array \u2014 without it the host keeps platform-agent's ADVISOR tools (createEvent, updateEvent, submitProcessJob), silently, and this row's own tools are never applied`)}else if(shape.toolNames.some(t=>typeof t!=="string"||t.trim()==="")){errors.push(`"tools.toolNames" must contain non-empty strings \u2014 the host matches them by exact name against its tool registry, so a blank entry silently selects nothing`)}else if(AGENT_TOOL_NAMES.length>0){const known=new Set(AGENT_TOOL_NAMES);const names=shape.toolNames;const unknown=names.filter(t=>!known.has(t));if(unknown.length>0&&unknown.length<names.length){errors.push(`"tools.toolNames" names ${unknown.length} tool(s) of ${names.length} that the host does not have: ${unknown.join(", ")}. The runtime matches by exact name and drops the rest silently, so this agent would run with fewer tools than it declares. Valid names are in AGENT_TOOL_NAMES (host contract 2.15.0+)`)}else if(unknown.length===names.length){warnings.push(`none of this agent's ${names.length} tool name(s) are in the host's chat-agent registry, which reads as a BEA or other non-host runtime rather than a typo \u2014 every name would have to be wrong for a typo to look like this. Not checkable here: only the host chat runtime publishes its tool names (AGENT_TOOL_NAMES, contract 2.15.0+)`)}}}const prompt=def.prompt;if(prompt!==void 0){const shape=prompt;if(typeof prompt!=="object"||prompt===null||Array.isArray(prompt)){errors.push(`"prompt" must be an object with a "system" string \u2014 the host keeps platform-agent's ADVISOR system prompt for any other shape`)}else if(typeof shape?.system!=="string"||shape.system.trim()===""){errors.push(`"prompt.system" must be a non-empty string \u2014 without it the host keeps platform-agent's ADVISOR system prompt ("assistant for financial advisors")`)}}return{errors,warnings}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name,is_template:true}}toRow(def){const row={org_id:this.options.orgId,name:def.name,is_template:true,archived:false,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.prompt!==void 0)row.prompt=def.prompt;if(def.tools!==void 0)row.tools=def.tools;if(def.model!==void 0)row.model=def.model;if(def.output_schema!==void 0)row.output_schema=def.output_schema;if(def.skills!==void 0)row.skills=def.skills;if(def.metadata!==void 0)row.metadata=def.metadata;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("agent").select("id, name").eq("org_id",this.options.orgId).eq("is_template",true).eq("archived",false);if(error)throw new Error(`agent lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){if(row.name)out.set(row.name,row.id)}return out}};var DataTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},data_format:{type:"enum",values:["text","json","file"],default:"text"},schema:{type:"any"}},ignored:["org_id","org_name"]}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,data_format:def.data_format,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.schema!==void 0)row.schema=def.schema;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("client_data_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const out=new Map;for(const row of data??[]){out.set(row.name,row.id)}return out}};import{existsSync as existsSync4,readdirSync as readdirSync2}from"node:fs";import{join as join4}from"node:path";var ORG_OVERRIDE_DIR=join4(DATA_DIR,"event-types");var DECLARATIONS=["kind","visible_to","creatable_by","data_schema","retention","requires_consent","hooks","components","calendar_export","access_logged"];var EventTypeManager=class extends LayerManager{get shape(){return{fields:{name:{type:"string",required:true,minLength:1},description:{type:"string"},icon:{type:"string"},color:{type:"string"},category:{type:"string"},display_order:{type:"number"},metadata:{type:"object"},kind:{type:"enum",values:["scheduled","record"]},visible_to:{type:"array"},creatable_by:{type:"array"},data_schema:{type:"object"},retention:{type:"string"},requires_consent:{type:"boolean"},hooks:{type:"array"},components:{type:"object"},calendar_export:{type:"enum",values:["none","busy","details"]},access_logged:{type:"boolean"}}}}naturalKeyOf(def){return def.name}naturalKeyFilter(def){return{org_id:this.options.orgId,name:def.name}}toRow(def){const row={org_id:this.options.orgId,name:def.name,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.icon!==void 0)row.icon=def.icon;if(def.color!==void 0)row.color=def.color;if(def.metadata!==void 0)row.metadata=def.metadata;for(const key of DECLARATIONS)if(def[key]!==void 0)row[key]=def[key];return row}orgFiles(){if(!existsSync4(ORG_OVERRIDE_DIR))return[];return readdirSync2(ORG_OVERRIDE_DIR).filter(f=>f.endsWith(".json")).sort()}resolvePath(fileName){const orgPath=join4(ORG_OVERRIDE_DIR,fileName);return existsSync4(orgPath)?orgPath:join4(this.dir()??"",fileName)}files(){const merged=new Set([...super.files(),...this.orgFiles()]);const all=[...merged].sort();const filter=this.options.nameFilter;if(!filter)return all;return all.filter(f=>f===`${filter}.json`||f.replace(/\.json$/,"")===filter)}loadFile(fileName){const raw=readJsonFile(this.resolvePath(fileName));const result=validateShape(raw,this.shape);if(!result.ok||!result.value)throw new Error(result.errors.join("; "));return result.value}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("event_type").select("id, name").eq("org_id",this.options.orgId);if(error)throw new Error(`event_type: ${error.message}`);const keys=new Map;for(const row of data??[]){keys.set(row.name,row.id)}return keys}};var FeedProducerManager=class extends LayerManager{get shape(){return{fields:{key:{type:"string",required:true,minLength:1},name:{type:"string",required:true,minLength:1},description:{type:"string"},kind:{type:"enum",values:["process","system","manual","user"],default:"process"},is_default:{type:"boolean",default:false},config:{type:"object",default:{}},status:{type:"enum",values:["active","paused","retired"],default:"active"},metadata:{type:"object"},source_table:{type:"string"},source_id:{type:"string"}}}}naturalKeyOf(def){return def.key}naturalKeyFilter(def){return{org_id:this.options.orgId,key:def.key}}toRow(def){const row={org_id:this.options.orgId,key:def.key,name:def.name,kind:def.kind,is_default:def.is_default,config:def.config,status:def.status,updated_at:new Date().toISOString()};if(def.description!==void 0)row.description=def.description;if(def.metadata!==void 0)row.metadata=def.metadata;if(def.source_table!==void 0)row.source_table=def.source_table;if(def.source_id!==void 0)row.source_id=def.source_id;return row}async fetchDbKeys(){const{data,error}=await this.options.supabase.from("feed_producer").select("id, key").eq("org_id",this.options.orgId);if(error){throw new Error(`feed_producer lookup failed: ${error.message}. This layer needs the 20260804000000_feed_system migration.`)}const out=new Map;for(const row of data??[]){out.set(row.key,row.id)}return out}};import{join as join5}from"node:path";import{createRequire}from"node:module";import{pathToFileURL as pathToFileURL2}from"node:url";import Ajv2020 from"ajv/dist/2020.js";var question_schema_min_default={anyOf:[{$ref:"#/$defs/question"},{$ref:"#/$defs/questionGroup"}],$defs:{question:{type:"object",properties:{key:{type:"string",pattern:"^[A-Za-z][A-Za-z0-9_.-]{0,119}$"},type:{type:"string",enum:["text","textarea","number","date","select","radio","multiselect","file"]},label:{type:"string",minLength:1,maxLength:1e3},required:{type:"boolean"},options:{anyOf:[{minItems:1,maxItems:500,type:"array",items:{type:"object",properties:{value:{anyOf:[{type:"string",maxLength:200},{type:"number"}]},label:{type:"string",minLength:1,maxLength:500}},required:["value","label"],additionalProperties:false}},{type:"null"}]},validation:{anyOf:[{type:"object",properties:{min:{type:"number"},max:{type:"number"}},additionalProperties:false},{type:"null"}]},default:{},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]},description:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},helpText:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},placeholder:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},content_before:{anyOf:[{maxItems:50,type:"array",items:{$ref:"#/$defs/contentBlock"}},{type:"null"}]},content_after:{anyOf:[{maxItems:50,type:"array",items:{$ref:"#/$defs/contentBlock"}},{type:"null"}]},upload:{anyOf:[{type:"object",properties:{instructions:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},acceptedFileTypes:{maxItems:50,type:"array",items:{type:"string",minLength:1,maxLength:100}},maxFileSizeMB:{type:"number",exclusiveMinimum:0,maximum:100},allowCameraCapture:{type:"boolean"},requirePersonalVerification:{type:"boolean"},uploadZoneLabel:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},uploadZoneSubLabel:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]},successMessage:{anyOf:[{type:"string",maxLength:4e3},{type:"null"}]}},additionalProperties:false},{type:"null"}]}},required:["key","type","label","required"],additionalProperties:false,allOf:[{if:{properties:{type:{enum:["select","radio","multiselect"]}}},then:{required:["options"],properties:{options:{type:"array",minItems:1}}},else:{properties:{options:{type:"null"}}}},{if:{properties:{type:{enum:["file"]}}},else:{properties:{upload:{type:"null"}}}},{if:{properties:{type:{enum:["number"]}}},else:{properties:{validation:{type:"null"}}}}]},condition:{type:"object",properties:{all:{maxItems:50,type:"array",items:{type:"object",properties:{q:{type:"string",minLength:1,maxLength:120},op:{type:"string",enum:["eq","ne","in","not_in","gt","lt","gte","lte","contains","not_contains","empty","not_empty"]},value:{}},required:["q","op"],additionalProperties:false}},not:{type:"boolean"}},required:["all"],additionalProperties:false},contentBlock:{type:"object",properties:{id:{type:"string",minLength:1,maxLength:120},type:{type:"string",minLength:1,maxLength:60},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]}},required:["id","type"],additionalProperties:{}},questionGroup:{type:"object",properties:{group:{type:"boolean",const:true},label:{anyOf:[{type:"string",maxLength:1e3},{type:"null"}]},when:{anyOf:[{$ref:"#/$defs/condition"},{type:"null"}]},questions:{minItems:1,maxItems:100,type:"array",items:{$ref:"#/$defs/question"}}},required:["group","questions"],additionalProperties:false}}};var check;function questionProblems(state){check??=new Ajv2020({strict:false,allErrors:true}).compile(question_schema_min_default);const out=[];if(!check(state)){for(const e of check.errors??[])if(e.keyword!=="if"&&e.keyword!=="anyOf")out.push(`${e.instancePath||"/"} ${e.message}`);if(!out.length)out.push("doesn\u2019t match the form-definition contract")}const s=state;const v=s?.validation;if(typeof v?.min==="number"&&typeof v?.max==="number"&&v.min>v.max)out.push("QUESTION_VALIDATION: min is greater than max");if(s?.group&&Array.isArray(s.questions)){const keys=s.questions.map(q=>q?.key).filter(Boolean);const dup=keys.find((k,i)=>keys.indexOf(k)!==i);if(dup)out.push(`QUESTION_KEY_DUPLICATE: ${dup} appears twice in the group`)}return out}var byOrder=xs=>[...xs??[]].sort((a,b)=>Number(a.orderIndex??0)-Number(b.orderIndex??0));var ref=key=>`@q_${key}`;var and=(a,b)=>{if(!a)return b??void 0;if(!b)return a;if(!a.not&&!b.not)return{all:[...a.all,...b.all]};return void 0};function formSpecOf(label,file,deps){const problems=[];const key=String(file.template_id??"");if(!key)problems.push(`${label}: a form template needs a template_id (its template_key)`);const when=(raw,where)=>{const{condition,problems:p}=deps.normalize(typeof raw==="string"?raw:deps.decode(raw));for(const x of p)problems.push(`${label}: ${where}: ${x}`);return condition??void 0};const questions=[];const seenKeys=new Map;const question=(k,name,state,where)=>{const prior=seenKeys.get(k);if(prior)problems.push(`${label}: ${where}: question key "${k}" is also ${prior}'s`);seenKeys.set(k,where);questions.push({key:k,name,state:{key:k,...state}})};const cells=[];const steps=[];const chapters=[];const stepIds=new Set;for(const page of byOrder(file.pages)){chapters.push({id:page.pageId,title:page.title});const pageWhen=when(page.conditionalLogic,`page ${page.pageId}`);for(const section of byOrder(page.sections)){const sid=section.sectionId;if(stepIds.has(sid))problems.push(`${label}: section id "${sid}" is used twice; step ids are deep links`);stepIds.add(sid);const ids=[];const cell=c=>(cells.push(c),ids.push(c.id));const content=(suffix,value)=>{if(value!==void 0&&value!==null&&!(Array.isArray(value)&&!value.length))cell({id:`${sid}-${suffix}`,component:"SectionContent",props:{content:value}})};content("before",section.contentBefore);const type=section.sectionType??"standard";const fields=byOrder(section.fields);const upload=type==="upload"?section.uploadConfig??{}:null;const uploadKey=typeof upload?.fieldId==="string"?upload.fieldId:"";for(const f of fields){const where=`field ${f.fieldId}`;const isUpload=upload&&f.fieldId===uploadKey;question(f.fieldId,f.label,{type:isUpload?"file":f.fieldType,...isUpload?{upload:(({fieldId:_f,...rest})=>rest)(upload)}:{},label:f.label,required:f.required===true,...f.options!=null?{options:deps.decode(f.options)}:{},...f.validation!=null?{validation:deps.decode(f.validation)}:{},...f.defaultValue!=null?{default:f.defaultValue}:{},...f.conditionalLogic!=null?(()=>{const w2=when(f.conditionalLogic,where);return w2?{when:w2}:{}})():{},...f.description!=null?{description:f.description}:{},...f.helpText!=null?{helpText:f.helpText}:{},...f.placeholder!=null?{placeholder:f.placeholder}:{},...f.contentBefore!=null?{content_before:f.contentBefore}:{},...f.contentAfter!=null?{content_after:f.contentAfter}:{}},where)}const extras=[["reviewConfig","SectionReview"],["advisorReviewConfig","SectionAdvisorReview"],["statusConfig","SectionStatus"]];switch(type){case"standard":if(fields.length)cell({id:sid,component:"SectionQuestions",props:{questions:fields.map(f=>ref(f.fieldId))}});break;case"content":cell({id:sid,component:"SectionContent",props:{content:section.content??null}});break;case"review":case"advisor-review":case"status":{const[prop,component]=extras.find(([,c])=>c==={review:"SectionReview","advisor-review":"SectionAdvisorReview",status:"SectionStatus"}[type]);cell({id:sid,component,props:{config:section[prop]??null}});break}case"document":cell({id:sid,component:"SectionDocument",props:{config:section.documentConfig??null}});break;case"upload":{if(!uploadKey){problems.push(`${label}: section ${sid}: an upload needs uploadConfig.fieldId (its question key)`);break}if(!fields.some(f=>f.fieldId===uploadKey)){const{fieldId:_f,...rest}=upload;question(uploadKey,section.title,{type:"file",label:section.title,required:false,upload:rest},`upload ${sid}`)}cell({id:sid,component:"SectionQuestions",props:{questions:[...fields.map(f=>f.fieldId),...fields.some(f=>f.fieldId===uploadKey)?[]:[uploadKey]].map(ref)}});break}case"cell":{const c=section.cellConfig;if(!c?.component)problems.push(`${label}: section ${sid}: a cell section needs cellConfig.component`);else cell({...c,id:sid});let n=0;for(const[prop,component]of extras)if(section[prop]!=null)cell({id:`${sid}-${++n}`,component,props:{config:section[prop]}});break}default:problems.push(`${label}: section ${sid}: unknown sectionType "${type}"`)}content("after",section.contentAfter);const sectionWhen=when(section.conditionalLogic,`section ${sid}`);const w=and(pageWhen,sectionWhen);if(pageWhen&§ionWhen&&!w)problems.push(`${label}: section ${sid}: a negated page and section condition can't be combined`);steps.push({id:sid,title:section.title,chapter:page.pageId,cell_ids:ids,...w?{when:w}:{}})}}for(const q of questions)for(const p of questionProblems(q.state))problems.push(`${label}: question ${q.key}: ${p}`);const meta=k=>file[k]!==void 0?{[k]:file[k]}:{};return{spec:{label,templateKey:key,name:file.template_name,pageType:`form:${key}`,model:{...meta("description"),...meta("template_type"),...file.profile_block_id!==void 0?{profile_block:{id:file.profile_block_id,name:file.profile_block_name??null}}:{},...meta("metadata")},questions,document:{cells,layout:{steps,chapters}}},problems}}function writeSpecOf(spec){return{v:1,template_key:spec.templateKey,name:spec.name,...Object.keys(spec.model).length?{model:spec.model}:{},questions:spec.questions.map(q=>({key:q.key,name:q.name,state:q.state})),page:{content:{cells:spec.document.cells},layout:spec.document.layout}}}async function writeFormTemplates(api,processId,specs){const out=[];for(const spec of specs)out.push({label:spec.label,templateKey:spec.templateKey,result:await api.write(processId,writeSpecOf(spec))});return out}var BACKOFF_SECONDS=[2,4,8,16,30,30,30,30,30,30,30,30];var TemplateReleaseError=class extends Error{constructor(message,code){super(message);this.code=code}code;name="TemplateReleaseError"};var codeOf=e=>/^(?:DENIED|INVALID|CONFLICT|FAILED):\s*([A-Z][A-Z0-9_]+)/.exec(e.message)?.[1];var detailOf=e=>{try{const v=e.details?JSON.parse(e.details):void 0;return v&&typeof v==="object"?v:void 0}catch{return void 0}};async function releaseTemplate(scopeNodeId,deps){const now=deps.now??Date.now;const sleep=deps.sleep??(ms=>new Promise(r=>setTimeout(r,ms)));const log=deps.log??(()=>{});let jobId;let queued=false;for(let attempt=0;;attempt++){if(now()>=deps.deadline)throw new TemplateReleaseError(`the token's time ran out waiting for the page refresh${jobId?` (job ${jobId})`:""}; nothing was published. Run again.`,"DEADLINE");const{data,error}=await deps.rpc("version_release",{p_scope_node_id:scopeNodeId});if(!error){const out=data;if(out.status==="cut"&&out.version_id){log(`release ${out.number} cut`);const moved=await deps.rpc("version_publish",{p_scope_node_id:scopeNodeId,p_channel:"library",p_release_id:out.version_id});if(moved.error)throw new TemplateReleaseError(`release ${out.number} was cut, but moving library to it was refused: ${moved.error.message}`,codeOf(moved.error));log(`library \u2192 release ${out.number}`);return{releaseId:out.version_id,number:out.number??0}}if(out.status==="refresh_queued"){if(queued)throw new TemplateReleaseError(`the pages were refreshed (job ${jobId}) but are still out of date (job ${out.job_id} queued again); nothing was published.`,"REQUEUED");queued=true;jobId=out.job_id;log(`page refresh queued (job ${jobId})`)}else{throw new TemplateReleaseError(`version_release returned ${JSON.stringify(out.status)}; nothing was published.`)}}else if(codeOf(error)==="VERSION_PAGE_STALE"){const detail=detailOf(error);jobId=typeof detail?.job_id==="string"?detail.job_id:jobId;if(Array.isArray(detail?.pages)){const reasons=detail.pages.filter(p=>!p.published).map(p=>p.reason).filter(Boolean);throw new TemplateReleaseError(`the page refresh (job ${jobId}) ran but didn't publish the pages${reasons.length?` (${[...new Set(reasons)].join(", ")})`:""}; nothing was published.`,"REFRESH_FAILED")}}else if(codeOf(error)==="VERSION_BUSY"){log("the template is busy with another write; retrying")}else{throw new TemplateReleaseError(`the release was refused: ${error.message}`,codeOf(error))}const wait=BACKOFF_SECONDS[Math.min(attempt,BACKOFF_SECONDS.length-1)]*1e3;await sleep(Math.min(wait,Math.max(0,deps.deadline-now())))}}var releaseWith=(client,scopeNodeId,deps)=>releaseTemplate(scopeNodeId,{...deps,rpc:(fn,args)=>client.rpc(fn,args)});var TemplateCallError=class extends Error{constructor(fn,message){super(`${fn}: ${message}`);this.fn=fn}fn;name="TemplateCallError"};var must=(fn,out)=>{if(out.error)throw new TemplateCallError(fn,out.error.message);return out.data};var idOf=(fn,data)=>{if(typeof data==="string")return data;const d=data;const id=d?.id??d?.state_id??d?.graph_node_id;if(typeof id!=="string")throw new TemplateCallError(fn,`returned no id (${JSON.stringify(data)})`);return id};function templateApi(db){return{anchors:async processId=>{const graphs=must("graph",await db.from("graph").select("id").eq("process_id",processId).eq("graph_type","process-eng"));const ids=(graphs??[]).map(g=>g.id);const out=new Map;if(!ids.length)return out;const rows=must("graph_node",await db.from("graph_node").select("id, node!inner(name, node_type, content)").in("graph",ids).eq("archived",false).is("version",null).eq("node.node_type","project_page"));for(const r of rows??[]){const type=r.node?.content?.page_type;if(!type)continue;if(out.has(type))throw new TemplateCallError("graph_node",`two live anchors for page type "${type}" in process ${processId}`);out.set(type,{anchorId:r.id,name:r.node.name??""})}return out},pageCreate:async({processId,name,pageType})=>idOf("page_create",must("page_create",await db.rpc("page_create",{p_project_id:null,p_name:name,p_page_type:pageType,p_process_id:processId}))),pageUpdate:async({anchorId,name,model})=>{must("page_update",await db.rpc("page_update",{p_page_id:anchorId,...name?{p_name:name}:{},p_model:model}))},pageArtifact:async anchorId=>{const id=must("page_artifact",await db.rpc("page_artifact",{p_anchor:anchorId}));if(!id)throw new TemplateCallError("page_artifact",`anchor ${anchorId} has no artifact`);return id},writeDocument:async({artifactId,data,fixture})=>idOf("graph_append_state",must("graph_append_state",await db.rpc("graph_append_state",{p_graph_node_id:artifactId,p_system:"data",p_data:data,...fixture?{p_metadata:{fixture:true}}:{}}))),setUpdate:async(graphNodeId,update)=>{must("graph_edit",await db.rpc("graph_edit",{p_graph_node_id:graphNodeId,p_patch:{update:null}}));if(update)must("graph_edit",await db.rpc("graph_edit",{p_graph_node_id:graphNodeId,p_patch:{update}}))},setAudience:async(anchorId,audience)=>{must("page_set_client",await db.rpc("page_set_client",{p_page_id:anchorId,p_on:audience==="client"}));if(audience==="client")return;const placed=must("graph_node",await db.from("graph_node").select("node, node_row:node(audience)").eq("id",anchorId).maybeSingle());if(!placed)throw new TemplateCallError("graph_node",`anchor ${anchorId} isn't readable`);const current=placed.node_row?.audience??null;if(current==="subject"||current===audience)return;must("node",await db.from("node").update({audience}).eq("id",placed.node).select("id").single())},scopeRoot:async processId=>{const id=must("template_scope_root",await db.rpc("template_scope_root",{p_process_id:processId}));if(!id)throw new TemplateCallError("template_scope_root",`process ${processId} has no eng root scope yet`);return id}}}function formTemplateApi(db){return{write:async(processId,spec)=>{const{data,error}=await db.rpc("form_template_write",{p_process:processId,p_spec:spec});if(error)throw new TemplateCallError("form_template_write",`${spec.template_key}: ${error.message}${error.details?` (${error.details})`:""}`);return data}}}import{createClient as createClient2}from"@supabase/supabase-js";var TOKEN_ENV="PERIPHERIES_TEMPLATE_TOKEN";var PROCESS_ENV="PERIPHERIES_TEMPLATE_PROCESS_ID";var MIN_LIFE_MS=6e4;var TemplateTokenError=class extends Error{name="TemplateTokenError"};function decodeClaims(token){const part=token.split(".")[1];if(!part)throw new TemplateTokenError(`${TOKEN_ENV} isn't a token.`);try{return JSON.parse(Buffer.from(part.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"))}catch{throw new TemplateTokenError(`${TOKEN_ENV} isn't a token.`)}}function templateToken(processId,env=process.env,now=Date.now()){const token=env[TOKEN_ENV];if(!token){throw new TemplateTokenError(`${TOKEN_ENV} isn't set. A template release runs under the agency's with-template-token wrapper for this template's process; it never uses the secret key.`)}const claims=decodeClaims(token);if(claims.grant_kind!=="mandate")throw new TemplateTokenError(`${TOKEN_ENV} isn't a mandate token (grant_kind ${JSON.stringify(claims.grant_kind??null)}).`);if(claims.obo!==void 0)throw new TemplateTokenError(`${TOKEN_ENV} speaks for a person; a template release runs as the template's own mandate.`);if(!claims.act?.job)throw new TemplateTokenError(`${TOKEN_ENV} names no template_release job.`);const want=processId.toLowerCase();if(claims.sub?.toLowerCase()!==want)throw new TemplateTokenError(`${TOKEN_ENV} is for process ${claims.sub??"none"}, not ${processId}, the template being released.`);const named=env[PROCESS_ENV];if(named&&named.toLowerCase()!==want)throw new TemplateTokenError(`${PROCESS_ENV} is ${named}, not ${processId}, the template being released.`);if(env.SUPABASE_URL){const host=u=>{try{return u?new URL(u).host.toLowerCase():null}catch{return null}};const issuer=host(claims.iss);if(!issuer)throw new TemplateTokenError(`${TOKEN_ENV} names no issuer, so it can't be matched to SUPABASE_URL.`);if(issuer!==host(env.SUPABASE_URL))throw new TemplateTokenError(`${TOKEN_ENV} was issued by ${issuer}, not SUPABASE_URL's host; check --env and the env file.`)}if(typeof claims.exp!=="number")throw new TemplateTokenError(`${TOKEN_ENV} has no expiry.`);const deadline=claims.exp*1e3-MIN_LIFE_MS;if(deadline<=now)throw new TemplateTokenError(`${TOKEN_ENV} expires in under a minute; run the wrapper again.`);return{token,claims,deadline}}function templateClient(url,publishableKey,token){return createClient2(url,publishableKey,{accessToken:async()=>token,auth:{persistSession:false}})}function decodeJsonbValue(value){let current=value;for(let depth=0;depth<4&&typeof current==="string";depth++){const trimmed=current.trim();if(!trimmed.startsWith("{")&&!trimmed.startsWith("[")&&!trimmed.startsWith('"'))break;try{const parsed=JSON.parse(trimmed);if(parsed===current)break;current=parsed}catch{break}}return current!==null&&typeof current==="object"?current:value}var RETIRED_SECTION_CONFIG_KEYS={nextButtonText:'authored 10x across 8 files and consumed by nothing \u2014 DocumentSectionRenderer and UploadSectionRenderer render no forward control; advancement is FormNavigationButtons at form scope. Removed 2026-08-27. The requirement it encoded is recorded as a rule ("the forward control names its destination"), not as literals, in the host plan 02 \xA711 \u2014 reviving the key re-creates the literals and loses the rule.'};function retiredSectionConfigKeyErrors(def){const out=[];let visited=0;const pages=Array.isArray(def.pages)?def.pages:[];pages.forEach((page,pi)=>{const sections=Array.isArray(page?.sections)?page.sections:[];sections.forEach((section,si)=>{for(const[configKey,config]of Object.entries(section??{})){if(!configKey.endsWith("Config")||!config||typeof config!=="object")continue;visited+=1;for(const key of Object.keys(config)){const reason=RETIRED_SECTION_CONFIG_KEYS[key];if(reason){out.push(`retired key "${key}" at pages[${pi}].sections[${si}].${configKey} \u2014 ${reason}`)}}}})});return{errors:out,visited}}var FormTemplateManager=class extends LayerManager{get shape(){return{fields:{template_name:{type:"string",required:true,minLength:1},name:{type:"string"},description:{type:"string"},template_type:{type:"enum",values:["form","workbook"],default:"form"},is_published:{type:"boolean",default:false},is_archived:{type:"boolean",default:false},metadata:{type:"object"},shell:{type:"object"},pages:{type:"array",required:true}},ignored:["template_id","org_id","version","profile_block_id","profile_block_name"]}}validateAll(){const result=super.validateAll();let visited=0;for(const file of this.files()){try{visited+=retiredSectionConfigKeyErrors(super.loadFile(file)).visited}catch{}}if(visited===0){result.errors.push({file:"(layer)",message:"the retired-key walker visited zero section config objects across every form template \u2014 its traversal assumptions no longer match the file shape, so the retired-key check is passing vacuously. Fix the walker before trusting a green run."})}return result}loadFile(fileName){const def=super.loadFile(fileName);const{errors}=retiredSectionConfigKeyErrors(def);if(errors.length)throw new Error(errors.join("; "));return def}naturalKeyOf(def){return String(def.template_id??def.name??def.template_name)}naturalKeyFilter(def){return{template_key:this.naturalKeyOf(def)}}toRow(){throw new Error("form templates are written by `engine template release` (form_template_write), never as rows")}async fetchDbKeys(){return new Map}normalize=null;normalizeProblem=null;async prepare(){if(this.normalize||this.normalizeProblem)return;try{const require2=createRequire(join5(REPO_ROOT,"package.json"));const mod=await import(pathToFileURL2(require2.resolve("@peripheries/renderer/forms")).href);if(typeof mod.normalizeCondition!=="function")throw new Error("no normalizeCondition");this.normalize=mod.normalizeCondition}catch(error){const why=error instanceof Error?error.message:String(error);this.normalizeProblem=`form templates need @peripheries/renderer 0.23 or later in this repo, whose \`forms\` entry converts their conditions; loading it failed: ${why}`}}specs(){const specs=[];const problems=[];if(!this.normalize)return{specs,problems:[this.normalizeProblem??"call prepare() first"]};for(const file of this.files()){const def=this.loadFile(file);if(def.is_published===false||def.is_archived===true)continue;const out=formSpecOf(file,def,{decode:decodeJsonbValue,normalize:this.normalize});specs.push(out.spec);problems.push(...out.problems)}return{specs,problems}}extraIssues(def){if(def.is_published===false||def.is_archived===true)return{errors:[],warnings:[`not published: not released (${def.template_name})`]};if(!this.normalize)return{errors:[this.normalizeProblem??"form templates: the renderer's condition converter isn't loaded, so the forms can't be checked"],warnings:[]};const{problems}=formSpecOf(def.template_name,def,{decode:decodeJsonbValue,normalize:this.normalize});return{errors:problems,warnings:[]}}async syncAll(){const started=Date.now();const entities=this.files().map(f=>({entity:f,action:"skipped",id:null,reason:"released only by `engine template release` under the with-template-token wrapper, for templates.json form_template_process"}));return{layer:this.def.name,created:0,updated:0,skipped:entities.length,entities,errors:[],duration_ms:Date.now()-started}}async orphans(){return[]}async healthChecks(){return[]}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 configured=loadTemplatesConfig().form_template_process??null;if(!processId){errors.push({entity:"form-templates",error:`${PROCESS_ENV} isn't set; run under the with-template-token wrapper`});return finish()}if(configured!==processId){for(const f of this.files())entities.push({entity:f,action:"skipped",id:null,reason:configured?`another process (${configured}); released in its own wrapped run`:"templates.json names no form_template_process"});return finish()}await this.prepare();let specs;try{const out=this.specs();for(const p of out.problems)errors.push({entity:"form-templates",error:p});if(errors.length)return finish();specs=out.specs}catch(error){errors.push({entity:"form-templates",error:error instanceof Error?error.message:String(error)});return finish()}if(this.options.dryRun){for(const sp of specs)entities.push({entity:`${sp.label} \u2192 ${sp.pageType}`,action:"skipped",id:null,reason:"dry run"});return finish()}try{const{token,deadline}=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).");const db=templateClient(url,key,token);const written=await writeFormTemplates(formTemplateApi(db),processId,specs);let changed=false;for(const w of written){changed||=w.result.changed;entities.push({entity:`${w.label} \u2192 form:${w.templateKey}`,action:w.result.changed?"updated":"skipped",id:w.result.form_id,...w.result.changed?{}:{reason:"unchanged"}})}if(!changed)return finish();const root=await templateApi(db).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:"form-templates",error:error instanceof Error?error.message:String(error)})}return finish()}};var DEFAULT="Password123!";function devPassword(targetEnv){const set=process.env.DEV_USER_PASSWORD;if(targetEnv==="local")return set||DEFAULT;if(!set||set===DEFAULT){throw new Error(`dev users on ${targetEnv} need DEV_USER_PASSWORD set to a secret of your own; the built-in default is public and is for a local stack only`)}return set}async function findOrgBySlug(supabase,orgSlug){const{data,error}=await supabase.from("organization").select("id, name, advisor, metadata").eq("metadata->>org_slug",orgSlug).limit(2);if(error){throw new Error(`organization lookup failed for org_slug="${orgSlug}": ${error.message}`)}const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} organization rows have metadata->>org_slug="${orgSlug}" \u2014 ambiguous. Ids: ${rows.map(r=>r.id).join(", ")}. Fix the database before seeding.`)}const row=rows[0];if(!row)return null;const metadata=row.metadata??{};return{id:row.id,name:row.name,advisor:row.advisor===true,advisorOrgId:metadata["advisor_org_id"]??null,advisorOrgSlug:metadata["advisor_org_slug"]??null}}var DevUserManager=class extends LayerManager{get shape(){return{fields:{type:{type:"enum",values:["advisor","client"],required:true},email:{type:"string",required:true,minLength:3},name:{type:"string",required:true,minLength:1},first_name:{type:"string",required:true,minLength:1},last_name:{type:"string",required:true,minLength:1},user_metadata:{type:"object"},org_memberships:{type:"array",required:true},org_role:{type:"enum",values:["owner","editor"]},advisor_orgs:{type:"array"},client_record:{type:"object"}}}}naturalKeyOf(def){return def.email}naturalKeyFilter(def){return{email:def.email}}toRow(){throw new Error("DevUserManager writes via syncAll()")}devPassword(){return devPassword(this.options.targetEnv)}async findAuthUser(email){const perPage=200;for(let page=1;page<=50;page++){const{data,error}=await this.options.supabase.auth.admin.listUsers({page,perPage});if(error)throw new Error(`auth.admin.listUsers failed: ${error.message}`);const users=data?.users??[];const match=users.find(u=>u.email?.toLowerCase()===email.toLowerCase());if(match)return match.id;if(users.length<perPage)return null}return null}async ensureAuthUser(def){const existing=await this.findAuthUser(def.email);if(existing)return{id:existing,created:false};const{data,error}=await this.options.supabase.auth.admin.createUser({email:def.email,password:this.devPassword(),email_confirm:true,user_metadata:{name:def.name,role:def.type,...def.user_metadata??{}}});if(error)throw new Error(`auth.admin.createUser failed for ${def.email}: ${error.message}`);const id=data?.user?.id;if(!id)throw new Error(`no user id returned for ${def.email}`);return{id,created:true}}async orgIdFor(slug){if(slug===this.options.orgSlug)return this.options.orgId;const org=await findOrgBySlug(this.options.supabase,slug);return org?.id??null}async ensureOrgMember(userId,orgId,def){const{data,error}=await this.options.supabase.from("org_member").select("id").eq("user_id",userId).eq("org_id",orgId).limit(2);if(error)throw new Error(`org_member lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} org_member rows for this user+org \u2014 role resolution will throw. Run doctor.`)}if(rows.length===1){const{error:updateError}=await this.options.supabase.from("org_member").update({first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name}).eq("id",rows[0].id);if(updateError)throw new Error(`org_member update failed: ${updateError.message}`);return}const{error:insertError}=await this.options.supabase.from("org_member").insert({user_id:userId,org_id:orgId,first_name:def.first_name,last_name:def.last_name,nick_name:def.first_name});if(insertError)throw new Error(`org_member insert failed: ${insertError.message}`)}async ensureOrgRole(userId,orgId,role){const{data,error}=await this.options.supabase.from("access_grant").select("id").eq("subject_kind","user").eq("subject_id",userId).eq("scope_kind","org").eq("scope_id",orgId).eq("role",role).is("revoked_at",null).limit(1);if(error)throw new Error(`access_grant lookup failed: ${error.message}`);if((data??[]).length)return;const{error:insertError}=await this.options.supabase.from("access_grant").insert({subject_kind:"user",subject_id:userId,role,scope_kind:"org",scope_id:orgId,purpose:"dev user (template seed)"});if(insertError)throw new Error(`access_grant insert (${role}) failed: ${insertError.message}`)}async ensureAdvisor(userId,def){const primarySlug=def.org_memberships[0];if(!primarySlug)throw new Error(`advisor ${def.name} has no org_memberships`);const primaryOrgId=await this.orgIdFor(primarySlug);if(!primaryOrgId)throw new Error(`cannot resolve primary org "${primarySlug}"`);const{data,error}=await this.options.supabase.from("advisor").select("id").eq("user_id",userId).eq("org_id",primaryOrgId).limit(2);if(error)throw new Error(`advisor lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} advisor rows for this user+org \u2014 role resolution will throw. Run doctor.`)}let advisorId;if(rows.length===1){advisorId=rows[0].id;await this.options.supabase.from("advisor").update({name:def.name,email:def.email}).eq("id",advisorId)}else{const{data:inserted,error:insertError}=await this.options.supabase.from("advisor").insert({user_id:userId,org_id:primaryOrgId,name:def.name,email:def.email}).select("id").single();if(insertError)throw new Error(`advisor insert failed: ${insertError.message}`);advisorId=inserted.id}for(const slug of def.advisor_orgs??[]){const linkedOrgId=await this.orgIdFor(slug);if(!linkedOrgId){console.warn(`[template] \u26A0 advisor_orgs: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping the link. Seed that org, then re-run.`);continue}const{data:link}=await this.options.supabase.from("advisor_org").select("id").eq("advisor_id",advisorId).eq("org_id",linkedOrgId).maybeSingle();if(!link){const{error:linkError}=await this.options.supabase.from("advisor_org").insert({advisor_id:advisorId,org_id:linkedOrgId});if(linkError)throw new Error(`advisor_org insert failed: ${linkError.message}`)}}return advisorId}async ensureClient(userId,def){const record=def.client_record;if(!record)throw new Error(`client ${def.name} has no client_record`);const orgSlug=def.org_memberships[0]??this.options.orgSlug;const orgId=await this.orgIdFor(orgSlug);if(!orgId)throw new Error(`cannot resolve client org "${orgSlug}"`);const data={...record.metadata??{}};if(record.slug)data.slug=record.slug;const{data:found,error}=await this.options.supabase.from("client").select("id, data, user_id").eq("org_id",orgId).eq("name",record.name);if(error)throw new Error(`client lookup failed: ${error.message}`);let rows=found??[];if(rows.length>1){const mine=rows.filter(r=>r.user_id===userId);if(mine.length===1){console.warn(`[template] \u26A0 ${rows.length} client rows named "${record.name}" in this org; using the one already linked to ${def.email} (${mine[0].id}). Run doctor \u2014 the others are strays.`);rows=mine}else{throw new Error(`${rows.length} client rows named "${record.name}" in this org and ${mine.length} linked to ${def.email} \u2014 ambiguous, refusing to guess. Run doctor for the ids.`)}}if(rows[0]){const merged={...rows[0].data??{},...data};const{error:updateError}=await this.options.supabase.from("client").update({user_id:userId,data:merged,description:record.description??null}).eq("id",rows[0].id);if(updateError)throw new Error(`client update failed: ${updateError.message}`);return rows[0].id}const{data:inserted,error:insertError}=await this.options.supabase.from("client").insert({org_id:orgId,name:record.name,description:record.description??null,user_id:userId,data}).select("id").single();if(insertError)throw new Error(`client insert failed: ${insertError.message}`);return inserted.id}async fetchDbKeys(){const out=new Map;for(const file of this.files()){let def;try{def=this.loadFile(file)}catch{continue}const id=await this.findAuthUser(def.email);if(id)out.set(def.email,id)}return out}async healthChecks(){const{data,error}=await this.options.supabase.from("client").select("id, name, user_id").eq("org_id",this.options.orgId);if(error){return[{name:"dev-user-clients",severity:"warn",message:`lookup failed: ${error.message}`}]}const rows=data??[];const byName=new Map;for(const row of rows){byName.set(row.name,[...byName.get(row.name)??[],{id:row.id,user_id:row.user_id}])}const managed=new Set;for(const file of this.files()){try{const def=this.loadFile(file);if(def.client_record?.name)managed.add(def.client_record.name)}catch{}}const blocking=[];let unmanagedDuplicates=0;for(const[name,group]of byName){if(group.length<2)continue;if(!managed.has(name)){unmanagedDuplicates++;continue}const linked=group.filter(r=>r.user_id);if(linked.length===1)continue;blocking.push(`${name}: ${group.length} rows, ${linked.length} linked \u2014 ${group.map(r=>r.id).join(", ")}`)}const checks=[blocking.length===0?{name:"dev-user-clients",severity:"ok",message:`${managed.size} managed client(s), all resolvable`}:{name:"dev-user-clients",severity:"fail",message:`${blocking.length} managed client name(s) ambiguous \u2014 dev-users sync will refuse them`,detail:blocking}];if(unmanagedDuplicates>0){checks.push({name:"client-name-duplicates",severity:"warn",message:`${unmanagedDuplicates} client name(s) outside this layer appear more than once (bulk-imported member records; client has no unique constraint on (org_id, name)). Informational \u2014 a duplicate import or two members sharing a name.`})}return checks}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:"dev-users",action:"skipped",id:null,reason:"dev fixtures never run against prod"}],errors,duration_ms:Date.now()-started}}const files=this.files();const ordered=[...files.filter(f=>f.startsWith("advisor-")),...files.filter(f=>!f.startsWith("advisor-"))];for(const file of ordered){try{const def=this.loadFile(file);if(this.options.dryRun){const existing=await this.findAuthUser(def.email);entities.push({entity:`${def.type}:${def.email}`,action:existing?"updated":"created",id:existing??"(new)"});continue}const user=await this.ensureAuthUser(def);for(const slug of def.type==="client"?[]:def.org_memberships){const orgId=await this.orgIdFor(slug);if(!orgId){console.warn(`[template] \u26A0 org_memberships: "${slug}" not found in ${this.options.targetEnv} \u2014 skipping membership.`);continue}await this.ensureOrgMember(user.id,orgId,def)}if(def.org_role&&def.type!=="client"){const orgId=await this.orgIdFor(def.org_memberships[0]??"");if(!orgId)throw new Error(`org_role needs its org: "${def.org_memberships[0]}" not found`);await this.ensureOrgRole(user.id,orgId,def.org_role)}if(def.type==="advisor"){const advisorId=await this.ensureAdvisor(user.id,def);this.mappings.set("advisor",def.email,advisorId)}else{const clientId=await this.ensureClient(user.id,def);this.mappings.set("client",def.email,clientId);const slug=def.client_record?.slug;if(slug)this.mappings.set("client",slug,clientId)}entities.push({entity:`${def.type}:${def.email}`,action:user.created?"created":"updated",id:user.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}}};var DevClientDataManager=class extends LayerManager{get shape(){return{fields:{client:{type:"string",required:true,minLength:1},entries:{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("DevClientDataManager 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 resolveDataTypeId(name){const{data,error}=await this.options.supabase.from("client_data_type").select("id, data_format").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`client_data_type lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} client_data_type rows named "${name}" in this org \u2014 ambiguous. That table has no unique constraint on (org_id, name); run doctor for the ids.`)}const row=rows[0];return row?{id:row.id,format:row.data_format}:null}async syncAll(){const started=Date.now();const entities=[];const errors=[];let created=0;let updated=0;let skipped=0;if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-client-data",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 clientId=await this.resolveClientId(def.client);if(!clientId){entities.push({entity:def.client,action:"skipped",id:null,reason:`no client matches "${def.client}" \u2014 seed dev-users first:
|
|
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
|
-
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}}};var DevWorkbookManager=class _DevWorkbookManager 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 resolveTemplateId(name){const{data,error}=await this.options.supabase.from("client_form_template").select("id").eq("org_id",this.options.orgId).eq("name",name).limit(2);if(error)throw new Error(`client_form_template lookup failed: ${error.message}`);const rows=data??[];if(rows.length>1){throw new Error(`${rows.length} client_form_template rows named "${name}" \u2014 ambiguous.`)}return rows[0]?.id??null}async resolveField(templateId,fieldName){const own=await this.options.supabase.from("client_input_field").select("id, field_type").eq("template_id",templateId).eq("name",fieldName).limit(1);if(own.error)throw new Error(`client_input_field lookup failed: ${own.error.message}`);const ownRow=(own.data??[])[0];if(ownRow)return{id:ownRow.id,fieldType:ownRow.field_type,sharedFrom:null};const shared=await this.options.supabase.from("client_input_field").select("id, field_type, client_form_template!inner(name, org_id)").eq("name",fieldName).eq("client_form_template.org_id",this.options.orgId).limit(5);if(shared.error)throw new Error(`client_input_field lookup failed: ${shared.error.message}`);const rows=shared.data??[];const nameOf=row2=>Array.isArray(row2.client_form_template)?row2.client_form_template[0]?.name??"?":row2.client_form_template.name;if(rows.length>1){throw new Error(`field "${fieldName}" is not declared by this workbook and is declared by ${rows.length} other templates (${rows.map(nameOf).join(", ")}) \u2014 ambiguous, refusing to guess which block the answer belongs to.`)}const row=rows[0];if(!row)return null;return{id:row.id,fieldType:row.field_type,sharedFrom:nameOf(row)}}async syncAll(){const started=Date.now();const entities=[];const errors=[];let created=0;let updated=0;let skipped=0;if(this.options.targetEnv==="prod"){return{layer:this.def.name,created:0,updated:0,skipped:1,entities:[{entity:"dev-workbooks",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 clientId=await this.resolveClientId(def.client);if(!clientId){entities.push({entity:def.client,action:"skipped",id:null,reason:`no client matches "${def.client}" \u2014 seed dev-users first:
|
|
7
|
-
npm run template -- sync --layer dev-users --env ${this.options.targetEnv}`});skipped++;continue}const projectId=def.project?await this.resolveProjectId(def.project):null;if(def.project&&!projectId){console.warn(`[template] \u26A0 project "${def.project}" not found \u2014 workbooks will be seeded without project_id. Seed the projects layer if a project link is wanted.`)}for(const workbook of def.workbooks){const templateId=await this.resolveTemplateId(workbook.template_name);if(!templateId){entities.push({entity:workbook.template_name,action:"skipped",id:null,reason:`no client_form_template named "${workbook.template_name}". That row belongs to the form-templates LAYER, not this one \u2014 and the name is an exact cross-repo key (blocks.ts workbookTemplateNames), so check for a typo before re-seeding:
|
|
8
|
-
npm run template -- sync --layer form-templates --env ${this.options.targetEnv}`});skipped++;continue}const{data:existingRows,error:existingError}=await this.options.supabase.from("client_form").select("id").eq("client_id",clientId).eq("template_id",templateId).limit(1);if(existingError){throw new Error(`client_form lookup failed: ${existingError.message}`)}const existing=(existingRows??[])[0];let replaced=null;if(existing&&this.options.replaceExisting){const{count,error:countError}=await this.options.supabase.from("client_input").select("id",{count:"exact",head:true}).eq("form_id",existing.id);if(countError)throw new Error(`client_input count failed: ${countError.message}`);if(this.options.dryRun){entities.push({entity:workbook.template_name,action:"updated",id:existing.id,reason:`dry run \u2014 would DELETE this workbook and its ${count??0} answer(s), then re-seed it`});updated++;continue}const{error:inputsError}=await this.options.supabase.from("client_input").delete().eq("form_id",existing.id);if(inputsError)throw new Error(`client_input delete failed: ${inputsError.message}`);const{error:formError}=await this.options.supabase.from("client_form").delete().eq("id",existing.id);if(formError)throw new Error(`client_form delete failed: ${formError.message}`);replaced={answers:count??0}}else if(existing){entities.push({entity:workbook.template_name,action:"skipped",id:existing.id,reason:"workbook already exists \u2014 member progress is never overwritten"});skipped++;continue}if(this.options.dryRun){entities.push({entity:workbook.template_name,action:"created",id:null,reason:"dry run"});created++;continue}const completed=workbook.completed===true;const now=new Date().toISOString();const{data:inserted,error:insertError}=await this.options.supabase.from("client_form").insert({client_id:clientId,template_id:templateId,org_id:this.options.orgId,project_id:projectId,status:workbook.status??(completed?"completed":"in_progress"),progress:workbook.progress??{},started_at:now,completed_at:completed?now:null}).select("id").single();if(insertError)throw new Error(`client_form insert failed: ${insertError.message}`);const formId=inserted.id;if(replaced)updated++;else created++;const answerCount=await this.seedAnswers(formId,templateId,workbook);entities.push({entity:`${workbook.template_name} (${completed?"completed":workbook.status??"in_progress"}, ${answerCount} answer(s))`,action:replaced?"updated":"created",id:formId,...replaced?{reason:`replaced an existing workbook and its ${replaced.answers} answer(s)`}:{}})}}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}}static envelope(fieldType,raw){if(fieldType==="number"){const n=typeof raw==="number"?raw:Number(raw);if(Number.isNaN(n)){throw new Error(`field_type "number" but value "${raw}" is not a number`)}return{data:n,type:fieldType}}return{data:String(raw),type:fieldType}}async seedAnswers(formId,templateId,workbook){let count=0;for(const answer of workbook.answers??[]){const field=await this.resolveField(templateId,answer.field_name);if(!field){console.warn(`[template] \u26A0 ${workbook.template_name}: no client_input_field named "${answer.field_name}" \u2014 answer skipped. Field names come from the form-templates layer; check the workbook definition.`);continue}if(field.sharedFrom){console.log(`[template] ${workbook.template_name}: "${answer.field_name}" resolved from shared template "${field.sharedFrom}"`)}if(!answer.revision){const{data:priorRows,error:priorError}=await this.options.supabase.from("client_input").select("id").eq("form_id",formId).eq("field_id",field.id).limit(1);if(priorError)throw new Error(`client_input lookup failed: ${priorError.message}`);if((priorRows??[]).length)continue}let value;try{value=_DevWorkbookManager.envelope(field.fieldType,answer.value)}catch(error2){throw new Error(`${workbook.template_name}.${answer.field_name}: ${error2 instanceof Error?error2.message:String(error2)}`)}const{error}=await this.options.supabase.from("client_input").insert({form_id:formId,field_id:field.id,org_id:this.options.orgId,value});if(error)throw new Error(`client_input insert failed: ${error.message}`);count++}return count}};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 readFileSync3}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):
|
|
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):
|
|
9
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:
|
|
10
|
-
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 collectCellComponents(cells){return[...new Set(cells.map(c=>c?.component).filter(c=>Boolean(c)))]}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(readFileSync3(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 readFileSync4}from"node:fs";import{join as join6}from"node:path";var CONFIG_FILE=join6(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(readFileSync4(CONFIG_FILE,"utf8"));const outer=validateShape(parsed,{fields:{advisor_org_slug:{type:"string",required:true,minLength:1},domains:{type:"array",required:true},branding:{type:"object"},metadata:{type:"object"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/public.json is invalid:
|
|
11
|
-
- ${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,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(readFileSync4(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()}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}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`})}}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()}for(const domain of config.domains){if(!selected.has(domain.domain_pattern))continue;try{const outcome=await upsertByNaturalKey(this.options.supabase,"org_template_public",{advisor_org_id:advisorOrgId,domain_pattern:domain.domain_pattern},{advisor_org_id:advisorOrgId,domain_pattern:domain.domain_pattern,is_wildcard:domain.is_wildcard??false,priority:domain.priority??100,is_active:domain.is_active??true,public_layout:layout,branding:config.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{data,error}=await this.options.supabase.from("org_template_public").select("id, domain_pattern").eq("advisor_org_id",advisorOrgId);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 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(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 readFileSync5}from"node:fs";import{relative as relative2}from"node:path";var GENERATOR2="template/cli sync --layer page-templates";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 def.page_type?{org_id:this.options.orgId,page_type:def.page_type,is_template:true}:{org_id:this.options.orgId,name:def.name,is_template:true}}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})}}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(readFileSync5(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{data,error}=await this.options.supabase.from("project_page").select("id, name, page_type").eq("org_id",this.options.orgId).eq("is_template",true);if(error)throw new Error(`project_page lookup failed: ${error.message}`);const out=new Map;this.duplicateDbKeys.clear();for(const row of data??[]){const key=row.page_type??row.name;if(out.has(key))this.duplicateDbKeys.add(key);out.set(key,row.id)}return out}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(!file.page_type){warnings.push({file:label,message:"no page_type \u2014 the row can only be addressed by name, and project_template routes address pages by params.pageType"})}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)}}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=[];const errors=[];const bundles=new Map;const bundleFor=async source=>{if(bundles.has(source.bundleName))return bundles.get(source.bundleName)??null;const resolved=await resolveBundleIfAny(this.options.supabase,this.options.orgId,this.options.orgSlug,{version:this.options.versionPin??null,bundleName:source.bundleName});bundles.set(source.bundleName,resolved);return resolved};for(const source of this.sources()){try{const file=stripHarnessOnly(this.read(source.path));const cells=file.content.cells??[];const bundle=await bundleFor(source);if(!bundle){entities.push({entity:source.label,action:"skipped",id:null,reason:`no component_bundle in lineage "${source.bundleName}" yet \u2014 cells inline registry metadata from it. Deploy the bundle, then re-run.`});continue}const componentNames=collectCellComponents(cells);const registry=await fetchRegistry(this.options.supabase,bundle.id,componentNames);const missing=missingBundleComponents(componentNames,registry);if(missing.length){console.warn(`[template] \u26A0 ${source.label}: ${missing.length} cell component(s) not in bundle ${bundle.version}: ${missing.join(", ")}`)}const naturalKey=this.naturalKeyFilter(file);let existingMetadata={};let lookup=this.options.supabase.from("project_page").select("id, metadata").eq("org_id",this.options.orgId).eq("is_template",true);lookup=file.page_type?lookup.eq("page_type",file.page_type):lookup.eq("name",file.name);const{data:existingRows,error:readError}=await lookup.limit(2);if(readError)throw new Error(`project_page lookup failed: ${readError.message}`);const existing=existingRows??[];if(existing[0])existingMetadata=existing[0].metadata??{};const row={org_id:this.options.orgId,project_id:null,is_template:true,name:file.name,status:file.status??"active",page_type:file.page_type??null,content:file.content,layout:file.layout??{type:"grid",structure:cells.map(c=>({cellId:c.id}))},metadata:{...existingMetadata,...file.metadata??{},bundle_refs:[bundle.org_slug],template_meta:{source:relative2(REPO_ROOT,source.path),generated_at:new Date().toISOString(),generator:GENERATOR2,bundle_id:bundle.id,bundle_version:bundle.version,bundle_org_slug:bundle.org_slug,bundle_status:bundle.status,bundle_resolved_by:bundle.resolved_by,project_template:source.projectSlug,cell_count:cells.length,missing_components:missing}},updated_at:new Date().toISOString()};const outcome=await upsertByNaturalKey(this.options.supabase,"project_page",naturalKey,row,{dryRun:this.options.dryRun});if(!this.options.dryRun){this.mappings.set("project_page",file.page_type??file.name,outcome.id)}entities.push({entity:`${source.label} \u2192 ${file.page_type??file.name} (${cells.length} cells)`,action:outcome.action,id:outcome.id})}catch(error){errors.push({entity:source.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{data,error}=await this.options.supabase.from("project_page").select("id, name, page_type, created_at").eq("org_id",this.options.orgId).eq("is_template",true);if(error){return[{name:"page-template-dupes",severity:"warn",message:`lookup failed: ${error.message}`}]}const rows=data??[];const groups=new Map;for(const row of rows){const key=row.page_type??`name:${row.name}`;groups.set(key,[...groups.get(key)??[],row])}const duplicated=[...groups.entries()].filter(([,group])=>group.length>1);if(duplicated.length===0){return[{name:"page-template-dupes",severity:"ok",message:`${groups.size} page template(s), one row each`}]}return[{name:"page-template-dupes",severity:"fail",message:`${duplicated.length} page type(s) have more than one template row \u2014 sync refuses to write them, and a page-builder agent cannot tell which is current. Created by scripts/upload-page-template.mjs, which upserts on a freshly generated id`,detail:duplicated.map(([key,group])=>`${key}: ${group.length} rows \u2014 ${group.map(r=>`${r.id.slice(0,8)} (${r.created_at.slice(0,10)})`).join(", ")}`)}]}};import{existsSync as existsSync8,readFileSync as readFileSync6}from"node:fs";import{join as join7,relative as relative3}from"node:path";var GENERATOR3="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:join7(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(readFileSync6(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:relative3(REPO_ROOT,source.path),bundle,componentNames,registry,generator:GENERATOR3})},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("project_page").select("page_type").eq("org_id",this.options.orgId).eq("is_template",true).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:"fail",message:`${source.slug}: ${absent.length} routed page type(s) have NO template row \u2014 those routes render nothing for a client`,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:
|
|
12
|
-
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 readFileSync7,statSync}from"node:fs";import{extname,join as join8,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(join8(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,readFileSync7(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)",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:"client_form_template",naturalKey:"(org_id, name) + nested page/section/field keys",mappingEntity:"form_template",sourceDir:"data/form-templates",envs:["local","develop","prod"],implemented:true,notes:"Writes 5 mapping entities (form_template/page/section/field + shared_field). data/shared-fields.json is synced first so `shared: true` fields reuse the canonical uuid."},"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:"project_page",naturalKey:"(org_id, page_type, is_template=true) \u2014 name only when page_type is null",mappingEntity:"project_page",sourceDir:null,envs:["local","develop","prod"],implemented:true,notes:"Every pages/*/page.json under each configured project template, in one run. Replaces scripts/upload-page-template.mjs, which upserted on a freshly generated id and so created a duplicate template row on every re-run."},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 readFileSync8,renameSync,writeFileSync}from"node:fs";import{join as join9}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=join9(this.mappingsDir,mappingFileName(entity,this.targetEnv));const data={};if(existsSync10(path)){try{const parsed=JSON.parse(readFileSync8(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=join9(this.mappingsDir,mappingFileName(entity,this.targetEnv));const temp=`${path}.tmp`;writeFileSync(temp,`${JSON.stringify(body,null,2)}
|
|
13
|
-
`,"utf8");renameSync(temp,path);written.push(path)}this.dirty.clear();return written}};function loadLayersConfig(){const path=
|
|
14
|
-
- ${result.errors.join("\n - ")}`)}return{layers:result.value.layers.map(l=>assertLayerName(String(l))),bundleRefFloor:result.value.bundle_ref_floor??1}}async function buildContext(args){const org=loadOrgConfig();const targetEnv=args.targetEnv;const{layers:ownedLayers,bundleRefFloor}=loadLayersConfig();const mappings=new MappingStore(MAPPINGS_DIR,targetEnv);let supabase=null;let orgId=null;if(args.offline){loadEnv(targetEnv,REPO_ROOT)}else{supabase=createSupabaseClient(targetEnv,REPO_ROOT);const resolved=await findOrgBySlug(supabase,org.slug);orgId=resolved?.id??mappings.getId("org",org.slug)??null}return{args,org,targetEnv,repoRoot:REPO_ROOT,supabase,get orgId(){return orgId},mappings,ownedLayers,bundleRefFloor,async refreshOrgId(){if(!supabase)return null;const resolved=await findOrgBySlug(supabase,org.slug);orgId=resolved?.id??mappings.getId("org",org.slug)??null;return orgId},managerOptions(request={}){if(!supabase)throw new Error("this command needs a database connection (remove --offline)");if(!orgId&&!request.allowMissingOrg){throw new Error(`organization "${org.slug}" does not exist in ${targetEnv}. Run \`npm run template -- seed --env ${targetEnv} --layer org\` to create it, or check that this is the environment you meant.`)}return{supabase,orgSlug:org.slug,orgId:orgId??"",targetEnv,repoRoot:REPO_ROOT,dryRun:args.dryRun,nameFilter:args.name,versionPin:args.version,replaceExisting:args.flags.has("replace-existing")}}}}import{createHash}from"node:crypto";import{existsSync as existsSync12,readdirSync as readdirSync3,readFileSync as readFileSync10,statSync as statSync2}from"node:fs";import{join as join11,relative as relative4}from"node:path";var shared_data_default={files:{"data/shared/event-types/call.json":"068fbc183cafe31337b97567ab44488966ff7f0e9a70201f610fda343ba383d7","data/shared/event-types/meeting.json":"6e244514140faa6e9e62a6cad989d67a9f32f6ff574391d83c5431cbce819791","data/shared/event-types/note.json":"094af621fcc63acd6a59e1f9826239f9a1fbacd0bec85dc8061cfc4e6cd83e82"}};var SHARED_DATA_DIR=join11("data","shared");function walkFiles(dir,keep){if(!existsSync12(dir))return[];const out=[];for(const name of readdirSync3(dir)){const full=join11(dir,name);if(statSync2(full).isDirectory())out.push(...walkFiles(full,keep));else if(keep(name))out.push(relative4(TEMPLATE_ROOT,full).split("\\").join("/"))}return out.sort()}function listSharedDataFiles(){return walkFiles(join11(TEMPLATE_ROOT,SHARED_DATA_DIR),n=>n.endsWith(".json"))}function computeSharedDataHashes(){const out={};for(const f of listSharedDataFiles())out[f]=createHash("sha256").update(readFileSync10(join11(TEMPLATE_ROOT,f))).digest("hex");return out}function sharedDataDriftCheck(){const expected=shared_data_default.files;const actual=computeSharedDataHashes();const problems=[...Object.keys(expected).filter(f=>actual[f]&&actual[f]!==expected[f]).map(f=>`changed: ${f}`),...Object.keys(expected).filter(f=>!actual[f]).map(f=>`missing: ${f}`),...Object.keys(actual).filter(f=>!expected[f]).map(f=>`not in the engine's shared data: ${f}`)];if(problems.length===0){return{name:"shared-data-drift",severity:"ok",message:`all ${Object.keys(actual).length} shared data file(s) match the engine's`}}return{name:"shared-data-drift",severity:"fail",message:`${problems.length} file(s) under template/${SHARED_DATA_DIR} differ from the engine's shared data. Every org seeds the same definitions, so they must be identical; change them in the engine, then here.`,detail:problems}}import{existsSync as existsSync13,readdirSync as readdirSync4,readFileSync as readFileSync11}from"node:fs";import{join as join12}from"node:path";function packageBundleName(){const path=join12(REPO_ROOT,"package.json");if(!existsSync13(path))return null;try{const parsed=JSON.parse(readFileSync11(path,"utf8"));const raw=parsed.oaBundle;const value=typeof raw==="string"?raw:raw?.name;return value?{where:"package.json oaBundle",value}:null}catch{return null}}function configFiles(){const out=[];for(const entry of readdirSync4(REPO_ROOT)){if(/^vite\.config.*\.ts$/.test(entry))out.push(join12(REPO_ROOT,entry))}const harnessDir=join12(REPO_ROOT,"test","visual");if(existsSync13(harnessDir)){for(const entry of readdirSync4(harnessDir)){if(/\.config\.ts$/.test(entry))out.push(join12(harnessDir,entry))}}return out.sort()}function inlinedNamespaces(){const pattern=/__OA_BUNDLE_NAME__\s*:\s*JSON\.stringify\(\s*['"]([^'"]+)['"]\s*\)/g;const out=[];for(const file of configFiles()){let text;try{text=readFileSync11(file,"utf8")}catch{continue}for(const match of text.matchAll(pattern)){out.push({where:file.slice(REPO_ROOT.length+1),value:match[1]})}}return out}function identityChecks(targetEnv){const org=loadOrgConfig();const checks=[];const sources=[packageBundleName(),...inlinedNamespaces()].filter(s=>s!==null);const distinct=[...new Set(sources.map(s=>s.value))];if(sources.length===0){checks.push({name:"identity-bundle-name",severity:"warn",message:'no bundle namespace found in package.json or any vite config \u2014 the theme namespace falls back to "oa-bundle"'})}else if(distinct.length>1){checks.push({name:"identity-bundle-name",severity:"fail",message:`this repo builds ${distinct.length} different [data-oa-bundle] namespaces \u2014 every bundle an org ships must use the same one, or a page mixing them loses styling on all but one`,detail:sources.map(s=>`${s.where}: "${s.value}"`)})}else if(distinct[0]!==org.slug){checks.push({name:"identity-bundle-name",severity:"warn",message:`the bundle namespace is "${distinct[0]}" but this org is "${org.slug}" (${sources.map(s=>s.where).join(", ")}). It only has to be constant across this org's lineages, but matching the slug is how you get that for free. Changing it requires rebuilding and redeploying every lineage together.`})}else{checks.push({name:"identity-bundle-name",severity:"ok",message:`bundle namespace "${distinct[0]}" matches the org slug, consistent across ${sources.length} source(s)`})}const envSlug=process.env.ORG_SLUG??null;const viteSlug=process.env.VITE_ORG_SLUG??null;const mismatched=[...envSlug&&envSlug!==org.slug?[`ORG_SLUG=${envSlug}`]:[],...viteSlug&&viteSlug!==org.slug?[`VITE_ORG_SLUG=${viteSlug}`]:[]];if(mismatched.length>0){checks.push({name:"identity-env-slug",severity:"fail",message:`.env.${targetEnv} names a different org than config/org.json ("${org.slug}"): ${mismatched.join(", ")}. scripts/*.mjs deploy into ORG_SLUG, so bundles and registry rows would land on that org while this CLI configures "${org.slug}".`})}else if(!envSlug){checks.push({name:"identity-env-slug",severity:"warn",message:`.env.${targetEnv} sets no ORG_SLUG, so every deploy script needs --org typed by hand \u2014 which is exactly how a wrong slug gets in. Add ORG_SLUG=${org.slug}.`})}else{checks.push({name:"identity-env-slug",severity:"ok",message:`.env.${targetEnv} ORG_SLUG = "${envSlug}"`})}return checks}function renderSeedReport(report){const lines=[];const mode=report.dryRun?" (dry run \u2014 nothing written)":"";lines.push("");lines.push(`${report.command}: ${report.orgSlug} \u2192 ${report.targetEnv}${mode}`);if(report.orgId)lines.push(` org_id: ${report.orgId}`);lines.push("");for(const layer of report.layers){const counts=`created ${layer.created}, updated ${layer.updated}, skipped ${layer.skipped}`;const status=layer.errors.length>0?"FAILED":"ok";lines.push(` ${layer.layer.padEnd(22)} ${status.padEnd(7)} ${counts} (${layer.duration_ms}ms)`);for(const error of layer.errors){lines.push(` \u2717 ${error.entity}: ${error.error}`)}}const totalErrors=report.layers.reduce((sum,l)=>sum+l.errors.length,0);lines.push("");lines.push(report.success?` \u2713 ${report.layers.length} layer(s) in ${report.totalDuration_ms}ms`:` \u2717 ${totalErrors} error(s) across ${report.layers.length} layer(s)`);lines.push("");return lines.join("\n")}function renderStatus(statuses,header){const lines=["",header,""];lines.push(` ${"layer".padEnd(22)} ${"kind".padEnd(5)} ${"files".padStart(5)} ${"db".padStart(5)} ${"mapped".padStart(6)} state`);for(const s of statuses){const db=s.dbRows===null?"\u2014":String(s.dbRows);const notes=[];if(!s.implemented)notes.push("not ported (host-owned)");if(s.error)notes.push(`ERROR: ${s.error}`);if(s.missingInDb.length)notes.push(`${s.missingInDb.length} file(s) not in DB`);if(s.missingInFiles.length)notes.push(`${s.missingInFiles.length} DB row(s) without a file`);if(s.unmappedInDb.length)notes.push(`${s.unmappedInDb.length} unmapped`);if(!notes.length)notes.push("in sync");lines.push(` ${s.layer.padEnd(22)} ${s.kind.padEnd(5)} ${String(s.files).padStart(5)} ${db.padStart(5)} ${String(s.mappedIds).padStart(6)} ${notes.join("; ")}`)}const detail=statuses.filter(s=>s.missingInDb.length||s.missingInFiles.length||s.unmappedInDb.length);if(detail.length){lines.push("");lines.push(" drift detail");for(const s of detail){if(s.missingInDb.length)lines.push(` ${s.layer} \u2014 in files, not in DB: ${s.missingInDb.join(", ")}`);if(s.missingInFiles.length)lines.push(` ${s.layer} \u2014 in DB, no file: ${s.missingInFiles.join(", ")}`);if(s.unmappedInDb.length)lines.push(` ${s.layer} \u2014 unmapped: ${s.unmappedInDb.join(", ")}`)}}lines.push("");return lines.join("\n")}function renderValidation(results){const lines=[""];let errors=0;for(const result of results){const count=result.errors.length;errors+=count;const state=count===0?"ok":`${count} error(s)`;lines.push(` ${result.layer.padEnd(22)} ${String(result.filesChecked).padStart(3)} file(s) ${state}`);for(const issue of result.errors)lines.push(` \u2717 ${issue.file}: ${issue.message}`);for(const issue of result.warnings)lines.push(` \u26A0 ${issue.file}: ${issue.message}`)}lines.push("");lines.push(errors===0?" \u2713 all files valid":` \u2717 ${errors} error(s)`);lines.push("");return lines.join("\n")}function emit(json,payload,human){console.log(json?JSON.stringify(payload,null,2):human)}async function checkAdvisorLinkage(ctx,supabase){const org=await findOrgBySlug(supabase,ctx.org.slug);if(!org){return{name:"advisor-linkage",severity:"fail",message:`organization "${ctx.org.slug}" not found in ${ctx.targetEnv}`}}if(ctx.org.advisor){return org.advisor?{name:"advisor-linkage",severity:"ok",message:"advisor org, flag set correctly"}:{name:"advisor-linkage",severity:"fail",message:"config says advisor org but organization.advisor is false"}}const expected=ctx.org.advisor_org_slug;if(!expected){return{name:"advisor-linkage",severity:"ok",message:"client org with no advisor configured"}}const advisorOrg=await findOrgBySlug(supabase,expected);if(!advisorOrg){return{name:"advisor-linkage",severity:"warn",message:`advisor org "${expected}" does not exist in ${ctx.targetEnv} yet \u2014 seed it first`}}if(org.advisorOrgId!==advisorOrg.id){return{name:"advisor-linkage",severity:"fail",message:`organization.metadata.advisor_org_id is ${org.advisorOrgId??"null"} but "${expected}" is ${advisorOrg.id} \u2014 advisor-scoped lookups will return nothing`}}return{name:"advisor-linkage",severity:"ok",message:`linked to ${expected} (${advisorOrg.id})`}}async function checkDuplicateDataTypes(ctx,supabase){const{data,error}=await supabase.from("client_data_type").select("id, name").eq("org_id",ctx.orgId);if(error){return{name:"duplicate-rows",severity:"warn",message:`lookup failed: ${error.message}`}}const byName=new Map;for(const row of data??[]){byName.set(row.name,[...byName.get(row.name)??[],row.id])}const dupes=[...byName.entries()].filter(([,ids])=>ids.length>1);return dupes.length===0?{name:"duplicate-rows",severity:"ok",message:`client_data_type: ${byName.size} distinct name(s), no duplicates`}:{name:"duplicate-rows",severity:"fail",message:`client_data_type has ${dupes.length} duplicated name(s) \u2014 sync will refuse to write them`,detail:dupes.map(([name,ids])=>`${name}: ${ids.join(", ")}`)}}async function checkDuplicateMemberships(ctx,supabase){const detail=[];const{data:members,error:memberError}=await supabase.from("org_member").select("id, user_id").eq("org_id",ctx.orgId);if(memberError){return{name:"duplicate-memberships",severity:"warn",message:`org_member lookup failed: ${memberError.message}`}}const memberCounts=new Map;for(const row of members??[]){memberCounts.set(row.user_id,(memberCounts.get(row.user_id)??0)+1)}for(const[userId,count]of memberCounts){if(count>1)detail.push(`org_member: user ${userId} has ${count} rows`)}const{data:advisors,error:advisorError}=await supabase.from("advisor").select("id, user_id").eq("org_id",ctx.orgId);if(!advisorError){const advisorCounts=new Map;for(const row of advisors??[]){advisorCounts.set(row.user_id,(advisorCounts.get(row.user_id)??0)+1)}for(const[userId,count]of advisorCounts){if(count>1)detail.push(`advisor: user ${userId} has ${count} rows`)}}return detail.length===0?{name:"duplicate-memberships",severity:"ok",message:`${memberCounts.size} org member(s), no duplicate rows`}:{name:"duplicate-memberships",severity:"fail",message:"duplicate membership rows \u2014 role resolution uses .maybeSingle() and will throw, hanging login on a portal\u2192platform redirect loop",detail}}async function checkLiveBundle(ctx,supabase){const{data:bundles,error:bundleError}=await supabase.from("component_bundle").select("id, version, bundle_name, is_live, status").eq("org_id",ctx.orgId).eq("bundle_name",ctx.org.bundle_name).eq("is_live",true);if(bundleError){return{name:"live-bundle",severity:"warn",message:`lookup failed: ${bundleError.message}`}}const live=bundles??[];if(live.length===0){return{name:"live-bundle",severity:"warn",message:`no is_live bundle in lineage "${ctx.org.bundle_name}" \u2014 the host has nothing to serve`}}if(live.length>1){return{name:"live-bundle",severity:"fail",message:`${live.length} bundles are is_live in lineage "${ctx.org.bundle_name}" \u2014 exactly one may be`,detail:live.map(b=>b.version)}}const liveVersion=live[0].version;const{data:templates,error:templateError}=await supabase.from("org_template").select("id, remote_version").eq("metadata->>org_slug",ctx.org.slug);if(templateError){return{name:"live-bundle",severity:"warn",message:`is_live=${liveVersion}; org_template lookup failed: ${templateError.message}`}}const rows=templates??[];const pointer=rows[0]?.remote_version??null;if(pointer&&pointer!==liveVersion){return{name:"live-bundle",severity:"warn",message:`org_template.remote_version=${pointer} but is_live=${liveVersion}. The host serves the is_live row. Reconcile from THIS repo:
|
|
15
|
-
npm run deploy:cutover -- --org <slug> --version ${liveVersion} --<env>`}}return{name:"live-bundle",severity:"ok",message:`is_live=${liveVersion}, pointer agrees`}}var LOCAL_CHECKS=new Set(["shared-data-drift","identity-bundle-name","identity-env-slug"]);async function runDoctor(ctx){const filter=ctx.args.check;const localOnly=filter!==null&&LOCAL_CHECKS.has(filter);const checks=[sharedDataDriftCheck(),...identityChecks(ctx.targetEnv)];if(!localOnly){if(!ctx.supabase){throw new Error(`doctor needs a database connection (remove --offline), or pass --check ${[...LOCAL_CHECKS].join(" / --check ")} for the checks that run without one.`)}if(!ctx.orgId){throw new Error(`organization "${ctx.org.slug}" does not exist in ${ctx.targetEnv} \u2014 nothing to check.`)}const supabase=ctx.supabase;checks.push(await checkAdvisorLinkage(ctx,supabase),await checkDuplicateDataTypes(ctx,supabase),await checkDuplicateMemberships(ctx,supabase),await checkLiveBundle(ctx,supabase));const layers=resolveLayers(ctx.ownedLayers,{layer:null,targetEnv:ctx.targetEnv});const managerOptions=ctx.managerOptions();for(const layer of layers){const manager=createManager(layer,managerOptions,ctx.mappings);if(!manager)continue;try{checks.push(...await manager.healthChecks())}catch(error){checks.push({name:`${layer}-checks`,severity:"warn",message:error instanceof Error?error.message:String(error)})}}}const selected=filter?checks.filter(c=>c.name===filter):checks;if(selected.length===0){throw new Error(`--check "${filter}" matched no check. Ran: ${checks.map(c=>c.name).join(", ")}.`)}const failed=selected.filter(c=>c.severity==="fail");const warned=selected.filter(c=>c.severity==="warn");const scope=filter?` [--check ${filter}]`:"";const lines=["",localOnly?`doctor: ${ctx.org.slug}${scope}`:`doctor: ${ctx.org.slug} \u2192 ${ctx.targetEnv}${scope}`,""];for(const
|
|
16
|
-
`:" mapping files already up to date\n");emit(ctx.args.json,{...report,mappings_written:written},human);return report.success?0:1}async function
|
|
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 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},branding:{type:"object"},metadata:{type:"object"}},ignored:["_comment"]});if(!outer.ok||!outer.value){throw new Error(`template/config/public.json is invalid:
|
|
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,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()}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}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`})}}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()}for(const domain of config.domains){if(!selected.has(domain.domain_pattern))continue;try{const outcome=await upsertByNaturalKey(this.options.supabase,"org_template_public",{advisor_org_id:advisorOrgId,domain_pattern:domain.domain_pattern},{advisor_org_id:advisorOrgId,domain_pattern:domain.domain_pattern,is_wildcard:domain.is_wildcard??false,priority:domain.priority??100,is_active:domain.is_active??true,public_layout:layout,branding:config.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{data,error}=await this.options.supabase.from("org_template_public").select("id, domain_pattern").eq("advisor_org_id",advisorOrgId);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 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(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").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
|
+
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)",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
|
+
`,"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:
|
|
12
|
+
- ${result.errors.join("\n - ")}`)}return{layers:result.value.layers.map(l=>assertLayerName(String(l))),bundleRefFloor:result.value.bundle_ref_floor??1}}async function buildContext(args){const org=loadOrgConfig();const targetEnv=args.targetEnv;const{layers:ownedLayers,bundleRefFloor}=loadLayersConfig();const mappings=new MappingStore(MAPPINGS_DIR,targetEnv);let supabase=null;let orgId=null;if(args.offline){loadEnv(targetEnv,REPO_ROOT)}else{supabase=createSupabaseClient(targetEnv,REPO_ROOT);const resolved=await findOrgBySlug(supabase,org.slug);orgId=resolved?.id??mappings.getId("org",org.slug)??null}return{args,org,targetEnv,repoRoot:REPO_ROOT,supabase,get orgId(){return orgId},mappings,ownedLayers,bundleRefFloor,async refreshOrgId(){if(!supabase)return null;const resolved=await findOrgBySlug(supabase,org.slug);orgId=resolved?.id??mappings.getId("org",org.slug)??null;return orgId},managerOptions(request={}){if(!supabase)throw new Error("this command needs a database connection (remove --offline)");if(!orgId&&!request.allowMissingOrg){throw new Error(`organization "${org.slug}" does not exist in ${targetEnv}. Run \`npm run template -- seed --env ${targetEnv} --layer org\` to create it, or check that this is the environment you meant.`)}return{supabase,orgSlug:org.slug,orgId:orgId??"",targetEnv,repoRoot:REPO_ROOT,dryRun:args.dryRun,nameFilter:args.name,versionPin:args.version,replaceExisting:args.flags.has("replace-existing")}}}}import{createHash}from"node:crypto";import{existsSync as existsSync12,readdirSync as readdirSync4,readFileSync as readFileSync11,statSync as statSync2}from"node:fs";import{join as join12,relative as relative3}from"node:path";var shared_data_default={files:{"data/shared/event-types/call.json":"068fbc183cafe31337b97567ab44488966ff7f0e9a70201f610fda343ba383d7","data/shared/event-types/meeting.json":"6e244514140faa6e9e62a6cad989d67a9f32f6ff574391d83c5431cbce819791","data/shared/event-types/note.json":"094af621fcc63acd6a59e1f9826239f9a1fbacd0bec85dc8061cfc4e6cd83e82"}};var SHARED_DATA_DIR=join12("data","shared");function walkFiles(dir,keep){if(!existsSync12(dir))return[];const out=[];for(const name of readdirSync4(dir)){const full=join12(dir,name);if(statSync2(full).isDirectory())out.push(...walkFiles(full,keep));else if(keep(name))out.push(relative3(TEMPLATE_ROOT,full).split("\\").join("/"))}return out.sort()}function listSharedDataFiles(){return walkFiles(join12(TEMPLATE_ROOT,SHARED_DATA_DIR),n=>n.endsWith(".json"))}function computeSharedDataHashes(){const out={};for(const f of listSharedDataFiles())out[f]=createHash("sha256").update(readFileSync11(join12(TEMPLATE_ROOT,f))).digest("hex");return out}function sharedDataDriftCheck(){const expected=shared_data_default.files;const actual=computeSharedDataHashes();const problems=[...Object.keys(expected).filter(f=>actual[f]&&actual[f]!==expected[f]).map(f=>`changed: ${f}`),...Object.keys(expected).filter(f=>!actual[f]).map(f=>`missing: ${f}`),...Object.keys(actual).filter(f=>!expected[f]).map(f=>`not in the engine's shared data: ${f}`)];if(problems.length===0){return{name:"shared-data-drift",severity:"ok",message:`all ${Object.keys(actual).length} shared data file(s) match the engine's`}}return{name:"shared-data-drift",severity:"fail",message:`${problems.length} file(s) under template/${SHARED_DATA_DIR} differ from the engine's shared data. Every org seeds the same definitions, so they must be identical; change them in the engine, then here.`,detail:problems}}import{existsSync as existsSync13,readdirSync as readdirSync5,readFileSync as readFileSync12}from"node:fs";import{join as join13}from"node:path";function packageBundleName(){const path=join13(REPO_ROOT,"package.json");if(!existsSync13(path))return null;try{const parsed=JSON.parse(readFileSync12(path,"utf8"));const raw=parsed.oaBundle;const value=typeof raw==="string"?raw:raw?.name;return value?{where:"package.json oaBundle",value}:null}catch{return null}}function configFiles(){const out=[];for(const entry of readdirSync5(REPO_ROOT)){if(/^vite\.config.*\.ts$/.test(entry))out.push(join13(REPO_ROOT,entry))}const harnessDir=join13(REPO_ROOT,"test","visual");if(existsSync13(harnessDir)){for(const entry of readdirSync5(harnessDir)){if(/\.config\.ts$/.test(entry))out.push(join13(harnessDir,entry))}}return out.sort()}function inlinedNamespaces(){const pattern=/__OA_BUNDLE_NAME__\s*:\s*JSON\.stringify\(\s*['"]([^'"]+)['"]\s*\)/g;const out=[];for(const file of configFiles()){let text;try{text=readFileSync12(file,"utf8")}catch{continue}for(const match of text.matchAll(pattern)){out.push({where:file.slice(REPO_ROOT.length+1),value:match[1]})}}return out}function identityChecks(targetEnv){const org=loadOrgConfig();const checks=[];const sources=[packageBundleName(),...inlinedNamespaces()].filter(s=>s!==null);const distinct=[...new Set(sources.map(s=>s.value))];if(sources.length===0){checks.push({name:"identity-bundle-name",severity:"warn",message:'no bundle namespace found in package.json or any vite config \u2014 the theme namespace falls back to "oa-bundle"'})}else if(distinct.length>1){checks.push({name:"identity-bundle-name",severity:"fail",message:`this repo builds ${distinct.length} different [data-oa-bundle] namespaces \u2014 every bundle an org ships must use the same one, or a page mixing them loses styling on all but one`,detail:sources.map(s=>`${s.where}: "${s.value}"`)})}else if(distinct[0]!==org.slug){checks.push({name:"identity-bundle-name",severity:"warn",message:`the bundle namespace is "${distinct[0]}" but this org is "${org.slug}" (${sources.map(s=>s.where).join(", ")}). It only has to be constant across this org's lineages, but matching the slug is how you get that for free. Changing it requires rebuilding and redeploying every lineage together.`})}else{checks.push({name:"identity-bundle-name",severity:"ok",message:`bundle namespace "${distinct[0]}" matches the org slug, consistent across ${sources.length} source(s)`})}const envSlug=process.env.ORG_SLUG??null;const viteSlug=process.env.VITE_ORG_SLUG??null;const mismatched=[...envSlug&&envSlug!==org.slug?[`ORG_SLUG=${envSlug}`]:[],...viteSlug&&viteSlug!==org.slug?[`VITE_ORG_SLUG=${viteSlug}`]:[]];if(mismatched.length>0){checks.push({name:"identity-env-slug",severity:"fail",message:`.env.${targetEnv} names a different org than config/org.json ("${org.slug}"): ${mismatched.join(", ")}. scripts/*.mjs deploy into ORG_SLUG, so bundles and registry rows would land on that org while this CLI configures "${org.slug}".`})}else if(!envSlug){checks.push({name:"identity-env-slug",severity:"warn",message:`.env.${targetEnv} sets no ORG_SLUG, so every deploy script needs --org typed by hand \u2014 which is exactly how a wrong slug gets in. Add ORG_SLUG=${org.slug}.`})}else{checks.push({name:"identity-env-slug",severity:"ok",message:`.env.${targetEnv} ORG_SLUG = "${envSlug}"`})}return checks}function renderSeedReport(report){const lines=[];const mode=report.dryRun?" (dry run \u2014 nothing written)":"";lines.push("");lines.push(`${report.command}: ${report.orgSlug} \u2192 ${report.targetEnv}${mode}`);if(report.orgId)lines.push(` org_id: ${report.orgId}`);lines.push("");for(const layer of report.layers){const counts=`created ${layer.created}, updated ${layer.updated}, skipped ${layer.skipped}`;const status=layer.errors.length>0?"FAILED":"ok";lines.push(` ${layer.layer.padEnd(22)} ${status.padEnd(7)} ${counts} (${layer.duration_ms}ms)`);for(const error of layer.errors){lines.push(` \u2717 ${error.entity}: ${error.error}`)}}const totalErrors=report.layers.reduce((sum,l)=>sum+l.errors.length,0);lines.push("");lines.push(report.success?` \u2713 ${report.layers.length} layer(s) in ${report.totalDuration_ms}ms`:` \u2717 ${totalErrors} error(s) across ${report.layers.length} layer(s)`);lines.push("");return lines.join("\n")}function renderStatus(statuses,header){const lines=["",header,""];lines.push(` ${"layer".padEnd(22)} ${"kind".padEnd(5)} ${"files".padStart(5)} ${"db".padStart(5)} ${"mapped".padStart(6)} state`);for(const s of statuses){const db=s.dbRows===null?"\u2014":String(s.dbRows);const notes=[];if(!s.implemented)notes.push("not ported (host-owned)");if(s.error)notes.push(`ERROR: ${s.error}`);if(s.missingInDb.length)notes.push(`${s.missingInDb.length} file(s) not in DB`);if(s.missingInFiles.length)notes.push(`${s.missingInFiles.length} DB row(s) without a file`);if(s.unmappedInDb.length)notes.push(`${s.unmappedInDb.length} unmapped`);if(!notes.length)notes.push("in sync");lines.push(` ${s.layer.padEnd(22)} ${s.kind.padEnd(5)} ${String(s.files).padStart(5)} ${db.padStart(5)} ${String(s.mappedIds).padStart(6)} ${notes.join("; ")}`)}const detail=statuses.filter(s=>s.missingInDb.length||s.missingInFiles.length||s.unmappedInDb.length);if(detail.length){lines.push("");lines.push(" drift detail");for(const s of detail){if(s.missingInDb.length)lines.push(` ${s.layer} \u2014 in files, not in DB: ${s.missingInDb.join(", ")}`);if(s.missingInFiles.length)lines.push(` ${s.layer} \u2014 in DB, no file: ${s.missingInFiles.join(", ")}`);if(s.unmappedInDb.length)lines.push(` ${s.layer} \u2014 unmapped: ${s.unmappedInDb.join(", ")}`)}}lines.push("");return lines.join("\n")}function renderValidation(results){const lines=[""];let errors=0;for(const result of results){const count=result.errors.length;errors+=count;const state=count===0?"ok":`${count} error(s)`;lines.push(` ${result.layer.padEnd(22)} ${String(result.filesChecked).padStart(3)} file(s) ${state}`);for(const issue of result.errors)lines.push(` \u2717 ${issue.file}: ${issue.message}`);for(const issue of result.warnings)lines.push(` \u26A0 ${issue.file}: ${issue.message}`)}lines.push("");lines.push(errors===0?" \u2713 all files valid":` \u2717 ${errors} error(s)`);lines.push("");return lines.join("\n")}function emit(json,payload,human){console.log(json?JSON.stringify(payload,null,2):human)}async function checkAdvisorLinkage(ctx,supabase){const org=await findOrgBySlug(supabase,ctx.org.slug);if(!org){return{name:"advisor-linkage",severity:"fail",message:`organization "${ctx.org.slug}" not found in ${ctx.targetEnv}`}}if(ctx.org.advisor){return org.advisor?{name:"advisor-linkage",severity:"ok",message:"advisor org, flag set correctly"}:{name:"advisor-linkage",severity:"fail",message:"config says advisor org but organization.advisor is false"}}const expected=ctx.org.advisor_org_slug;if(!expected){return{name:"advisor-linkage",severity:"ok",message:"client org with no advisor configured"}}const advisorOrg=await findOrgBySlug(supabase,expected);if(!advisorOrg){return{name:"advisor-linkage",severity:"warn",message:`advisor org "${expected}" does not exist in ${ctx.targetEnv} yet \u2014 seed it first`}}if(org.advisorOrgId!==advisorOrg.id){return{name:"advisor-linkage",severity:"fail",message:`organization.metadata.advisor_org_id is ${org.advisorOrgId??"null"} but "${expected}" is ${advisorOrg.id} \u2014 advisor-scoped lookups will return nothing`}}return{name:"advisor-linkage",severity:"ok",message:`linked to ${expected} (${advisorOrg.id})`}}async function checkDuplicateDataTypes(ctx,supabase){const{data,error}=await supabase.from("client_data_type").select("id, name").eq("org_id",ctx.orgId);if(error){return{name:"duplicate-rows",severity:"warn",message:`lookup failed: ${error.message}`}}const byName=new Map;for(const row of data??[]){byName.set(row.name,[...byName.get(row.name)??[],row.id])}const dupes=[...byName.entries()].filter(([,ids])=>ids.length>1);return dupes.length===0?{name:"duplicate-rows",severity:"ok",message:`client_data_type: ${byName.size} distinct name(s), no duplicates`}:{name:"duplicate-rows",severity:"fail",message:`client_data_type has ${dupes.length} duplicated name(s) \u2014 sync will refuse to write them`,detail:dupes.map(([name,ids])=>`${name}: ${ids.join(", ")}`)}}async function checkDuplicateMemberships(ctx,supabase){const detail=[];const{data:members,error:memberError}=await supabase.from("org_member").select("id, user_id").eq("org_id",ctx.orgId);if(memberError){return{name:"duplicate-memberships",severity:"warn",message:`org_member lookup failed: ${memberError.message}`}}const memberCounts=new Map;for(const row of members??[]){memberCounts.set(row.user_id,(memberCounts.get(row.user_id)??0)+1)}for(const[userId,count]of memberCounts){if(count>1)detail.push(`org_member: user ${userId} has ${count} rows`)}const{data:advisors,error:advisorError}=await supabase.from("advisor").select("id, user_id").eq("org_id",ctx.orgId);if(!advisorError){const advisorCounts=new Map;for(const row of advisors??[]){advisorCounts.set(row.user_id,(advisorCounts.get(row.user_id)??0)+1)}for(const[userId,count]of advisorCounts){if(count>1)detail.push(`advisor: user ${userId} has ${count} rows`)}}return detail.length===0?{name:"duplicate-memberships",severity:"ok",message:`${memberCounts.size} org member(s), no duplicate rows`}:{name:"duplicate-memberships",severity:"fail",message:"duplicate membership rows \u2014 role resolution uses .maybeSingle() and will throw, hanging login on a portal\u2192platform redirect loop",detail}}async function checkLiveBundle(ctx,supabase){const{data:bundles,error:bundleError}=await supabase.from("component_bundle").select("id, version, bundle_name, is_live, status").eq("org_id",ctx.orgId).eq("bundle_name",ctx.org.bundle_name).eq("is_live",true);if(bundleError){return{name:"live-bundle",severity:"warn",message:`lookup failed: ${bundleError.message}`}}const live=bundles??[];if(live.length===0){return{name:"live-bundle",severity:"warn",message:`no is_live bundle in lineage "${ctx.org.bundle_name}" \u2014 the host has nothing to serve`}}if(live.length>1){return{name:"live-bundle",severity:"fail",message:`${live.length} bundles are is_live in lineage "${ctx.org.bundle_name}" \u2014 exactly one may be`,detail:live.map(b=>b.version)}}const liveVersion=live[0].version;const{data:templates,error:templateError}=await supabase.from("org_template").select("id, remote_version").eq("metadata->>org_slug",ctx.org.slug);if(templateError){return{name:"live-bundle",severity:"warn",message:`is_live=${liveVersion}; org_template lookup failed: ${templateError.message}`}}const rows=templates??[];const pointer=rows[0]?.remote_version??null;if(pointer&&pointer!==liveVersion){return{name:"live-bundle",severity:"warn",message:`org_template.remote_version=${pointer} but is_live=${liveVersion}. The host serves the is_live row. Reconcile from THIS repo:
|
|
13
|
+
npm run deploy:cutover -- --org <slug> --version ${liveVersion} --<env>`}}return{name:"live-bundle",severity:"ok",message:`is_live=${liveVersion}, pointer agrees`}}var LOCAL_CHECKS=new Set(["shared-data-drift","identity-bundle-name","identity-env-slug"]);async function runDoctor(ctx){const filter=ctx.args.check;const localOnly=filter!==null&&LOCAL_CHECKS.has(filter);const checks=[sharedDataDriftCheck(),...identityChecks(ctx.targetEnv)];if(!localOnly){if(!ctx.supabase){throw new Error(`doctor needs a database connection (remove --offline), or pass --check ${[...LOCAL_CHECKS].join(" / --check ")} for the checks that run without one.`)}if(!ctx.orgId){throw new Error(`organization "${ctx.org.slug}" does not exist in ${ctx.targetEnv} \u2014 nothing to check.`)}const supabase=ctx.supabase;checks.push(await checkAdvisorLinkage(ctx,supabase),await checkDuplicateDataTypes(ctx,supabase),await checkDuplicateMemberships(ctx,supabase),await checkLiveBundle(ctx,supabase));const layers=resolveLayers(ctx.ownedLayers,{layer:null,targetEnv:ctx.targetEnv});const managerOptions=ctx.managerOptions();for(const layer of layers){const manager=createManager(layer,managerOptions,ctx.mappings);if(!manager)continue;try{checks.push(...await manager.healthChecks())}catch(error){checks.push({name:`${layer}-checks`,severity:"warn",message:error instanceof Error?error.message:String(error)})}}}const selected=filter?checks.filter(c=>c.name===filter):checks;if(selected.length===0){throw new Error(`--check "${filter}" matched no check. Ran: ${checks.map(c=>c.name).join(", ")}.`)}const failed=selected.filter(c=>c.severity==="fail");const warned=selected.filter(c=>c.severity==="warn");const scope=filter?` [--check ${filter}]`:"";const lines=["",localOnly?`doctor: ${ctx.org.slug}${scope}`:`doctor: ${ctx.org.slug} \u2192 ${ctx.targetEnv}${scope}`,""];for(const check2 of selected){const icon=check2.severity==="ok"?"\u2713":check2.severity==="warn"?"\u26A0":"\u2717";lines.push(` ${icon} ${check2.name.padEnd(22)} ${check2.message}`);for(const line of check2.detail??[])lines.push(` ${line}`)}lines.push("");lines.push(failed.length===0?warned.length===0?" \u2713 all checks passed":` \u26A0 ${warned.length} warning(s), no failures`:` \u2717 ${failed.length} check(s) failed`);lines.push("");emit(ctx.args.json,{org:ctx.org.slug,target_env:ctx.targetEnv,check:filter,checks:selected},lines.join("\n"));return failed.length===0?0:1}function runList(ctx){const rows=LAYER_ORDER.map(layer=>{const def=LAYERS[layer];return{layer,kind:def.kind,owned:ctx.ownedLayers.includes(layer),ported:def.implemented,table:def.table,natural_key:def.naturalKey,mapping_entity:def.mappingEntity,source_dir:def.sourceDir?`template/${def.sourceDir}`:"(src/ \u2014 see notes)",envs:def.envs,notes:def.notes??null}});const lines=["",`layers for ${ctx.org.slug} (${ctx.org.advisor?"advisor org":"client org"}${ctx.org.advisor_org_slug?`, advisor: ${ctx.org.advisor_org_slug}`:""})`,"",` ${"layer".padEnd(22)} ${"kind".padEnd(5)} ${"ported".padEnd(7)} source`];for(const row of rows){if(!row.owned)continue;lines.push(` ${row.layer.padEnd(22)} ${row.kind.padEnd(5)} ${(row.ported?"yes":"no").padEnd(7)} ${row.source_dir}`);if(!row.ported&&row.notes)lines.push(` ${row.notes}`)}const unowned=rows.filter(r=>!r.owned);if(unowned.length){lines.push("");lines.push(` not owned by this org: ${unowned.map(r=>r.layer).join(", ")}`)}lines.push("");emit(ctx.args.json,{org:ctx.org.slug,layers:rows},lines.join("\n"));return 0}async function runPrune(ctx){const confirmed=ctx.args.flags.has("confirm-prune");const layers=resolveLayers(ctx.ownedLayers,{layer:ctx.args.layer?assertLayerName(ctx.args.layer):null,targetEnv:ctx.targetEnv});const managerOptions=ctx.managerOptions();const found=[];const deleted=[];const errors=[];for(const layer of layers){const manager=createManager(layer,managerOptions,ctx.mappings);if(!manager)continue;for(const orphan of await manager.orphans()){found.push({...orphan,layer})}}if(confirmed&&!ctx.args.dryRun){const order=["client_input_field","client_form_section","client_form_page"];const sorted=[...found].sort((a,b)=>order.indexOf(a.table)-order.indexOf(b.table));for(const orphan of sorted){const{error}=await ctx.supabase.from(orphan.table).delete().eq("id",orphan.id);if(error){errors.push({label:orphan.label,error:error.message})}else{deleted.push(orphan)}}}const byTable=new Map;for(const orphan of found)byTable.set(orphan.table,(byTable.get(orphan.table)??0)+1);const lines=["",`prune: ${ctx.org.slug} \u2192 ${ctx.targetEnv}`+(confirmed&&!ctx.args.dryRun?"":" (preview \u2014 nothing deleted)"),""];if(found.length===0){lines.push(" \u2713 no orphaned rows")}else{for(const[table,count]of byTable)lines.push(` ${table.padEnd(24)} ${count} orphan(s)`);lines.push("");for(const orphan of found){const mark=deleted.includes(orphan)?"deleted":"would delete";lines.push(` ${mark.padEnd(13)} ${orphan.table.padEnd(22)} ${orphan.label}`)}if(!confirmed){lines.push("");lines.push(" Pass --confirm-prune to delete these rows.")}}for(const failure of errors)lines.push(` \u2717 ${failure.label}: ${failure.error}`);lines.push("");emit(ctx.args.json,{org:ctx.org.slug,target_env:ctx.targetEnv,confirmed:confirmed&&!ctx.args.dryRun,found,deleted,errors},lines.join("\n"));return errors.length===0?0:1}var OrgSeeder=class{constructor(ctx){this.ctx=ctx}ctx;async run(options){const started=Date.now();const layers=resolveLayers(this.ctx.ownedLayers,{layer:options.layer??null,targetEnv:this.ctx.targetEnv});if(options.layer&&layers.length===0){const def=LAYERS[options.layer];throw new Error(`layer "${options.layer}" is not available for ${this.ctx.targetEnv}`+(def.envs.includes(this.ctx.targetEnv)?" \u2014 it is not listed in template/config/layers.json":` \u2014 it only runs in: ${def.envs.join(", ")}`))}const results=[];for(const layer of layers){const bootstrapping=layer==="org"&&this.ctx.orgId===null;const manager=createManager(layer,this.ctx.managerOptions({allowMissingOrg:bootstrapping}),this.ctx.mappings);if(!manager){results.push({layer,created:0,updated:0,skipped:1,entities:[{entity:layer,action:"skipped",id:null,reason:LAYERS[layer].notes??"not ported yet \u2014 still seeded by the host base-template"}],errors:[],duration_ms:0});continue}const result=options.operation==="pull"?await manager.pull():await manager.syncAll();results.push(result);if(layer==="org"&&result.errors.length===0){await this.ctx.refreshOrgId()}if(layer==="org"&&this.ctx.orgId===null){const remaining=layers.slice(layers.indexOf(layer)+1);for(const skipped of remaining){results.push({layer:skipped,created:0,updated:0,skipped:1,entities:[{entity:skipped,action:"skipped",id:null,reason:this.ctx.args.dryRun?"dry run: the org row was not actually created, so nothing downstream can resolve against it. Re-run without --dry-run.":"the org layer did not produce an organization id \u2014 fix that error first; everything here depends on it."}],errors:[],duration_ms:0})}break}}if(!this.ctx.args.dryRun)this.ctx.mappings.flush();return{orgSlug:this.ctx.org.slug,orgId:this.ctx.orgId,targetEnv:this.ctx.targetEnv,dryRun:this.ctx.args.dryRun,command:options.command,layers:results,totalDuration_ms:Date.now()-started,success:results.every(r=>r.errors.length===0)}}};async function runPull(ctx){const layer=ctx.args.layer?assertLayerName(ctx.args.layer):null;const report=await new OrgSeeder(ctx).run({command:"pull",operation:"pull",layer});const written=ctx.args.dryRun?ctx.mappings.changedEntities():ctx.mappings.flushedEntities();const human=renderSeedReport(report)+(ctx.args.dryRun?" (dry run \u2014 no mapping files written)\n":written.length?` mapping files updated: ${written.join(", ")}
|
|
14
|
+
`:" mapping files already up to date\n");emit(ctx.args.json,{...report,mappings_written:written},human);return report.success?0:1}async function runRelease(args){loadEnv(args.targetEnv,REPO_ROOT);const processId=process.env[PROCESS_ENV];if(!processId||!process.env[TOKEN_ENV]){console.error(`\u2717 release runs under the agency's with-template-token wrapper (${TOKEN_ENV} and ${PROCESS_ENV}); it never uses the secret key.`);return 1}const url=process.env.SUPABASE_URL;const key=process.env.SUPABASE_PUBLISHABLE_KEY;if(!url||!key){console.error("\u2717 SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are needed (never the secret key).");return 1}const{token}=templateToken(processId);const org=loadOrgConfig();const options={supabase:templateClient(url,key,token),orgSlug:org.slug,orgId:"",targetEnv:args.targetEnv,repoRoot:REPO_ROOT,dryRun:args.dryRun,nameFilter:args.name};const mappings=new MappingStore(MAPPINGS_DIR,args.targetEnv);const forms=loadTemplatesConfig().form_template_process===processId.toLowerCase();const what=forms?"form templates":"page templates";const result=forms?await new FormTemplateManager(LAYERS["form-templates"],options,mappings).release():await new PageTemplateManager(LAYERS["page-templates"],options,mappings).release();if(args.json)console.log(JSON.stringify(result,null,2));else{for(const e of result.entities)console.log(` ${e.action.padEnd(8)} ${e.entity}${"reason"in e&&e.reason?` (${e.reason})`:""}`);for(const e of result.errors)console.error(` \u2717 ${e.entity}: ${e.error}`);console.log(result.errors.length?`
|
|
15
|
+
\u2717 ${what} not released (${result.errors.length} problem(s)); nothing past the first failure was published.`:`
|
|
16
|
+
\u2713 ${what} ${args.dryRun?"checked (dry run, nothing written)":"released"}.`)}return result.errors.length?1:0}async function runSeed(ctx){const report=await new OrgSeeder(ctx).run({command:"seed",operation:"sync"});emit(ctx.args.json,report,renderSeedReport(report));return report.success?0:1}async function runStatus(ctx){const layers=resolveLayers(ctx.ownedLayers,{layer:ctx.args.layer?ctx.args.layer:null,targetEnv:ctx.targetEnv});const statuses=[];let managerOptions=null;let contextError=null;try{managerOptions=ctx.managerOptions()}catch(error){contextError=error instanceof Error?error.message:String(error)}for(const layer of layers){const def=LAYERS[layer];const manager=managerOptions?createManager(layer,managerOptions,ctx.mappings):null;if(!manager){statuses.push({layer,kind:def.kind,implemented:def.implemented,files:0,dbRows:null,mappedIds:def.mappingEntity?ctx.mappings.count(def.mappingEntity):0,missingInDb:[],missingInFiles:[],unmappedInDb:[],...contextError&&def.implemented?{error:contextError}:{}});continue}statuses.push(await manager.status())}const payload={org:ctx.org.slug,org_id:ctx.orgId,target_env:ctx.targetEnv,advisor_org_slug:ctx.org.advisor_org_slug??null,context_error:contextError,layers:statuses};const header=`status: ${ctx.org.slug} \u2192 ${ctx.targetEnv}`+(ctx.orgId?` (org_id ${ctx.orgId})`:" (org row NOT FOUND)");emit(ctx.args.json,payload,renderStatus(statuses,header));return contextError&&statuses.some(s=>s.implemented)?1:0}async function runSync(ctx){if(!ctx.args.layer){throw new Error("sync needs --layer <name>. Use `seed` to run every layer, or `list` to see the layers.")}const layer=assertLayerName(ctx.args.layer);const report=await new OrgSeeder(ctx).run({command:"sync",operation:"sync",layer});emit(ctx.args.json,report,renderSeedReport(report));return report.success?0:1}function offlineManagerOptions(ctx){return{supabase:null,orgSlug:ctx.org.slug,orgId:"(offline)",targetEnv:ctx.targetEnv,repoRoot:ctx.repoRoot,dryRun:true,nameFilter:ctx.args.name}}async function runValidate(ctx){const layers=resolveLayers(ctx.ownedLayers,{layer:ctx.args.layer?ctx.args.layer:null,targetEnv:ctx.targetEnv});const options=offlineManagerOptions(ctx);const results=[];let bundleRefsVisited=0;for(const layer of layers){const manager=createManager(layer,options,ctx.mappings);if(!manager){results.push({layer,filesChecked:0,errors:[],warnings:[{file:"(layer)",message:LAYERS[layer].notes??"not ported yet \u2014 no files in this repo"}]});continue}await manager.prepare?.();results.push(manager.validateAll());bundleRefsVisited+=manager.bundleRefsVisited}const floorLayer=layers[0];if(!ctx.args.layer&&floorLayer&&bundleRefsVisited<ctx.bundleRefFloor){results.push({layer:floorLayer,filesChecked:0,errors:[{file:"(repo)",message:"the bundle-ref walker visited zero refs across every layer \u2014 it is passing vacuously. Either every ref was deleted from this repo, or the walk in template/lib/bundle-refs.ts stopped matching the keys refs are stored under."}],warnings:[]})}const errorCount=results.reduce((sum,r)=>sum+r.errors.length,0);emit(ctx.args.json,{org:ctx.org.slug,layers:results,error_count:errorCount},renderValidation(results));return errorCount===0?0:1}var USAGE=`
|
|
17
17
|
org template CLI \u2014 seed and sync this org's configuration
|
|
18
18
|
|
|
19
19
|
status read-only: per-layer file / DB / mapping state and drift
|
|
@@ -24,6 +24,10 @@ org template CLI \u2014 seed and sync this org's configuration
|
|
|
24
24
|
pull record database ids into the mapping files
|
|
25
25
|
doctor invariant checks (duplicates, is_live pointer, mapping coverage)
|
|
26
26
|
prune delete rows no file declares (preview by default; --confirm-prune to apply)
|
|
27
|
+
release release one page-template process's pages, under the agency's
|
|
28
|
+
with-template-token wrapper (never the secret key; --dry-run checks only)
|
|
29
|
+
the cut locks the template: never run it alongside other writes to
|
|
30
|
+
the same template (another release, a seed)
|
|
27
31
|
|
|
28
32
|
Options
|
|
29
33
|
--env <local|develop|prod> target database (default: local)
|
|
@@ -56,6 +60,6 @@ cutover. If this repo has scripts/cutover.mjs and a deploy:cutover script, that
|
|
|
56
60
|
is the one \u2014 the host refuses any org listed in its REPO_OWNED_CUTOVER map.
|
|
57
61
|
Never run both.
|
|
58
62
|
npm run deploy:cutover -- --org <slug> --version <v> --develop
|
|
59
|
-
`;var WRITE_COMMANDS=new Set(["sync","seed","pull","prune"]);async function main(){const args=parseArgs(process.argv.slice(2));if(!args.command||args.flags.has("help")||args.command==="help"){console.log(USAGE);return args.command&&args.command!=="help"?1:0}const org=loadOrgConfig();assertWriteAllowed(args,{requiresWrite:WRITE_COMMANDS.has(args.command),orgSlug:org.slug,repoRoot:process.cwd()});const ctx=await buildContext(args);switch(args.command){case"status":return runStatus(ctx);case"validate":return runValidate(ctx);case"list":return runList(ctx);case"sync":return runSync(ctx);case"seed":return runSeed(ctx);case"pull":return runPull(ctx);case"doctor":return runDoctor(ctx);case"prune":return runPrune(ctx);default:console.error(`unknown command "${args.command}"`);console.log(USAGE);return 1}}main().then(code=>process.exit(code)).catch(error=>{console.error(`
|
|
63
|
+
`;var WRITE_COMMANDS=new Set(["sync","seed","pull","prune","release"]);async function main(){const args=parseArgs(process.argv.slice(2));if(!args.command||args.flags.has("help")||args.command==="help"){console.log(USAGE);return args.command&&args.command!=="help"?1:0}const org=loadOrgConfig();assertWriteAllowed(args,{requiresWrite:WRITE_COMMANDS.has(args.command),orgSlug:org.slug,repoRoot:process.cwd()});if(args.command==="release")return runRelease(args);const ctx=await buildContext(args);switch(args.command){case"status":return runStatus(ctx);case"validate":return runValidate(ctx);case"list":return runList(ctx);case"sync":return runSync(ctx);case"seed":return runSeed(ctx);case"pull":return runPull(ctx);case"doctor":return runDoctor(ctx);case"prune":return runPrune(ctx);default:console.error(`unknown command "${args.command}"`);console.log(USAGE);return 1}}main().then(code=>process.exit(code)).catch(error=>{console.error(`
|
|
60
64
|
\u2717 ${error instanceof Error?error.message:String(error)}
|
|
61
65
|
`);process.exit(1)});
|