@open-mercato/cli 0.4.2-canary-c02407ff85

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.
Files changed (51) hide show
  1. package/bin/mercato +21 -0
  2. package/build.mjs +78 -0
  3. package/dist/bin.js +51 -0
  4. package/dist/bin.js.map +7 -0
  5. package/dist/index.js +5 -0
  6. package/dist/index.js.map +7 -0
  7. package/dist/lib/db/commands.js +350 -0
  8. package/dist/lib/db/commands.js.map +7 -0
  9. package/dist/lib/db/index.js +7 -0
  10. package/dist/lib/db/index.js.map +7 -0
  11. package/dist/lib/generators/entity-ids.js +257 -0
  12. package/dist/lib/generators/entity-ids.js.map +7 -0
  13. package/dist/lib/generators/index.js +12 -0
  14. package/dist/lib/generators/index.js.map +7 -0
  15. package/dist/lib/generators/module-di.js +73 -0
  16. package/dist/lib/generators/module-di.js.map +7 -0
  17. package/dist/lib/generators/module-entities.js +104 -0
  18. package/dist/lib/generators/module-entities.js.map +7 -0
  19. package/dist/lib/generators/module-registry.js +1081 -0
  20. package/dist/lib/generators/module-registry.js.map +7 -0
  21. package/dist/lib/resolver.js +205 -0
  22. package/dist/lib/resolver.js.map +7 -0
  23. package/dist/lib/utils.js +161 -0
  24. package/dist/lib/utils.js.map +7 -0
  25. package/dist/mercato.js +1045 -0
  26. package/dist/mercato.js.map +7 -0
  27. package/dist/registry.js +7 -0
  28. package/dist/registry.js.map +7 -0
  29. package/jest.config.cjs +19 -0
  30. package/package.json +71 -0
  31. package/src/__tests__/mercato.test.ts +90 -0
  32. package/src/bin.ts +74 -0
  33. package/src/index.ts +2 -0
  34. package/src/lib/__tests__/resolver.test.ts +101 -0
  35. package/src/lib/__tests__/utils.test.ts +270 -0
  36. package/src/lib/db/__tests__/commands.test.ts +131 -0
  37. package/src/lib/db/commands.ts +431 -0
  38. package/src/lib/db/index.ts +1 -0
  39. package/src/lib/generators/__tests__/generators.test.ts +197 -0
  40. package/src/lib/generators/entity-ids.ts +336 -0
  41. package/src/lib/generators/index.ts +4 -0
  42. package/src/lib/generators/module-di.ts +89 -0
  43. package/src/lib/generators/module-entities.ts +124 -0
  44. package/src/lib/generators/module-registry.ts +1222 -0
  45. package/src/lib/resolver.ts +308 -0
  46. package/src/lib/utils.ts +200 -0
  47. package/src/mercato.ts +1106 -0
  48. package/src/registry.ts +2 -0
  49. package/tsconfig.build.json +4 -0
  50. package/tsconfig.json +12 -0
  51. package/watch.mjs +6 -0
@@ -0,0 +1,1222 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { PackageResolver } from '../resolver'
4
+ import {
5
+ calculateChecksum,
6
+ calculateStructureChecksum,
7
+ readChecksumRecord,
8
+ writeChecksumRecord,
9
+ toVar,
10
+ moduleHasExport,
11
+ logGenerationResult,
12
+ type GeneratorResult,
13
+ createGeneratorResult,
14
+ } from '../utils'
15
+
16
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
17
+
18
+ export interface ModuleRegistryOptions {
19
+ resolver: PackageResolver
20
+ quiet?: boolean
21
+ }
22
+
23
+ export async function generateModuleRegistry(options: ModuleRegistryOptions): Promise<GeneratorResult> {
24
+ const { resolver, quiet = false } = options
25
+ const result = createGeneratorResult()
26
+
27
+ const outputDir = resolver.getOutputDir()
28
+ const outFile = path.join(outputDir, 'modules.generated.ts')
29
+ const checksumFile = path.join(outputDir, 'modules.generated.checksum')
30
+ const widgetsOutFile = path.join(outputDir, 'dashboard-widgets.generated.ts')
31
+ const widgetsChecksumFile = path.join(outputDir, 'dashboard-widgets.generated.checksum')
32
+ const injectionWidgetsOutFile = path.join(outputDir, 'injection-widgets.generated.ts')
33
+ const injectionWidgetsChecksumFile = path.join(outputDir, 'injection-widgets.generated.checksum')
34
+ const injectionTablesOutFile = path.join(outputDir, 'injection-tables.generated.ts')
35
+ const injectionTablesChecksumFile = path.join(outputDir, 'injection-tables.generated.checksum')
36
+ const searchOutFile = path.join(outputDir, 'search.generated.ts')
37
+ const searchChecksumFile = path.join(outputDir, 'search.generated.checksum')
38
+
39
+ const enabled = resolver.loadEnabledModules()
40
+ const imports: string[] = []
41
+ const moduleDecls: string[] = []
42
+ let importId = 0
43
+ const trackedRoots = new Set<string>()
44
+ const requiresByModule = new Map<string, string[]>()
45
+ const allDashboardWidgets = new Map<string, { moduleId: string; source: 'app' | 'package'; importPath: string }>()
46
+ const allInjectionWidgets = new Map<string, { moduleId: string; source: 'app' | 'package'; importPath: string }>()
47
+ const allInjectionTables: Array<{ moduleId: string; importPath: string; importName: string }> = []
48
+ const searchConfigs: string[] = []
49
+ const searchImports: string[] = []
50
+
51
+ for (const entry of enabled) {
52
+ const modId = entry.id
53
+ const roots = resolver.getModulePaths(entry)
54
+ const imps = resolver.getModuleImportBase(entry)
55
+ trackedRoots.add(roots.appBase)
56
+ trackedRoots.add(roots.pkgBase)
57
+
58
+ // For @app modules, use relative paths since @/ alias doesn't work in Node.js runtime
59
+ // From .mercato/generated/, go up two levels (../..) to reach the app root, then into src/modules/
60
+ const isAppModule = entry.from === '@app'
61
+ const appImportBase = isAppModule ? `../../src/modules/${modId}` : imps.appBase
62
+
63
+ const frontendRoutes: string[] = []
64
+ const backendRoutes: string[] = []
65
+ const apis: string[] = []
66
+ let cliImportName: string | null = null
67
+ const translations: string[] = []
68
+ const subscribers: string[] = []
69
+ const workers: string[] = []
70
+ let infoImportName: string | null = null
71
+ let extensionsImportName: string | null = null
72
+ let fieldsImportName: string | null = null
73
+ let featuresImportName: string | null = null
74
+ let customEntitiesImportName: string | null = null
75
+ let searchImportName: string | null = null
76
+ let customFieldSetsExpr: string = '[]'
77
+ const dashboardWidgets: string[] = []
78
+ const injectionWidgets: string[] = []
79
+ let injectionTableImportName: string | null = null
80
+
81
+ // Module metadata: index.ts (overrideable)
82
+ const appIndex = path.join(roots.appBase, 'index.ts')
83
+ const pkgIndex = path.join(roots.pkgBase, 'index.ts')
84
+ const indexTs = fs.existsSync(appIndex) ? appIndex : fs.existsSync(pkgIndex) ? pkgIndex : null
85
+ if (indexTs) {
86
+ infoImportName = `I${importId++}_${toVar(modId)}`
87
+ const importPath = indexTs.startsWith(roots.appBase) ? `${appImportBase}/index` : `${imps.pkgBase}/index`
88
+ imports.push(`import * as ${infoImportName} from '${importPath}'`)
89
+ // Try to eagerly read ModuleInfo.requires for dependency validation
90
+ try {
91
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
92
+ const mod = require(indexTs)
93
+ const reqs: string[] | undefined =
94
+ mod?.metadata && Array.isArray(mod.metadata.requires) ? mod.metadata.requires : undefined
95
+ if (reqs && reqs.length) requiresByModule.set(modId, reqs)
96
+ } catch {}
97
+ }
98
+
99
+ // Pages: frontend
100
+ const feApp = path.join(roots.appBase, 'frontend')
101
+ const fePkg = path.join(roots.pkgBase, 'frontend')
102
+ if (fs.existsSync(feApp) || fs.existsSync(fePkg)) {
103
+ const found: string[] = []
104
+ const walk = (dir: string, rel: string[] = []) => {
105
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
106
+ if (e.isDirectory()) {
107
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
108
+ walk(path.join(dir, e.name), [...rel, e.name])
109
+ } else if (e.isFile() && e.name.endsWith('.tsx')) found.push([...rel, e.name].join('/'))
110
+ }
111
+ }
112
+ if (fs.existsSync(fePkg)) walk(fePkg)
113
+ if (fs.existsSync(feApp)) walk(feApp)
114
+ let files = Array.from(new Set(found))
115
+ // Ensure static routes win over dynamic ones (e.g., 'create' before '[id]')
116
+ const isDynamic = (p: string) => /\/(\[|\[\[\.\.\.)/.test(p) || /^\[/.test(p)
117
+ files.sort((a, b) => {
118
+ const ad = isDynamic(a) ? 1 : 0
119
+ const bd = isDynamic(b) ? 1 : 0
120
+ if (ad !== bd) return ad - bd // static first
121
+ // Longer, more specific paths later to not shadow peers
122
+ return a.localeCompare(b)
123
+ })
124
+ // Next-style page.tsx
125
+ for (const rel of files.filter((f) => f.endsWith('/page.tsx') || f === 'page.tsx')) {
126
+ const segs = rel.split('/')
127
+ segs.pop()
128
+ const importName = `C${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
129
+ const pageModName = `CM${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
130
+ const appFile = path.join(feApp, ...segs, 'page.tsx')
131
+ const fromApp = fs.existsSync(appFile)
132
+ const sub = segs.length ? `${segs.join('/')}/page` : 'page'
133
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/frontend/${sub}`
134
+ const routePath = '/' + (segs.join('/') || '')
135
+ const metaCandidates = [
136
+ path.join(fromApp ? feApp : fePkg, ...segs, 'page.meta.ts'),
137
+ path.join(fromApp ? feApp : fePkg, ...segs, 'meta.ts'),
138
+ ]
139
+ const metaPath = metaCandidates.find((p) => fs.existsSync(p))
140
+ let metaExpr = 'undefined'
141
+ if (metaPath) {
142
+ const metaImportName = `M${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
143
+ const metaImportPath = `${fromApp ? appImportBase : imps.pkgBase}/frontend/${[...segs, path.basename(metaPath).replace(/\.ts$/, '')].join('/')}`
144
+ imports.push(`import * as ${metaImportName} from '${metaImportPath}'`)
145
+ metaExpr = `(${metaImportName}.metadata as any)`
146
+ imports.push(`import ${importName} from '${importPath}'`)
147
+ } else {
148
+ metaExpr = `(${pageModName} as any).metadata`
149
+ imports.push(`import ${importName}, * as ${pageModName} from '${importPath}'`)
150
+ }
151
+ frontendRoutes.push(
152
+ `{ pattern: '${routePath || '/'}', requireAuth: (${metaExpr})?.requireAuth, requireRoles: (${metaExpr})?.requireRoles, requireFeatures: (${metaExpr})?.requireFeatures, title: (${metaExpr})?.pageTitle ?? (${metaExpr})?.title, titleKey: (${metaExpr})?.pageTitleKey ?? (${metaExpr})?.titleKey, group: (${metaExpr})?.pageGroup ?? (${metaExpr})?.group, groupKey: (${metaExpr})?.pageGroupKey ?? (${metaExpr})?.groupKey, icon: (${metaExpr})?.icon, order: (${metaExpr})?.pageOrder ?? (${metaExpr})?.order, priority: (${metaExpr})?.pagePriority ?? (${metaExpr})?.priority, navHidden: (${metaExpr})?.navHidden, visible: (${metaExpr})?.visible, enabled: (${metaExpr})?.enabled, breadcrumb: (${metaExpr})?.breadcrumb, Component: ${importName} }`
153
+ )
154
+ }
155
+ // Back-compat direct files
156
+ for (const rel of files.filter((f) => !f.endsWith('/page.tsx') && f !== 'page.tsx')) {
157
+ const segs = rel.split('/')
158
+ const file = segs.pop()!
159
+ const name = file.replace(/\.tsx$/, '')
160
+ const routeSegs = [...segs, name].filter(Boolean)
161
+ const importName = `C${importId++}_${toVar(modId)}_${toVar(routeSegs.join('_') || 'index')}`
162
+ const pageModName = `CM${importId++}_${toVar(modId)}_${toVar(routeSegs.join('_') || 'index')}`
163
+ const appFile = path.join(feApp, ...segs, `${name}.tsx`)
164
+ const fromApp = fs.existsSync(appFile)
165
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/frontend/${[...segs, name].join('/')}`
166
+ const routePath = '/' + (routeSegs.join('/') || '')
167
+ const metaCandidates = [
168
+ path.join(fromApp ? feApp : fePkg, ...segs, name + '.meta.ts'),
169
+ path.join(fromApp ? feApp : fePkg, ...segs, 'meta.ts'),
170
+ ]
171
+ const metaPath = metaCandidates.find((p) => fs.existsSync(p))
172
+ let metaExpr = 'undefined'
173
+ if (metaPath) {
174
+ const metaImportName = `M${importId++}_${toVar(modId)}_${toVar(routeSegs.join('_') || 'index')}`
175
+ const metaBase = path.basename(metaPath)
176
+ const metaImportSub = metaBase === 'meta.ts' ? 'meta' : name + '.meta'
177
+ const metaImportPath = `${fromApp ? appImportBase : imps.pkgBase}/frontend/${[...segs, metaImportSub].join('/')}`
178
+ imports.push(`import * as ${metaImportName} from '${metaImportPath}'`)
179
+ metaExpr = `(${metaImportName}.metadata as any)`
180
+ imports.push(`import ${importName} from '${importPath}'`)
181
+ } else {
182
+ metaExpr = `(${pageModName} as any).metadata`
183
+ imports.push(`import ${importName}, * as ${pageModName} from '${importPath}'`)
184
+ }
185
+ frontendRoutes.push(
186
+ `{ pattern: '${routePath || '/'}', requireAuth: (${metaExpr})?.requireAuth, requireRoles: (${metaExpr})?.requireRoles, requireFeatures: (${metaExpr})?.requireFeatures, title: (${metaExpr})?.pageTitle ?? (${metaExpr})?.title, titleKey: (${metaExpr})?.pageTitleKey ?? (${metaExpr})?.titleKey, group: (${metaExpr})?.pageGroup ?? (${metaExpr})?.group, groupKey: (${metaExpr})?.pageGroupKey ?? (${metaExpr})?.groupKey, visible: (${metaExpr})?.visible, enabled: (${metaExpr})?.enabled, Component: ${importName} }`
187
+ )
188
+ }
189
+ }
190
+
191
+ // Entity extensions: src/modules/<module>/data/extensions.ts
192
+ {
193
+ const appFile = path.join(roots.appBase, 'data', 'extensions.ts')
194
+ const pkgFile = path.join(roots.pkgBase, 'data', 'extensions.ts')
195
+ const hasApp = fs.existsSync(appFile)
196
+ const hasPkg = fs.existsSync(pkgFile)
197
+ if (hasApp || hasPkg) {
198
+ const importName = `X_${toVar(modId)}_${importId++}`
199
+ const importPath = hasApp ? `${appImportBase}/data/extensions` : `${imps.pkgBase}/data/extensions`
200
+ imports.push(`import * as ${importName} from '${importPath}'`)
201
+ extensionsImportName = importName
202
+ }
203
+ }
204
+
205
+ // RBAC feature declarations: module root acl.ts
206
+ {
207
+ const rootApp = path.join(roots.appBase, 'acl.ts')
208
+ const rootPkg = path.join(roots.pkgBase, 'acl.ts')
209
+ const hasRoot = fs.existsSync(rootApp) || fs.existsSync(rootPkg)
210
+ if (hasRoot) {
211
+ const importName = `ACL_${toVar(modId)}_${importId++}`
212
+ const useApp = fs.existsSync(rootApp) ? rootApp : rootPkg
213
+ const importPath = useApp.startsWith(roots.appBase) ? `${appImportBase}/acl` : `${imps.pkgBase}/acl`
214
+ imports.push(`import * as ${importName} from '${importPath}'`)
215
+ featuresImportName = importName
216
+ }
217
+ }
218
+
219
+ // Custom entities declarations: module root ce.ts
220
+ {
221
+ const appFile = path.join(roots.appBase, 'ce.ts')
222
+ const pkgFile = path.join(roots.pkgBase, 'ce.ts')
223
+ const hasApp = fs.existsSync(appFile)
224
+ const hasPkg = fs.existsSync(pkgFile)
225
+ if (hasApp || hasPkg) {
226
+ const importName = `CE_${toVar(modId)}_${importId++}`
227
+ const importPath = hasApp ? `${appImportBase}/ce` : `${imps.pkgBase}/ce`
228
+ imports.push(`import * as ${importName} from '${importPath}'`)
229
+ customEntitiesImportName = importName
230
+ }
231
+ }
232
+
233
+ // Search module configuration: module root search.ts
234
+ {
235
+ const appFile = path.join(roots.appBase, 'search.ts')
236
+ const pkgFile = path.join(roots.pkgBase, 'search.ts')
237
+ const hasApp = fs.existsSync(appFile)
238
+ const hasPkg = fs.existsSync(pkgFile)
239
+ if (hasApp || hasPkg) {
240
+ const importName = `SEARCH_${toVar(modId)}_${importId++}`
241
+ const importPath = hasApp ? `${appImportBase}/search` : `${imps.pkgBase}/search`
242
+ const importStmt = `import * as ${importName} from '${importPath}'`
243
+ imports.push(importStmt)
244
+ searchImports.push(importStmt)
245
+ searchImportName = importName
246
+ }
247
+ }
248
+
249
+ // Custom field declarations: src/modules/<module>/data/fields.ts
250
+ {
251
+ const appFile = path.join(roots.appBase, 'data', 'fields.ts')
252
+ const pkgFile = path.join(roots.pkgBase, 'data', 'fields.ts')
253
+ const hasApp = fs.existsSync(appFile)
254
+ const hasPkg = fs.existsSync(pkgFile)
255
+ if (hasApp || hasPkg) {
256
+ const importName = `F_${toVar(modId)}_${importId++}`
257
+ const importPath = hasApp ? `${appImportBase}/data/fields` : `${imps.pkgBase}/data/fields`
258
+ imports.push(`import * as ${importName} from '${importPath}'`)
259
+ fieldsImportName = importName
260
+ }
261
+ }
262
+
263
+ // Pages: backend
264
+ const beApp = path.join(roots.appBase, 'backend')
265
+ const bePkg = path.join(roots.pkgBase, 'backend')
266
+ if (fs.existsSync(beApp) || fs.existsSync(bePkg)) {
267
+ const found: string[] = []
268
+ const walk = (dir: string, rel: string[] = []) => {
269
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
270
+ if (e.isDirectory()) {
271
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
272
+ walk(path.join(dir, e.name), [...rel, e.name])
273
+ } else if (e.isFile() && e.name.endsWith('.tsx')) found.push([...rel, e.name].join('/'))
274
+ }
275
+ }
276
+ if (fs.existsSync(bePkg)) walk(bePkg)
277
+ if (fs.existsSync(beApp)) walk(beApp)
278
+ let files = Array.from(new Set(found))
279
+ const isDynamic = (p: string) => /\/(\[|\[\[\.\.\.)/.test(p) || /^\[/.test(p)
280
+ files.sort((a, b) => {
281
+ const ad = isDynamic(a) ? 1 : 0
282
+ const bd = isDynamic(b) ? 1 : 0
283
+ if (ad !== bd) return ad - bd
284
+ return a.localeCompare(b)
285
+ })
286
+ // Next-style
287
+ for (const rel of files.filter((f) => f.endsWith('/page.tsx') || f === 'page.tsx')) {
288
+ const segs = rel.split('/')
289
+ segs.pop()
290
+ const importName = `B${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
291
+ const pageModName = `BM${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
292
+ const appFile = path.join(beApp, ...segs, 'page.tsx')
293
+ const fromApp = fs.existsSync(appFile)
294
+ const sub = segs.length ? `${segs.join('/')}/page` : 'page'
295
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/backend/${sub}`
296
+ const basePath = segs.join('/') || modId
297
+ const routePath = '/backend/' + basePath
298
+ const metaCandidates = [
299
+ path.join(fromApp ? beApp : bePkg, ...segs, 'page.meta.ts'),
300
+ path.join(fromApp ? beApp : bePkg, ...segs, 'meta.ts'),
301
+ ]
302
+ const metaPath = metaCandidates.find((p) => fs.existsSync(p))
303
+ let metaExpr = 'undefined'
304
+ if (metaPath) {
305
+ const metaImportName = `BM${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
306
+ const metaImportPath = `${fromApp ? appImportBase : imps.pkgBase}/backend/${[...segs, path.basename(metaPath).replace(/\.ts$/, '')].join('/')}`
307
+ imports.push(`import * as ${metaImportName} from '${metaImportPath}'`)
308
+ metaExpr = `(${metaImportName}.metadata as any)`
309
+ imports.push(`import ${importName} from '${importPath}'`)
310
+ } else {
311
+ metaExpr = `(${pageModName} as any).metadata`
312
+ imports.push(`import ${importName}, * as ${pageModName} from '${importPath}'`)
313
+ }
314
+ backendRoutes.push(
315
+ `{ pattern: '${routePath}', requireAuth: (${metaExpr})?.requireAuth, requireRoles: (${metaExpr})?.requireRoles, requireFeatures: (${metaExpr})?.requireFeatures, title: (${metaExpr})?.pageTitle ?? (${metaExpr})?.title, titleKey: (${metaExpr})?.pageTitleKey ?? (${metaExpr})?.titleKey, group: (${metaExpr})?.pageGroup ?? (${metaExpr})?.group, groupKey: (${metaExpr})?.pageGroupKey ?? (${metaExpr})?.groupKey, icon: (${metaExpr})?.icon, order: (${metaExpr})?.pageOrder ?? (${metaExpr})?.order, priority: (${metaExpr})?.pagePriority ?? (${metaExpr})?.priority, navHidden: (${metaExpr})?.navHidden, visible: (${metaExpr})?.visible, enabled: (${metaExpr})?.enabled, breadcrumb: (${metaExpr})?.breadcrumb, Component: ${importName} }`
316
+ )
317
+ }
318
+ // Direct files
319
+ for (const rel of files.filter((f) => !f.endsWith('/page.tsx') && f !== 'page.tsx')) {
320
+ const segs = rel.split('/')
321
+ const file = segs.pop()!
322
+ const name = file.replace(/\.tsx$/, '')
323
+ const importName = `B${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
324
+ const pageModName = `BM${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
325
+ const appFile = path.join(beApp, ...segs, `${name}.tsx`)
326
+ const fromApp = fs.existsSync(appFile)
327
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/backend/${[...segs, name].join('/')}`
328
+ const routePath = '/backend/' + [modId, ...segs, name].filter(Boolean).join('/')
329
+ const metaCandidates = [
330
+ path.join(fromApp ? beApp : bePkg, ...segs, name + '.meta.ts'),
331
+ path.join(fromApp ? beApp : bePkg, ...segs, 'meta.ts'),
332
+ ]
333
+ const metaPath = metaCandidates.find((p) => fs.existsSync(p))
334
+ let metaExpr = 'undefined'
335
+ if (metaPath) {
336
+ const metaImportName = `BM${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
337
+ const metaBase = path.basename(metaPath)
338
+ const metaImportSub = metaBase === 'meta.ts' ? 'meta' : name + '.meta'
339
+ const metaImportPath = `${fromApp ? appImportBase : imps.pkgBase}/backend/${[...segs, metaImportSub].join('/')}`
340
+ imports.push(`import * as ${metaImportName} from '${metaImportPath}'`)
341
+ metaExpr = `${metaImportName}.metadata`
342
+ imports.push(`import ${importName} from '${importPath}'`)
343
+ } else {
344
+ metaExpr = `(${pageModName} as any).metadata`
345
+ imports.push(`import ${importName}, * as ${pageModName} from '${importPath}'`)
346
+ }
347
+ backendRoutes.push(
348
+ `{ pattern: '${routePath}', requireAuth: (${metaExpr})?.requireAuth, requireRoles: (${metaExpr})?.requireRoles, requireFeatures: (${metaExpr})?.requireFeatures, title: (${metaExpr})?.pageTitle ?? (${metaExpr})?.title, titleKey: (${metaExpr})?.pageTitleKey ?? (${metaExpr})?.titleKey, group: (${metaExpr})?.pageGroup ?? (${metaExpr})?.group, groupKey: (${metaExpr})?.pageGroupKey ?? (${metaExpr})?.groupKey, icon: (${metaExpr})?.icon, order: (${metaExpr})?.pageOrder ?? (${metaExpr})?.order, priority: (${metaExpr})?.pagePriority ?? (${metaExpr})?.priority, navHidden: (${metaExpr})?.navHidden, visible: (${metaExpr})?.visible, enabled: (${metaExpr})?.enabled, breadcrumb: (${metaExpr})?.breadcrumb, Component: ${importName} }`
349
+ )
350
+ }
351
+ }
352
+
353
+ // APIs
354
+ const apiApp = path.join(roots.appBase, 'api')
355
+ const apiPkg = path.join(roots.pkgBase, 'api')
356
+ if (fs.existsSync(apiApp) || fs.existsSync(apiPkg)) {
357
+ // route.ts aggregations
358
+ const routeFiles: string[] = []
359
+ const walk = (dir: string, rel: string[] = []) => {
360
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
361
+ if (e.isDirectory()) {
362
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
363
+ walk(path.join(dir, e.name), [...rel, e.name])
364
+ } else if (e.isFile() && e.name === 'route.ts') routeFiles.push([...rel, e.name].join('/'))
365
+ }
366
+ }
367
+ if (fs.existsSync(apiPkg)) walk(apiPkg)
368
+ if (fs.existsSync(apiApp)) walk(apiApp)
369
+ const routeList = Array.from(new Set(routeFiles))
370
+ const isDynamicRoute = (p: string) => p.split('/').some((seg) => /\[|\[\[\.\.\./.test(seg))
371
+ routeList.sort((a, b) => {
372
+ const ad = isDynamicRoute(a) ? 1 : 0
373
+ const bd = isDynamicRoute(b) ? 1 : 0
374
+ if (ad !== bd) return ad - bd
375
+ return a.localeCompare(b)
376
+ })
377
+ for (const rel of routeList) {
378
+ const segs = rel.split('/')
379
+ segs.pop()
380
+ const reqSegs = [modId, ...segs]
381
+ const importName = `R${importId++}_${toVar(modId)}_${toVar(segs.join('_') || 'index')}`
382
+ const appFile = path.join(apiApp, ...segs, 'route.ts')
383
+ const fromApp = fs.existsSync(appFile)
384
+ const apiSegPath = segs.join('/')
385
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/api${apiSegPath ? `/${apiSegPath}` : ''}/route`
386
+ const routePath = '/' + reqSegs.filter(Boolean).join('/')
387
+ const sourceFile = fromApp ? appFile : path.join(apiPkg, ...segs, 'route.ts')
388
+ const hasOpenApi = await moduleHasExport(sourceFile, 'openApi')
389
+ const docsPart = hasOpenApi ? `, docs: ${importName}.openApi` : ''
390
+ imports.push(`import * as ${importName} from '${importPath}'`)
391
+ apis.push(`{ path: '${routePath}', metadata: (${importName} as any).metadata, handlers: ${importName} as any${docsPart} }`)
392
+ }
393
+
394
+ // Single files
395
+ const plainFiles: string[] = []
396
+ const methodNames = new Set(['get', 'post', 'put', 'patch', 'delete'])
397
+ const walkPlain = (dir: string, rel: string[] = []) => {
398
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
399
+ if (e.isDirectory()) {
400
+ if (methodNames.has(e.name.toLowerCase())) continue
401
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
402
+ walkPlain(path.join(dir, e.name), [...rel, e.name])
403
+ } else if (e.isFile() && e.name.endsWith('.ts') && e.name !== 'route.ts') {
404
+ if (/\.(test|spec)\.ts$/.test(e.name)) continue
405
+ plainFiles.push([...rel, e.name].join('/'))
406
+ }
407
+ }
408
+ }
409
+ if (fs.existsSync(apiPkg)) walkPlain(apiPkg)
410
+ if (fs.existsSync(apiApp)) walkPlain(apiApp)
411
+ const plainList = Array.from(new Set(plainFiles))
412
+ for (const rel of plainList) {
413
+ const segs = rel.split('/')
414
+ const file = segs.pop()!
415
+ const pathWithoutExt = file.replace(/\.ts$/, '')
416
+ const fullSegs = [...segs, pathWithoutExt]
417
+ const routePath = '/' + [modId, ...fullSegs].filter(Boolean).join('/')
418
+ const importName = `R${importId++}_${toVar(modId)}_${toVar(fullSegs.join('_') || 'index')}`
419
+ const appFile = path.join(apiApp, ...fullSegs) + '.ts'
420
+ const fromApp = fs.existsSync(appFile)
421
+ const plainSegPath = fullSegs.join('/')
422
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/api${plainSegPath ? `/${plainSegPath}` : ''}`
423
+ const pkgFile = path.join(apiPkg, ...fullSegs) + '.ts'
424
+ const sourceFile = fromApp ? appFile : pkgFile
425
+ const hasOpenApi = await moduleHasExport(sourceFile, 'openApi')
426
+ const docsPart = hasOpenApi ? `, docs: ${importName}.openApi` : ''
427
+ imports.push(`import * as ${importName} from '${importPath}'`)
428
+ apis.push(`{ path: '${routePath}', metadata: (${importName} as any).metadata, handlers: ${importName} as any${docsPart} }`)
429
+ }
430
+ // Legacy per-method
431
+ const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
432
+ for (const method of methods) {
433
+ const coreMethodDir = path.join(apiPkg, method.toLowerCase())
434
+ const appMethodDir = path.join(apiApp, method.toLowerCase())
435
+ const methodDir = fs.existsSync(appMethodDir) ? appMethodDir : coreMethodDir
436
+ if (!fs.existsSync(methodDir)) continue
437
+ const apiFiles: string[] = []
438
+ const walk2 = (dir: string, rel: string[] = []) => {
439
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
440
+ if (e.isDirectory()) {
441
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
442
+ walk2(path.join(dir, e.name), [...rel, e.name])
443
+ } else if (e.isFile() && e.name.endsWith('.ts')) {
444
+ if (/\.(test|spec)\.ts$/.test(e.name)) continue
445
+ apiFiles.push([...rel, e.name].join('/'))
446
+ }
447
+ }
448
+ }
449
+ walk2(methodDir)
450
+ const methodList = Array.from(new Set(apiFiles))
451
+ for (const rel of methodList) {
452
+ const segs = rel.split('/')
453
+ const file = segs.pop()!
454
+ const pathWithoutExt = file.replace(/\.ts$/, '')
455
+ const fullSegs = [...segs, pathWithoutExt]
456
+ const routePath = '/' + [modId, ...fullSegs].filter(Boolean).join('/')
457
+ const importName = `H${importId++}_${toVar(modId)}_${toVar(method)}_${toVar(fullSegs.join('_'))}`
458
+ const fromApp = methodDir === appMethodDir
459
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/api/${method.toLowerCase()}/${fullSegs.join('/')}`
460
+ const metaName = `RM${importId++}_${toVar(modId)}_${toVar(method)}_${toVar(fullSegs.join('_'))}`
461
+ const sourceFile = path.join(methodDir, ...segs, file)
462
+ const hasOpenApi = await moduleHasExport(sourceFile, 'openApi')
463
+ const docsPart = hasOpenApi ? `, docs: ${metaName}.openApi` : ''
464
+ imports.push(`import ${importName}, * as ${metaName} from '${importPath}'`)
465
+ apis.push(`{ method: '${method}', path: '${routePath}', handler: ${importName}, metadata: ${metaName}.metadata${docsPart} }`)
466
+ }
467
+ }
468
+ }
469
+
470
+ // CLI
471
+ const cliApp = path.join(roots.appBase, 'cli.ts')
472
+ const cliPkg = path.join(roots.pkgBase, 'cli.ts')
473
+ const cliPath = fs.existsSync(cliApp) ? cliApp : fs.existsSync(cliPkg) ? cliPkg : null
474
+ if (cliPath) {
475
+ const importName = `CLI_${toVar(modId)}`
476
+ const importPath = cliPath.startsWith(roots.appBase) ? `${appImportBase}/cli` : `${imps.pkgBase}/cli`
477
+ imports.push(`import ${importName} from '${importPath}'`)
478
+ cliImportName = importName
479
+ }
480
+
481
+ // Translations: merge core + app with app overriding
482
+ const i18nApp = path.join(roots.appBase, 'i18n')
483
+ const i18nCore = path.join(roots.pkgBase, 'i18n')
484
+ const locales = new Set<string>()
485
+ if (fs.existsSync(i18nCore))
486
+ for (const e of fs.readdirSync(i18nCore, { withFileTypes: true }))
487
+ if (e.isFile() && e.name.endsWith('.json')) locales.add(e.name.replace(/\.json$/, ''))
488
+ if (fs.existsSync(i18nApp))
489
+ for (const e of fs.readdirSync(i18nApp, { withFileTypes: true }))
490
+ if (e.isFile() && e.name.endsWith('.json')) locales.add(e.name.replace(/\.json$/, ''))
491
+ for (const locale of locales) {
492
+ const coreHas = fs.existsSync(path.join(i18nCore, `${locale}.json`))
493
+ const appHas = fs.existsSync(path.join(i18nApp, `${locale}.json`))
494
+ if (coreHas && appHas) {
495
+ const cName = `T_${toVar(modId)}_${toVar(locale)}_C`
496
+ const aName = `T_${toVar(modId)}_${toVar(locale)}_A`
497
+ imports.push(`import ${cName} from '${imps.pkgBase}/i18n/${locale}.json'`)
498
+ imports.push(`import ${aName} from '${appImportBase}/i18n/${locale}.json'`)
499
+ translations.push(
500
+ `'${locale}': { ...( ${cName} as unknown as Record<string,string> ), ...( ${aName} as unknown as Record<string,string> ) }`
501
+ )
502
+ } else if (appHas) {
503
+ const aName = `T_${toVar(modId)}_${toVar(locale)}_A`
504
+ imports.push(`import ${aName} from '${appImportBase}/i18n/${locale}.json'`)
505
+ translations.push(`'${locale}': ${aName} as unknown as Record<string,string>`)
506
+ } else if (coreHas) {
507
+ const cName = `T_${toVar(modId)}_${toVar(locale)}_C`
508
+ imports.push(`import ${cName} from '${imps.pkgBase}/i18n/${locale}.json'`)
509
+ translations.push(`'${locale}': ${cName} as unknown as Record<string,string>`)
510
+ }
511
+ }
512
+
513
+ // Subscribers: src/modules/<module>/subscribers/*.ts
514
+ const subApp = path.join(roots.appBase, 'subscribers')
515
+ const subPkg = path.join(roots.pkgBase, 'subscribers')
516
+ if (fs.existsSync(subApp) || fs.existsSync(subPkg)) {
517
+ const found: string[] = []
518
+ const walk = (dir: string, rel: string[] = []) => {
519
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
520
+ if (e.isDirectory()) {
521
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
522
+ walk(path.join(dir, e.name), [...rel, e.name])
523
+ } else if (e.isFile() && e.name.endsWith('.ts')) {
524
+ if (/\.(test|spec)\.ts$/.test(e.name)) continue
525
+ found.push([...rel, e.name].join('/'))
526
+ }
527
+ }
528
+ }
529
+ if (fs.existsSync(subPkg)) walk(subPkg)
530
+ if (fs.existsSync(subApp)) walk(subApp)
531
+ const files = Array.from(new Set(found))
532
+ for (const rel of files) {
533
+ const segs = rel.split('/')
534
+ const file = segs.pop()!
535
+ const name = file.replace(/\.ts$/, '')
536
+ const importName = `Subscriber${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
537
+ const metaName = `SubscriberMeta${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
538
+ const appFile = path.join(subApp, ...segs, `${name}.ts`)
539
+ const fromApp = fs.existsSync(appFile)
540
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/subscribers/${[...segs, name].join('/')}`
541
+ imports.push(`import ${importName}, * as ${metaName} from '${importPath}'`)
542
+ const sid = [modId, ...segs, name].filter(Boolean).join(':')
543
+ subscribers.push(
544
+ `{ id: (((${metaName}.metadata) as any)?.id || '${sid}'), event: ((${metaName}.metadata) as any)?.event, persistent: ((${metaName}.metadata) as any)?.persistent, handler: ${importName} }`
545
+ )
546
+ }
547
+ }
548
+
549
+ // Workers: src/modules/<module>/workers/*.ts
550
+ // Only includes files that export `metadata` with a `queue` property
551
+ {
552
+ const wrkApp = path.join(roots.appBase, 'workers')
553
+ const wrkPkg = path.join(roots.pkgBase, 'workers')
554
+ if (fs.existsSync(wrkApp) || fs.existsSync(wrkPkg)) {
555
+ const found: string[] = []
556
+ const walk = (dir: string, rel: string[] = []) => {
557
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
558
+ if (e.isDirectory()) {
559
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
560
+ walk(path.join(dir, e.name), [...rel, e.name])
561
+ } else if (e.isFile() && e.name.endsWith('.ts')) {
562
+ if (/\.(test|spec)\.ts$/.test(e.name)) continue
563
+ found.push([...rel, e.name].join('/'))
564
+ }
565
+ }
566
+ }
567
+ if (fs.existsSync(wrkPkg)) walk(wrkPkg)
568
+ if (fs.existsSync(wrkApp)) walk(wrkApp)
569
+ const files = Array.from(new Set(found))
570
+ for (const rel of files) {
571
+ const segs = rel.split('/')
572
+ const file = segs.pop()!
573
+ const name = file.replace(/\.ts$/, '')
574
+ const appFile = path.join(wrkApp, ...segs, `${name}.ts`)
575
+ const fromApp = fs.existsSync(appFile)
576
+ // Use package import path for checking exports (file path fails due to relative imports)
577
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/workers/${[...segs, name].join('/')}`
578
+ // Only include files that export metadata with a queue property
579
+ if (!(await moduleHasExport(importPath, 'metadata'))) continue
580
+ const importName = `Worker${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
581
+ const metaName = `WorkerMeta${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
582
+ imports.push(`import ${importName}, * as ${metaName} from '${importPath}'`)
583
+ const wid = [modId, 'workers', ...segs, name].filter(Boolean).join(':')
584
+ workers.push(
585
+ `{ id: (${metaName}.metadata as { id?: string })?.id || '${wid}', queue: (${metaName}.metadata as { queue: string }).queue, concurrency: (${metaName}.metadata as { concurrency?: number })?.concurrency ?? 1, handler: ${importName} as (job: unknown, ctx: unknown) => Promise<void> }`
586
+ )
587
+ }
588
+ }
589
+ }
590
+
591
+ // Build combined customFieldSets expression from data/fields.ts and ce.ts (entities[].fields)
592
+ {
593
+ const parts: string[] = []
594
+ if (fieldsImportName)
595
+ parts.push(`(( ${fieldsImportName}.default ?? ${fieldsImportName}.fieldSets) as any) || []`)
596
+ if (customEntitiesImportName)
597
+ parts.push(
598
+ `((( ${customEntitiesImportName}.default ?? ${customEntitiesImportName}.entities) as any) || []).filter((e: any) => Array.isArray(e.fields) && e.fields.length).map((e: any) => ({ entity: e.id, fields: e.fields, source: '${modId}' }))`
599
+ )
600
+ customFieldSetsExpr = parts.length ? `[...${parts.join(', ...')}]` : '[]'
601
+ }
602
+
603
+ // Dashboard widgets: src/modules/<module>/widgets/dashboard/**/widget.ts(x)
604
+ {
605
+ const widgetApp = path.join(roots.appBase, 'widgets', 'dashboard')
606
+ const widgetPkg = path.join(roots.pkgBase, 'widgets', 'dashboard')
607
+ if (fs.existsSync(widgetApp) || fs.existsSync(widgetPkg)) {
608
+ const found: string[] = []
609
+ const walk = (dir: string, rel: string[] = []) => {
610
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
611
+ if (e.isDirectory()) {
612
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
613
+ walk(path.join(dir, e.name), [...rel, e.name])
614
+ } else if (e.isFile() && /^widget\.(t|j)sx?$/.test(e.name)) {
615
+ found.push([...rel, e.name].join('/'))
616
+ }
617
+ }
618
+ }
619
+ if (fs.existsSync(widgetPkg)) walk(widgetPkg)
620
+ if (fs.existsSync(widgetApp)) walk(widgetApp)
621
+ const files = Array.from(new Set(found)).sort()
622
+ for (const rel of files) {
623
+ const appFile = path.join(widgetApp, ...rel.split('/'))
624
+ const fromApp = fs.existsSync(appFile)
625
+ const segs = rel.split('/')
626
+ const file = segs.pop()!
627
+ const base = file.replace(/\.(t|j)sx?$/, '')
628
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/widgets/dashboard/${[...segs, base].join('/')}`
629
+ const key = [modId, ...segs, base].filter(Boolean).join(':')
630
+ const source = fromApp ? 'app' : 'package'
631
+ dashboardWidgets.push(
632
+ `{ moduleId: '${modId}', key: '${key}', source: '${source}', loader: () => import('${importPath}').then((mod) => mod.default ?? mod) }`
633
+ )
634
+ const existing = allDashboardWidgets.get(key)
635
+ if (!existing || (existing.source !== 'app' && source === 'app')) {
636
+ allDashboardWidgets.set(key, { moduleId: modId, source, importPath })
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ // Injection widgets: src/modules/<module>/widgets/injection/**/widget.ts(x)
643
+ {
644
+ const widgetApp = path.join(roots.appBase, 'widgets', 'injection')
645
+ const widgetPkg = path.join(roots.pkgBase, 'widgets', 'injection')
646
+ if (fs.existsSync(widgetApp) || fs.existsSync(widgetPkg)) {
647
+ const found: string[] = []
648
+ const walk = (dir: string, rel: string[] = []) => {
649
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
650
+ if (e.isDirectory()) {
651
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
652
+ walk(path.join(dir, e.name), [...rel, e.name])
653
+ } else if (e.isFile() && /^widget\.(t|j)sx?$/.test(e.name)) {
654
+ found.push([...rel, e.name].join('/'))
655
+ }
656
+ }
657
+ }
658
+ if (fs.existsSync(widgetPkg)) walk(widgetPkg)
659
+ if (fs.existsSync(widgetApp)) walk(widgetApp)
660
+ const files = Array.from(new Set(found)).sort()
661
+ for (const rel of files) {
662
+ const appFile = path.join(widgetApp, ...rel.split('/'))
663
+ const fromApp = fs.existsSync(appFile)
664
+ const segs = rel.split('/')
665
+ const file = segs.pop()!
666
+ const base = file.replace(/\.(t|j)sx?$/, '')
667
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/widgets/injection/${[...segs, base].join('/')}`
668
+ const key = [modId, ...segs, base].filter(Boolean).join(':')
669
+ const source = fromApp ? 'app' : 'package'
670
+ injectionWidgets.push(
671
+ `{ moduleId: '${modId}', key: '${key}', source: '${source}', loader: () => import('${importPath}').then((mod) => mod.default ?? mod) }`
672
+ )
673
+ const existing = allInjectionWidgets.get(key)
674
+ if (!existing || (existing.source !== 'app' && source === 'app')) {
675
+ allInjectionWidgets.set(key, { moduleId: modId, source, importPath })
676
+ }
677
+ }
678
+ }
679
+ }
680
+
681
+ // Injection table: src/modules/<module>/widgets/injection-table.ts
682
+ {
683
+ const appFile = path.join(roots.appBase, 'widgets', 'injection-table.ts')
684
+ const pkgFile = path.join(roots.pkgBase, 'widgets', 'injection-table.ts')
685
+ const hasApp = fs.existsSync(appFile)
686
+ const hasPkg = fs.existsSync(pkgFile)
687
+ if (hasApp || hasPkg) {
688
+ const importName = `InjTable_${toVar(modId)}_${importId++}`
689
+ const importPath = hasApp ? `${appImportBase}/widgets/injection-table` : `${imps.pkgBase}/widgets/injection-table`
690
+ imports.push(`import * as ${importName} from '${importPath}'`)
691
+ injectionTableImportName = importName
692
+ allInjectionTables.push({ moduleId: modId, importPath, importName })
693
+ }
694
+ }
695
+
696
+ if (searchImportName) {
697
+ searchConfigs.push(`{ moduleId: '${modId}', config: (${searchImportName}.default ?? ${searchImportName}.searchConfig ?? ${searchImportName}.config ?? null) }`)
698
+ }
699
+
700
+ moduleDecls.push(`{
701
+ id: '${modId}',
702
+ ${infoImportName ? `info: ${infoImportName}.metadata,` : ''}
703
+ ${frontendRoutes.length ? `frontendRoutes: [${frontendRoutes.join(', ')}],` : ''}
704
+ ${backendRoutes.length ? `backendRoutes: [${backendRoutes.join(', ')}],` : ''}
705
+ ${apis.length ? `apis: [${apis.join(', ')}],` : ''}
706
+ ${cliImportName ? `cli: ${cliImportName},` : ''}
707
+ ${translations.length ? `translations: { ${translations.join(', ')} },` : ''}
708
+ ${subscribers.length ? `subscribers: [${subscribers.join(', ')}],` : ''}
709
+ ${workers.length ? `workers: [${workers.join(', ')}],` : ''}
710
+ ${extensionsImportName ? `entityExtensions: ((${extensionsImportName}.default ?? ${extensionsImportName}.extensions) as import('@open-mercato/shared/modules/entities').EntityExtension[]) || [],` : ''}
711
+ customFieldSets: ${customFieldSetsExpr},
712
+ ${featuresImportName ? `features: ((${featuresImportName}.default ?? ${featuresImportName}.features) as any) || [],` : ''}
713
+ ${customEntitiesImportName ? `customEntities: ((${customEntitiesImportName}.default ?? ${customEntitiesImportName}.entities) as any) || [],` : ''}
714
+ ${dashboardWidgets.length ? `dashboardWidgets: [${dashboardWidgets.join(', ')}],` : ''}
715
+ }`)
716
+ }
717
+
718
+ const output = `// AUTO-GENERATED by mercato generate registry
719
+ import type { Module } from '@open-mercato/shared/modules/registry'
720
+ ${imports.join('\n')}
721
+
722
+ export const modules: Module[] = [
723
+ ${moduleDecls.join(',\n ')}
724
+ ]
725
+ export const modulesInfo = modules.map(m => ({ id: m.id, ...(m.info || {}) }))
726
+ export default modules
727
+ `
728
+ const widgetEntriesList = Array.from(allDashboardWidgets.entries()).sort(([a], [b]) => a.localeCompare(b))
729
+ const widgetDecls = widgetEntriesList.map(
730
+ ([key, data]) =>
731
+ ` { moduleId: '${data.moduleId}', key: '${key}', source: '${data.source}', loader: () => import('${data.importPath}').then((mod) => mod.default ?? mod) }`
732
+ )
733
+ const widgetsOutput = `// AUTO-GENERATED by mercato generate registry
734
+ import type { ModuleDashboardWidgetEntry } from '@open-mercato/shared/modules/registry'
735
+
736
+ export const dashboardWidgetEntries: ModuleDashboardWidgetEntry[] = [
737
+ ${widgetDecls.join(',\n')}
738
+ ]
739
+ `
740
+ const searchEntriesLiteral = searchConfigs.join(',\n ')
741
+ const searchImportSection = searchImports.join('\n')
742
+ const searchOutput = `// AUTO-GENERATED by mercato generate registry
743
+ import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
744
+ ${searchImportSection ? `\n${searchImportSection}\n` : '\n'}type SearchConfigEntry = { moduleId: string; config: SearchModuleConfig | null }
745
+
746
+ const entriesRaw: SearchConfigEntry[] = [
747
+ ${searchEntriesLiteral ? ` ${searchEntriesLiteral}\n` : ''}]
748
+ const entries = entriesRaw.filter((entry): entry is { moduleId: string; config: SearchModuleConfig } => entry.config != null)
749
+
750
+ export const searchModuleConfigEntries = entries
751
+ export const searchModuleConfigs: SearchModuleConfig[] = entries.map((entry) => entry.config)
752
+ `
753
+
754
+ // Validate module dependencies declared via ModuleInfo.requires
755
+ {
756
+ const enabledIds = new Set(enabled.map((e) => e.id))
757
+ const problems: string[] = []
758
+ for (const [modId, reqs] of requiresByModule.entries()) {
759
+ const missing = reqs.filter((r) => !enabledIds.has(r))
760
+ if (missing.length) {
761
+ problems.push(`- Module "${modId}" requires: ${missing.join(', ')}`)
762
+ }
763
+ }
764
+ if (problems.length) {
765
+ console.error('\nModule dependency check failed:')
766
+ for (const p of problems) console.error(p)
767
+ console.error('\nFix: Enable required module(s) in src/modules.ts. Example:')
768
+ console.error(
769
+ ' export const enabledModules = [ { id: \'' +
770
+ Array.from(new Set(requiresByModule.values()).values()).join("' }, { id: '") +
771
+ "' } ]"
772
+ )
773
+ process.exit(1)
774
+ }
775
+ }
776
+
777
+ const structureChecksum = calculateStructureChecksum(Array.from(trackedRoots))
778
+
779
+ const modulesChecksum = { content: calculateChecksum(output), structure: structureChecksum }
780
+ const existingModulesChecksum = readChecksumRecord(checksumFile)
781
+ const shouldWriteModules =
782
+ !existingModulesChecksum ||
783
+ existingModulesChecksum.content !== modulesChecksum.content ||
784
+ existingModulesChecksum.structure !== modulesChecksum.structure
785
+ if (shouldWriteModules) {
786
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
787
+ fs.writeFileSync(outFile, output)
788
+ writeChecksumRecord(checksumFile, modulesChecksum)
789
+ result.filesWritten.push(outFile)
790
+ } else {
791
+ result.filesUnchanged.push(outFile)
792
+ }
793
+ if (!quiet) logGenerationResult(path.relative(process.cwd(), outFile), shouldWriteModules)
794
+
795
+ const widgetsChecksum = { content: calculateChecksum(widgetsOutput), structure: structureChecksum }
796
+ const existingWidgetsChecksum = readChecksumRecord(widgetsChecksumFile)
797
+ const shouldWriteWidgets =
798
+ !existingWidgetsChecksum ||
799
+ existingWidgetsChecksum.content !== widgetsChecksum.content ||
800
+ existingWidgetsChecksum.structure !== widgetsChecksum.structure
801
+ if (shouldWriteWidgets) {
802
+ fs.writeFileSync(widgetsOutFile, widgetsOutput)
803
+ writeChecksumRecord(widgetsChecksumFile, widgetsChecksum)
804
+ result.filesWritten.push(widgetsOutFile)
805
+ } else {
806
+ result.filesUnchanged.push(widgetsOutFile)
807
+ }
808
+ if (!quiet) logGenerationResult(path.relative(process.cwd(), widgetsOutFile), shouldWriteWidgets)
809
+
810
+ const injectionWidgetEntriesList = Array.from(allInjectionWidgets.entries()).sort(([a], [b]) => a.localeCompare(b))
811
+ const injectionWidgetDecls = injectionWidgetEntriesList.map(
812
+ ([key, data]) =>
813
+ ` { moduleId: '${data.moduleId}', key: '${key}', source: '${data.source}', loader: () => import('${data.importPath}').then((mod) => mod.default ?? mod) }`
814
+ )
815
+ const injectionWidgetsOutput = `// AUTO-GENERATED by mercato generate registry
816
+ import type { ModuleInjectionWidgetEntry } from '@open-mercato/shared/modules/registry'
817
+
818
+ export const injectionWidgetEntries: ModuleInjectionWidgetEntry[] = [
819
+ ${injectionWidgetDecls.join(',\n')}
820
+ ]
821
+ `
822
+ const injectionTableImports = allInjectionTables.map(
823
+ (entry) => `import * as ${entry.importName} from '${entry.importPath}'`
824
+ )
825
+ const injectionTableDecls = allInjectionTables.map(
826
+ (entry) =>
827
+ ` { moduleId: '${entry.moduleId}', table: ((${entry.importName}.default ?? ${entry.importName}.injectionTable) as any) || {} }`
828
+ )
829
+ const injectionTablesOutput = `// AUTO-GENERATED by mercato generate registry
830
+ import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'
831
+ ${injectionTableImports.join('\n')}
832
+
833
+ export const injectionTables: Array<{ moduleId: string; table: ModuleInjectionTable }> = [
834
+ ${injectionTableDecls.join(',\n')}
835
+ ]
836
+ `
837
+ const injectionWidgetsChecksum = { content: calculateChecksum(injectionWidgetsOutput), structure: structureChecksum }
838
+ const existingInjectionWidgetsChecksum = readChecksumRecord(injectionWidgetsChecksumFile)
839
+ const shouldWriteInjectionWidgets =
840
+ !existingInjectionWidgetsChecksum ||
841
+ existingInjectionWidgetsChecksum.content !== injectionWidgetsChecksum.content ||
842
+ existingInjectionWidgetsChecksum.structure !== injectionWidgetsChecksum.structure
843
+ if (shouldWriteInjectionWidgets) {
844
+ fs.writeFileSync(injectionWidgetsOutFile, injectionWidgetsOutput)
845
+ writeChecksumRecord(injectionWidgetsChecksumFile, injectionWidgetsChecksum)
846
+ result.filesWritten.push(injectionWidgetsOutFile)
847
+ } else {
848
+ result.filesUnchanged.push(injectionWidgetsOutFile)
849
+ }
850
+ if (!quiet) logGenerationResult(path.relative(process.cwd(), injectionWidgetsOutFile), shouldWriteInjectionWidgets)
851
+
852
+ const injectionTablesChecksum = { content: calculateChecksum(injectionTablesOutput), structure: structureChecksum }
853
+ const existingInjectionTablesChecksum = readChecksumRecord(injectionTablesChecksumFile)
854
+ const shouldWriteInjectionTables =
855
+ !existingInjectionTablesChecksum ||
856
+ existingInjectionTablesChecksum.content !== injectionTablesChecksum.content ||
857
+ existingInjectionTablesChecksum.structure !== injectionTablesChecksum.structure
858
+ if (shouldWriteInjectionTables) {
859
+ fs.writeFileSync(injectionTablesOutFile, injectionTablesOutput)
860
+ writeChecksumRecord(injectionTablesChecksumFile, injectionTablesChecksum)
861
+ result.filesWritten.push(injectionTablesOutFile)
862
+ } else {
863
+ result.filesUnchanged.push(injectionTablesOutFile)
864
+ }
865
+ if (!quiet) logGenerationResult(path.relative(process.cwd(), injectionTablesOutFile), shouldWriteInjectionTables)
866
+
867
+ const searchChecksum = { content: calculateChecksum(searchOutput), structure: structureChecksum }
868
+ const existingSearchChecksum = readChecksumRecord(searchChecksumFile)
869
+ const shouldWriteSearch =
870
+ !existingSearchChecksum ||
871
+ existingSearchChecksum.content !== searchChecksum.content ||
872
+ existingSearchChecksum.structure !== searchChecksum.structure
873
+ if (shouldWriteSearch) {
874
+ fs.writeFileSync(searchOutFile, searchOutput)
875
+ writeChecksumRecord(searchChecksumFile, searchChecksum)
876
+ result.filesWritten.push(searchOutFile)
877
+ } else {
878
+ result.filesUnchanged.push(searchOutFile)
879
+ }
880
+ if (!quiet) logGenerationResult(path.relative(process.cwd(), searchOutFile), shouldWriteSearch)
881
+
882
+ return result
883
+ }
884
+
885
+ /**
886
+ * Generate a CLI-specific module registry that excludes Next.js dependent code.
887
+ * This produces modules.cli.generated.ts which can be loaded without Next.js runtime.
888
+ *
889
+ * Includes: module metadata, CLI commands, translations, subscribers, workers, entity extensions,
890
+ * features/ACL, custom entities, vector config, custom fields
891
+ * Excludes: frontend routes, backend routes, API handlers, dashboard/injection widgets
892
+ */
893
+ export async function generateModuleRegistryCli(options: ModuleRegistryOptions): Promise<GeneratorResult> {
894
+ const { resolver, quiet = false } = options
895
+ const result = createGeneratorResult()
896
+
897
+ const outputDir = resolver.getOutputDir()
898
+ const outFile = path.join(outputDir, 'modules.cli.generated.ts')
899
+ const checksumFile = path.join(outputDir, 'modules.cli.generated.checksum')
900
+
901
+ const enabled = resolver.loadEnabledModules()
902
+ const imports: string[] = []
903
+ const moduleDecls: string[] = []
904
+ let importId = 0
905
+ const trackedRoots = new Set<string>()
906
+ const requiresByModule = new Map<string, string[]>()
907
+
908
+ for (const entry of enabled) {
909
+ const modId = entry.id
910
+ const roots = resolver.getModulePaths(entry)
911
+ const imps = resolver.getModuleImportBase(entry)
912
+ trackedRoots.add(roots.appBase)
913
+ trackedRoots.add(roots.pkgBase)
914
+
915
+ // For @app modules, use relative paths since @/ alias doesn't work in Node.js runtime
916
+ // From .mercato/generated/, go up two levels (../..) to reach the app root, then into src/modules/
917
+ const isAppModule = entry.from === '@app'
918
+ const appImportBase = isAppModule ? `../../src/modules/${modId}` : imps.appBase
919
+
920
+ let cliImportName: string | null = null
921
+ const translations: string[] = []
922
+ const subscribers: string[] = []
923
+ const workers: string[] = []
924
+ let infoImportName: string | null = null
925
+ let extensionsImportName: string | null = null
926
+ let fieldsImportName: string | null = null
927
+ let featuresImportName: string | null = null
928
+ let customEntitiesImportName: string | null = null
929
+ let vectorImportName: string | null = null
930
+ let customFieldSetsExpr: string = '[]'
931
+
932
+ // Module metadata: index.ts (overrideable)
933
+ const appIndex = path.join(roots.appBase, 'index.ts')
934
+ const pkgIndex = path.join(roots.pkgBase, 'index.ts')
935
+ const indexTs = fs.existsSync(appIndex) ? appIndex : fs.existsSync(pkgIndex) ? pkgIndex : null
936
+ if (indexTs) {
937
+ infoImportName = `I${importId++}_${toVar(modId)}`
938
+ const importPath = indexTs.startsWith(roots.appBase) ? `${appImportBase}/index` : `${imps.pkgBase}/index`
939
+ imports.push(`import * as ${infoImportName} from '${importPath}'`)
940
+ // Try to eagerly read ModuleInfo.requires for dependency validation
941
+ try {
942
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
943
+ const mod = require(indexTs)
944
+ const reqs: string[] | undefined =
945
+ mod?.metadata && Array.isArray(mod.metadata.requires) ? mod.metadata.requires : undefined
946
+ if (reqs && reqs.length) requiresByModule.set(modId, reqs)
947
+ } catch {}
948
+ }
949
+
950
+ // Entity extensions: src/modules/<module>/data/extensions.ts
951
+ {
952
+ const appFile = path.join(roots.appBase, 'data', 'extensions.ts')
953
+ const pkgFile = path.join(roots.pkgBase, 'data', 'extensions.ts')
954
+ const hasApp = fs.existsSync(appFile)
955
+ const hasPkg = fs.existsSync(pkgFile)
956
+ if (hasApp || hasPkg) {
957
+ const importName = `X_${toVar(modId)}_${importId++}`
958
+ const importPath = hasApp ? `${appImportBase}/data/extensions` : `${imps.pkgBase}/data/extensions`
959
+ imports.push(`import * as ${importName} from '${importPath}'`)
960
+ extensionsImportName = importName
961
+ }
962
+ }
963
+
964
+ // RBAC feature declarations: module root acl.ts
965
+ {
966
+ const rootApp = path.join(roots.appBase, 'acl.ts')
967
+ const rootPkg = path.join(roots.pkgBase, 'acl.ts')
968
+ const hasRoot = fs.existsSync(rootApp) || fs.existsSync(rootPkg)
969
+ if (hasRoot) {
970
+ const importName = `ACL_${toVar(modId)}_${importId++}`
971
+ const useApp = fs.existsSync(rootApp) ? rootApp : rootPkg
972
+ const importPath = useApp.startsWith(roots.appBase) ? `${appImportBase}/acl` : `${imps.pkgBase}/acl`
973
+ imports.push(`import * as ${importName} from '${importPath}'`)
974
+ featuresImportName = importName
975
+ }
976
+ }
977
+
978
+ // Custom entities declarations: module root ce.ts
979
+ {
980
+ const appFile = path.join(roots.appBase, 'ce.ts')
981
+ const pkgFile = path.join(roots.pkgBase, 'ce.ts')
982
+ const hasApp = fs.existsSync(appFile)
983
+ const hasPkg = fs.existsSync(pkgFile)
984
+ if (hasApp || hasPkg) {
985
+ const importName = `CE_${toVar(modId)}_${importId++}`
986
+ const importPath = hasApp ? `${appImportBase}/ce` : `${imps.pkgBase}/ce`
987
+ imports.push(`import * as ${importName} from '${importPath}'`)
988
+ customEntitiesImportName = importName
989
+ }
990
+ }
991
+
992
+ // Vector search configuration: module root vector.ts
993
+ {
994
+ const appFile = path.join(roots.appBase, 'vector.ts')
995
+ const pkgFile = path.join(roots.pkgBase, 'vector.ts')
996
+ const hasApp = fs.existsSync(appFile)
997
+ const hasPkg = fs.existsSync(pkgFile)
998
+ if (hasApp || hasPkg) {
999
+ const importName = `VECTOR_${toVar(modId)}_${importId++}`
1000
+ const importPath = hasApp ? `${appImportBase}/vector` : `${imps.pkgBase}/vector`
1001
+ imports.push(`import * as ${importName} from '${importPath}'`)
1002
+ vectorImportName = importName
1003
+ }
1004
+ }
1005
+
1006
+ // Custom field declarations: src/modules/<module>/data/fields.ts
1007
+ {
1008
+ const appFile = path.join(roots.appBase, 'data', 'fields.ts')
1009
+ const pkgFile = path.join(roots.pkgBase, 'data', 'fields.ts')
1010
+ const hasApp = fs.existsSync(appFile)
1011
+ const hasPkg = fs.existsSync(pkgFile)
1012
+ if (hasApp || hasPkg) {
1013
+ const importName = `F_${toVar(modId)}_${importId++}`
1014
+ const importPath = hasApp ? `${appImportBase}/data/fields` : `${imps.pkgBase}/data/fields`
1015
+ imports.push(`import * as ${importName} from '${importPath}'`)
1016
+ fieldsImportName = importName
1017
+ }
1018
+ }
1019
+
1020
+ // CLI
1021
+ const cliApp = path.join(roots.appBase, 'cli.ts')
1022
+ const cliPkg = path.join(roots.pkgBase, 'cli.ts')
1023
+ const cliPath = fs.existsSync(cliApp) ? cliApp : fs.existsSync(cliPkg) ? cliPkg : null
1024
+ if (cliPath) {
1025
+ const importName = `CLI_${toVar(modId)}`
1026
+ const importPath = cliPath.startsWith(roots.appBase) ? `${appImportBase}/cli` : `${imps.pkgBase}/cli`
1027
+ imports.push(`import ${importName} from '${importPath}'`)
1028
+ cliImportName = importName
1029
+ }
1030
+
1031
+ // Translations: merge core + app with app overriding
1032
+ const i18nApp = path.join(roots.appBase, 'i18n')
1033
+ const i18nCore = path.join(roots.pkgBase, 'i18n')
1034
+ const locales = new Set<string>()
1035
+ if (fs.existsSync(i18nCore))
1036
+ for (const e of fs.readdirSync(i18nCore, { withFileTypes: true }))
1037
+ if (e.isFile() && e.name.endsWith('.json')) locales.add(e.name.replace(/\.json$/, ''))
1038
+ if (fs.existsSync(i18nApp))
1039
+ for (const e of fs.readdirSync(i18nApp, { withFileTypes: true }))
1040
+ if (e.isFile() && e.name.endsWith('.json')) locales.add(e.name.replace(/\.json$/, ''))
1041
+ for (const locale of locales) {
1042
+ const coreHas = fs.existsSync(path.join(i18nCore, `${locale}.json`))
1043
+ const appHas = fs.existsSync(path.join(i18nApp, `${locale}.json`))
1044
+ if (coreHas && appHas) {
1045
+ const cName = `T_${toVar(modId)}_${toVar(locale)}_C`
1046
+ const aName = `T_${toVar(modId)}_${toVar(locale)}_A`
1047
+ imports.push(`import ${cName} from '${imps.pkgBase}/i18n/${locale}.json'`)
1048
+ imports.push(`import ${aName} from '${appImportBase}/i18n/${locale}.json'`)
1049
+ translations.push(
1050
+ `'${locale}': { ...( ${cName} as unknown as Record<string,string> ), ...( ${aName} as unknown as Record<string,string> ) }`
1051
+ )
1052
+ } else if (appHas) {
1053
+ const aName = `T_${toVar(modId)}_${toVar(locale)}_A`
1054
+ imports.push(`import ${aName} from '${appImportBase}/i18n/${locale}.json'`)
1055
+ translations.push(`'${locale}': ${aName} as unknown as Record<string,string>`)
1056
+ } else if (coreHas) {
1057
+ const cName = `T_${toVar(modId)}_${toVar(locale)}_C`
1058
+ imports.push(`import ${cName} from '${imps.pkgBase}/i18n/${locale}.json'`)
1059
+ translations.push(`'${locale}': ${cName} as unknown as Record<string,string>`)
1060
+ }
1061
+ }
1062
+
1063
+ // Subscribers: src/modules/<module>/subscribers/*.ts
1064
+ const subApp = path.join(roots.appBase, 'subscribers')
1065
+ const subPkg = path.join(roots.pkgBase, 'subscribers')
1066
+ if (fs.existsSync(subApp) || fs.existsSync(subPkg)) {
1067
+ const found: string[] = []
1068
+ const walk = (dir: string, rel: string[] = []) => {
1069
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1070
+ if (e.isDirectory()) {
1071
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
1072
+ walk(path.join(dir, e.name), [...rel, e.name])
1073
+ } else if (e.isFile() && e.name.endsWith('.ts')) {
1074
+ if (/\.(test|spec)\.ts$/.test(e.name)) continue
1075
+ found.push([...rel, e.name].join('/'))
1076
+ }
1077
+ }
1078
+ }
1079
+ if (fs.existsSync(subPkg)) walk(subPkg)
1080
+ if (fs.existsSync(subApp)) walk(subApp)
1081
+ const files = Array.from(new Set(found))
1082
+ for (const rel of files) {
1083
+ const segs = rel.split('/')
1084
+ const file = segs.pop()!
1085
+ const name = file.replace(/\.ts$/, '')
1086
+ const importName = `Subscriber${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
1087
+ const metaName = `SubscriberMeta${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
1088
+ const appFile = path.join(subApp, ...segs, `${name}.ts`)
1089
+ const fromApp = fs.existsSync(appFile)
1090
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/subscribers/${[...segs, name].join('/')}`
1091
+ imports.push(`import ${importName}, * as ${metaName} from '${importPath}'`)
1092
+ const sid = [modId, ...segs, name].filter(Boolean).join(':')
1093
+ subscribers.push(
1094
+ `{ id: (((${metaName}.metadata) as any)?.id || '${sid}'), event: ((${metaName}.metadata) as any)?.event, persistent: ((${metaName}.metadata) as any)?.persistent, handler: ${importName} }`
1095
+ )
1096
+ }
1097
+ }
1098
+
1099
+ // Workers: src/modules/<module>/workers/*.ts
1100
+ // Only includes files that export `metadata` with a `queue` property
1101
+ {
1102
+ const wrkApp = path.join(roots.appBase, 'workers')
1103
+ const wrkPkg = path.join(roots.pkgBase, 'workers')
1104
+ if (fs.existsSync(wrkApp) || fs.existsSync(wrkPkg)) {
1105
+ const found: string[] = []
1106
+ const walk = (dir: string, rel: string[] = []) => {
1107
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1108
+ if (e.isDirectory()) {
1109
+ if (e.name === '__tests__' || e.name === '__mocks__') continue
1110
+ walk(path.join(dir, e.name), [...rel, e.name])
1111
+ } else if (e.isFile() && e.name.endsWith('.ts')) {
1112
+ if (/\.(test|spec)\.ts$/.test(e.name)) continue
1113
+ found.push([...rel, e.name].join('/'))
1114
+ }
1115
+ }
1116
+ }
1117
+ if (fs.existsSync(wrkPkg)) walk(wrkPkg)
1118
+ if (fs.existsSync(wrkApp)) walk(wrkApp)
1119
+ const files = Array.from(new Set(found))
1120
+ for (const rel of files) {
1121
+ const segs = rel.split('/')
1122
+ const file = segs.pop()!
1123
+ const name = file.replace(/\.ts$/, '')
1124
+ const appFile = path.join(wrkApp, ...segs, `${name}.ts`)
1125
+ const fromApp = fs.existsSync(appFile)
1126
+ // Use package import path for checking exports (file path fails due to relative imports)
1127
+ const importPath = `${fromApp ? appImportBase : imps.pkgBase}/workers/${[...segs, name].join('/')}`
1128
+ // Only include files that export metadata with a queue property
1129
+ if (!(await moduleHasExport(importPath, 'metadata'))) continue
1130
+ const importName = `Worker${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
1131
+ const metaName = `WorkerMeta${importId++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}`
1132
+ imports.push(`import ${importName}, * as ${metaName} from '${importPath}'`)
1133
+ const wid = [modId, 'workers', ...segs, name].filter(Boolean).join(':')
1134
+ workers.push(
1135
+ `{ id: (${metaName}.metadata as { id?: string })?.id || '${wid}', queue: (${metaName}.metadata as { queue: string }).queue, concurrency: (${metaName}.metadata as { concurrency?: number })?.concurrency ?? 1, handler: ${importName} as (job: unknown, ctx: unknown) => Promise<void> }`
1136
+ )
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ // Build combined customFieldSets expression from data/fields.ts and ce.ts (entities[].fields)
1142
+ {
1143
+ const parts: string[] = []
1144
+ if (fieldsImportName)
1145
+ parts.push(`(( ${fieldsImportName}.default ?? ${fieldsImportName}.fieldSets) as any) || []`)
1146
+ if (customEntitiesImportName)
1147
+ parts.push(
1148
+ `((( ${customEntitiesImportName}.default ?? ${customEntitiesImportName}.entities) as any) || []).filter((e: any) => Array.isArray(e.fields) && e.fields.length).map((e: any) => ({ entity: e.id, fields: e.fields, source: '${modId}' }))`
1149
+ )
1150
+ customFieldSetsExpr = parts.length ? `[...${parts.join(', ...')}]` : '[]'
1151
+ }
1152
+
1153
+ moduleDecls.push(`{
1154
+ id: '${modId}',
1155
+ ${infoImportName ? `info: ${infoImportName}.metadata,` : ''}
1156
+ ${cliImportName ? `cli: ${cliImportName},` : ''}
1157
+ ${translations.length ? `translations: { ${translations.join(', ')} },` : ''}
1158
+ ${subscribers.length ? `subscribers: [${subscribers.join(', ')}],` : ''}
1159
+ ${workers.length ? `workers: [${workers.join(', ')}],` : ''}
1160
+ ${extensionsImportName ? `entityExtensions: ((${extensionsImportName}.default ?? ${extensionsImportName}.extensions) as any) || [],` : ''}
1161
+ customFieldSets: ${customFieldSetsExpr},
1162
+ ${featuresImportName ? `features: ((${featuresImportName}.default ?? ${featuresImportName}.features) as any) || [],` : ''}
1163
+ ${customEntitiesImportName ? `customEntities: ((${customEntitiesImportName}.default ?? ${customEntitiesImportName}.entities) as any) || [],` : ''}
1164
+ ${vectorImportName ? `vector: (${vectorImportName}.default ?? ${vectorImportName}.vectorConfig ?? ${vectorImportName}.config ?? undefined),` : ''}
1165
+ }`)
1166
+ }
1167
+
1168
+ const output = `// AUTO-GENERATED by mercato generate registry (CLI version)
1169
+ // This file excludes Next.js dependent code (routes, APIs, widgets)
1170
+ import type { Module } from '@open-mercato/shared/modules/registry'
1171
+ ${imports.join('\n')}
1172
+
1173
+ export const modules: Module[] = [
1174
+ ${moduleDecls.join(',\n ')}
1175
+ ]
1176
+ export const modulesInfo = modules.map(m => ({ id: m.id, ...(m.info || {}) }))
1177
+ export default modules
1178
+ `
1179
+
1180
+ // Validate module dependencies declared via ModuleInfo.requires
1181
+ {
1182
+ const enabledIds = new Set(enabled.map((e) => e.id))
1183
+ const problems: string[] = []
1184
+ for (const [modId, reqs] of requiresByModule.entries()) {
1185
+ const missing = reqs.filter((r) => !enabledIds.has(r))
1186
+ if (missing.length) {
1187
+ problems.push(`- Module "${modId}" requires: ${missing.join(', ')}`)
1188
+ }
1189
+ }
1190
+ if (problems.length) {
1191
+ console.error('\nModule dependency check failed:')
1192
+ for (const p of problems) console.error(p)
1193
+ console.error('\nFix: Enable required module(s) in src/modules.ts. Example:')
1194
+ console.error(
1195
+ ' export const enabledModules = [ { id: \'' +
1196
+ Array.from(new Set(requiresByModule.values()).values()).join("' }, { id: '") +
1197
+ "' } ]"
1198
+ )
1199
+ process.exit(1)
1200
+ }
1201
+ }
1202
+
1203
+ const structureChecksum = calculateStructureChecksum(Array.from(trackedRoots))
1204
+
1205
+ const checksum = { content: calculateChecksum(output), structure: structureChecksum }
1206
+ const existingChecksum = readChecksumRecord(checksumFile)
1207
+ const shouldWrite =
1208
+ !existingChecksum ||
1209
+ existingChecksum.content !== checksum.content ||
1210
+ existingChecksum.structure !== checksum.structure
1211
+ if (shouldWrite) {
1212
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
1213
+ fs.writeFileSync(outFile, output)
1214
+ writeChecksumRecord(checksumFile, checksum)
1215
+ result.filesWritten.push(outFile)
1216
+ } else {
1217
+ result.filesUnchanged.push(outFile)
1218
+ }
1219
+ if (!quiet) logGenerationResult(path.relative(process.cwd(), outFile), shouldWrite)
1220
+
1221
+ return result
1222
+ }