@open-mercato/cli 0.10.1-develop.7990.1.82d0a183c5 → 0.10.1-develop.7994.1.c9f31972aa

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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/agentic/guides/module-facts.json +162 -162
  3. package/dist/agentic/guides/module-facts.v2.json +162 -162
  4. package/dist/agentic/guides/module-tool.md +21 -0
  5. package/dist/agentic/guides/modules/agent_orchestrator/index.md +1 -1
  6. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  7. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  8. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  9. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  10. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  11. package/dist/agentic/guides/modules/auth/index.md +1 -1
  12. package/dist/agentic/guides/modules/availability/index.md +1 -1
  13. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  14. package/dist/agentic/guides/modules/carrier_inpost/index.md +1 -1
  15. package/dist/agentic/guides/modules/cart/index.md +1 -1
  16. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  17. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  18. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  19. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  20. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  21. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  22. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  23. package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
  24. package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
  25. package/dist/agentic/guides/modules/channel_smtp/index.md +1 -1
  26. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  27. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  28. package/dist/agentic/guides/modules/configs/index.md +1 -1
  29. package/dist/agentic/guides/modules/content/index.md +1 -1
  30. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  31. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  32. package/dist/agentic/guides/modules/customer_groups/index.md +1 -1
  33. package/dist/agentic/guides/modules/customers/index.md +1 -1
  34. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  35. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  36. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  37. package/dist/agentic/guides/modules/devices/index.md +1 -1
  38. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  39. package/dist/agentic/guides/modules/directory/index.md +1 -1
  40. package/dist/agentic/guides/modules/documents/index.md +1 -1
  41. package/dist/agentic/guides/modules/ecommerce/index.md +1 -1
  42. package/dist/agentic/guides/modules/entities/index.md +1 -1
  43. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  44. package/dist/agentic/guides/modules/events/index.md +1 -1
  45. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  46. package/dist/agentic/guides/modules/forms/index.md +1 -1
  47. package/dist/agentic/guides/modules/gateway_autopay/index.md +1 -1
  48. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  49. package/dist/agentic/guides/modules/gateway_tpay/index.md +1 -1
  50. package/dist/agentic/guides/modules/generators/index.md +1 -1
  51. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  52. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  53. package/dist/agentic/guides/modules/messages/index.md +1 -1
  54. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  55. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  56. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  57. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  58. package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
  59. package/dist/agentic/guides/modules/planner/index.md +1 -1
  60. package/dist/agentic/guides/modules/portal/index.md +1 -1
  61. package/dist/agentic/guides/modules/progress/index.md +1 -1
  62. package/dist/agentic/guides/modules/promotions/index.md +1 -1
  63. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  64. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  65. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  66. package/dist/agentic/guides/modules/resources/index.md +1 -1
  67. package/dist/agentic/guides/modules/sales/index.md +1 -1
  68. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  69. package/dist/agentic/guides/modules/search/index.md +1 -1
  70. package/dist/agentic/guides/modules/security/index.md +1 -1
  71. package/dist/agentic/guides/modules/seeds/index.md +1 -1
  72. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  73. package/dist/agentic/guides/modules/sso/index.md +1 -1
  74. package/dist/agentic/guides/modules/staff/index.md +1 -1
  75. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  76. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  77. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  78. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  79. package/dist/agentic/guides/modules/telemetry/index.md +1 -1
  80. package/dist/agentic/guides/modules/tillio/index.md +1 -1
  81. package/dist/agentic/guides/modules/translations/index.md +1 -1
  82. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  83. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  84. package/dist/agentic/guides/modules/wms/index.md +1 -1
  85. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  86. package/dist/agentic/guides/reference-module-facts.json +1 -1
  87. package/dist/agentic/guides/upstream/manifest.json +1 -1
  88. package/dist/agentic/shared/ai/harness/cases.json +23 -9
  89. package/dist/agentic/shared/ai/harness/source-link-inventory.json +90 -4
  90. package/dist/lib/__integration__/TC-INT-007.spec.js +207 -19
  91. package/dist/lib/__integration__/TC-INT-007.spec.js.map +2 -2
  92. package/dist/lib/__integration__/TC-INT-009.spec.js +267 -0
  93. package/dist/lib/__integration__/TC-INT-009.spec.js.map +7 -0
  94. package/dist/lib/eject.js +25 -9
  95. package/dist/lib/eject.js.map +2 -2
  96. package/dist/lib/generators/entity-ids.js +2 -0
  97. package/dist/lib/generators/entity-ids.js.map +2 -2
  98. package/dist/lib/generators/module-di.js +2 -0
  99. package/dist/lib/generators/module-di.js.map +2 -2
  100. package/dist/lib/generators/module-entities.js +2 -0
  101. package/dist/lib/generators/module-entities.js.map +2 -2
  102. package/dist/lib/generators/module-package-sources.js +6 -4
  103. package/dist/lib/generators/module-package-sources.js.map +2 -2
  104. package/dist/lib/generators/module-registry.js +32 -86
  105. package/dist/lib/generators/module-registry.js.map +2 -2
  106. package/dist/lib/generators/openapi.js +2 -0
  107. package/dist/lib/generators/openapi.js.map +2 -2
  108. package/dist/lib/generators/source-exports.js +73 -0
  109. package/dist/lib/generators/source-exports.js.map +7 -0
  110. package/dist/lib/init-summary.js +44 -0
  111. package/dist/lib/init-summary.js.map +7 -0
  112. package/dist/lib/module-github.js +98 -0
  113. package/dist/lib/module-github.js.map +7 -0
  114. package/dist/lib/module-identity.js +131 -0
  115. package/dist/lib/module-identity.js.map +7 -0
  116. package/dist/lib/module-inspection.js +124 -0
  117. package/dist/lib/module-inspection.js.map +7 -0
  118. package/dist/lib/module-install.js +150 -21
  119. package/dist/lib/module-install.js.map +3 -3
  120. package/dist/lib/module-package.js +205 -15
  121. package/dist/lib/module-package.js.map +2 -2
  122. package/dist/lib/modules-config.js +69 -30
  123. package/dist/lib/modules-config.js.map +2 -2
  124. package/dist/lib/resolver.js +33 -34
  125. package/dist/lib/resolver.js.map +2 -2
  126. package/dist/mercato.js +14 -23
  127. package/dist/mercato.js.map +2 -2
  128. package/package.json +7 -7
  129. package/src/__tests__/mercato.test.ts +14 -4
  130. package/src/lib/__fixtures__/official-module-package/dist/modules/test_package/widgets/injection/test/widget.js +1 -0
  131. package/src/lib/__integration__/TC-INT-007.spec.ts +219 -19
  132. package/src/lib/__integration__/TC-INT-009.spec.ts +279 -0
  133. package/src/lib/__tests__/eject.test.ts +98 -4
  134. package/src/lib/__tests__/init-summary.test.ts +50 -0
  135. package/src/lib/__tests__/module-github.test.ts +108 -0
  136. package/src/lib/__tests__/module-identity.test.ts +144 -0
  137. package/src/lib/__tests__/module-inspection.test.ts +195 -0
  138. package/src/lib/__tests__/module-install.test.ts +399 -10
  139. package/src/lib/__tests__/module-package.test.ts +154 -0
  140. package/src/lib/__tests__/modules-config.test.ts +55 -0
  141. package/src/lib/__tests__/resolver.packages.test.ts +29 -0
  142. package/src/lib/eject.ts +28 -10
  143. package/src/lib/generators/__tests__/module-id-collisions.test.ts +146 -0
  144. package/src/lib/generators/__tests__/module-package-sources.test.ts +19 -2
  145. package/src/lib/generators/__tests__/module-requirements.test.ts +56 -0
  146. package/src/lib/generators/entity-ids.ts +2 -0
  147. package/src/lib/generators/module-di.ts +2 -0
  148. package/src/lib/generators/module-entities.ts +2 -0
  149. package/src/lib/generators/module-package-sources.ts +7 -5
  150. package/src/lib/generators/module-registry.ts +33 -115
  151. package/src/lib/generators/openapi.ts +3 -0
  152. package/src/lib/generators/source-exports.ts +93 -0
  153. package/src/lib/init-summary.ts +54 -0
  154. package/src/lib/module-github.ts +109 -0
  155. package/src/lib/module-identity.ts +145 -0
  156. package/src/lib/module-inspection.ts +133 -0
  157. package/src/lib/module-install.ts +170 -22
  158. package/src/lib/module-package.ts +243 -16
  159. package/src/lib/modules-config.ts +80 -32
  160. package/src/lib/resolver.ts +35 -39
  161. package/src/mercato.ts +14 -24
  162. package/src/types/cross-spawn.d.ts +2 -2
@@ -2,10 +2,16 @@ import { EventEmitter } from 'node:events'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
- import type { PackageResolver } from '../resolver'
5
+ import { readEnabledModulesFromConfig, type PackageResolver } from '../resolver'
6
6
  import { addOfficialModule, enableOfficialModule } from '../module-install'
7
- import * as modulesConfig from '../modules-config'
7
+ import * as github from '../module-github'
8
+ import * as inspection from '../module-inspection'
8
9
  import spawn from 'cross-spawn'
10
+ import { execFileSync } from 'node:child_process'
11
+ import { getModuleLocators } from '../module-identity'
12
+ import { resolveModuleRequirement } from '@open-mercato/shared/modules/module-locators'
13
+
14
+ import * as modulesConfig from '../modules-config'
9
15
 
10
16
  jest.mock('cross-spawn', () => ({ __esModule: true, default: jest.fn() }))
11
17
 
@@ -18,10 +24,12 @@ function buildPackageFixture(
18
24
  ejectable?: boolean
19
25
  extraSourceFiles?: Array<{ relativePath: string; content: string }>
20
26
  packageName?: string
27
+ requires?: string[]
21
28
  },
22
29
  ): void {
23
30
  const ejectable = options?.ejectable ?? false
24
31
  const packageName = options?.packageName ?? '@open-mercato/test-package'
32
+ const requires = options?.requires ?? []
25
33
  for (const base of ['src', 'dist']) {
26
34
  fs.mkdirSync(path.join(packageRoot, base, 'modules', moduleId), { recursive: true })
27
35
  }
@@ -31,7 +39,7 @@ function buildPackageFixture(
31
39
  )
32
40
  fs.writeFileSync(
33
41
  path.join(packageRoot, 'src', 'modules', moduleId, 'index.ts'),
34
- `export const metadata = { title: 'Test', ejectable: ${ejectable ? 'true' : 'false'} }\n`,
42
+ `export const metadata = { title: 'Test', ejectable: ${ejectable ? 'true' : 'false'}, requires: ${JSON.stringify(requires)} }\n`,
35
43
  )
36
44
  fs.writeFileSync(
37
45
  path.join(packageRoot, 'dist', 'modules', moduleId, 'index.js'),
@@ -42,6 +50,18 @@ function buildPackageFixture(
42
50
  const targetPath = path.join(packageRoot, 'src', 'modules', moduleId, file.relativePath)
43
51
  fs.mkdirSync(path.dirname(targetPath), { recursive: true })
44
52
  fs.writeFileSync(targetPath, file.content)
53
+
54
+ if (/\.[jt]sx?$/.test(file.relativePath)) {
55
+ const runtimePath = path.join(
56
+ packageRoot,
57
+ 'dist',
58
+ 'modules',
59
+ moduleId,
60
+ file.relativePath.replace(/\.[jt]sx?$/, '.js'),
61
+ )
62
+ fs.mkdirSync(path.dirname(runtimePath), { recursive: true })
63
+ fs.writeFileSync(runtimePath, 'module.exports = {}\n')
64
+ }
45
65
  }
46
66
  }
47
67
 
@@ -53,6 +73,7 @@ function buildResolver(
53
73
  const modulesTsPath = path.join(appDir, 'src', 'modules.ts')
54
74
  fs.mkdirSync(path.join(appDir, 'src'), { recursive: true })
55
75
  fs.writeFileSync(modulesTsPath, modulesTsContent)
76
+ const initiallyEnabledModules = readEnabledModulesFromConfig(modulesTsPath)
56
77
 
57
78
  return {
58
79
  getAppDir: () => appDir,
@@ -60,8 +81,17 @@ function buildResolver(
60
81
  getPackageRoot: () => packageRoot,
61
82
  isMonorepo: () => false,
62
83
  getRootDir: () => appDir,
63
- loadEnabledModules: () => [],
64
- getModulePaths: () => ({ appBase: '', pkgBase: '' }),
84
+ discoverPackages: () => [],
85
+ loadEnabledModules: () => initiallyEnabledModules,
86
+ getModulePaths: (entry) => ({
87
+ appBase: path.join(appDir, 'src', 'modules', entry.id),
88
+ pkgBase: path.join(packageRoot, 'src', 'modules', entry.id),
89
+ }),
90
+ getModuleImportBase: (entry) => ({
91
+ appBase: `@/modules/${entry.id}`,
92
+ pkgBase: `${entry.from ?? '@open-mercato/core'}/modules/${entry.id}`,
93
+ }),
94
+ getPackageOutputDir: () => path.join(packageRoot, 'generated'),
65
95
  getOutputDir: () => path.join(appDir, '.mercato', 'generated'),
66
96
  } as unknown as PackageResolver
67
97
  }
@@ -77,6 +107,14 @@ describe('enableOfficialModule', () => {
77
107
  fs.rmSync(tmpDir, { recursive: true, force: true })
78
108
  })
79
109
 
110
+ it.each(['../outside-package', '/absolute/package', 'npm:package', '@vendor/package@next'])('rejects %s as an installed binding before package resolution', async (binding) => {
111
+ const resolver = buildResolver(path.join(tmpDir, 'app'), path.join(tmpDir, 'package'), 'export const enabledModules = []\n')
112
+ const lookup = jest.spyOn(resolver, 'getPackageRoot')
113
+ await expect(enableOfficialModule(resolver, binding, undefined, false, true)).rejects.toThrow('Unsupported installed package binding')
114
+ expect(lookup).not.toHaveBeenCalled()
115
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe('export const enabledModules = []\n')
116
+ })
117
+
80
118
  it('succeeds without changes when the module is already enabled with the same source', async () => {
81
119
  const packageRoot = path.join(tmpDir, 'node_modules', '@open-mercato', 'test-package')
82
120
  const appDir = path.join(tmpDir, 'app')
@@ -197,6 +235,7 @@ describe('enableOfficialModule', () => {
197
235
  packageRoot,
198
236
  "export const enabledModules = []\n",
199
237
  )
238
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ dependencies: { '@open-mercato/test-package': '0.1.0' } }))
200
239
 
201
240
  await expect(
202
241
  enableOfficialModule(
@@ -215,8 +254,46 @@ describe('enableOfficialModule', () => {
215
254
  expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'test_package', 'index.ts'))).toBe(true)
216
255
  expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'test_package', 'backend', 'page.tsx'))).toBe(true)
217
256
  expect(fs.readFileSync(path.join(appDir, 'src', 'modules.ts'), 'utf8')).toContain(
218
- "{ id: 'test_package', from: '@app' }",
257
+ "{ id: 'test_package', from: '@app', locator: 'npm:@open-mercato/test-package', packageName: '@open-mercato/test-package' }",
258
+ )
259
+ })
260
+
261
+ it('rejects missing descriptor dependencies before registration or ejection', async () => {
262
+ const packageRoot = path.join(tmpDir, 'node_modules', '@open-mercato', 'test-package')
263
+ const appDir = path.join(tmpDir, 'app')
264
+ const initialModulesSource = "export const enabledModules = [{ id: 'customers' }]\n"
265
+ buildPackageFixture(packageRoot, 'test_package', {
266
+ ejectable: true,
267
+ requires: ['customers', 'catalog'],
268
+ })
269
+ const resolver = buildResolver(appDir, packageRoot, initialModulesSource)
270
+
271
+ await expect(
272
+ enableOfficialModule(resolver, '@open-mercato/test-package', undefined, true),
273
+ ).rejects.toThrow(
274
+ 'Module "test_package" requires module(s) that are not enabled: catalog.\n'
275
+ + 'Enable the missing module(s) in src/modules.ts first, then rerun this command.',
219
276
  )
277
+
278
+ expect(fs.readFileSync(path.join(appDir, 'src', 'modules.ts'), 'utf8')).toBe(initialModulesSource)
279
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'test_package'))).toBe(false)
280
+ })
281
+
282
+ it('allows registration when every descriptor dependency is enabled', async () => {
283
+ const packageRoot = path.join(tmpDir, 'node_modules', '@open-mercato', 'test-package')
284
+ const appDir = path.join(tmpDir, 'app')
285
+ buildPackageFixture(packageRoot, 'test_package', {
286
+ requires: ['customers', 'catalog'],
287
+ })
288
+ const resolver = buildResolver(
289
+ appDir,
290
+ packageRoot,
291
+ "export const enabledModules = [{ id: 'customers' }, { id: 'catalog' }]\n",
292
+ )
293
+
294
+ await expect(
295
+ enableOfficialModule(resolver, '@open-mercato/test-package'),
296
+ ).resolves.toMatchObject({ moduleId: 'test_package', registrationChanged: true })
220
297
  })
221
298
 
222
299
  it('rejects --eject for a module gated behind a disabled env flag before copying any source', async () => {
@@ -344,15 +421,323 @@ describe('third-party module packages', () => {
344
421
  })
345
422
  })
346
423
 
424
+
425
+ describe('canonical module preflight', () => {
426
+ let root: string
427
+ beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-canonical-')) })
428
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }))
429
+
430
+ it.each(['directory', 'archive'] as const)('keeps an already-enabled file %s a no-op without reinstalling', async (kind) => {
431
+ const packageRoot = path.join(root, 'package')
432
+ const appDir = path.join(root, 'app')
433
+ const packageName = '@vendor/consents'
434
+ buildPackageFixture(packageRoot, 'consents', { packageName })
435
+ if (kind === 'archive') execFileSync('tar', ['-czf', path.join(root, 'candidate.tgz'), '-C', root, 'package'])
436
+ const fileLocator = kind === 'archive' ? 'file:../candidate.tgz' : 'file:../package'
437
+ const source = `export const enabledModules = [{ id: 'consents', from: '${packageName}' }]\n`
438
+ const resolver = buildResolver(appDir, packageRoot, source)
439
+ const manifest = JSON.stringify({ dependencies: { [packageName]: fileLocator } })
440
+ fs.writeFileSync(path.join(appDir, 'package.json'), manifest)
441
+ jest.mocked(spawn).mockClear()
442
+
443
+ await expect(addOfficialModule(resolver, `${packageName}@${fileLocator}`, false, undefined, true)).resolves.toMatchObject({ moduleId: 'consents', registrationChanged: false })
444
+ expect(spawn).not.toHaveBeenCalled()
445
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
446
+ expect(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).toBe(manifest)
447
+ expect(fs.existsSync(resolver.getOutputDir())).toBe(false)
448
+ })
449
+
450
+ it.each(['directory', 'archive'] as const)('rejects a different unknown file %s even when its module is already enabled', async (kind) => {
451
+ const packageRoot = path.join(root, 'package')
452
+ const appDir = path.join(root, 'app')
453
+ const packageName = '@vendor/consents'
454
+ buildPackageFixture(packageRoot, 'consents', { packageName })
455
+ if (kind === 'archive') execFileSync('tar', ['-czf', path.join(root, 'candidate.tgz'), '-C', root, 'package'])
456
+ const fileLocator = kind === 'archive' ? 'file:../candidate.tgz' : 'file:../package'
457
+ const source = `export const enabledModules = [{ id: 'consents', from: '${packageName}' }]\n`
458
+ const resolver = buildResolver(appDir, packageRoot, source)
459
+ const manifest = JSON.stringify({ dependencies: { [packageName]: 'file:../other-package' } })
460
+ fs.writeFileSync(path.join(appDir, 'package.json'), manifest)
461
+ jest.mocked(spawn).mockClear()
462
+
463
+ await expect(addOfficialModule(resolver, `${packageName}@${fileLocator}`, false, undefined, true)).rejects.toThrow('Package binding collision')
464
+ expect(spawn).not.toHaveBeenCalled()
465
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
466
+ expect(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).toBe(manifest)
467
+ })
468
+
469
+ it.each([
470
+ ['directory', false], ['archive', false], ['directory', true], ['archive', true],
471
+ ] as const)('keeps a new file %s install provenance unknown with eject=%s', async (kind, eject) => {
472
+ const packageRoot = path.join(root, 'package')
473
+ const appDir = path.join(root, 'app')
474
+ const packageName = '@vendor/consents'
475
+ buildPackageFixture(packageRoot, 'consents', { packageName, ejectable: true })
476
+ if (kind === 'archive') execFileSync('tar', ['-czf', path.join(root, 'candidate.tgz'), '-C', root, 'package'])
477
+ const fileLocator = kind === 'archive' ? 'file:../candidate.tgz' : 'file:../package'
478
+ const resolver = buildResolver(appDir, packageRoot, 'export const enabledModules = []\n')
479
+ resolver.loadEnabledModules = () => readEnabledModulesFromConfig(resolver.getModulesConfigPath())
480
+ jest.mocked(spawn).mockImplementation(() => {
481
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ dependencies: { [packageName]: fileLocator } }))
482
+ const child = new EventEmitter()
483
+ queueMicrotask(() => child.emit('exit', 0))
484
+ return child as ReturnType<typeof spawn>
485
+ })
486
+ try {
487
+ await addOfficialModule(resolver, `${packageName}@${fileLocator}`, eject, undefined, true)
488
+ const [entry] = resolver.loadEnabledModules()
489
+ expect(entry).toMatchObject({ id: 'consents', from: eject ? '@app' : packageName })
490
+ expect(entry.locator).toBeUndefined()
491
+ if (eject) {
492
+ expect(entry.packageName).toBe(packageName)
493
+ expect(fs.readFileSync(path.join(appDir, 'src/modules/consents/index.ts'), 'utf8')).toBe(fs.readFileSync(path.join(packageRoot, 'src/modules/consents/index.ts'), 'utf8'))
494
+ }
495
+ const locators = getModuleLocators(entry, resolver)
496
+ expect(locators).toEqual([])
497
+ expect(resolveModuleRequirement(`npm:${packageName}`, [{ id: entry.id, locators }])).toBeUndefined()
498
+ expect(resolveModuleRequirement('consents', [{ id: entry.id, locators }])).toBe('consents')
499
+
500
+ const dependentRoot = path.join(root, 'dependent')
501
+ buildPackageFixture(dependentRoot, 'dependent', { packageName: '@vendor/dependent', requires: [`npm:${packageName}`], ejectable: true })
502
+ resolver.getPackageRoot = (binding) => binding === '@vendor/dependent' ? dependentRoot : packageRoot
503
+ const sourceBefore = fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')
504
+ await expect(enableOfficialModule(resolver, '@vendor/dependent', undefined, true, true)).rejects.toThrow('requires module(s) that are not enabled: npm:@vendor/consents')
505
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(sourceBefore)
506
+ expect(fs.existsSync(path.join(appDir, 'src/modules/dependent'))).toBe(false)
507
+ } finally { jest.mocked(spawn).mockReset() }
508
+ })
509
+
510
+ it.each([
511
+ ['directory', false], ['archive', false], ['directory', true], ['archive', true],
512
+ ] as const)('rejects an unknown file %s origin before replacing an enabled binding with implicit core=%s', async (kind, implicitCore) => {
513
+ const packageRoot = path.join(root, 'package')
514
+ const appDir = path.join(root, 'app')
515
+ const packageName = implicitCore ? '@open-mercato/core' : '@vendor/consents'
516
+ buildPackageFixture(packageRoot, 'consents', { packageName, ejectable: true })
517
+ if (kind === 'archive') execFileSync('tar', ['-czf', path.join(root, 'candidate.tgz'), '-C', root, 'package'])
518
+ const source = `export const enabledModules = [{ id: 'consents', ${implicitCore ? '' : `from: '${packageName}', `}locator: 'npm:${packageName}' }]\n`
519
+ const resolver = buildResolver(appDir, packageRoot, source)
520
+ if (implicitCore) resolver.loadEnabledModules = () => [{ id: 'consents', locator: `npm:${packageName}` }]
521
+ const manifest = JSON.stringify({ dependencies: { [packageName]: '1.0.0' } })
522
+ fs.writeFileSync(path.join(appDir, 'package.json'), manifest)
523
+ jest.mocked(spawn).mockClear()
524
+ const fileLocator = kind === 'archive' ? 'file:../candidate.tgz' : 'file:../package'
525
+ await expect(addOfficialModule(resolver, `${packageName}@${fileLocator}`, true, undefined, true)).rejects.toThrow('Package binding collision')
526
+ expect(spawn).not.toHaveBeenCalled()
527
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
528
+ expect(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).toBe(manifest)
529
+ expect(fs.existsSync(path.join(appDir, 'src/modules/consents'))).toBe(false)
530
+ })
531
+
532
+ it('revalidates implicit core binding consumers after package installation before any registration or eject copy', async () => {
533
+ const appDir = path.join(root, 'app')
534
+ const packageRoot = path.join(appDir, 'node_modules', '@open-mercato', 'core')
535
+ const packageName = '@open-mercato/core'
536
+ buildPackageFixture(packageRoot, 'active', { packageName })
537
+ buildPackageFixture(packageRoot, 'candidate', { packageName, ejectable: true })
538
+ const source = "export const enabledModules = [{ id: 'active' }]\n"
539
+ const resolver = buildResolver(appDir, packageRoot, source)
540
+ resolver.loadEnabledModules = () => [{ id: 'active' }]
541
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ dependencies: { [packageName]: '0.1.0' } }))
542
+ const inspect = jest.spyOn(inspection, 'inspectNpmModuleTarget').mockReturnValue({ packageName, packageSpec: `${packageName}@next`, locator: `npm:${packageName}`, moduleIds: ['active', 'candidate'] })
543
+ jest.mocked(spawn).mockImplementation(() => {
544
+ for (const base of ['src', 'dist']) fs.rmSync(path.join(packageRoot, base, 'modules/active'), { recursive: true })
545
+ const child = new EventEmitter()
546
+ queueMicrotask(() => child.emit('exit', 0))
547
+ return child as ReturnType<typeof spawn>
548
+ })
549
+ try {
550
+ await expect(addOfficialModule(resolver, `${packageName}@next`, true, 'candidate')).rejects.toThrow('does not contain module "active"')
551
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
552
+ expect(fs.existsSync(path.join(appDir, 'src/modules/candidate'))).toBe(false)
553
+ } finally { inspect.mockRestore(); jest.mocked(spawn).mockReset() }
554
+ })
555
+
556
+ it('does not satisfy a required scoped package with a same-name module from another scope', async () => {
557
+ const packageRoot = path.join(root, 'package')
558
+ const appDir = path.join(root, 'app')
559
+ const source = "export const enabledModules = [{ id: 'marketing_consents', from: '@other/marketing_consents' }]\n"
560
+ buildPackageFixture(packageRoot, 'test_package', { requires: ['@pkarw/marketing_consents@latest'] })
561
+ const resolver = buildResolver(appDir, packageRoot, source)
562
+ await expect(enableOfficialModule(resolver, '@open-mercato/test-package')).rejects.toThrow("npx create-mercato-module install '@pkarw/marketing_consents@latest'")
563
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
564
+ })
565
+
566
+ it('matches canonical npm requirements to the enabled package source', async () => {
567
+ const packageRoot = path.join(root, 'package')
568
+ const appDir = path.join(root, 'app')
569
+ buildPackageFixture(packageRoot, 'test_package', { requires: ['npm:@pkarw/marketing_consents@1.2.0'] })
570
+ for (const base of ['src', 'dist']) {
571
+ fs.mkdirSync(path.join(packageRoot, base, 'modules', 'marketing_consents'), { recursive: true })
572
+ fs.writeFileSync(path.join(packageRoot, base, 'modules', 'marketing_consents', base === 'src' ? 'index.ts' : 'index.js'), 'export const metadata = {}')
573
+ }
574
+ const resolver = buildResolver(appDir, packageRoot, "export const enabledModules = [{ id: 'marketing_consents', from: '@pkarw/marketing_consents' }]\n")
575
+ const requiredPackageRoot = path.join(root, 'required-package')
576
+ fs.mkdirSync(requiredPackageRoot, { recursive: true })
577
+ fs.writeFileSync(path.join(requiredPackageRoot, 'package.json'), JSON.stringify({ name: '@pkarw/marketing_consents' }))
578
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ dependencies: { '@pkarw/marketing_consents': '1.2.0' } }))
579
+ resolver.getPackageRoot = (packageName) => packageName === '@pkarw/marketing_consents' ? requiredPackageRoot : packageRoot
580
+ await expect(enableOfficialModule(resolver, '@open-mercato/test-package', 'test_package')).resolves.toMatchObject({ moduleId: 'test_package' })
581
+ })
582
+
583
+ it('rejects name collisions before copying ejected sources', async () => {
584
+ const packageRoot = path.join(root, 'package')
585
+ const appDir = path.join(root, 'app')
586
+ buildPackageFixture(packageRoot, 'marketing_consents', { ejectable: true })
587
+ const source = "export const enabledModules = [{ id: 'marketing_consents', from: '@other/marketing_consents' }]\n"
588
+ const resolver = buildResolver(appDir, packageRoot, source)
589
+ await expect(enableOfficialModule(resolver, '@open-mercato/test-package', undefined, true)).rejects.toThrow('Module name collision')
590
+ expect(fs.existsSync(path.join(appDir, 'src/modules/marketing_consents'))).toBe(false)
591
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
592
+ })
593
+
594
+ it('rejects a GitHub same-name collision before package installation', async () => {
595
+ const packageRoot = path.join(root, 'package')
596
+ const appDir = path.join(root, 'app')
597
+ const source = "export const enabledModules = [{ id: 'marketing_consents', from: '@other/marketing_consents' }]\n"
598
+ const resolver = buildResolver(appDir, packageRoot, source)
599
+ const lookup = jest.spyOn(github, 'resolveGithubModuleTarget').mockReturnValue({
600
+ packageName: '@pkarw/marketing_consents', moduleIds: ['marketing_consents'],
601
+ packageSpec: '@pkarw/marketing_consents@github:pkarw/consents#' + 'a'.repeat(40), locator: 'github:pkarw/consents',
602
+ })
603
+ try {
604
+ await expect(addOfficialModule(resolver, 'github:pkarw/consents', false, undefined, true)).rejects.toThrow('Module name collision')
605
+ expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(false)
606
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
607
+ } finally {
608
+ lookup.mockRestore()
609
+ }
610
+ })
611
+
612
+ it('rejects replacing the same npm name from a different GitHub origin', async () => {
613
+ const packageRoot = path.join(root, 'package')
614
+ const appDir = path.join(root, 'app')
615
+ const resolver = buildResolver(appDir, packageRoot, "export const enabledModules = [{ id: 'marketing_consents', from: '@pkarw/consents', locator: 'github:pkarw/original' }]\n")
616
+ const lookup = jest.spyOn(github, 'resolveGithubModuleTarget').mockReturnValue({
617
+ packageName: '@pkarw/consents', moduleIds: ['marketing_consents'],
618
+ packageSpec: '@pkarw/consents@github:pkarw/replacement#' + 'a'.repeat(40), locator: 'github:pkarw/replacement',
619
+ })
620
+ try {
621
+ await expect(addOfficialModule(resolver, 'github:pkarw/replacement', false, undefined, true)).rejects.toThrow('github:pkarw/original')
622
+ } finally {
623
+ lookup.mockRestore()
624
+ }
625
+ })
626
+
627
+
628
+ it('requires explicit trust for GitHub sources even if they declare an official npm namespace', async () => {
629
+ const resolver = buildResolver(path.join(root, 'app'), path.join(root, 'package'), 'export const enabledModules = []\n')
630
+ const lookup = jest.spyOn(github, 'resolveGithubModuleTarget').mockReturnValue({
631
+ packageName: '@open-mercato/consents', moduleIds: ['marketing_consents'],
632
+ packageSpec: '@open-mercato/consents@github:attacker/consents#' + 'a'.repeat(40), locator: 'github:attacker/consents',
633
+ })
634
+ try {
635
+ await expect(addOfficialModule(resolver, 'github:attacker/consents', false)).rejects.toThrow('--allow-third-party')
636
+ expect(lookup).not.toHaveBeenCalled()
637
+ } finally {
638
+ lookup.mockRestore()
639
+ }
640
+ })
641
+
642
+
643
+ it('rejects duplicate existing registrations before eject source writes', async () => {
644
+ const packageRoot = path.join(root, 'package')
645
+ const appDir = path.join(root, 'app')
646
+ buildPackageFixture(packageRoot, 'marketing_consents', { ejectable: true })
647
+ const source = "export const enabledModules = [{ id: 'marketing_consents', from: '@open-mercato/test-package' }, { id: 'marketing_consents', from: '@other/consents' }]\n"
648
+ const resolver = buildResolver(appDir, packageRoot, source)
649
+ await expect(enableOfficialModule(resolver, '@open-mercato/test-package', undefined, true)).rejects.toThrow('Duplicate enabled module ID')
650
+ expect(fs.existsSync(path.join(appDir, 'src/modules/marketing_consents'))).toBe(false)
651
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
652
+ })
653
+
654
+ it('rejects a changed GitHub binding before Yarn even when the selected ID differs', async () => {
655
+ const packageRoot = path.join(root, 'package')
656
+ const appDir = path.join(root, 'app')
657
+ buildPackageFixture(packageRoot, 'original', { packageName: '@vendor/bundle' })
658
+ const source = "export const enabledModules = [{ id: 'original', from: '@vendor/bundle', locator: 'github:vendor/original' }]\n"
659
+ const resolver = buildResolver(appDir, packageRoot, source)
660
+ const lookup = jest.spyOn(github, 'resolveGithubModuleTarget').mockReturnValue({
661
+ packageName: '@vendor/bundle', moduleIds: ['replacement'], locator: 'github:vendor/replacement',
662
+ packageSpec: '@vendor/bundle@github:vendor/replacement#' + 'a'.repeat(40),
663
+ })
664
+ try {
665
+ await expect(addOfficialModule(resolver, 'github:vendor/replacement', false, undefined, true)).rejects.toThrow('Package binding collision')
666
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
667
+ expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(false)
668
+ } finally { lookup.mockRestore() }
669
+ })
670
+
671
+ it('rejects npm updates that remove an active sibling before installation', async () => {
672
+ const packageRoot = path.join(root, 'package')
673
+ const appDir = path.join(root, 'app')
674
+ buildPackageFixture(packageRoot, 'original', { packageName: '@vendor/bundle' })
675
+ const source = "export const enabledModules = [{ id: 'original', from: '@vendor/bundle', locator: 'npm:@vendor/bundle' }]\n"
676
+ const resolver = buildResolver(appDir, packageRoot, source)
677
+ const lookup = jest.spyOn(inspection, 'inspectNpmModuleTarget').mockReturnValue({
678
+ packageName: '@vendor/bundle', moduleIds: ['replacement'], locator: 'npm:@vendor/bundle', packageSpec: '@vendor/bundle@2.0.0',
679
+ })
680
+ try {
681
+ await expect(addOfficialModule(resolver, '@vendor/bundle@next', false, 'replacement', true)).rejects.toThrow('preserve its source and module layout')
682
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
683
+ } finally { lookup.mockRestore() }
684
+ })
685
+
686
+ it('preserves a single package registration with a local overlay on repeated enable', async () => {
687
+ const packageRoot = path.join(root, 'package')
688
+ const appDir = path.join(root, 'app')
689
+ buildPackageFixture(packageRoot, 'test_package')
690
+ const source = "export const enabledModules = [{ id: 'test_package', from: '@open-mercato/test-package' }]\n"
691
+ const resolver = buildResolver(appDir, packageRoot, source)
692
+ const localPath = path.join(appDir, 'src/modules/test_package/index.ts')
693
+ fs.mkdirSync(path.dirname(localPath), { recursive: true })
694
+ fs.writeFileSync(localPath, 'export const metadata = { title: "Overlay" }')
695
+ await expect(enableOfficialModule(resolver, '@open-mercato/test-package')).resolves.toMatchObject({ registrationChanged: false })
696
+ expect(fs.readFileSync(localPath, 'utf8')).toContain('Overlay')
697
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
698
+ })
699
+
700
+ it('reports a post-install version disagreement without registering or ejecting', async () => {
701
+ const packageRoot = path.join(root, 'package')
702
+ const appDir = path.join(root, 'app')
703
+ buildPackageFixture(packageRoot, 'test_package', { ejectable: true })
704
+ const source = 'export const enabledModules = []\n'
705
+ const resolver = buildResolver(appDir, packageRoot, source)
706
+ const lookup = jest.spyOn(inspection, 'inspectNpmModuleTarget').mockReturnValue({
707
+ packageName: '@open-mercato/test-package', packageSpec: '@open-mercato/test-package@next',
708
+ locator: 'npm:@open-mercato/test-package', moduleIds: ['test_package'], version: '2.0.0',
709
+ })
710
+ jest.mocked(spawn).mockImplementation(() => {
711
+ const child = new EventEmitter()
712
+ queueMicrotask(() => child.emit('exit', 0))
713
+ return child as ReturnType<typeof spawn>
714
+ })
715
+ try {
716
+ await expect(addOfficialModule(resolver, '@open-mercato/test-package@next', true)).rejects.toThrow('differs from inspected version')
717
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(source)
718
+ expect(fs.existsSync(path.join(appDir, 'src/modules/test_package'))).toBe(false)
719
+ expect(spawn).toHaveBeenCalledWith(expect.anything(), ['add', '@open-mercato/test-package@next'], expect.anything())
720
+ } finally { lookup.mockRestore(); jest.mocked(spawn).mockReset() }
721
+ })
722
+
723
+ })
724
+
347
725
  describe('addOfficialModule', () => {
348
726
  let tmpDir: string
349
727
 
350
728
  beforeEach(() => {
351
729
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-install-add-test-'))
352
730
  mockedSpawn.mockReset()
731
+ jest.spyOn(inspection, 'inspectNpmModuleTarget').mockImplementation((packageSpec, packageName) => ({
732
+ packageName,
733
+ packageSpec,
734
+ moduleIds: ['test_package'],
735
+ locator: `npm:${packageName}`,
736
+ }))
353
737
  })
354
738
 
355
739
  afterEach(() => {
740
+ jest.restoreAllMocks()
356
741
  fs.rmSync(tmpDir, { recursive: true, force: true })
357
742
  })
358
743
 
@@ -468,9 +853,11 @@ describe('addOfficialModule', () => {
468
853
  new Error('Module "test_package" is gated off. Enable it by setting OM_ENABLE_TEST_PACKAGE=true, then run `yarn generate`.'),
469
854
  { code: 'MODULE_GATED_OFF' },
470
855
  )
856
+ const prepareRegistration = modulesConfig.prepareModuleRegistration
471
857
  const registrationSpy = jest
472
- .spyOn(modulesConfig, 'ensureModuleRegistration')
473
- .mockImplementation(() => {
858
+ .spyOn(modulesConfig, 'prepareModuleRegistration')
859
+ .mockImplementationOnce(prepareRegistration)
860
+ .mockImplementationOnce(() => {
474
861
  throw gatedOffError
475
862
  })
476
863
 
@@ -490,9 +877,11 @@ describe('addOfficialModule', () => {
490
877
  writeAppPackageJson(appDir)
491
878
  const resolver = buildResolver(appDir, packageRoot, 'export const enabledModules = []\n')
492
879
  simulateYarnAdd(appDir, '@open-mercato/test-package')
880
+ const prepareRegistration = modulesConfig.prepareModuleRegistration
493
881
  const registrationSpy = jest
494
- .spyOn(modulesConfig, 'ensureModuleRegistration')
495
- .mockImplementation(() => {
882
+ .spyOn(modulesConfig, 'prepareModuleRegistration')
883
+ .mockImplementationOnce(prepareRegistration)
884
+ .mockImplementationOnce(() => {
496
885
  throw new Error('Could not update modules.ts.')
497
886
  })
498
887