@open-mercato/cli 0.4.9-develop-db9ecc46fc → 0.4.9-develop-d989387b7a

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 (58) hide show
  1. package/dist/agentic/shared/AGENTS.md.template +2 -0
  2. package/dist/agentic/shared/ai/skills/eject-and-customize/SKILL.md +3 -1
  3. package/dist/bin.js +1 -0
  4. package/dist/bin.js.map +2 -2
  5. package/dist/lib/__fixtures__/official-module-package/src/index.js +1 -0
  6. package/dist/lib/__fixtures__/official-module-package/src/index.js.map +7 -0
  7. package/dist/lib/__fixtures__/official-module-package/src/modules/test_package/index.js +10 -0
  8. package/dist/lib/__fixtures__/official-module-package/src/modules/test_package/index.js.map +7 -0
  9. package/dist/lib/eject.js +30 -38
  10. package/dist/lib/eject.js.map +2 -2
  11. package/dist/lib/generators/index.js +2 -0
  12. package/dist/lib/generators/index.js.map +2 -2
  13. package/dist/lib/generators/module-package-sources.js +45 -0
  14. package/dist/lib/generators/module-package-sources.js.map +7 -0
  15. package/dist/lib/module-install-args.js +40 -0
  16. package/dist/lib/module-install-args.js.map +7 -0
  17. package/dist/lib/module-install.js +157 -0
  18. package/dist/lib/module-install.js.map +7 -0
  19. package/dist/lib/module-package.js +245 -0
  20. package/dist/lib/module-package.js.map +7 -0
  21. package/dist/lib/modules-config.js +255 -0
  22. package/dist/lib/modules-config.js.map +7 -0
  23. package/dist/lib/resolver.js +19 -5
  24. package/dist/lib/resolver.js.map +2 -2
  25. package/dist/lib/testing/integration-discovery.js +20 -9
  26. package/dist/lib/testing/integration-discovery.js.map +2 -2
  27. package/dist/lib/testing/integration.js +86 -47
  28. package/dist/lib/testing/integration.js.map +2 -2
  29. package/dist/mercato.js +120 -43
  30. package/dist/mercato.js.map +3 -3
  31. package/package.json +5 -4
  32. package/src/__tests__/mercato.test.ts +6 -1
  33. package/src/bin.ts +1 -0
  34. package/src/lib/__fixtures__/official-module-package/dist/modules/test_package/index.js +2 -0
  35. package/src/lib/__fixtures__/official-module-package/package.json +33 -0
  36. package/src/lib/__fixtures__/official-module-package/src/index.ts +1 -0
  37. package/src/lib/__fixtures__/official-module-package/src/modules/test_package/index.ts +6 -0
  38. package/src/lib/__fixtures__/official-module-package/src/modules/test_package/widgets/injection/test/widget.tsx +3 -0
  39. package/src/lib/__tests__/eject.test.ts +107 -1
  40. package/src/lib/__tests__/module-install-args.test.ts +35 -0
  41. package/src/lib/__tests__/module-install.test.ts +217 -0
  42. package/src/lib/__tests__/module-package.test.ts +215 -0
  43. package/src/lib/__tests__/modules-config.test.ts +104 -0
  44. package/src/lib/__tests__/resolve-environment.test.ts +141 -0
  45. package/src/lib/eject.ts +45 -55
  46. package/src/lib/generators/__tests__/generators.test.ts +11 -0
  47. package/src/lib/generators/__tests__/module-package-sources.test.ts +121 -0
  48. package/src/lib/generators/index.ts +1 -0
  49. package/src/lib/generators/module-package-sources.ts +59 -0
  50. package/src/lib/module-install-args.ts +50 -0
  51. package/src/lib/module-install.ts +234 -0
  52. package/src/lib/module-package.ts +355 -0
  53. package/src/lib/modules-config.ts +393 -0
  54. package/src/lib/resolver.ts +46 -4
  55. package/src/lib/testing/__tests__/integration-discovery.test.ts +30 -0
  56. package/src/lib/testing/integration-discovery.ts +23 -8
  57. package/src/lib/testing/integration.ts +97 -57
  58. package/src/mercato.ts +128 -49
@@ -0,0 +1,393 @@
1
+ import fs from 'node:fs'
2
+ import ts from 'typescript'
3
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
+
5
+ export type ModuleEntry = {
6
+ id: string
7
+ from?: '@open-mercato/core' | '@app' | string
8
+ }
9
+
10
+ type ModuleOccurrence = {
11
+ entry: ModuleEntry
12
+ node: ts.ObjectLiteralExpression
13
+ }
14
+
15
+ type ModuleConfigShape = {
16
+ arrayNode: ts.ArrayLiteralExpression
17
+ occurrences: ModuleOccurrence[]
18
+ }
19
+
20
+ function parseModuleEntryFromObjectLiteral(node: ts.ObjectLiteralExpression): ModuleEntry | null {
21
+ let id: string | null = null
22
+ let from: string | null = null
23
+
24
+ for (const property of node.properties) {
25
+ if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) continue
26
+
27
+ if (property.name.text === 'id' && ts.isStringLiteralLike(property.initializer)) {
28
+ id = property.initializer.text
29
+ }
30
+
31
+ if (property.name.text === 'from' && ts.isStringLiteralLike(property.initializer)) {
32
+ from = property.initializer.text
33
+ }
34
+ }
35
+
36
+ if (!id) return null
37
+
38
+ return {
39
+ id,
40
+ from: from ?? '@open-mercato/core',
41
+ }
42
+ }
43
+
44
+ function parseProcessEnvAccess(
45
+ node: ts.Expression,
46
+ env: NodeJS.ProcessEnv,
47
+ ): { matched: boolean; value: string | undefined } {
48
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) {
49
+ const target = node.expression
50
+ if (
51
+ ts.isPropertyAccessExpression(target)
52
+ && ts.isIdentifier(target.expression)
53
+ && target.expression.text === 'process'
54
+ && target.name.text === 'env'
55
+ ) {
56
+ return { matched: true, value: env[node.name.text] }
57
+ }
58
+ }
59
+
60
+ if (
61
+ ts.isElementAccessExpression(node)
62
+ && ts.isPropertyAccessExpression(node.expression)
63
+ && ts.isIdentifier(node.expression.expression)
64
+ && node.expression.expression.text === 'process'
65
+ && node.expression.name.text === 'env'
66
+ && ts.isStringLiteralLike(node.argumentExpression)
67
+ ) {
68
+ return { matched: true, value: env[node.argumentExpression.text] }
69
+ }
70
+
71
+ return { matched: false, value: undefined }
72
+ }
73
+
74
+ function evaluateStaticExpressionWithScope(
75
+ node: ts.Expression,
76
+ env: NodeJS.ProcessEnv,
77
+ scope: Map<string, unknown>,
78
+ ): unknown {
79
+ if (ts.isParenthesizedExpression(node)) {
80
+ return evaluateStaticExpressionWithScope(node.expression, env, scope)
81
+ }
82
+
83
+ if (ts.isIdentifier(node)) {
84
+ return scope.get(node.text)
85
+ }
86
+
87
+ if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
88
+ return node.text
89
+ }
90
+
91
+ if (ts.isNumericLiteral(node)) {
92
+ return Number(node.text)
93
+ }
94
+
95
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true
96
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false
97
+ if (node.kind === ts.SyntaxKind.NullKeyword) return null
98
+
99
+ const envAccess = parseProcessEnvAccess(node, env)
100
+ if (envAccess.matched) {
101
+ return envAccess.value
102
+ }
103
+
104
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
105
+ return !Boolean(evaluateStaticExpressionWithScope(node.operand, env, scope))
106
+ }
107
+
108
+ if (ts.isBinaryExpression(node)) {
109
+ if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
110
+ return Boolean(evaluateStaticExpressionWithScope(node.left, env, scope))
111
+ && Boolean(evaluateStaticExpressionWithScope(node.right, env, scope))
112
+ }
113
+
114
+ if (node.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
115
+ return Boolean(evaluateStaticExpressionWithScope(node.left, env, scope))
116
+ || Boolean(evaluateStaticExpressionWithScope(node.right, env, scope))
117
+ }
118
+
119
+ if (node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken) {
120
+ return evaluateStaticExpressionWithScope(node.left, env, scope)
121
+ === evaluateStaticExpressionWithScope(node.right, env, scope)
122
+ }
123
+
124
+ if (node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken) {
125
+ return evaluateStaticExpressionWithScope(node.left, env, scope)
126
+ !== evaluateStaticExpressionWithScope(node.right, env, scope)
127
+ }
128
+ }
129
+
130
+ if (
131
+ ts.isCallExpression(node)
132
+ && ts.isIdentifier(node.expression)
133
+ && node.expression.text === 'parseBooleanWithDefault'
134
+ ) {
135
+ const rawValueNode = node.arguments[0]
136
+ const fallbackNode = node.arguments[1]
137
+ const rawValue = rawValueNode
138
+ ? evaluateStaticExpressionWithScope(rawValueNode, env, scope)
139
+ : undefined
140
+ const fallbackValue = fallbackNode
141
+ ? evaluateStaticExpressionWithScope(fallbackNode, env, scope)
142
+ : false
143
+
144
+ return parseBooleanWithDefault(
145
+ typeof rawValue === 'string' ? rawValue : undefined,
146
+ Boolean(fallbackValue),
147
+ )
148
+ }
149
+
150
+ return undefined
151
+ }
152
+
153
+ function evaluateStaticCondition(
154
+ node: ts.Expression,
155
+ env: NodeJS.ProcessEnv,
156
+ scope: Map<string, unknown>,
157
+ ): boolean {
158
+ return Boolean(evaluateStaticExpressionWithScope(node, env, scope))
159
+ }
160
+
161
+ function collectPushEntriesFromStatement(
162
+ statement: ts.Statement,
163
+ env: NodeJS.ProcessEnv,
164
+ targetVariableName: string,
165
+ scope: Map<string, unknown>,
166
+ ): ModuleOccurrence[] {
167
+ if (ts.isBlock(statement)) {
168
+ return statement.statements.flatMap((child) =>
169
+ collectPushEntriesFromStatement(child, env, targetVariableName, scope),
170
+ )
171
+ }
172
+
173
+ if (ts.isIfStatement(statement)) {
174
+ if (evaluateStaticCondition(statement.expression, env, scope)) {
175
+ return collectPushEntriesFromStatement(statement.thenStatement, env, targetVariableName, scope)
176
+ }
177
+
178
+ if (statement.elseStatement) {
179
+ return collectPushEntriesFromStatement(statement.elseStatement, env, targetVariableName, scope)
180
+ }
181
+
182
+ return []
183
+ }
184
+
185
+ if (!ts.isExpressionStatement(statement)) return []
186
+
187
+ const expression = statement.expression
188
+ if (!ts.isCallExpression(expression) || !ts.isPropertyAccessExpression(expression.expression)) {
189
+ return []
190
+ }
191
+
192
+ const pushTarget = expression.expression.expression
193
+ const pushMethod = expression.expression.name
194
+
195
+ if (!ts.isIdentifier(pushTarget) || pushTarget.text !== targetVariableName || pushMethod.text !== 'push') {
196
+ return []
197
+ }
198
+
199
+ return expression.arguments.flatMap((argument) => {
200
+ if (!ts.isObjectLiteralExpression(argument)) return []
201
+ const entry = parseModuleEntryFromObjectLiteral(argument)
202
+ return entry ? [{ entry, node: argument }] : []
203
+ })
204
+ }
205
+
206
+ function parseModuleConfigShape(
207
+ source: string,
208
+ env: NodeJS.ProcessEnv = process.env,
209
+ ): ModuleConfigShape {
210
+ const sourceFile = ts.createSourceFile('modules.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
211
+ const variableName = 'enabledModules'
212
+ const occurrences: ModuleOccurrence[] = []
213
+ const scope = new Map<string, unknown>()
214
+ let arrayNode: ts.ArrayLiteralExpression | null = null
215
+ let foundDeclaration = false
216
+
217
+ for (const statement of sourceFile.statements) {
218
+ if (ts.isVariableStatement(statement)) {
219
+ for (const declaration of statement.declarationList.declarations) {
220
+ if (!ts.isIdentifier(declaration.name)) continue
221
+
222
+ if (declaration.name.text === variableName) {
223
+ if (!declaration.initializer || !ts.isArrayLiteralExpression(declaration.initializer)) continue
224
+
225
+ arrayNode = declaration.initializer
226
+ occurrences.push(...declaration.initializer.elements.flatMap((element) => {
227
+ if (!ts.isObjectLiteralExpression(element)) return []
228
+ const entry = parseModuleEntryFromObjectLiteral(element)
229
+ return entry ? [{ entry, node: element }] : []
230
+ }))
231
+ foundDeclaration = true
232
+ continue
233
+ }
234
+
235
+ if (!declaration.initializer) continue
236
+
237
+ scope.set(
238
+ declaration.name.text,
239
+ evaluateStaticExpressionWithScope(declaration.initializer, env, scope),
240
+ )
241
+ }
242
+ continue
243
+ }
244
+
245
+ if (!foundDeclaration) continue
246
+
247
+ occurrences.push(...collectPushEntriesFromStatement(statement, env, variableName, scope))
248
+ }
249
+
250
+ if (!arrayNode) {
251
+ throw new Error('Could not find enabledModules array declaration in src/modules.ts')
252
+ }
253
+
254
+ return { arrayNode, occurrences }
255
+ }
256
+
257
+ function normalizeModuleEntry(entry: ModuleEntry): ModuleEntry {
258
+ return {
259
+ id: entry.id,
260
+ from: entry.from ?? '@open-mercato/core',
261
+ }
262
+ }
263
+
264
+ function escapeSingleQuotedString(value: string): string {
265
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
266
+ }
267
+
268
+ function buildModuleEntryLiteral(entry: ModuleEntry): string {
269
+ const normalized = normalizeModuleEntry(entry)
270
+ return `{ id: '${escapeSingleQuotedString(normalized.id)}', from: '${escapeSingleQuotedString(normalized.from ?? '@open-mercato/core')}' }`
271
+ }
272
+
273
+ function lineIndentAt(source: string, position: number): string {
274
+ const lineStart = source.lastIndexOf('\n', Math.max(0, position - 1)) + 1
275
+ const lineToPosition = source.slice(lineStart, position)
276
+ const match = lineToPosition.match(/^\s*/)
277
+ return match?.[0] ?? ''
278
+ }
279
+
280
+ function replaceArrayLiteral(
281
+ source: string,
282
+ arrayNode: ts.ArrayLiteralExpression,
283
+ entry: ModuleEntry,
284
+ sourceFile: ts.SourceFile,
285
+ ): string {
286
+ const existingElements = arrayNode.elements.map((element) =>
287
+ source.slice(element.getStart(sourceFile), element.end).trim(),
288
+ )
289
+ const closingIndent = lineIndentAt(source, arrayNode.end - 1)
290
+ const elementIndent =
291
+ arrayNode.elements.length > 0
292
+ ? lineIndentAt(source, arrayNode.elements[0].getStart(sourceFile))
293
+ : `${closingIndent} `
294
+ const updatedArray = [
295
+ '[',
296
+ ...[...existingElements, buildModuleEntryLiteral(entry)].map((value) => `${elementIndent}${value},`),
297
+ `${closingIndent}]`,
298
+ ].join('\n')
299
+
300
+ return source.slice(0, arrayNode.getStart(sourceFile)) + updatedArray + source.slice(arrayNode.end)
301
+ }
302
+
303
+ function upsertModuleSource(objectLiteral: string, from: string): string {
304
+ if (/from\s*:\s*'[^']*'/.test(objectLiteral)) {
305
+ return objectLiteral.replace(/from\s*:\s*'[^']*'/, `from: '${escapeSingleQuotedString(from)}'`)
306
+ }
307
+
308
+ if (/from\s*:\s*"[^"]*"/.test(objectLiteral)) {
309
+ return objectLiteral.replace(/from\s*:\s*"[^"]*"/, `from: "${from.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
310
+ }
311
+
312
+ return objectLiteral.replace(/\}\s*$/, `, from: '${escapeSingleQuotedString(from)}' }`)
313
+ }
314
+
315
+ export function ensureModuleRegistration(
316
+ modulesPath: string,
317
+ entry: ModuleEntry,
318
+ ): { changed: boolean; registeredAs: string } {
319
+ if (!fs.existsSync(modulesPath)) {
320
+ throw new Error(`modules.ts not found at ${modulesPath}`)
321
+ }
322
+
323
+ const source = fs.readFileSync(modulesPath, 'utf8')
324
+ const sourceFile = ts.createSourceFile('modules.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
325
+ const config = parseModuleConfigShape(source)
326
+ const normalized = normalizeModuleEntry(entry)
327
+ const matches = config.occurrences.filter((occurrence) => occurrence.entry.id === normalized.id)
328
+
329
+ if (matches.length > 1) {
330
+ throw new Error(
331
+ `Module "${normalized.id}" is registered multiple times in ${modulesPath}. Resolve duplicates manually before running this command.`,
332
+ )
333
+ }
334
+
335
+ if (matches.length === 1) {
336
+ const existing = normalizeModuleEntry(matches[0].entry)
337
+ if (existing.from === normalized.from) {
338
+ return { changed: false, registeredAs: normalized.from ?? '@open-mercato/core' }
339
+ }
340
+
341
+ throw new Error(
342
+ `Module "${normalized.id}" is already registered from "${existing.from}". Remove or eject the existing registration first.`,
343
+ )
344
+ }
345
+
346
+ const updated = replaceArrayLiteral(source, config.arrayNode, normalized, sourceFile)
347
+ fs.writeFileSync(modulesPath, updated)
348
+
349
+ return { changed: true, registeredAs: normalized.from ?? '@open-mercato/core' }
350
+ }
351
+
352
+ export function setModuleRegistrationSource(
353
+ modulesPath: string,
354
+ moduleId: string,
355
+ from: string,
356
+ ): { changed: boolean } {
357
+ if (!fs.existsSync(modulesPath)) {
358
+ throw new Error(`modules.ts not found at ${modulesPath}`)
359
+ }
360
+
361
+ const source = fs.readFileSync(modulesPath, 'utf8')
362
+ const config = parseModuleConfigShape(source)
363
+ const matches = config.occurrences.filter((occurrence) => occurrence.entry.id === moduleId)
364
+
365
+ if (matches.length === 0) {
366
+ throw new Error(
367
+ `Could not find module entry for "${moduleId}" in ${modulesPath}. Expected a pattern like: { id: '${moduleId}', from: '...' } or { id: '${moduleId}' }`,
368
+ )
369
+ }
370
+
371
+ if (matches.length > 1) {
372
+ throw new Error(
373
+ `Module "${moduleId}" is registered multiple times in ${modulesPath}. Resolve duplicates manually before running this command.`,
374
+ )
375
+ }
376
+
377
+ const match = matches[0]
378
+ const current = normalizeModuleEntry(match.entry)
379
+ if (current.from === from) {
380
+ return { changed: false }
381
+ }
382
+
383
+ const objectLiteral = source.slice(match.node.getStart(), match.node.end)
384
+ const updatedObjectLiteral = upsertModuleSource(objectLiteral, from)
385
+ const updated =
386
+ source.slice(0, match.node.getStart()) +
387
+ updatedObjectLiteral +
388
+ source.slice(match.node.end)
389
+
390
+ fs.writeFileSync(modulesPath, updated)
391
+
392
+ return { changed: true }
393
+ }
@@ -3,6 +3,25 @@ import fs from 'node:fs'
3
3
  import ts from 'typescript'
4
4
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
5
5
 
6
+ /**
7
+ * Resolved execution environment for the CLI.
8
+ * Produced once by resolveEnvironment() and consumed by all subsystems.
9
+ * Eliminates per-subsystem isMonorepo() branching and hardcoded path segments.
10
+ */
11
+ export type CliEnvironment = {
12
+ /** Whether the CLI is running inside a Yarn/npm workspace monorepo. */
13
+ mode: 'monorepo' | 'standalone'
14
+ /** Workspace or project root (where package.json / node_modules live). */
15
+ rootDir: string
16
+ /** Next.js application directory (contains src/, package.json, next.config.*). */
17
+ appDir: string
18
+ /**
19
+ * Resolve the root directory of an installed @open-mercato package.
20
+ * Monorepo: packages/<pkg>/ Standalone: node_modules/<pkg>/
21
+ */
22
+ packageRoot: (packageName: string) => string
23
+ }
24
+
6
25
  export type ModuleEntry = {
7
26
  id: string
8
27
  from?: '@open-mercato/core' | '@app' | string
@@ -419,18 +438,24 @@ function detectMonorepoFromNodeModules(appDir: string): { isMonorepo: boolean; m
419
438
 
420
439
  export function createResolver(cwd: string = process.cwd()): PackageResolver {
421
440
  // First detect if we're in a monorepo by checking if node_modules packages are symlinks
422
- const { isMonorepo: _isMonorepo, monorepoRoot } = detectMonorepoFromNodeModules(cwd)
423
- const rootDir = monorepoRoot ?? cwd
441
+ const { isMonorepo: _isMonorepo, monorepoRoot, nodeModulesRoot } = detectMonorepoFromNodeModules(cwd)
442
+ // In workspaces with hoisted real directories, package sources live under the discovered node_modules root.
443
+ const rootDir = monorepoRoot ?? nodeModulesRoot ?? cwd
444
+
445
+ // shouldResolveAppFromRoot: true when we have a workspace/install root that differs from cwd
446
+ // (monorepo symlinks detected, or node_modules root found above cwd)
447
+ const shouldResolveAppFromRoot =
448
+ _isMonorepo || (nodeModulesRoot !== null && path.resolve(nodeModulesRoot) !== path.resolve(cwd))
424
449
 
425
450
  // The app directory depends on context:
426
451
  // - In monorepo: use detectAppDir to find apps/mercato or similar
427
452
  // - When symlinks not detected (e.g. Docker volume node_modules): still use apps/mercato if present at rootDir
428
453
  // - Otherwise: app is at cwd
429
- const candidateAppDir = detectAppDir(rootDir, true)
454
+ const candidateAppDir = shouldResolveAppFromRoot ? detectAppDir(rootDir, true) : rootDir
430
455
  const appDir =
431
456
  _isMonorepo
432
457
  ? candidateAppDir
433
- : candidateAppDir !== rootDir && fs.existsSync(candidateAppDir)
458
+ : shouldResolveAppFromRoot && candidateAppDir !== rootDir && fs.existsSync(candidateAppDir)
434
459
  ? candidateAppDir
435
460
  : cwd
436
461
 
@@ -486,3 +511,20 @@ export function createResolver(cwd: string = process.cwd()): PackageResolver {
486
511
  },
487
512
  }
488
513
  }
514
+
515
+ /**
516
+ * Resolve the CLI execution environment as a plain value-object.
517
+ * Call once at subsystem init; use the returned object for all path decisions.
518
+ */
519
+ export function resolveEnvironment(cwd: string = process.cwd()): CliEnvironment {
520
+ const resolver = createResolver(cwd)
521
+ const _isMonorepo = resolver.isMonorepo()
522
+ const rootDir = resolver.getRootDir()
523
+ const appDir = resolver.getAppDir()
524
+ return {
525
+ mode: _isMonorepo ? 'monorepo' : 'standalone',
526
+ rootDir,
527
+ appDir,
528
+ packageRoot: (packageName: string) => pkgRootFor(rootDir, packageName, _isMonorepo),
529
+ }
530
+ }
@@ -109,4 +109,34 @@ describe('integration discovery', () => {
109
109
  'packages/create-app/template/src/modules/auth/__integration__/TC-AUTH-001.spec.ts',
110
110
  ])
111
111
  })
112
+
113
+ it('discovers standalone app integration tests from src/modules', async () => {
114
+ await writeTestFile(tempRoot, 'src/modules/auth/.gitkeep')
115
+ await writeTestFile(tempRoot, 'src/modules/sales/.gitkeep')
116
+ await writeTestFile(
117
+ tempRoot,
118
+ 'src/modules/sales/__integration__/TC-SALES-020.spec.ts',
119
+ 'export {}\n',
120
+ )
121
+
122
+ const discovered = discoverIntegrationSpecFiles(tempRoot, path.join(tempRoot, '.ai', 'qa', 'tests'))
123
+ expect(discovered.map((entry) => entry.path)).toEqual([
124
+ 'src/modules/sales/__integration__/TC-SALES-020.spec.ts',
125
+ ])
126
+ })
127
+
128
+ it('discovers standalone package integration tests from node_modules/@open-mercato', async () => {
129
+ await writeTestFile(tempRoot, 'src/modules/customers/.gitkeep')
130
+ await writeTestFile(tempRoot, 'node_modules/@open-mercato/core/src/modules/customers/.gitkeep')
131
+ await writeTestFile(
132
+ tempRoot,
133
+ 'node_modules/@open-mercato/core/src/modules/customers/__integration__/TC-CRM-020.spec.ts',
134
+ 'export {}\n',
135
+ )
136
+
137
+ const discovered = discoverIntegrationSpecFiles(tempRoot, path.join(tempRoot, '.ai', 'qa', 'tests'))
138
+ expect(discovered.map((entry) => entry.path)).toEqual([
139
+ 'node_modules/@open-mercato/core/src/modules/customers/__integration__/TC-CRM-020.spec.ts',
140
+ ])
141
+ })
112
142
  })
@@ -96,31 +96,44 @@ function collectDirectDirectoryNames(directoryPath: string): string[] {
96
96
  .map((entry) => entry.name)
97
97
  }
98
98
 
99
+ function collectModuleIdsFromModulesRoot(modulesRoot: string, enabledModules: Set<string>): void {
100
+ for (const moduleName of collectDirectDirectoryNames(modulesRoot)) {
101
+ enabledModules.add(normalizeModuleId(moduleName))
102
+ }
103
+ }
104
+
99
105
  function resolveEnabledModuleIds(projectRoot: string): Set<string> {
100
106
  const enabledModules = new Set<string>()
107
+ const appModulesRoot = path.join(projectRoot, 'src', 'modules')
101
108
  const appsRoot = path.join(projectRoot, 'apps')
102
109
  const packagesRoot = path.join(projectRoot, 'packages')
110
+ const installedPackagesRoot = path.join(projectRoot, 'node_modules', '@open-mercato')
103
111
  const enterpriseEnabled = isEnterpriseModulesEnabled()
104
112
 
113
+ // Standalone app: src/modules at project root
114
+ collectModuleIdsFromModulesRoot(appModulesRoot, enabledModules)
115
+
105
116
  for (const appName of collectDirectDirectoryNames(appsRoot)) {
106
117
  const moduleRoot = path.join(appsRoot, appName, 'src', 'modules')
107
- for (const moduleName of collectDirectDirectoryNames(moduleRoot)) {
108
- enabledModules.add(normalizeModuleId(moduleName))
109
- }
118
+ collectModuleIdsFromModulesRoot(moduleRoot, enabledModules)
110
119
  }
111
120
  for (const packageName of collectDirectDirectoryNames(packagesRoot)) {
112
121
  if (packageName === 'enterprise' && !enterpriseEnabled) {
113
122
  continue
114
123
  }
115
124
  const moduleRoot = path.join(packagesRoot, packageName, 'src', 'modules')
116
- for (const moduleName of collectDirectDirectoryNames(moduleRoot)) {
117
- enabledModules.add(normalizeModuleId(moduleName))
125
+ collectModuleIdsFromModulesRoot(moduleRoot, enabledModules)
126
+ }
127
+ // Standalone app: installed @open-mercato packages in node_modules
128
+ for (const packageName of collectDirectDirectoryNames(installedPackagesRoot)) {
129
+ if (packageName === 'enterprise' && !enterpriseEnabled) {
130
+ continue
118
131
  }
132
+ const moduleRoot = path.join(installedPackagesRoot, packageName, 'src', 'modules')
133
+ collectModuleIdsFromModulesRoot(moduleRoot, enabledModules)
119
134
  }
120
135
  const createAppTemplateModulesRoot = path.join(projectRoot, 'packages', 'create-app', 'template', 'src', 'modules')
121
- for (const moduleName of collectDirectDirectoryNames(createAppTemplateModulesRoot)) {
122
- enabledModules.add(normalizeModuleId(moduleName))
123
- }
136
+ collectModuleIdsFromModulesRoot(createAppTemplateModulesRoot, enabledModules)
124
137
 
125
138
  return enabledModules
126
139
  }
@@ -223,8 +236,10 @@ export function discoverIntegrationSpecFiles(projectRoot: string, legacyIntegrat
223
236
  const overlayRoot = resolveOverlayRootPath()
224
237
  const enterpriseEnabled = isEnterpriseModulesEnabled()
225
238
  const discoveryRoots = [
239
+ path.join(projectRoot, 'src', 'modules'),
226
240
  path.join(projectRoot, 'apps'),
227
241
  path.join(projectRoot, 'packages'),
242
+ path.join(projectRoot, 'node_modules', '@open-mercato'),
228
243
  ]
229
244
 
230
245
  for (const specFile of collectSpecFilesFromDirectory(legacyIntegrationRoot)) {