96 lines
12 KiB
JavaScript
96 lines
12 KiB
JavaScript
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<Record<string,unknown>>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #search><KbxSearchPanel v-model="search" :fields="searchFields" @search="execute('search')" /></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #summary><KbxSummaryBar :items="summaryItems" /></template>`},
|
|
master:{component:'KbxMasterPage',imports:['KbxDataGrid','KbxFormGrid','KbxFormSection'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]`,slots:` <template #list><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #detail><KbxFormSection title="기본정보"><KbxFormGrid><div>TODO: 업무 필드</div></KbxFormGrid></KbxFormSection></template>`},
|
|
transaction:{component:'KbxTransactionPage',imports:['KbxDataGrid','KbxFormGrid','KbxFormSection','KbxSummaryBar'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #header><KbxFormSection title="기본정보"><KbxFormGrid><div>TODO: Header 업무 필드</div></KbxFormGrid></KbxFormSection></template>\n <template #detail><KbxDataGrid :rows="rows" :columns="columns" row-key="clientId" editable /></template>\n <template #summary><KbxSummaryBar :items="summaryItems" align="end" /></template>`},
|
|
'fast-entry':{component:'KbxFastEntryPage',imports:['KbxDataGrid','KbxValidationSummary','KbxSummaryBar'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst errors:KbxValidationError[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #guide><span>Enter 다음 셀 · F2 조회 · Ctrl+V 붙여넣기</span></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="clientId" editable /></template>\n <template #validation><KbxValidationSummary :errors="errors" /></template>\n <template #summary><KbxSummaryBar :items="summaryItems" /></template>`},
|
|
'master-detail':{component:'KbxMasterDetailPage',imports:['KbxSearchPanel','KbxDataGrid'],setup:`const search=reactive<Record<string,unknown>>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]`,slots:` <template #search><KbxSearchPanel v-model="search" :fields="searchFields" @search="execute('search')" /></template>\n <template #master><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #detail><KbxDataGrid :rows="[]" :columns="columns" row-key="id" /></template>`},
|
|
queue:{component:'KbxQueuePage',imports:['KbxDataGrid','KbxExceptionSummary','KbxSummaryBar'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst counters:KbxWorkQueueCounter[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #summary><KbxSummaryBar :items="summaryItems" /></template>\n <template #exceptions><KbxExceptionSummary :counters="counters" /></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>`},
|
|
reconcile:{component:'KbxReconcilePage',imports:['KbxSearchPanel','KbxDataGrid'],setup:`const search=reactive<Record<string,unknown>>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]`,slots:` <template #search><KbxSearchPanel v-model="search" :fields="searchFields" @search="execute('search')" /></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #resolution><span>TODO: 권한이 명시된 해결 Action</span></template>`},
|
|
import:{component:'KbxImportPage',imports:['KbxProgressSteps','KbxExcelImport'],setup:`const steps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]`,slots:` <template #steps><KbxProgressSteps :steps="steps" active-key="file" /></template>\n <KbxExcelImport />\n <template #result><span>TODO: 반영 결과</span></template>`},
|
|
'wms-mobile':{component:'KbxWmsMobilePage',imports:['KbxBarcodeCapture','KbxNetworkIndicator','KbxWmsActionButton'],extraProps:' :online="true"',setup:'',slots:` <KbxBarcodeCapture />\n <template #actions><KbxWmsActionButton label="작업 실행" /></template>`},
|
|
}[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), `<script setup lang="ts">\nimport { ${imports.join(', ')} } from '@kbx/ui'${typeImport}${vueImport}\nimport { ${screenVar} } from './${definitionFile.replace(/\.ts$/, '')}'\n\n${blueprint.setup}\nfunction execute(commandId:string){ void commandId /* TODO: connect business-specific orchestration only. */ }\n</script>\n\n<template>\n <${blueprint.component} :screen="${screenVar}"${blueprint.extraProps??''} @command="execute">\n${blueprint.slots}\n </${blueprint.component}>\n</template>\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')
|