import fs from 'node:fs' import path from 'node:path' const args = Object.fromEntries(process.argv.slice(2).flatMap((value, index, all) => { if (!value.startsWith('--')) return [] const key = value.slice(2) const next = all[index + 1] return [[key, next && !next.startsWith('--') ? next : 'true']] })) const required = ['module', 'area', 'number', 'type', 'name', 'path', 'permission'] for (const key of required) if (!args[key]) throw new Error(`missing --${key}`) const moduleName = args.module.toUpperCase() const area = args.area.toUpperCase() const number = String(args.number).padStart(3, '0') const type = args.type const title = args.name const screenId = `${moduleName}-${area}-${number}` const allowedModules = new Set(['OMS', 'ERP', 'WMS', 'COMMON']) const allowedTypes = new Set(['list','master','transaction','fast-entry','master-detail','queue','reconcile','import','wms-mobile']) if (!allowedModules.has(moduleName)) throw new Error(`invalid module: ${moduleName}`) if (!allowedTypes.has(type)) throw new Error(`invalid type: ${type}`) if (!/^\d{3}$/.test(number)) throw new Error('number must resolve to 3 digits') const root = process.env.KBX_ROOT ? path.resolve(process.env.KBX_ROOT) : process.cwd() const recipeContract=JSON.parse(fs.readFileSync(path.join(process.cwd(),'contracts/screens/kbx.screen-recipes.json'),'utf8')) const recipe=recipeContract.recipes.find(item=>item.type===type) if(!recipe)throw new Error(`screen recipe missing for type ${type}`) const needsWrite=recipe.defaultCommands.some(command=>command.permissionKind==='write') if(needsWrite&&!args['write-permission'])throw new Error(`--write-permission is required for ${type} screens; mutating commands must never be scaffolded without an explicit permission`) if (path.isAbsolute(args.path) || args.path.split(/[\\/]/).includes('..')) throw new Error('--path must be a safe relative module path') const modulesRoot = path.join(root, 'apps/web/src/modules') const target = path.resolve(modulesRoot, args.path) if (!target.startsWith(path.resolve(modulesRoot) + path.sep)) throw new Error('--path escapes modules root') if (fs.existsSync(target) && fs.readdirSync(target).length > 0) throw new Error(`target already exists and is not empty: ${target}`) fs.mkdirSync(target, { recursive: true }) const token = `${moduleName.toLowerCase()}${area.charAt(0).toUpperCase()}${area.slice(1).toLowerCase()}${number}` const screenVar = `${token}Screen` const pageName = `${moduleName.charAt(0)}${moduleName.slice(1).toLowerCase()}${area.charAt(0)}${area.slice(1).toLowerCase()}${number}Page` const definitionFile = `${token}.definition.ts` const pageFile = `${pageName}.vue` const q=value=>`'${String(value).replaceAll('\\','\\\\').replaceAll("'","\\'")}'` const commandLines=recipe.defaultCommands.map(command=>{ const permission=command.permissionKind==='write'?args['write-permission']:(command.permissionKind==='execute'?args.permission:null) const fields=[`id:${q(command.id)}`,`label:${q(command.label)}`,`group:${q(command.group)}`] if(command.shortcut)fields.push(`shortcut:${q(command.shortcut)}`) if(command.variant)fields.push(`variant:${q(command.variant)}`) if(permission)fields.push(`permission:${q(permission)}`) return ` { ${fields.join(', ')} },` }) fs.writeFileSync(path.join(target, definitionFile), `import { defineKbxScreen } from '@kbx/ui'\n\nexport const ${screenVar} = defineKbxScreen({\n id: '${screenId}',\n version: '1.0.0',\n module: '${moduleName}',\n type: '${type}',\n templateCode: '${recipe.code}',\n title: '${title}',\n helpKey: '${screenId}',\n permissions: ['${args.permission}'],\n commands: [\n${commandLines.join('\n')}\n ],\n telemetry: { enabled: true },\n})\n`) const blueprint = { list:{component:'KbxListPage',imports:['KbxSearchPanel','KbxDataGrid','KbxSummaryBar'],setup:`const search=reactive>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` \n \n `}, master:{component:'KbxMasterPage',imports:['KbxDataGrid','KbxFormGrid','KbxFormSection'],setup:`const rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]`,slots:` \n `}, transaction:{component:'KbxTransactionPage',imports:['KbxDataGrid','KbxFormGrid','KbxFormSection','KbxSummaryBar'],setup:`const rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` \n \n `}, 'fast-entry':{component:'KbxFastEntryPage',imports:['KbxDataGrid','KbxValidationSummary','KbxSummaryBar'],setup:`const rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]\nconst errors:KbxValidationError[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` \n \n \n `}, 'master-detail':{component:'KbxMasterDetailPage',imports:['KbxSearchPanel','KbxDataGrid'],setup:`const search=reactive>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]`,slots:` \n \n `}, queue:{component:'KbxQueuePage',imports:['KbxDataGrid','KbxExceptionSummary','KbxSummaryBar'],setup:`const rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]\nconst counters:KbxWorkQueueCounter[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` \n \n `}, reconcile:{component:'KbxReconcilePage',imports:['KbxSearchPanel','KbxDataGrid'],setup:`const search=reactive>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record[]=[]\nconst columns:KbxGridColumn>[]=[]`,slots:` \n \n `}, import:{component:'KbxImportPage',imports:['KbxProgressSteps','KbxExcelImport'],setup:`const steps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]`,slots:` \n \n `}, 'wms-mobile':{component:'KbxWmsMobilePage',imports:['KbxBarcodeCapture','KbxNetworkIndicator','KbxWmsActionButton'],extraProps:' :online="true"',setup:'',slots:` \n `}, }[type] const imports=[blueprint.component,...blueprint.imports] const needsReactive=blueprint.setup.includes('reactive<') const contractTypes=[] for(const typeName of ['KbxSearchField','KbxGridColumn','KbxSummaryItem','KbxValidationError','KbxWorkQueueCounter'])if(blueprint.setup.includes(typeName))contractTypes.push(typeName) const typeImport=contractTypes.length?`\nimport type { ${contractTypes.join(', ')} } from '@kbx/contracts'`:'' const vueImport=needsReactive?"\nimport { reactive } from 'vue'":'' fs.writeFileSync(path.join(target, pageFile), `\n\n\n`) fs.writeFileSync(path.join(target, 'routes.ts'), `export const route = {\n path: '/${args.path.replaceAll('\\\\','/').replaceAll('\\','/')}',\n name: '${screenId}',\n component: () => import('./${pageFile}'),\n meta: { screenId: '${screenId}', permission: '${args.permission}' },\n}\n`) const testPlanVar=`${token}TestPlan` fs.writeFileSync(path.join(target, `${token}.test-plan.ts`), `import type { KbxGeneratedScreenTestPlan } from '@kbx/contracts'\n\nexport const ${testPlanVar} = {\n screenId: '${screenId}',\n templateCode: '${recipe.code}',\n canonicalScenarioIds: ${JSON.stringify(recipe.canonicalScenarioIds)},\n requiredChecks: ${JSON.stringify(recipe.testProfile.requiredChecks)},\n requiredEvidence: ${JSON.stringify(recipe.testProfile.requiredEvidence)},\n} as const satisfies KbxGeneratedScreenTestPlan\n`) const lines=[`# ${screenId} ${title}`,'',`Generated by KBX scaffolder using ${recipe.code} ${type} recipe.`,'','## Recipe policies','',...recipe.requiredPolicies.map(x=>`- Data/process: \`${x}\``),...recipe.recoveryPolicies.map(x=>`- Recovery: \`${x}\``),...recipe.securityPolicies.map(x=>`- Security: \`${x}\``),'','## Canonical regression scenarios','',...recipe.canonicalScenarioIds.map(x=>`- \`${x}\``),'','## Must complete before merge','','- Replace TODO business fields/data only; keep the canonical KBX template/component composition.','- Bind server query/command contracts; do not move business truth into the client.','- Keep Loading / Empty / Error / Refreshing and permission behavior explicit.','- Use the generated `*.test-plan.ts` as the minimum verification contract, then add screen-specific Vitest/Playwright evidence.','- Run `node scripts/validate-kbx.mjs` before merge.',''] fs.writeFileSync(path.join(target,'README.md'),lines.join('\n')) const helpFile = path.join(root, 'apps/web/src/help/helpRegistry.ts') let help = fs.readFileSync(helpFile, 'utf8') if (!help.includes(`'${screenId}'`)) { const insert = ` '${screenId}': {\n key: '${screenId}', title: '${title}', purpose: 'TODO: 이 화면의 업무 목적을 한 문장으로 작성하세요.',\n steps: ['TODO: 사용자가 실제로 수행하는 첫 번째 업무 단계를 작성하세요.'],\n },\n` const pos = help.lastIndexOf('}') help = help.slice(0, pos) + insert + help.slice(pos) fs.writeFileSync(helpFile, help) } console.log(`created ${screenId} at ${path.relative(root, target)}`) console.log(`recipe ${recipe.code} ${type} -> ${recipe.templateComponent}`) console.log(`canonical scenarios: ${recipe.canonicalScenarioIds.join(', ')}`) console.log('run: node scripts/generate-app-screen-registry.mjs && node scripts/validate-kbx.mjs')