@open-mercato/cli 0.10.1-develop.7983.1.fd91207efe → 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
@@ -0,0 +1,279 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { execFileSync, spawnSync } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..')
9
+ const cliBin = path.join(repoRoot, 'packages', 'cli', 'dist', 'bin.js')
10
+
11
+ function writeFile(filePath: string, content: string): void {
12
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
13
+ fs.writeFileSync(filePath, content)
14
+ }
15
+
16
+ function createPackage(packageRoot: string, packageName: string, moduleIds: string[]): void {
17
+ writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: packageName, version: '0.4.7', type: 'module' }))
18
+ for (const moduleId of moduleIds) {
19
+ writeFile(path.join(packageRoot, 'src', 'modules', moduleId, 'index.ts'), `export const metadata = { id: '${moduleId}', title: '${moduleId}', ejectable: true }\n`)
20
+ writeFile(path.join(packageRoot, 'dist', 'modules', moduleId, 'index.js'), `export const metadata = { id: '${moduleId}', title: '${moduleId}', ejectable: true }\n`)
21
+ writeFile(path.join(packageRoot, 'src', 'modules', moduleId, 'di.ts'), 'export function register() {}\n')
22
+ writeFile(path.join(packageRoot, 'dist', 'modules', moduleId, 'di.js'), 'export function register() {}\n')
23
+ }
24
+ }
25
+
26
+ function createApp(appDir: string, dependencies: Record<string, string> = {}): void {
27
+ writeFile(path.join(appDir, 'package.json'), JSON.stringify({ name: 'module-collision-integration', private: true, dependencies: { '@open-mercato/core': '0.4.7', ...dependencies } }))
28
+ writeFile(path.join(appDir, 'yarn.lock'), '')
29
+ writeFile(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
30
+ createPackage(path.join(appDir, 'node_modules', '@open-mercato', 'core'), '@open-mercato/core', [])
31
+ }
32
+
33
+ function runMercato(args: string[], appDir: string, environment: NodeJS.ProcessEnv = {}): { status: number | null; output: string } {
34
+ const result = spawnSync(process.execPath, [cliBin, ...args], {
35
+ cwd: appDir,
36
+ encoding: 'utf8',
37
+ env: { ...process.env, FORCE_COLOR: '0', NODE_NO_WARNINGS: '1', ...environment },
38
+ })
39
+ if (result.error) throw result.error
40
+ return { status: result.status, output: `${result.stdout}\n${result.stderr}` }
41
+ }
42
+
43
+ function installCommandStubs(appDir: string, candidateName?: string, candidateModuleId?: string): NodeJS.ProcessEnv {
44
+ const executableDir = path.join(appDir, 'bin')
45
+ const marker = path.join(appDir, 'yarn-invoked')
46
+ const yarnPath = path.join(executableDir, process.platform === 'win32' ? 'yarn.cmd' : 'yarn')
47
+ const yarnScript = `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'invoked'); process.exit(1)\n`
48
+ writeFile(yarnPath, process.platform === 'win32'
49
+ ? `@"${process.execPath}" "%~dp0yarn-stub.cjs" %*\r\n`
50
+ : `#!${process.execPath}\n${yarnScript}`)
51
+ writeFile(path.join(executableDir, 'yarn-stub.cjs'), yarnScript)
52
+ fs.chmodSync(yarnPath, 0o755)
53
+ if (candidateName && candidateModuleId) {
54
+ const manifest = Buffer.from(JSON.stringify({ name: candidateName })).toString('base64')
55
+ const ghScript = `const endpoint = process.argv[3] || ''
56
+ const value = endpoint === 'user' ? { login: 'candidate' }
57
+ : endpoint.includes('/commits/') ? { sha: 'a'.repeat(40) }
58
+ : endpoint.includes('/contents/') ? { encoding: 'base64', content: ${JSON.stringify(manifest)} }
59
+ : endpoint.includes('/git/trees/') ? { tree: [{ path: 'src/modules/${candidateModuleId}/index.ts' }, { path: 'dist/modules/${candidateModuleId}/index.js' }] }
60
+ : { default_branch: 'main' }
61
+ process.stdout.write(JSON.stringify(value))\n`
62
+ const ghPath = path.join(executableDir, process.platform === 'win32' ? 'gh.cmd' : 'gh')
63
+ writeFile(ghPath, process.platform === 'win32'
64
+ ? `@"${process.execPath}" "%~dp0gh-stub.cjs" %*\r\n`
65
+ : `#!${process.execPath}\n${ghScript}`)
66
+ writeFile(path.join(executableDir, 'gh-stub.cjs'), ghScript)
67
+ fs.chmodSync(ghPath, 0o755)
68
+ }
69
+ return { PATH: executableDir + path.delimiter + process.env.PATH }
70
+ }
71
+
72
+ function snapshotDirectory(directory: string): Record<string, string> {
73
+ if (!fs.existsSync(directory)) return {}
74
+ const files: Record<string, string> = {}
75
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
76
+ const fullPath = path.join(directory, entry.name)
77
+ if (entry.isDirectory()) {
78
+ for (const [relativePath, content] of Object.entries(snapshotDirectory(fullPath))) files[path.join(entry.name, relativePath)] = content
79
+ } else {
80
+ files[entry.name] = fs.readFileSync(fullPath).toString('base64')
81
+ }
82
+ }
83
+ return files
84
+ }
85
+
86
+ test.describe('TC-INT-009: CLI source collision lifecycle safeguards', () => {
87
+ for (const kind of ['directory', 'archive']) {
88
+ test(`rejects a local ${kind} with unknown provenance before replacing an active npm binding`, () => {
89
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-file-origin-'))
90
+ const appDir = path.join(root, 'app')
91
+ try {
92
+ const binding = '@publisher/consents'
93
+ createApp(appDir, { [binding]: '0.4.7' })
94
+ createPackage(path.join(appDir, 'node_modules', binding), binding, ['consents'])
95
+ const packageRoot = path.join(root, 'package')
96
+ createPackage(packageRoot, binding, ['consents'])
97
+ if (kind === 'archive') execFileSync('tar', ['-czf', path.join(root, 'candidate.tgz'), '-C', root, 'package'])
98
+ const modulesSource = `export const enabledModules = [{ id: 'consents', from: '${binding}', locator: 'npm:${binding}' }]\n`
99
+ writeFile(path.join(appDir, 'src', 'modules.ts'), modulesSource)
100
+ const manifestBefore = fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')
101
+ const installedBefore = snapshotDirectory(path.join(appDir, 'node_modules'))
102
+ const environment = installCommandStubs(appDir)
103
+ const locator = kind === 'archive' ? 'file:../candidate.tgz' : 'file:../package'
104
+ const result = runMercato(['module', 'add', `${binding}@${locator}`, '--eject', '--allow-third-party'], appDir, environment)
105
+
106
+ expect(result.status, result.output).not.toBe(0)
107
+ expect(result.output).toContain('Package binding collision')
108
+ expect(result.output).toContain('unknown origin')
109
+ expect(result.output).toContain('consents')
110
+ expect(fs.existsSync(path.join(appDir, 'yarn-invoked'))).toBe(false)
111
+ expect(fs.readFileSync(path.join(appDir, 'src', 'modules.ts'), 'utf8')).toBe(modulesSource)
112
+ expect(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).toBe(manifestBefore)
113
+ expect(snapshotDirectory(path.join(appDir, 'node_modules'))).toEqual(installedBefore)
114
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'consents'))).toBe(false)
115
+ expect(fs.existsSync(path.join(appDir, '.mercato', 'generated'))).toBe(false)
116
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
117
+ })
118
+ }
119
+
120
+ test('rejects a GitHub replacement of an active binding even when the selected module ID differs', () => {
121
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-binding-collision-'))
122
+ try {
123
+ const binding = '@publisher/shared-package'
124
+ createApp(appDir, { [binding]: 'github:existing/provider#main' })
125
+ createPackage(path.join(appDir, 'node_modules', binding), binding, ['active_module'])
126
+ const modulesSource = `export const enabledModules = [{ id: 'active_module', from: '${binding}', locator: 'github:existing/provider', packageName: '${binding}' }]\n`
127
+ writeFile(path.join(appDir, 'src', 'modules.ts'), modulesSource)
128
+ const manifestBefore = fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')
129
+ const environment = installCommandStubs(appDir, binding, 'candidate_module')
130
+ const result = runMercato(['module', 'add', 'github:candidate/provider', '--allow-third-party'], appDir, environment)
131
+
132
+ expect(result.status, result.output).not.toBe(0)
133
+ expect(result.output).toContain('active_module')
134
+ expect(result.output).toContain(binding)
135
+ expect(result.output).toContain('github:existing/provider')
136
+ expect(result.output).toContain('github:candidate/provider')
137
+ expect(fs.existsSync(path.join(appDir, 'yarn-invoked'))).toBe(false)
138
+ expect(fs.readFileSync(path.join(appDir, 'src', 'modules.ts'), 'utf8')).toBe(modulesSource)
139
+ expect(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).toBe(manifestBefore)
140
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'candidate_module'))).toBe(false)
141
+ } finally {
142
+ fs.rmSync(appDir, { recursive: true, force: true })
143
+ }
144
+ })
145
+
146
+ test('rejects an npm file package runtime ID collision before invoking Yarn or copying sources', () => {
147
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-file-collision-'))
148
+ try {
149
+ createApp(appDir)
150
+ const candidateDir = path.join(appDir, 'candidate')
151
+ createPackage(candidateDir, '@publisher/candidate', ['consents'])
152
+ const modulesSource = "export const enabledModules = [{ id: 'consents', from: '@app', locator: 'github:existing/consents' }]\n"
153
+ writeFile(path.join(appDir, 'src', 'modules.ts'), modulesSource)
154
+ writeFile(path.join(appDir, 'src', 'modules', 'consents', 'index.ts'), 'export const metadata = { ejectable: true }\n')
155
+ const sourceBefore = snapshotDirectory(path.join(appDir, 'src'))
156
+ const manifestBefore = fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')
157
+ const result = runMercato(['module', 'add', '@publisher/candidate@file:./candidate', '--allow-third-party', '--eject'], appDir, installCommandStubs(appDir))
158
+
159
+ expect(result.status, result.output).not.toBe(0)
160
+ expect(result.output).toContain('consents')
161
+ expect(result.output).toMatch(/collision|already registered|conflict/i)
162
+ expect(fs.existsSync(path.join(appDir, 'yarn-invoked'))).toBe(false)
163
+ expect(snapshotDirectory(path.join(appDir, 'src'))).toEqual(sourceBefore)
164
+ expect(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).toBe(manifestBefore)
165
+ } finally {
166
+ fs.rmSync(appDir, { recursive: true, force: true })
167
+ }
168
+ })
169
+
170
+ test('every generate command rejects duplicate effective registrations before changing existing output', () => {
171
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-generate-duplicates-'))
172
+ try {
173
+ createApp(appDir)
174
+ writeFile(path.join(appDir, 'src', 'modules.ts'), "export const enabledModules = [{ id: 'consents', from: '@app' }]\nif (process.env.COLLISION_DUPLICATE_ENABLED === 'true') enabledModules.push({ id: 'consents', from: '@app' })\n")
175
+ writeFile(path.join(appDir, 'src', 'modules', 'consents', 'index.ts'), 'export const metadata = {}\n')
176
+ const outputDir = path.join(appDir, '.mercato', 'generated')
177
+ for (const file of ['entities.ids.generated.ts', 'modules.generated.ts', 'entities.generated.ts', 'di.generated.ts', 'module-package-sources.css']) writeFile(path.join(outputDir, file), 'preserved output\n')
178
+ const before = snapshotDirectory(outputDir)
179
+
180
+ for (const command of ['all', 'entity-ids', 'registry', 'entities', 'di']) {
181
+ const result = runMercato(['generate', command, '--quiet'], appDir, { COLLISION_DUPLICATE_ENABLED: 'true' })
182
+ expect(result.status, `${command}: ${result.output}`).not.toBe(0)
183
+ expect(result.output).toContain("Duplicate enabled module ID 'consents'")
184
+ expect(snapshotDirectory(outputDir), command).toEqual(before)
185
+ }
186
+ const singleProvider = runMercato(['generate', 'all', '--quiet'], appDir, { COLLISION_DUPLICATE_ENABLED: 'false' })
187
+ expect(singleProvider.status, singleProvider.output).toBe(0)
188
+ } finally {
189
+ fs.rmSync(appDir, { recursive: true, force: true })
190
+ }
191
+ })
192
+
193
+ test('an installed alias generates binding imports, ejects with canonical provenance, and disables cleanly', () => {
194
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-alias-lifecycle-'))
195
+ try {
196
+ const binding = 'consents_candidate'
197
+ const canonicalName = '@publisher/consents'
198
+ createApp(appDir, { [binding]: `npm:${canonicalName}@0.4.7` })
199
+ const packageRoot = path.join(appDir, 'node_modules', binding)
200
+ createPackage(packageRoot, canonicalName, ['consents', 'sibling'])
201
+ writeFile(path.join(packageRoot, 'src', 'modules', 'consents', 'sibling.ts'), "export { metadata } from '../sibling'\n")
202
+ const enabled = runMercato(['module', 'enable', binding, '--module', 'consents', '--allow-third-party'], appDir)
203
+ expect(enabled.status, enabled.output).toBe(0)
204
+ const outputDir = path.join(appDir, '.mercato', 'generated')
205
+ const modulesSource = fs.readFileSync(path.join(appDir, 'src', 'modules.ts'), 'utf8')
206
+ expect(modulesSource).toContain(`from: '${binding}'`)
207
+ expect(modulesSource).toContain(`packageName: '${canonicalName}'`)
208
+ expect(fs.readFileSync(path.join(outputDir, 'di.generated.ts'), 'utf8')).toContain(`${binding}/modules/consents/di`)
209
+ expect(fs.readFileSync(path.join(outputDir, 'module-package-sources.css'), 'utf8')).toContain(`node_modules/${binding}/src/`)
210
+
211
+ const ejected = runMercato(['module', 'eject', 'consents'], appDir)
212
+ expect(ejected.status, ejected.output).toBe(0)
213
+ const ejectedSource = fs.readFileSync(path.join(appDir, 'src', 'modules.ts'), 'utf8')
214
+ expect(ejectedSource).toContain("from: '@app'")
215
+ expect(ejectedSource).toContain(`packageName: '${canonicalName}'`)
216
+ expect(ejectedSource).toContain(`locator: 'npm:${canonicalName}'`)
217
+ expect(fs.readFileSync(path.join(appDir, 'src', 'modules', 'consents', 'sibling.ts'), 'utf8')).toContain(`${binding}/modules/sibling`)
218
+
219
+ writeFile(path.join(appDir, 'src', 'modules.ts'), 'export const enabledModules = []\n')
220
+ const disabled = runMercato(['generate', 'all', '--quiet'], appDir)
221
+ expect(disabled.status, disabled.output).toBe(0)
222
+ expect(fs.readFileSync(path.join(outputDir, 'di.generated.ts'), 'utf8')).not.toContain('consents')
223
+ expect(fs.readFileSync(path.join(outputDir, 'module-package-sources.css'), 'utf8')).toBe('')
224
+ expect(fs.existsSync(path.join(packageRoot, 'package.json'))).toBe(true)
225
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'consents', 'index.ts'))).toBe(true)
226
+ } finally {
227
+ fs.rmSync(appDir, { recursive: true, force: true })
228
+ }
229
+ })
230
+
231
+ test('missing alias exports fail enable and direct eject before source or config changes', () => {
232
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-alias-exports-'))
233
+ try {
234
+ const binding = 'consents_candidate'
235
+ createApp(appDir, { [binding]: 'npm:@publisher/consents@0.4.7' })
236
+ const packageRoot = path.join(appDir, 'node_modules', binding)
237
+ createPackage(packageRoot, '@publisher/consents', ['consents'])
238
+ writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@publisher/consents', exports: { '.': './dist/modules/consents/index.js' } }))
239
+ const configPath = path.join(appDir, 'src', 'modules.ts')
240
+ const emptyConfig = fs.readFileSync(configPath, 'utf8')
241
+
242
+ const enabled = runMercato(['module', 'enable', binding, '--module', 'consents', '--eject', '--allow-third-party'], appDir)
243
+ expect(enabled.status, enabled.output).not.toBe(0)
244
+ expect(enabled.output).toContain(`${binding}/modules/consents/`)
245
+ expect(fs.readFileSync(configPath, 'utf8')).toBe(emptyConfig)
246
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'consents'))).toBe(false)
247
+
248
+ const registeredConfig = `export const enabledModules = [{ id: 'consents', from: '${binding}' }]\n`
249
+ writeFile(configPath, registeredConfig)
250
+ const ejected = runMercato(['module', 'eject', 'consents'], appDir)
251
+ expect(ejected.status, ejected.output).not.toBe(0)
252
+ expect(ejected.output).toContain(`${binding}/modules/consents/`)
253
+ expect(fs.readFileSync(configPath, 'utf8')).toBe(registeredConfig)
254
+ expect(fs.existsSync(path.join(appDir, 'src', 'modules', 'consents'))).toBe(false)
255
+ } finally {
256
+ fs.rmSync(appDir, { recursive: true, force: true })
257
+ }
258
+ })
259
+
260
+ test('a single package registration with a local overlay remains valid', () => {
261
+ const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercato-single-overlay-'))
262
+ try {
263
+ const binding = '@publisher/consents'
264
+ createApp(appDir, { [binding]: '0.4.7' })
265
+ createPackage(path.join(appDir, 'node_modules', binding), binding, ['consents'])
266
+ writeFile(path.join(appDir, 'src', 'modules.ts'), `export const enabledModules = [{ id: 'consents', from: '${binding}' }]\n`)
267
+ writeFile(path.join(appDir, 'src', 'modules', 'consents', 'index.ts'), "export const metadata = { title: 'Local overlay' }\n")
268
+ const before = snapshotDirectory(path.join(appDir, 'src'))
269
+
270
+ const result = runMercato(['generate', 'all', '--quiet'], appDir)
271
+
272
+ expect(result.status, result.output).toBe(0)
273
+ expect(snapshotDirectory(path.join(appDir, 'src'))).toEqual(before)
274
+ expect(fs.readFileSync(path.join(appDir, '.mercato', 'generated', 'di.generated.ts'), 'utf8')).toContain(`${binding}/modules/consents/di`)
275
+ } finally {
276
+ fs.rmSync(appDir, { recursive: true, force: true })
277
+ }
278
+ })
279
+ })
@@ -9,7 +9,9 @@ import {
9
9
  listEjectableModules,
10
10
  ejectModule,
11
11
  } from '../eject'
12
- import type { PackageResolver } from '../resolver'
12
+ import { readEnabledModulesFromConfig, type PackageResolver } from '../resolver'
13
+ import { getModuleLocators } from '../module-identity'
14
+ import { resolveModuleRequirement } from '@open-mercato/shared/modules/module-locators'
13
15
 
14
16
  describe('eject', () => {
15
17
  let tmpDir: string
@@ -292,6 +294,7 @@ describe('eject', () => {
292
294
  fs.mkdirSync(path.join(installedPackageRoot, 'src', 'modules', 'test_package'), { recursive: true })
293
295
  fs.mkdirSync(path.join(installedPackageRoot, 'dist', 'modules', 'test_package'), { recursive: true })
294
296
 
297
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ dependencies: { '@open-mercato/test-package': '^0.1.0' } }))
295
298
  fs.writeFileSync(
296
299
  path.join(appDir, 'src', 'modules.ts'),
297
300
  "export const enabledModules = [{ id: 'test_package', from: '@open-mercato/test-package' }]\n",
@@ -331,7 +334,7 @@ describe('eject', () => {
331
334
  appBase: path.join(appDir, 'src', 'modules', 'test_package'),
332
335
  pkgBase: path.join(baseDir, 'packages', 'test-package', 'src', 'modules', 'test_package'),
333
336
  }),
334
- getPackageRoot: () => path.join(baseDir, 'packages', 'test-package'),
337
+ getPackageRoot: () => installedPackageRoot,
335
338
  } as unknown as PackageResolver
336
339
  }
337
340
 
@@ -348,6 +351,95 @@ describe('eject', () => {
348
351
  ])
349
352
  })
350
353
 
354
+ it('rejects duplicate module registrations before copying source', () => {
355
+ const resolver = createOfficialModuleResolver(tmpDir)
356
+ const duplicates = [
357
+ { id: 'test_package', from: '@open-mercato/test-package' },
358
+ { id: 'test_package', from: '@other/test-package' },
359
+ ]
360
+ fs.writeFileSync(resolver.getModulesConfigPath(), `export const enabledModules = ${JSON.stringify(duplicates)}`)
361
+ const sourceBefore = fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')
362
+ expect(() => ejectModule({ ...resolver, loadEnabledModules: () => duplicates }, 'test_package')).toThrow("Duplicate enabled module ID 'test_package'")
363
+ expect(fs.existsSync(resolver.getModulePaths(duplicates[0]).appBase)).toBe(false)
364
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(sourceBefore)
365
+ })
366
+
367
+ it('rejects duplicate registrations elsewhere in the effective set before copying', () => {
368
+ const resolver = createOfficialModuleResolver(tmpDir)
369
+ const entries = [...resolver.loadEnabledModules(), { id: 'customers' }, { id: 'customers', from: '@other/customers' }]
370
+ const before = fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')
371
+
372
+ expect(() => ejectModule({ ...resolver, loadEnabledModules: () => entries }, 'test_package')).toThrow("Duplicate enabled module ID 'customers'")
373
+ expect(fs.existsSync(resolver.getModulePaths(entries[0]).appBase)).toBe(false)
374
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(before)
375
+ })
376
+
377
+ it('checks the config mutation before copying when effective entries cannot be edited', () => {
378
+ const resolver = createOfficialModuleResolver(tmpDir)
379
+ const before = "const entries = [{ id: 'test_package', from: '@open-mercato/test-package' }]; export const enabledModules = entries;\n"
380
+ fs.writeFileSync(resolver.getModulesConfigPath(), before)
381
+
382
+ expect(() => ejectModule(resolver, 'test_package')).toThrow('Could not find enabledModules array')
383
+ expect(fs.existsSync(resolver.getModulePaths(resolver.loadEnabledModules()[0]).appBase)).toBe(false)
384
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(before)
385
+ })
386
+
387
+ it('rejects contradictory observed provenance before copying source', () => {
388
+ const resolver = createOfficialModuleResolver(tmpDir)
389
+ const entry = { id: 'test_package', from: '@open-mercato/test-package', locator: 'github:unexpected/provider' }
390
+ const before = "export const enabledModules = [{ id: 'test_package', from: '@open-mercato/test-package', locator: 'github:unexpected/provider' }]\n"
391
+ fs.writeFileSync(resolver.getModulesConfigPath(), before)
392
+
393
+ expect(() => ejectModule({ ...resolver, loadEnabledModules: () => [entry] }, 'test_package')).toThrow('disagrees with observed origin')
394
+ expect(fs.existsSync(resolver.getModulePaths(entry).appBase)).toBe(false)
395
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(before)
396
+ })
397
+
398
+ it('preserves an existing local overlay and gives a recovery without deleting source', () => {
399
+ const resolver = createOfficialModuleResolver(tmpDir)
400
+ const appBase = resolver.getModulePaths(resolver.loadEnabledModules()[0]).appBase
401
+ fs.mkdirSync(appBase, { recursive: true })
402
+ fs.writeFileSync(path.join(appBase, 'local.ts'), 'export const local = true\n')
403
+ const before = fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')
404
+
405
+ expect(() => ejectModule(resolver, 'test_package')).toThrow('Keep the existing local source')
406
+ expect(fs.readFileSync(path.join(appBase, 'local.ts'), 'utf8')).toBe('export const local = true\n')
407
+ expect(fs.existsSync(path.join(appBase, 'index.ts'))).toBe(false)
408
+ expect(fs.readFileSync(resolver.getModulesConfigPath(), 'utf8')).toBe(before)
409
+ })
410
+
411
+ it('keeps canonical provenance and rewrites sibling imports using the installed alias', () => {
412
+ const resolver = createOfficialModuleResolver(tmpDir)
413
+ const packageRoot = resolver.getPackageRoot('@open-mercato/test-package')
414
+ const aliasRoot = path.join(resolver.getAppDir(), 'node_modules', 'candidate_consents')
415
+ fs.mkdirSync(path.dirname(aliasRoot), { recursive: true })
416
+ fs.symlinkSync(packageRoot, aliasRoot, 'dir')
417
+ fs.writeFileSync(path.join(resolver.getAppDir(), 'package.json'), JSON.stringify({ dependencies: { candidate_consents: 'npm:@open-mercato/test-package@1.0.0' } }))
418
+ const entry = { id: 'test_package', from: 'candidate_consents' }
419
+ fs.writeFileSync(resolver.getModulesConfigPath(), `export const enabledModules = [${JSON.stringify(entry)}]\n`)
420
+ fs.mkdirSync(path.join(packageRoot, 'src', 'modules', 'sibling'), { recursive: true })
421
+ fs.writeFileSync(path.join(packageRoot, 'src', 'modules', 'sibling', 'index.ts'), 'export const sibling = true\n')
422
+ fs.writeFileSync(path.join(packageRoot, 'src', 'modules', 'test_package', 'widget.ts'), "export { sibling } from '../sibling'\n")
423
+ const aliasResolver = { ...resolver, loadEnabledModules: () => [entry], getPackageRoot: () => aliasRoot }
424
+
425
+ ejectModule(aliasResolver, 'test_package')
426
+
427
+ expect(readEnabledModulesFromConfig(resolver.getModulesConfigPath())[0]).toEqual({ id: 'test_package', from: '@app', packageName: '@open-mercato/test-package', locator: 'npm:@open-mercato/test-package' })
428
+ const appBase = resolver.getModulePaths(entry).appBase
429
+ expect(fs.readFileSync(path.join(appBase, 'widget.ts'), 'utf8')).toContain("from 'candidate_consents/modules/sibling'")
430
+ })
431
+
432
+ it('preserves GitHub origin and canonical package attribution through legacy eject', () => {
433
+ const resolver = createOfficialModuleResolver(tmpDir)
434
+ fs.writeFileSync(path.join(resolver.getAppDir(), 'package.json'), JSON.stringify({ dependencies: { '@open-mercato/test-package': 'github:pkarw/test-package#main' } }))
435
+ ejectModule(resolver, 'test_package')
436
+ const entry = readEnabledModulesFromConfig(resolver.getModulesConfigPath())[0]
437
+ expect(entry).toEqual({ id: 'test_package', from: '@app', packageName: '@open-mercato/test-package', locator: 'github:pkarw/test-package' })
438
+ const identities = [{ id: entry.id, locators: getModuleLocators(entry, resolver) }]
439
+ expect(resolveModuleRequirement('npm:@open-mercato/test-package', identities)).toBeUndefined()
440
+ expect(resolveModuleRequirement('github:pkarw/test-package', identities)).toBe('test_package')
441
+ })
442
+
351
443
  it('ejects module packages using module index.ts ejectable flag', () => {
352
444
  const resolver = createOfficialModuleResolver(tmpDir)
353
445
  const appModuleDir = path.join(tmpDir, 'apps', 'mercato', 'src', 'modules', 'test_package')
@@ -358,7 +450,7 @@ describe('eject', () => {
358
450
  expect(fs.existsSync(path.join(appModuleDir, 'index.ts'))).toBe(true)
359
451
  expect(fs.existsSync(path.join(appModuleDir, 'widget.ts'))).toBe(true)
360
452
  expect(fs.readFileSync(modulesTsPath, 'utf8')).toContain(
361
- "{ id: 'test_package', from: '@app' }",
453
+ "{ id: 'test_package', from: '@app', locator: 'npm:@open-mercato/test-package', packageName: '@open-mercato/test-package' }",
362
454
  )
363
455
  })
364
456
  })
@@ -381,6 +473,8 @@ describe('eject', () => {
381
473
  path.join(appDir, 'src', 'modules.ts'),
382
474
  "export const enabledModules = [{ id: 'customers', from: '@open-mercato/core' }]\n",
383
475
  )
476
+ fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@open-mercato/core' }))
477
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ dependencies: { '@open-mercato/core': '^0.1.0' } }))
384
478
  fs.writeFileSync(
385
479
  path.join(distModuleDir, 'index.js'),
386
480
  'var metadata = { title: "Customers", description: "Compiled", ejectable: true };\nexport { metadata };\n',
@@ -431,7 +525,7 @@ describe('eject', () => {
431
525
  expect(fs.existsSync(path.join(appModuleDir, 'index.ts'))).toBe(true)
432
526
  expect(fs.existsSync(path.join(appModuleDir, 'acl.ts'))).toBe(true)
433
527
  expect(fs.existsSync(path.join(appModuleDir, 'index.js'))).toBe(false)
434
- expect(fs.readFileSync(modulesTsPath, 'utf8')).toContain("{ id: 'customers', from: '@app' }")
528
+ expect(fs.readFileSync(modulesTsPath, 'utf8')).toContain("{ id: 'customers', from: '@app', locator: 'npm:@open-mercato/core', packageName: '@open-mercato/core' }")
435
529
  })
436
530
 
437
531
  it('falls back to compiled index.js metadata for dist-only packages', () => {
@@ -0,0 +1,50 @@
1
+ import { formatInitSummary } from '../init-summary'
2
+
3
+ const users = [
4
+ { icon: '👑', label: 'Superadmin', email: 'owner@example.test', password: 'demo-superadmin' },
5
+ { icon: '🧰', label: 'Admin', email: 'admin@example.test', password: 'demo-admin' },
6
+ { icon: '👷', label: 'Employee', email: 'employee@example.test', password: 'demo-employee' },
7
+ ]
8
+
9
+ describe('formatInitSummary', () => {
10
+ it('renders every supplied account and its own password without side borders', () => {
11
+ const output = formatInitSummary({ users, autoStart: true }).join('\n')
12
+ for (const user of users) {
13
+ expect(output).toContain(`${user.icon} ${user.label}`)
14
+ expect(output).toContain(user.email)
15
+ expect(output).toContain(user.password)
16
+ }
17
+ expect(output).not.toMatch(/[╔║╚]/u)
18
+ expect(output).toContain('The dev server will start automatically.')
19
+ expect(output).not.toContain('\u001b[')
20
+ })
21
+
22
+ it('shows the manual startup hint when initialization is invoked directly', () => {
23
+ const output = formatInitSummary({ users: users.slice(0, 1), autoStart: false }).join('\n')
24
+ expect(output).toContain('🚀 Start the dev server: yarn dev')
25
+ expect(output).not.toContain('automatically')
26
+ expect(output).not.toContain('🧰 Admin')
27
+ })
28
+
29
+ it.each([8, 20, 40, 80])('wraps long credentials within %i columns without dropping characters', (columns) => {
30
+ const password = 'long-demo-password-'.repeat(12)
31
+ const lines = formatInitSummary({ users: [{ ...users[0], password }], autoStart: true, columns })
32
+ for (const line of lines) {
33
+ const width = [...line].reduce((total, character) => total + (/\p{Extended_Pictographic}/u.test(character) ? 2 : 1), 0)
34
+ expect(width).toBeLessThanOrEqual(columns)
35
+ }
36
+ expect(lines.map((line) => line.trim()).join('')).toContain(password)
37
+ })
38
+
39
+ it('paints complete rows dark gray with light text and resets after each row', () => {
40
+ const lines = formatInitSummary({ users, autoStart: true, color: true, columns: 80 })
41
+ for (const line of lines) {
42
+ expect(line).toMatch(/^\u001b\[48;2;57;62;70m\u001b\[38;2;238;238;238m/u)
43
+ expect(line).toMatch(/\u001b\[0m$/u)
44
+ }
45
+ })
46
+
47
+ it.each([NaN, Infinity, 0, -1])('uses a safe width for invalid columns %s', (columns) => {
48
+ expect(formatInitSummary({ users, autoStart: false, columns })[0]).toHaveLength(80)
49
+ })
50
+ })
@@ -0,0 +1,108 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { resolveGithubModuleTarget } from '../module-github'
3
+
4
+ jest.mock('node:child_process', () => ({ execFileSync: jest.fn() }))
5
+ const execute = jest.mocked(execFileSync)
6
+ const sha = 'a'.repeat(40)
7
+
8
+ function mockGithub(manifest: unknown = { name: '@pkarw/consents' }, files = ['src/modules/marketing_consents/index.ts']): void {
9
+ execute.mockImplementation((_command, args) => {
10
+ const endpoint = args?.[1]
11
+ const value = endpoint === 'user' ? { login: 'pkarw' }
12
+ : typeof endpoint === 'string' && endpoint.includes('/commits/') ? { sha }
13
+ : typeof endpoint === 'string' && endpoint.includes('/contents/') ? { encoding: 'base64', content: Buffer.from(JSON.stringify(manifest)).toString('base64') }
14
+ : typeof endpoint === 'string' && endpoint.includes('/git/trees/') ? { tree: files.map((filePath) => ({ path: filePath })) }
15
+ : { default_branch: 'main' }
16
+ return JSON.stringify(value)
17
+ })
18
+ }
19
+
20
+ beforeEach(() => execute.mockReset())
21
+
22
+ test.each([
23
+ 'pkarw/consents', 'github:pkarw/consents', 'https://github.com/pkarw/consents.git/',
24
+ 'git@github.com:pkarw/consents.git', 'ssh://git@github.com/pkarw/consents.git',
25
+ 'git+https://github.com/pkarw/consents', 'github:consents',
26
+ 'git+ssh://git@github.com/pkarw/consents.git', 'http://github.com/pkarw/consents',
27
+ 'https://github.com/https://github.com/pkarw/consents', 'github.com/pkarw/consents',
28
+ ])('pins GitHub locator %s to one revision', (locator) => {
29
+ mockGithub()
30
+ expect(resolveGithubModuleTarget(locator, '/app')).toEqual({
31
+ packageName: '@pkarw/consents', packageSpec: `@pkarw/consents@github:pkarw/consents#${sha}`,
32
+ locator: 'github:pkarw/consents', moduleIds: ['marketing_consents'],
33
+ })
34
+ expect(execute).toHaveBeenCalledWith('gh', ['api', `repos/pkarw/consents/contents/package.json?ref=${sha}`], expect.objectContaining({ cwd: '/app' }))
35
+ expect(execute).toHaveBeenCalledWith('gh', expect.any(Array), expect.objectContaining({ maxBuffer: 16 * 1024 * 1024 }))
36
+ })
37
+
38
+ test('resolves selected refs once and inspects the same immutable revision', () => {
39
+ mockGithub()
40
+ resolveGithubModuleTarget('github:pkarw/consents#release/v1', '/app')
41
+ expect(execute).toHaveBeenCalledWith('gh', ['api', 'repos/pkarw/consents/commits/release%2Fv1'], expect.anything())
42
+ expect(execute).toHaveBeenCalledTimes(3)
43
+ })
44
+
45
+ test.each(['https://example.com/owner/repo', 'pkarw/consents/tree/main', 'github:pkarw/consents#--bad'])('rejects invalid locator %s', (locator) => {
46
+ mockGithub()
47
+ expect(() => resolveGithubModuleTarget(locator, '/app')).toThrow()
48
+ expect(execute).not.toHaveBeenCalled()
49
+ })
50
+
51
+ test('accepts a GitHub .git@ref locator', () => {
52
+ mockGithub()
53
+ resolveGithubModuleTarget('git+ssh://git@github.com/pkarw/consents.git@release/v1', '/app')
54
+ expect(execute).toHaveBeenCalledWith('gh', ['api', 'repos/pkarw/consents/commits/release%2Fv1'], expect.anything())
55
+ })
56
+
57
+ test('rejects malformed manifest package identities', () => {
58
+ mockGithub({ name: '--bad' })
59
+ expect(() => resolveGithubModuleTarget('pkarw/consents', '/app')).toThrow('valid npm package name')
60
+ })
61
+
62
+ test('reports authentication and repository errors without leaking command stderr', () => {
63
+ execute.mockImplementation(() => { throw new Error('private token stderr') })
64
+ expect(() => resolveGithubModuleTarget('pkarw/consents', '/app')).toThrow('gh auth login')
65
+ expect(() => resolveGithubModuleTarget('pkarw/consents', '/app')).not.toThrow('private token')
66
+ })
67
+
68
+ test('rejects an exact GitHub revision denying a generated subpath before installation', () => {
69
+ mockGithub({ name: '@pkarw/consents', exports: { './modules/marketing_consents/index': './dist/modules/marketing_consents/index.js' } }, [
70
+ 'src/modules/marketing_consents/index.ts', 'src/modules/marketing_consents/di.ts',
71
+ ])
72
+ expect(() => resolveGithubModuleTarget('github:pkarw/consents#main', '/app')).toThrow('@pkarw/consents/modules/marketing_consents/di')
73
+ })
74
+
75
+ test('allows source-only GitHub repositories to produce generated runtime artifacts during installation', () => {
76
+ mockGithub({ name: '@pkarw/consents', exports: { './modules/marketing_consents/*': './dist/modules/marketing_consents/*.js' } }, [
77
+ 'src/modules/marketing_consents/index.ts', 'src/modules/marketing_consents/di.ts',
78
+ ])
79
+
80
+ expect(() => resolveGithubModuleTarget('github:pkarw/consents#main', '/app')).not.toThrow()
81
+ })
82
+
83
+ test('checks every active sibling against GitHub exports even when selecting another module', () => {
84
+ mockGithub({ name: '@pkarw/consents', exports: { './modules/alpha/index': null, './modules/beta/index': './dist/modules/beta/index.js' } }, [
85
+ 'src/modules/alpha/index.ts', 'src/modules/beta/index.ts',
86
+ ])
87
+ expect(() => resolveGithubModuleTarget('github:pkarw/consents#main', '/app', {
88
+ moduleId: 'beta', enabledModules: [{ id: 'alpha', from: '@pkarw/consents' }],
89
+ })).toThrow('@pkarw/consents/modules/alpha/index')
90
+ })
91
+
92
+
93
+ test('reads exact-commit page source without executing private lowercase helpers', () => {
94
+ const moduleRoot = 'src/modules/marketing_consents/'
95
+ mockGithub({ name: '@pkarw/consents', exports: { './modules/marketing_consents/index': './dist/modules/marketing_consents/index.js' } }, [moduleRoot + 'index.ts', moduleRoot + 'backend/form-utils.tsx', moduleRoot + 'backend/orders/page.tsx'])
96
+ const metadataImplementation = execute.getMockImplementation()!
97
+ execute.mockImplementation((command, args, options) => {
98
+ const endpoint = String(args?.[1])
99
+ if (endpoint.includes('/backend/')) {
100
+ expect(endpoint).toContain(`?ref=${sha}`)
101
+ const content = endpoint.includes('form-utils') ? "throw new Error('must not execute'); export const renderLabel = () => null" : 'export default function Page() {}'
102
+ return JSON.stringify({ encoding: 'base64', content: Buffer.from(content).toString('base64') })
103
+ }
104
+ return metadataImplementation(command, args, options)
105
+ })
106
+ expect(() => resolveGithubModuleTarget('github:pkarw/consents', '/app')).toThrow('@pkarw/consents/modules/marketing_consents/backend/orders/page')
107
+ expect(execute.mock.calls.filter(([, args]) => String(args?.[1]).includes('form-utils'))).toHaveLength(1)
108
+ })