/** * cli:aggregate-outbox — generate.ts * * Glob → parse → verify → emit (mirrors aggregate-component-registry's shape): * * 1. Walk `src/features` for `<...>/outbox/*Outbox.ts` modules (the files * scaffold-api-client emits for offline: 'write' entities). * 2. Parse each for `export function registerOutbox` and * `export const _RESOURCE = ''`. * 3. HARD ERROR on a resource key declared by two files (last-registration-wins * at runtime would silently shadow one entity's specs). * 4. Emit src/extensions/outbox.generated.ts — one import + one call per module, * alphabetical by path. When NO module exists the file is STILL emitted * (header + placeholder comment) so main.tsx's unconditional import always * resolves. * * Sync + self-contained fs reads so tests can drive it on mkdtemp fixtures. */ import fs from 'node:fs' import path from 'node:path' import type { GeneratedFile } from './types.js' export const OUT_FILE = 'src/extensions/outbox.generated.ts' const REGISTER_FN_RE = /export\s+function\s+(register\w*Outbox)\s*\(/g const RESOURCE_RE = /export\s+const\s+([A-Z0-9_]+)_RESOURCE\s*=\s*'([^']+)'/g export interface OutboxModule { /** Web-root-relative path, posix separators (e.g. src/features/.../outbox/employeeOutbox.ts). */ file: string registerFns: string[] /** Resource keys declared via `export const X_RESOURCE = '...'`. */ resources: string[] } export interface GenerateResult { files: GeneratedFile[] modules: OutboxModule[] errors: string[] warnings: string[] } /** Recursively collect `<...>/outbox/*Outbox.ts` files (skips node_modules). */ function walkOutboxFiles(dir: string): string[] { const out: string[] = [] let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out } for (const entry of entries) { const abs = path.join(dir, entry.name) if (entry.isDirectory()) { if (entry.name === 'node_modules') continue out.push(...walkOutboxFiles(abs)) } else if ( entry.isFile() && /Outbox\.ts$/.test(entry.name) && path.basename(dir) === 'outbox' ) { out.push(abs) } } return out } const HEADER = `// ============================================================================ // outbox.generated.ts — Auto-aggregated offline-write outbox registrations // ============================================================================ // Generated by skills/development/frontend/pwa/cli/aggregate-outbox. // Do NOT edit by hand — re-run the CLI to regenerate. // Imported by src/main.tsx BEFORE initOutbox() so every spec is registered // before the boot drain replays queued mutations. ` export function generate(input: { webRootAbs: string }): GenerateResult { const errors: string[] = [] const warnings: string[] = [] const featuresDir = path.join(input.webRootAbs, 'src', 'features') const absFiles = fs.existsSync(featuresDir) ? walkOutboxFiles(featuresDir) : [] const modules: OutboxModule[] = absFiles .map((abs) => { const rel = path.relative(input.webRootAbs, abs).replace(/\\/g, '/') let content = '' try { content = fs.readFileSync(abs, 'utf-8') } catch { warnings.push(`Could not read ${rel} — skipped.`) return null } const registerFns: string[] = [] const resources: string[] = [] REGISTER_FN_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = REGISTER_FN_RE.exec(content)) !== null) registerFns.push(m[1]) RESOURCE_RE.lastIndex = 0 while ((m = RESOURCE_RE.exec(content)) !== null) resources.push(m[2]) if (registerFns.length === 0) { warnings.push(`${rel} exports no register*Outbox function — skipped (not aggregated).`) return null } if (resources.length === 0) { warnings.push(`${rel} declares no *_RESOURCE const — duplicate-resource detection cannot cover it.`) } return { file: rel, registerFns, resources } }) .filter((x): x is OutboxModule => x !== null) .sort((a, b) => a.file.localeCompare(b.file)) // Duplicate resource keys across files → hard error (silent runtime shadowing). const owners = new Map() for (const mod of modules) { for (const key of mod.resources) { const list = owners.get(key) ?? [] list.push(mod.file) owners.set(key, list) } } for (const [key, files] of owners) { if (files.length > 1) { errors.push( `Duplicate outbox resource key '${key}' declared by ${files.join(' AND ')} — resource keys must be unique across the app (the outbox folds records by this key).`, ) } } const files: GeneratedFile[] = [] if (errors.length === 0) { let body: string if (modules.length === 0) { body = '\n// No offline-write outbox modules yet.\n' } else { const imports = modules .map((mod) => { const spec = '@/' + mod.file.replace(/^src\//, '').replace(/\.ts$/, '') return `import { ${mod.registerFns.join(', ')} } from '${spec}';` }) .join('\n') const calls = modules .flatMap((mod) => mod.registerFns) .map((fn) => `${fn}();`) .join('\n') body = `\n${imports}\n\n${calls}\n` } files.push({ path: OUT_FILE, content: HEADER + body }) } return { files, modules, errors, warnings } }