@open-mercato/cli 0.6.7-develop.6553.1.2ec7246e04 → 0.6.7-develop.6566.1.15385fa179

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 (109) hide show
  1. package/.turbo/turbo-build.log +3 -3
  2. package/AGENTS.md +1 -0
  3. package/dist/agentic/guides/module-facts.json +53 -53
  4. package/dist/agentic/guides/modules/ai_assistant.md +1 -1
  5. package/dist/agentic/guides/modules/api_docs.md +1 -1
  6. package/dist/agentic/guides/modules/api_keys.md +1 -1
  7. package/dist/agentic/guides/modules/attachments.md +1 -1
  8. package/dist/agentic/guides/modules/audit_logs.md +1 -1
  9. package/dist/agentic/guides/modules/auth.md +1 -1
  10. package/dist/agentic/guides/modules/business_rules.md +1 -1
  11. package/dist/agentic/guides/modules/catalog.md +1 -1
  12. package/dist/agentic/guides/modules/channel_gmail.md +1 -1
  13. package/dist/agentic/guides/modules/channel_imap.md +1 -1
  14. package/dist/agentic/guides/modules/checkout.md +1 -1
  15. package/dist/agentic/guides/modules/communication_channels.md +1 -1
  16. package/dist/agentic/guides/modules/configs.md +1 -1
  17. package/dist/agentic/guides/modules/content.md +1 -1
  18. package/dist/agentic/guides/modules/currencies.md +1 -1
  19. package/dist/agentic/guides/modules/customer_accounts.md +1 -1
  20. package/dist/agentic/guides/modules/customers.md +1 -1
  21. package/dist/agentic/guides/modules/dashboards.md +1 -1
  22. package/dist/agentic/guides/modules/data_sync.md +1 -1
  23. package/dist/agentic/guides/modules/dictionaries.md +1 -1
  24. package/dist/agentic/guides/modules/directory.md +1 -1
  25. package/dist/agentic/guides/modules/entities.md +1 -1
  26. package/dist/agentic/guides/modules/events.md +1 -1
  27. package/dist/agentic/guides/modules/feature_toggles.md +1 -1
  28. package/dist/agentic/guides/modules/gateway_stripe.md +1 -1
  29. package/dist/agentic/guides/modules/generators.md +1 -1
  30. package/dist/agentic/guides/modules/inbox_ops.md +1 -1
  31. package/dist/agentic/guides/modules/integrations.md +1 -1
  32. package/dist/agentic/guides/modules/messages.md +1 -1
  33. package/dist/agentic/guides/modules/notifications.md +1 -1
  34. package/dist/agentic/guides/modules/onboarding.md +1 -1
  35. package/dist/agentic/guides/modules/payment_gateways.md +1 -1
  36. package/dist/agentic/guides/modules/perspectives.md +1 -1
  37. package/dist/agentic/guides/modules/planner.md +1 -1
  38. package/dist/agentic/guides/modules/portal.md +1 -1
  39. package/dist/agentic/guides/modules/progress.md +1 -1
  40. package/dist/agentic/guides/modules/query_index.md +1 -1
  41. package/dist/agentic/guides/modules/record_locks.md +1 -1
  42. package/dist/agentic/guides/modules/resources.md +1 -1
  43. package/dist/agentic/guides/modules/sales.md +1 -1
  44. package/dist/agentic/guides/modules/scheduler.md +1 -1
  45. package/dist/agentic/guides/modules/search.md +1 -1
  46. package/dist/agentic/guides/modules/security.md +1 -1
  47. package/dist/agentic/guides/modules/shipping_carriers.md +1 -1
  48. package/dist/agentic/guides/modules/sso.md +1 -1
  49. package/dist/agentic/guides/modules/staff.md +1 -1
  50. package/dist/agentic/guides/modules/storage_s3.md +1 -1
  51. package/dist/agentic/guides/modules/sync_akeneo.md +1 -1
  52. package/dist/agentic/guides/modules/sync_excel.md +1 -1
  53. package/dist/agentic/guides/modules/system_status_overlays.md +1 -1
  54. package/dist/agentic/guides/modules/translations.md +1 -1
  55. package/dist/agentic/guides/modules/webhooks.md +1 -1
  56. package/dist/agentic/guides/modules/workflows.md +1 -1
  57. package/dist/agentic/shared/AGENTS.md.template +1 -1
  58. package/dist/agentic/shared/ai/skills/tiers.schema.json +15 -0
  59. package/dist/agentic/shared/scripts/install-skills.sh +244 -37
  60. package/dist/lib/__integration__/TC-INT-008.spec.js +9 -11
  61. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  62. package/dist/lib/agentic-setup.js +14 -2
  63. package/dist/lib/agentic-setup.js.map +2 -2
  64. package/dist/lib/db/commands.js +1 -1
  65. package/dist/lib/db/commands.js.map +1 -1
  66. package/dist/lib/generate-watch-events.js +106 -0
  67. package/dist/lib/generate-watch-events.js.map +7 -0
  68. package/dist/lib/generators/ast/imports.js +1 -1
  69. package/dist/lib/generators/ast/imports.js.map +1 -1
  70. package/dist/lib/generators/entity-ids.js +21 -14
  71. package/dist/lib/generators/entity-ids.js.map +2 -2
  72. package/dist/lib/generators/extensions/injection-widgets.js +1 -1
  73. package/dist/lib/generators/extensions/injection-widgets.js.map +1 -1
  74. package/dist/lib/generators/module-facts.js +1 -1
  75. package/dist/lib/generators/module-facts.js.map +1 -1
  76. package/dist/lib/generators/module-registry.js +1 -1
  77. package/dist/lib/generators/module-registry.js.map +1 -1
  78. package/dist/lib/generators/openapi.js +114 -12
  79. package/dist/lib/generators/openapi.js.map +2 -2
  80. package/dist/lib/in-process-generate-watcher.js +13 -2
  81. package/dist/lib/in-process-generate-watcher.js.map +2 -2
  82. package/dist/lib/modules-config.js +1 -1
  83. package/dist/lib/modules-config.js.map +1 -1
  84. package/dist/lib/resolver.js +1 -1
  85. package/dist/lib/resolver.js.map +1 -1
  86. package/dist/mercato.js +68 -23
  87. package/dist/mercato.js.map +2 -2
  88. package/package.json +7 -6
  89. package/src/__tests__/mercato.test.ts +53 -2
  90. package/src/lib/__integration__/TC-INT-008.spec.ts +15 -15
  91. package/src/lib/__tests__/generate-watch-events.test.ts +112 -0
  92. package/src/lib/__tests__/generate-watch-structure.test.ts +20 -0
  93. package/src/lib/__tests__/in-process-generate-watcher.test.ts +193 -2
  94. package/src/lib/agentic-setup.ts +23 -4
  95. package/src/lib/db/commands.ts +1 -1
  96. package/src/lib/generate-watch-events.ts +125 -0
  97. package/src/lib/generators/__tests__/entity-ids.test.ts +98 -1
  98. package/src/lib/generators/__tests__/openapi.test.ts +174 -0
  99. package/src/lib/generators/__tests__/structural-contracts.test.ts +1 -1
  100. package/src/lib/generators/ast/imports.ts +1 -1
  101. package/src/lib/generators/entity-ids.ts +34 -14
  102. package/src/lib/generators/extensions/injection-widgets.ts +1 -1
  103. package/src/lib/generators/module-facts.ts +1 -1
  104. package/src/lib/generators/module-registry.ts +1 -1
  105. package/src/lib/generators/openapi.ts +167 -14
  106. package/src/lib/in-process-generate-watcher.ts +35 -6
  107. package/src/lib/modules-config.ts +1 -1
  108. package/src/lib/resolver.ts +1 -1
  109. package/src/mercato.ts +75 -27
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { GenerateWatcherChangeSignal } from './in-process-generate-watcher'
4
+
5
+ export type GenerateWatchTarget = {
6
+ directory: string
7
+ recursive: boolean
8
+ fileName?: string
9
+ }
10
+
11
+ type WatchHandle = {
12
+ close(): void
13
+ }
14
+
15
+ type WatchDirectory = (
16
+ target: GenerateWatchTarget,
17
+ onChange: (fileName?: string) => void,
18
+ onError: () => void,
19
+ ) => WatchHandle
20
+
21
+ export type GenerateWatchChangeSignalOptions = {
22
+ getWatchTargets: () => Promise<GenerateWatchTarget[]> | GenerateWatchTarget[]
23
+ watchDirectory?: WatchDirectory
24
+ }
25
+
26
+ function targetKey(target: GenerateWatchTarget): string {
27
+ return JSON.stringify([
28
+ path.resolve(target.directory),
29
+ target.recursive,
30
+ target.fileName ?? '',
31
+ ])
32
+ }
33
+
34
+ const defaultWatchDirectory: WatchDirectory = (target, onChange, onError) => {
35
+ const watcher = fs.watch(
36
+ target.directory,
37
+ { recursive: target.recursive },
38
+ (_eventType, fileName) => {
39
+ const normalizedName = String(fileName ?? '')
40
+ if (target.fileName && normalizedName && normalizedName !== target.fileName) return
41
+ onChange(normalizedName || undefined)
42
+ },
43
+ )
44
+ watcher.on('error', onError)
45
+ return watcher
46
+ }
47
+
48
+ export function createGenerateWatchChangeSignal(
49
+ options: GenerateWatchChangeSignalOptions,
50
+ ): GenerateWatcherChangeSignal {
51
+ const watchDirectory = options.watchDirectory ?? defaultWatchDirectory
52
+ const watchers = new Map<string, WatchHandle>()
53
+ let version = 0
54
+ let pollingFallback = false
55
+ let closed = false
56
+
57
+ const closeWatchers = () => {
58
+ for (const watcher of watchers.values()) {
59
+ try { watcher.close() } catch {}
60
+ }
61
+ watchers.clear()
62
+ }
63
+
64
+ const enterPollingFallback = () => {
65
+ if (pollingFallback) return
66
+ pollingFallback = true
67
+ version += 1
68
+ closeWatchers()
69
+ }
70
+
71
+ return {
72
+ currentVersion: () => version,
73
+ usesPollingFallback: () => pollingFallback,
74
+ refresh: async () => {
75
+ if (closed || pollingFallback) return
76
+
77
+ let targets: GenerateWatchTarget[]
78
+ try {
79
+ targets = await options.getWatchTargets()
80
+ } catch {
81
+ enterPollingFallback()
82
+ return
83
+ }
84
+
85
+ const normalizedTargets = new Map<string, GenerateWatchTarget>()
86
+ for (const target of targets) {
87
+ const normalized: GenerateWatchTarget = {
88
+ ...target,
89
+ directory: path.resolve(target.directory),
90
+ }
91
+ normalizedTargets.set(targetKey(normalized), normalized)
92
+ }
93
+
94
+ for (const [key, watcher] of watchers) {
95
+ if (normalizedTargets.has(key)) continue
96
+ try { watcher.close() } catch {}
97
+ watchers.delete(key)
98
+ }
99
+
100
+ for (const [key, target] of normalizedTargets) {
101
+ if (watchers.has(key)) continue
102
+ try {
103
+ const watcher = watchDirectory(
104
+ target,
105
+ () => { version += 1 },
106
+ enterPollingFallback,
107
+ )
108
+ if (closed || pollingFallback) {
109
+ try { watcher.close() } catch {}
110
+ continue
111
+ }
112
+ watchers.set(key, watcher)
113
+ } catch {
114
+ enterPollingFallback()
115
+ return
116
+ }
117
+ }
118
+ },
119
+ close: () => {
120
+ if (closed) return
121
+ closed = true
122
+ closeWatchers()
123
+ },
124
+ }
125
+ }
@@ -30,7 +30,10 @@ function createMockResolver(tmpRoot: string, enabled: ModuleEntry[]): PackageRes
30
30
  appBase: `@/modules/${entry.id}`,
31
31
  pkgBase: `@open-mercato/core/modules/${entry.id}`,
32
32
  }),
33
- getPackageOutputDir: () => outputDir,
33
+ getPackageOutputDir: (packageName: string) =>
34
+ packageName === '@app'
35
+ ? outputDir
36
+ : path.join(tmpRoot, 'packages', 'core', 'generated'),
34
37
  getPackageRoot: () => path.join(tmpRoot, 'packages', 'core'),
35
38
  }
36
39
  }
@@ -75,6 +78,100 @@ afterEach(() => {
75
78
  })
76
79
 
77
80
  describe('generateEntityIds', () => {
81
+ it('does not rewrite generated entity outputs when inputs are unchanged', async () => {
82
+ const moduleEntry: ModuleEntry = { id: 'orders', from: '@open-mercato/core' }
83
+ const entitiesFile = path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'orders', 'data', 'entities.ts')
84
+ fs.mkdirSync(path.dirname(entitiesFile), { recursive: true })
85
+ fs.writeFileSync(entitiesFile, 'export class SalesOrder { id!: string\n totalGross!: number\n}\n')
86
+ const resolver = createMockResolver(tmpDir, [moduleEntry])
87
+
88
+ const first = await generateEntityIds({ resolver, quiet: true })
89
+ expect(first.filesWritten.length).toBeGreaterThan(0)
90
+
91
+ const appOutput = resolver.getOutputDir()
92
+ const packageOutput = resolver.getPackageOutputDir('@open-mercato/core')
93
+ const generatedFiles = [
94
+ path.join(appOutput, 'entities.ids.generated.ts'),
95
+ path.join(appOutput, 'entity-fields-registry.ts'),
96
+ path.join(appOutput, 'entities', 'sales_order', 'index.ts'),
97
+ path.join(packageOutput, 'entities.ids.generated.ts'),
98
+ path.join(packageOutput, 'entity-fields-registry.ts'),
99
+ path.join(packageOutput, 'entities', 'sales_order', 'index.ts'),
100
+ ]
101
+ const pinnedTime = new Date('2026-01-01T00:00:00.000Z')
102
+ for (const filePath of generatedFiles) {
103
+ fs.utimesSync(filePath, pinnedTime, pinnedTime)
104
+ }
105
+
106
+ const second = await generateEntityIds({ resolver, quiet: true })
107
+
108
+ expect(second.filesWritten).toEqual([])
109
+ for (const filePath of generatedFiles) {
110
+ expect(fs.statSync(filePath).mtimeMs).toBe(pinnedTime.getTime())
111
+ }
112
+ })
113
+
114
+ it('reports field-only generated changes without rewriting the entity id map', async () => {
115
+ const moduleEntry: ModuleEntry = { id: 'orders', from: '@open-mercato/core' }
116
+ const entitiesFile = path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'orders', 'data', 'entities.ts')
117
+ fs.mkdirSync(path.dirname(entitiesFile), { recursive: true })
118
+ fs.writeFileSync(entitiesFile, 'export class SalesOrder { id!: string\n}\n')
119
+ const resolver = createMockResolver(tmpDir, [moduleEntry])
120
+ await generateEntityIds({ resolver, quiet: true })
121
+ const rootIds = path.join(resolver.getOutputDir(), 'entities.ids.generated.ts')
122
+ const pinnedTime = new Date('2026-01-01T00:00:00.000Z')
123
+ fs.utimesSync(rootIds, pinnedTime, pinnedTime)
124
+
125
+ fs.writeFileSync(entitiesFile, 'export class SalesOrder { id!: string\n status!: string\n}\n')
126
+ const result = await generateEntityIds({ resolver, quiet: true })
127
+
128
+ expect(result.filesWritten).not.toContain(rootIds)
129
+ expect(fs.statSync(rootIds).mtimeMs).toBe(pinnedTime.getTime())
130
+ expect(result.filesWritten).toContain(
131
+ path.join(resolver.getOutputDir(), 'entities', 'sales_order', 'index.ts'),
132
+ )
133
+ expect(result.filesWritten).toContain(
134
+ path.join(resolver.getOutputDir(), 'entity-fields-registry.ts'),
135
+ )
136
+ })
137
+
138
+ it('keeps deletion changes visible while removing stale entity field output', async () => {
139
+ const moduleEntry: ModuleEntry = { id: 'orders', from: '@open-mercato/core' }
140
+ const entitiesFile = path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'orders', 'data', 'entities.ts')
141
+ fs.mkdirSync(path.dirname(entitiesFile), { recursive: true })
142
+ fs.writeFileSync(entitiesFile, 'export class SalesOrder { id!: string }\nexport class SalesLine { id!: string }\n')
143
+ const resolver = createMockResolver(tmpDir, [moduleEntry])
144
+ await generateEntityIds({ resolver, quiet: true })
145
+ const staleDirectory = path.join(resolver.getOutputDir(), 'entities', 'sales_line')
146
+ expect(fs.existsSync(staleDirectory)).toBe(true)
147
+
148
+ fs.writeFileSync(entitiesFile, 'export class SalesOrder { id!: string }\n')
149
+ const result = await generateEntityIds({ resolver, quiet: true })
150
+
151
+ expect(fs.existsSync(staleDirectory)).toBe(false)
152
+ expect(result.filesWritten).toContain(
153
+ path.join(resolver.getOutputDir(), 'entities.ids.generated.ts'),
154
+ )
155
+ })
156
+
157
+ it('reports stale entity directory cleanup when every generated file is unchanged', async () => {
158
+ const moduleEntry: ModuleEntry = { id: 'orders', from: '@open-mercato/core' }
159
+ const entitiesFile = path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'orders', 'data', 'entities.ts')
160
+ fs.mkdirSync(path.dirname(entitiesFile), { recursive: true })
161
+ fs.writeFileSync(entitiesFile, 'export class SalesOrder { id!: string }\n')
162
+ const resolver = createMockResolver(tmpDir, [moduleEntry])
163
+ await generateEntityIds({ resolver, quiet: true })
164
+
165
+ const staleDirectory = path.join(resolver.getOutputDir(), 'entities', 'sales_line')
166
+ fs.mkdirSync(staleDirectory, { recursive: true })
167
+ fs.writeFileSync(path.join(staleDirectory, 'index.ts'), 'export const id = "id"\n')
168
+
169
+ const result = await generateEntityIds({ resolver, quiet: true })
170
+
171
+ expect(fs.existsSync(staleDirectory)).toBe(false)
172
+ expect(result.filesWritten).toEqual([staleDirectory])
173
+ })
174
+
78
175
  it('writes an inline entity fields registry without generated entity imports', async () => {
79
176
  const moduleEntry: ModuleEntry = { id: 'orders', from: '@open-mercato/core' }
80
177
  const entitiesFile = path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'orders', 'data', 'entities.ts')
@@ -42,6 +42,27 @@ describe('resolveOpenApiGeneratorProjectRoot', () => {
42
42
  describe('generateOpenApi', () => {
43
43
  let tmpDir: string
44
44
 
45
+ const sharedGeneratorSource = `
46
+ export function buildOpenApiDocument(modules: any[], options: any) {
47
+ const paths: Record<string, any> = {}
48
+ for (const module of modules) {
49
+ for (const api of module.apis ?? []) {
50
+ const methods: Record<string, any> = {}
51
+ for (const [method, spec] of Object.entries(api.handlers.openApi ?? {})) {
52
+ methods[method.toLowerCase()] = spec
53
+ }
54
+ paths[api.path.replace(/\\[([^\\]]+)\\]/g, '{$1}')] = methods
55
+ }
56
+ }
57
+ return {
58
+ openapi: '3.1.0',
59
+ info: { title: options.title, version: options.version, description: options.description },
60
+ servers: options.servers,
61
+ paths,
62
+ }
63
+ }
64
+ `
65
+
45
66
  function touchFile(filePath: string, content: string): void {
46
67
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
47
68
  fs.writeFileSync(filePath, content)
@@ -79,6 +100,14 @@ describe('generateOpenApi', () => {
79
100
 
80
101
  beforeEach(() => {
81
102
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-generator-test-'))
103
+ touchFile(
104
+ path.join(tmpDir, 'packages', 'shared', 'src', 'lib', 'openapi', 'generator.ts'),
105
+ sharedGeneratorSource,
106
+ )
107
+ touchFile(path.join(tmpDir, 'package.json'), '{"private":true}\n')
108
+ touchFile(path.join(tmpDir, 'yarn.lock'), '# test lockfile\n')
109
+ touchFile(path.join(tmpDir, 'app', 'package.json'), '{"private":true}\n')
110
+ touchFile(path.join(tmpDir, 'app', 'tsconfig.json'), '{"compilerOptions":{}}\n')
82
111
  })
83
112
 
84
113
  afterEach(() => {
@@ -130,4 +159,149 @@ describe('generateOpenApi', () => {
130
159
  expect(openApiDoc.paths['/api/demo/health']?.get?.summary).toBe('app health')
131
160
  expect(openApiDoc.paths['/api/demo/details']?.get?.summary).toBe('package details')
132
161
  })
162
+
163
+ it('skips route discovery and bundling when all tracked inputs are unchanged', async () => {
164
+ touchFile(
165
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'demo', 'api', 'health', 'route.ts'),
166
+ [
167
+ 'export async function GET() { return new Response("ok") }',
168
+ "export const openApi = { GET: { summary: 'health' } }",
169
+ '',
170
+ ].join('\n'),
171
+ )
172
+ const resolver = createMockResolver([{ id: 'demo', from: '@open-mercato/core' }])
173
+
174
+ await generateOpenApi({ resolver, quiet: true })
175
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
176
+ const result = await generateOpenApi({ resolver, quiet: false })
177
+
178
+ const generatedPath = path.join(tmpDir, 'output', 'generated', 'openapi.generated.json')
179
+ expect(result.filesWritten).toEqual([])
180
+ expect(result.filesUnchanged).toEqual([generatedPath])
181
+ expect(consoleSpy).toHaveBeenCalledWith(`[OpenAPI] Skipped (inputs unchanged): ${generatedPath}`)
182
+ expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('[OpenAPI] Found'))
183
+ consoleSpy.mockRestore()
184
+ })
185
+
186
+ it('invalidates the early cache when a transitive route dependency changes', async () => {
187
+ const moduleRoot = path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'demo')
188
+ const schemaPath = path.join(moduleRoot, 'schema.ts')
189
+ touchFile(schemaPath, "export const summary = 'first schema'\n")
190
+ touchFile(
191
+ path.join(moduleRoot, 'api', 'health', 'route.ts'),
192
+ [
193
+ "import { summary } from '../../schema'",
194
+ 'export async function GET() { return new Response("ok") }',
195
+ 'export const openApi = { GET: { summary } }',
196
+ '',
197
+ ].join('\n'),
198
+ )
199
+ const resolver = createMockResolver([{ id: 'demo', from: '@open-mercato/core' }])
200
+ await generateOpenApi({ resolver, quiet: true })
201
+
202
+ touchFile(schemaPath, "export const summary = 'second schema'\n")
203
+ const result = await generateOpenApi({ resolver, quiet: true })
204
+ const generatedPath = path.join(tmpDir, 'output', 'generated', 'openapi.generated.json')
205
+ const openApiDoc = JSON.parse(fs.readFileSync(generatedPath, 'utf8')) as {
206
+ paths: Record<string, { get?: { summary?: string } }>
207
+ }
208
+
209
+ expect(result.filesWritten).toEqual([generatedPath])
210
+ expect(openApiDoc.paths['/api/demo/health']?.get?.summary).toBe('second schema')
211
+ })
212
+
213
+ it('invalidates the early cache when enabled modules change', async () => {
214
+ touchFile(
215
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'first', 'api', 'route.ts'),
216
+ "export async function GET() {}\nexport const openApi = { GET: { summary: 'first' } }\n",
217
+ )
218
+ touchFile(
219
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'second', 'api', 'route.ts'),
220
+ "export async function GET() {}\nexport const openApi = { GET: { summary: 'second' } }\n",
221
+ )
222
+ let enabled: ModuleEntry[] = [{ id: 'first', from: '@open-mercato/core' }]
223
+ const delegate = createMockResolver(enabled)
224
+ const resolver: PackageResolver = {
225
+ ...delegate,
226
+ loadEnabledModules: () => enabled,
227
+ }
228
+ await generateOpenApi({ resolver, quiet: true })
229
+
230
+ enabled = [{ id: 'second', from: '@open-mercato/core' }]
231
+ await generateOpenApi({ resolver, quiet: true })
232
+ const generatedPath = path.join(tmpDir, 'output', 'generated', 'openapi.generated.json')
233
+ const openApiDoc = JSON.parse(fs.readFileSync(generatedPath, 'utf8')) as {
234
+ paths: Record<string, unknown>
235
+ }
236
+
237
+ expect(openApiDoc.paths['/api/first']).toBeUndefined()
238
+ expect(openApiDoc.paths['/api/second']).toBeDefined()
239
+ })
240
+
241
+ it('rebuilds when the generated document is missing or tampered with', async () => {
242
+ touchFile(
243
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'demo', 'api', 'health', 'route.ts'),
244
+ "export async function GET() {}\nexport const openApi = { GET: { summary: 'health' } }\n",
245
+ )
246
+ const resolver = createMockResolver([{ id: 'demo', from: '@open-mercato/core' }])
247
+ const generatedPath = path.join(tmpDir, 'output', 'generated', 'openapi.generated.json')
248
+ await generateOpenApi({ resolver, quiet: true })
249
+
250
+ fs.unlinkSync(generatedPath)
251
+ const restored = await generateOpenApi({ resolver, quiet: true })
252
+ expect(restored.filesWritten).toEqual([generatedPath])
253
+
254
+ fs.writeFileSync(generatedPath, '{"tampered":true}')
255
+ const repaired = await generateOpenApi({ resolver, quiet: true })
256
+ expect(repaired.filesWritten).toEqual([generatedPath])
257
+ expect(JSON.parse(fs.readFileSync(generatedPath, 'utf8'))).toHaveProperty('openapi', '3.1.0')
258
+ })
259
+
260
+ it('invalidates the early cache when the generated server URL changes', async () => {
261
+ const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
262
+ touchFile(
263
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'demo', 'api', 'route.ts'),
264
+ "export async function GET() {}\nexport const openApi = { GET: { summary: 'demo' } }\n",
265
+ )
266
+ const resolver = createMockResolver([{ id: 'demo', from: '@open-mercato/core' }])
267
+ const generatedPath = path.join(tmpDir, 'output', 'generated', 'openapi.generated.json')
268
+
269
+ try {
270
+ process.env.NEXT_PUBLIC_APP_URL = 'https://first.example.test'
271
+ await generateOpenApi({ resolver, quiet: true })
272
+ process.env.NEXT_PUBLIC_APP_URL = 'https://second.example.test'
273
+ const result = await generateOpenApi({ resolver, quiet: true })
274
+ const openApiDoc = JSON.parse(fs.readFileSync(generatedPath, 'utf8')) as {
275
+ servers: Array<{ url: string }>
276
+ }
277
+
278
+ expect(result.filesWritten).toEqual([generatedPath])
279
+ expect(openApiDoc.servers[0]?.url).toBe('https://second.example.test')
280
+ } finally {
281
+ if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
282
+ else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
283
+ }
284
+ })
285
+
286
+ it('does not cache a static fallback after a bundle failure', async () => {
287
+ touchFile(
288
+ path.join(tmpDir, 'packages', 'core', 'src', 'modules', 'demo', 'api', 'route.ts'),
289
+ "export async function GET() {}\nexport const openApi = { GET: { summary: 'demo' } }\n",
290
+ )
291
+ const resolver = createMockResolver([{ id: 'demo', from: '@open-mercato/core' }])
292
+ const generatorPath = path.join(tmpDir, 'packages', 'shared', 'src', 'lib', 'openapi', 'generator.ts')
293
+ const manifestPath = path.join(tmpDir, 'output', 'generated', 'openapi.generated.inputs.json')
294
+ await generateOpenApi({ resolver, quiet: true })
295
+
296
+ fs.unlinkSync(generatorPath)
297
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
298
+ await generateOpenApi({ resolver, quiet: false })
299
+ await generateOpenApi({ resolver, quiet: false })
300
+
301
+ expect(consoleSpy.mock.calls.filter(([message]) =>
302
+ typeof message === 'string' && message.startsWith('[OpenAPI] Found'),
303
+ )).toHaveLength(2)
304
+ expect(fs.existsSync(manifestPath)).toBe(false)
305
+ consoleSpy.mockRestore()
306
+ })
133
307
  })
@@ -14,7 +14,7 @@
14
14
  import fs from 'node:fs'
15
15
  import path from 'node:path'
16
16
  import os from 'node:os'
17
- import ts from 'typescript'
17
+ import ts from 'typescript-js'
18
18
  import type { PackageResolver, ModuleEntry } from '../../resolver'
19
19
  import { generateModuleRegistry, generateModuleRegistryApp, generateModuleRegistryCli } from '../module-registry'
20
20
  import { generateModuleDi } from '../module-di'
@@ -1,4 +1,4 @@
1
- import ts from 'typescript'
1
+ import ts from 'typescript-js'
2
2
  import {
3
3
  StructureKind,
4
4
  type ImportDeclarationStructure,
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import ts from 'typescript'
3
+ import ts from 'typescript-js'
4
4
  import { VariableDeclarationKind } from 'ts-morph'
5
5
  import type { PackageResolver, ModuleEntry } from '../resolver'
6
6
  import { MODULE_CODE_EXTENSIONS } from './scanner'
@@ -13,6 +13,7 @@ import {
13
13
  toSnake,
14
14
  rimrafDir,
15
15
  logGenerationResult,
16
+ writeIfChanged,
16
17
  type GeneratorResult,
17
18
  createGeneratorResult,
18
19
  } from '../utils'
@@ -240,7 +241,23 @@ function parseEntityFieldsFromFile(filePath: string, exportedClassNames: string[
240
241
  return result
241
242
  }
242
243
 
243
- function writePerEntityFieldFiles(outRoot: string, fieldsByEntity: EntityFieldMap): void {
244
+ function writeTrackedFile(
245
+ filePath: string,
246
+ content: string,
247
+ result: GeneratorResult,
248
+ ): void {
249
+ if (writeIfChanged(filePath, content)) {
250
+ result.filesWritten.push(filePath)
251
+ } else {
252
+ result.filesUnchanged.push(filePath)
253
+ }
254
+ }
255
+
256
+ function writePerEntityFieldFiles(
257
+ outRoot: string,
258
+ fieldsByEntity: EntityFieldMap,
259
+ result: GeneratorResult,
260
+ ): void {
244
261
  fs.mkdirSync(outRoot, { recursive: true })
245
262
  const desiredEntities = new Set(Object.keys(fieldsByEntity))
246
263
  for (const [entity, fields] of Object.entries(fieldsByEntity)) {
@@ -259,18 +276,24 @@ function writePerEntityFieldFiles(outRoot: string, fieldsByEntity: EntityFieldMa
259
276
  ],
260
277
  })
261
278
  }
262
- fs.writeFileSync(path.join(entDir, 'index.ts'), getSourceText(sourceFile))
279
+ writeTrackedFile(path.join(entDir, 'index.ts'), getSourceText(sourceFile), result)
263
280
  }
264
281
 
265
282
  const existingEntries = fs.existsSync(outRoot) ? fs.readdirSync(outRoot, { withFileTypes: true }) : []
266
283
  for (const entry of existingEntries) {
267
284
  if (!entry.isDirectory()) continue
268
285
  if (desiredEntities.has(entry.name)) continue
269
- rimrafDir(path.join(outRoot, entry.name))
286
+ const staleDirectory = path.join(outRoot, entry.name)
287
+ rimrafDir(staleDirectory)
288
+ result.filesWritten.push(staleDirectory)
270
289
  }
271
290
  }
272
291
 
273
- function writeEntityFieldsRegistry(generatedRoot: string, fieldsByEntity: EntityFieldMap): void {
292
+ function writeEntityFieldsRegistry(
293
+ generatedRoot: string,
294
+ fieldsByEntity: EntityFieldMap,
295
+ result: GeneratorResult,
296
+ ): void {
274
297
  const entities = Object.keys(fieldsByEntity).sort((a, b) => a.localeCompare(b))
275
298
  const registry: Record<string, Record<string, string>> = {}
276
299
  for (const entity of entities) {
@@ -302,8 +325,7 @@ function writeEntityFieldsRegistry(generatedRoot: string, fieldsByEntity: Entity
302
325
  statements: [returnStatement(elementAccess(identifier('entityFieldsRegistry'), identifier('slug')))],
303
326
  })
304
327
  const outPath = path.join(generatedRoot, 'entity-fields-registry.ts')
305
- ensureDir(outPath)
306
- fs.writeFileSync(outPath, getSourceText(sourceFile))
328
+ writeTrackedFile(outPath, getSourceText(sourceFile), result)
307
329
  }
308
330
 
309
331
  export async function generateEntityIds(options: EntityIdsOptions): Promise<GeneratorResult> {
@@ -472,9 +494,7 @@ export async function generateEntityIds(options: EntityIdsOptions): Promise<Gene
472
494
  type: 'typeof E',
473
495
  })
474
496
  const src = getSourceText(groupSourceFile)
475
- ensureDir(out)
476
- fs.writeFileSync(out, src)
477
- result.filesWritten.push(out)
497
+ writeTrackedFile(out, src, result)
478
498
 
479
499
  const fieldsRoot = path.join(pkgOutputDir, 'entities')
480
500
  const fieldsByModule = fieldsByGroup[g] || {}
@@ -485,10 +505,10 @@ export async function generateEntityIds(options: EntityIdsOptions): Promise<Gene
485
505
  combined[entity] = Array.from(new Set([...(combined[entity] || []), ...fields]))
486
506
  }
487
507
  }
488
- writePerEntityFieldFiles(fieldsRoot, combined)
508
+ writePerEntityFieldFiles(fieldsRoot, combined, result)
489
509
 
490
510
  // Generate static entity fields registry for Turbopack compatibility
491
- writeEntityFieldsRegistry(pkgOutputDir, combined)
511
+ writeEntityFieldsRegistry(pkgOutputDir, combined, result)
492
512
  }
493
513
 
494
514
  // Write combined entity fields to root generated/ folder
@@ -500,8 +520,8 @@ export async function generateEntityIds(options: EntityIdsOptions): Promise<Gene
500
520
  }
501
521
  }
502
522
  }
503
- writePerEntityFieldFiles(path.join(outputDir, 'entities'), combinedAll)
504
- writeEntityFieldsRegistry(outputDir, combinedAll)
523
+ writePerEntityFieldFiles(path.join(outputDir, 'entities'), combinedAll, result)
524
+ writeEntityFieldsRegistry(outputDir, combinedAll, result)
505
525
 
506
526
  return result
507
527
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { VariableDeclarationKind } from 'ts-morph'
4
- import ts from 'typescript'
4
+ import ts from 'typescript-js'
5
5
  import type { GeneratorExtension } from '../extension'
6
6
  import { resolveStandaloneSourceMirrorBase, scanModuleDir, SCAN_CONFIGS, type ModuleRoots } from '../scanner'
7
7
  import {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import ts from 'typescript'
3
+ import ts from 'typescript-js'
4
4
  import { toSnake } from '../utils'
5
5
 
6
6
  export interface ModuleEntityFact {
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { VariableDeclarationKind, type WriterFunction } from 'ts-morph'
5
- import ts from 'typescript'
5
+ import ts from 'typescript-js'
6
6
  import type { PackageResolver } from '../resolver'
7
7
  import {
8
8
  calculateStructureChecksum,