@open-mercato/cli 0.4.9-develop-94fb251ed3 → 0.4.9-develop-8d8db18714

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,234 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import { copyDirRecursive, rewriteCrossModuleImports } from './eject'
5
+ import {
6
+ parsePackageNameFromSpec,
7
+ resolveInstalledOfficialModulePackage,
8
+ validateEjectBoundaries,
9
+ type ValidatedOfficialModulePackage,
10
+ } from './module-package'
11
+ import { ensureModuleRegistration } from './modules-config'
12
+ import type { PackageResolver } from './resolver'
13
+
14
+ type ModuleCommandResult = {
15
+ moduleId: string
16
+ packageName: string
17
+ from: string
18
+ registrationChanged: boolean
19
+ }
20
+
21
+ type InstallTarget = {
22
+ cwd: string
23
+ args: string[]
24
+ }
25
+
26
+ function resolveYarnBinary(): string {
27
+ return process.platform === 'win32' ? 'yarn.cmd' : 'yarn'
28
+ }
29
+
30
+ function readAppPackageName(appDir: string): string {
31
+ const packageJsonPath = path.join(appDir, 'package.json')
32
+ if (!fs.existsSync(packageJsonPath)) {
33
+ throw new Error(`App package.json not found at ${packageJsonPath}.`)
34
+ }
35
+
36
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { name?: string }
37
+ if (!packageJson.name) {
38
+ throw new Error(`App package.json at ${packageJsonPath} is missing the "name" field.`)
39
+ }
40
+
41
+ return packageJson.name
42
+ }
43
+
44
+ function resolveInstallTarget(resolver: PackageResolver): InstallTarget {
45
+ if (!resolver.isMonorepo()) {
46
+ return {
47
+ cwd: resolver.getAppDir(),
48
+ args: ['add'],
49
+ }
50
+ }
51
+
52
+ return {
53
+ cwd: resolver.getRootDir(),
54
+ args: ['workspace', readAppPackageName(resolver.getAppDir()), 'add'],
55
+ }
56
+ }
57
+
58
+ function runCommand(
59
+ command: string,
60
+ args: string[],
61
+ cwd: string,
62
+ ): Promise<void> {
63
+ return new Promise((resolve, reject) => {
64
+ const child = spawn(command, args, {
65
+ cwd,
66
+ env: process.env,
67
+ stdio: 'inherit',
68
+ })
69
+
70
+ child.on('error', reject)
71
+ child.on('exit', (code) => {
72
+ if (code === 0) {
73
+ resolve()
74
+ return
75
+ }
76
+
77
+ reject(new Error(`Command failed: ${command} ${args.join(' ')} (exit ${code ?? 'unknown'}).`))
78
+ })
79
+ })
80
+ }
81
+
82
+ async function installPackageSpec(
83
+ resolver: PackageResolver,
84
+ packageSpec: string,
85
+ ): Promise<void> {
86
+ const target = resolveInstallTarget(resolver)
87
+ await runCommand(resolveYarnBinary(), [...target.args, packageSpec], target.cwd)
88
+ }
89
+
90
+ export async function runModuleGenerators(
91
+ resolver: PackageResolver,
92
+ quiet = true,
93
+ ): Promise<void> {
94
+ const {
95
+ generateEntityIds,
96
+ generateModuleDi,
97
+ generateModuleEntities,
98
+ generateModulePackageSources,
99
+ generateModuleRegistry,
100
+ generateModuleRegistryCli,
101
+ generateOpenApi,
102
+ } = await import('./generators')
103
+
104
+ await generateEntityIds({ resolver, quiet })
105
+ await generateModuleRegistry({ resolver, quiet })
106
+ await generateModuleRegistryCli({ resolver, quiet })
107
+ await generateModuleEntities({ resolver, quiet })
108
+ await generateModuleDi({ resolver, quiet })
109
+ await generateModulePackageSources({ resolver, quiet })
110
+ await generateOpenApi({ resolver, quiet })
111
+ }
112
+
113
+ async function runGeneratorsWithRegistrationNotice(
114
+ resolver: PackageResolver,
115
+ moduleId: string,
116
+ ): Promise<void> {
117
+ try {
118
+ await runModuleGenerators(resolver)
119
+ } catch (error) {
120
+ const message = error instanceof Error ? error.message : String(error)
121
+ throw new Error(
122
+ `Module "${moduleId}" is enabled, but generated artifacts are stale because generation failed. Rerun "yarn mercato generate" after fixing the underlying error. ${message}`,
123
+ )
124
+ }
125
+ }
126
+
127
+ function assertPackageName(packageName: string | null): asserts packageName is string {
128
+ if (!packageName || !packageName.startsWith('@open-mercato/')) {
129
+ throw new Error('Only @open-mercato/* package specs are supported by "mercato module add".')
130
+ }
131
+ }
132
+
133
+ function validateBeforeRegistration(
134
+ modulePackage: ValidatedOfficialModulePackage,
135
+ resolver: PackageResolver,
136
+ eject: boolean,
137
+ ): string {
138
+ const appModuleDir = path.join(resolver.getAppDir(), 'src', 'modules', modulePackage.metadata.moduleId)
139
+
140
+ if (eject && fs.existsSync(appModuleDir)) {
141
+ throw new Error(`Destination directory already exists: ${appModuleDir}. Remove it first or rerun without --eject.`)
142
+ }
143
+
144
+ return appModuleDir
145
+ }
146
+
147
+ function prepareRegistrationSource(
148
+ modulePackage: ValidatedOfficialModulePackage,
149
+ resolver: PackageResolver,
150
+ packageName: string,
151
+ eject: boolean,
152
+ ): string {
153
+ const appModuleDir = validateBeforeRegistration(modulePackage, resolver, eject)
154
+
155
+ if (!eject) {
156
+ return packageName
157
+ }
158
+
159
+ if (!modulePackage.metadata.ejectable) {
160
+ throw new Error(`Package "${packageName}" is not ejectable. --eject requires open-mercato.ejectable === true.`)
161
+ }
162
+
163
+ validateEjectBoundaries(modulePackage)
164
+ copyDirRecursive(modulePackage.sourceModuleDir, appModuleDir)
165
+ rewriteCrossModuleImports(
166
+ modulePackage.sourceModuleDir,
167
+ appModuleDir,
168
+ modulePackage.metadata.moduleId,
169
+ packageName,
170
+ )
171
+
172
+ return '@app'
173
+ }
174
+
175
+ async function registerResolvedOfficialModule(
176
+ resolver: PackageResolver,
177
+ modulePackage: ValidatedOfficialModulePackage,
178
+ packageName: string,
179
+ eject: boolean,
180
+ ): Promise<ModuleCommandResult> {
181
+ const from = prepareRegistrationSource(modulePackage, resolver, packageName, eject)
182
+
183
+ const registration = ensureModuleRegistration(
184
+ resolver.getModulesConfigPath(),
185
+ {
186
+ id: modulePackage.metadata.moduleId,
187
+ from,
188
+ },
189
+ )
190
+
191
+ if (!registration.changed) {
192
+ throw new Error(
193
+ `Module "${modulePackage.metadata.moduleId}" from "${from}" is already enabled in modules.ts.`,
194
+ )
195
+ }
196
+
197
+ await runGeneratorsWithRegistrationNotice(resolver, modulePackage.metadata.moduleId)
198
+
199
+ return {
200
+ moduleId: modulePackage.metadata.moduleId,
201
+ packageName,
202
+ from,
203
+ registrationChanged: registration.changed,
204
+ }
205
+ }
206
+
207
+ export async function addOfficialModule(
208
+ resolver: PackageResolver,
209
+ packageSpec: string,
210
+ eject: boolean,
211
+ moduleId?: string,
212
+ ): Promise<ModuleCommandResult> {
213
+ const packageName = parsePackageNameFromSpec(packageSpec)
214
+ assertPackageName(packageName)
215
+
216
+ await installPackageSpec(resolver, packageSpec)
217
+
218
+ const modulePackage = resolveInstalledOfficialModulePackage(resolver, packageName, moduleId)
219
+ return registerResolvedOfficialModule(resolver, modulePackage, packageName, eject)
220
+ }
221
+
222
+ export async function enableOfficialModule(
223
+ resolver: PackageResolver,
224
+ packageName: string,
225
+ moduleId?: string,
226
+ eject = false,
227
+ ): Promise<ModuleCommandResult> {
228
+ if (!packageName.startsWith('@open-mercato/')) {
229
+ throw new Error('Only @open-mercato/* packages can be enabled with "mercato module enable".')
230
+ }
231
+
232
+ const modulePackage = resolveInstalledOfficialModulePackage(resolver, packageName, moduleId)
233
+ return registerResolvedOfficialModule(resolver, modulePackage, packageName, eject)
234
+ }
@@ -0,0 +1,355 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+ import type { PackageResolver } from './resolver'
5
+
6
+ type PackageJsonRecord = {
7
+ name?: string
8
+ version?: string
9
+ dependencies?: Record<string, string>
10
+ peerDependencies?: Record<string, string>
11
+ }
12
+
13
+ export type ModulePackageMetadata = {
14
+ moduleId: string
15
+ ejectable: boolean
16
+ }
17
+
18
+ export type ModuleInfoSnapshot = {
19
+ title?: string
20
+ description?: string
21
+ }
22
+
23
+ export type ValidatedOfficialModulePackage = {
24
+ packageName: string
25
+ packageRoot: string
26
+ packageJson: PackageJsonRecord
27
+ metadata: ModulePackageMetadata
28
+ moduleInfo: ModuleInfoSnapshot
29
+ sourceModuleDir: string
30
+ distModuleDir: string
31
+ }
32
+
33
+ type DiscoveredModule = {
34
+ moduleId: string
35
+ ejectable: boolean
36
+ }
37
+
38
+ const SOURCE_FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
39
+ const SKIP_DIRS = new Set(['__tests__', '__mocks__', 'node_modules'])
40
+ const MODULE_ID_PATTERN = /^[a-z0-9]+(?:_[a-z0-9]+)*$/
41
+ const requireFromCli = createRequire(path.join(process.cwd(), 'package.json'))
42
+
43
+ function shouldSkipEntryName(name: string): boolean {
44
+ return SKIP_DIRS.has(name) || name === '.DS_Store' || name.startsWith('._')
45
+ }
46
+
47
+ function readPackageJson(packageJsonPath: string): PackageJsonRecord {
48
+ try {
49
+ const raw = fs.readFileSync(packageJsonPath, 'utf8')
50
+ return JSON.parse(raw) as PackageJsonRecord
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error)
53
+ throw new Error(`Failed to read package manifest at ${packageJsonPath}: ${message}`)
54
+ }
55
+ }
56
+
57
+ function parseModuleInfo(indexPath: string): { title?: string; description?: string; ejectable?: boolean } {
58
+ if (!fs.existsSync(indexPath)) return {}
59
+
60
+ const source = fs.readFileSync(indexPath, 'utf8')
61
+ const result: { title?: string; description?: string; ejectable?: boolean } = {}
62
+
63
+ const titleMatch = source.match(/\btitle\s*:\s*['"]([^'"]+)['"]/)
64
+ if (titleMatch) result.title = titleMatch[1]
65
+
66
+ const descriptionMatch = source.match(/\bdescription\s*:\s*['"]([^'"]+)['"]/)
67
+ if (descriptionMatch) result.description = descriptionMatch[1]
68
+
69
+ const ejectableMatch = source.match(/\bejectable\s*:\s*(true|false)/)
70
+ if (ejectableMatch) result.ejectable = ejectableMatch[1] === 'true'
71
+
72
+ return result
73
+ }
74
+
75
+ export function discoverModulesInPackage(packageRoot: string): DiscoveredModule[] {
76
+ const srcModulesDir = path.join(packageRoot, 'src', 'modules')
77
+ const distModulesDir = path.join(packageRoot, 'dist', 'modules')
78
+
79
+ const enumerationDir = fs.existsSync(srcModulesDir)
80
+ ? srcModulesDir
81
+ : fs.existsSync(distModulesDir)
82
+ ? distModulesDir
83
+ : null
84
+
85
+ if (!enumerationDir) return []
86
+
87
+ const modules: DiscoveredModule[] = []
88
+
89
+ for (const entry of fs.readdirSync(enumerationDir, { withFileTypes: true })) {
90
+ if (!entry.isDirectory() || shouldSkipEntryName(entry.name) || !MODULE_ID_PATTERN.test(entry.name)) continue
91
+
92
+ const moduleId = entry.name
93
+ const srcIndexPath = path.join(packageRoot, 'src', 'modules', moduleId, 'index.ts')
94
+ const distIndexPath = path.join(packageRoot, 'dist', 'modules', moduleId, 'index.js')
95
+ const indexPath = fs.existsSync(srcIndexPath) ? srcIndexPath : distIndexPath
96
+
97
+ const info = parseModuleInfo(indexPath)
98
+ modules.push({ moduleId, ejectable: info.ejectable ?? false })
99
+ }
100
+
101
+ return modules
102
+ }
103
+
104
+ function resolveRelativeImportTarget(sourceFile: string, importPath: string): string | null {
105
+ if (!importPath.startsWith('.')) return null
106
+
107
+ const basePath = path.resolve(path.dirname(sourceFile), importPath)
108
+ const candidates = [basePath]
109
+
110
+ for (const ext of SOURCE_FILE_EXTENSIONS) {
111
+ candidates.push(`${basePath}${ext}`)
112
+ candidates.push(path.join(basePath, `index${ext}`))
113
+ }
114
+
115
+ for (const candidate of candidates) {
116
+ if (fs.existsSync(candidate)) {
117
+ return candidate
118
+ }
119
+ }
120
+
121
+ return null
122
+ }
123
+
124
+ function collectSourceFiles(dir: string): string[] {
125
+ const files: string[] = []
126
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
127
+
128
+ for (const entry of entries) {
129
+ if (shouldSkipEntryName(entry.name)) continue
130
+
131
+ const fullPath = path.join(dir, entry.name)
132
+ if (entry.isDirectory()) {
133
+ files.push(...collectSourceFiles(fullPath))
134
+ continue
135
+ }
136
+
137
+ const ext = path.extname(entry.name)
138
+ if (!SOURCE_FILE_EXTENSIONS.includes(ext)) continue
139
+ files.push(fullPath)
140
+ }
141
+
142
+ return files
143
+ }
144
+
145
+ function collectBoundaryViolations(moduleDir: string): string[] {
146
+ const modulesRoot = path.resolve(moduleDir, '..')
147
+ const modulePrefix = `${moduleDir}${path.sep}`
148
+ const modulesPrefix = `${modulesRoot}${path.sep}`
149
+ const files = collectSourceFiles(moduleDir)
150
+ const violations = new Set<string>()
151
+
152
+ for (const filePath of files) {
153
+ const content = fs.readFileSync(filePath, 'utf8')
154
+ const matches = [
155
+ ...content.matchAll(/\bfrom\s*['"]([^'"]+)['"]/g),
156
+ ...content.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g),
157
+ ]
158
+
159
+ for (const match of matches) {
160
+ const specifier = match[1]
161
+ if (!specifier) continue
162
+ const resolvedTarget = resolveRelativeImportTarget(filePath, specifier)
163
+ if (!resolvedTarget) continue
164
+ if (resolvedTarget.startsWith(modulePrefix)) continue
165
+ if (resolvedTarget.startsWith(modulesPrefix)) continue
166
+
167
+ const relativeSource = path.relative(moduleDir, filePath).split(path.sep).join('/')
168
+ const relativeTarget = path.relative(path.dirname(filePath), resolvedTarget).split(path.sep).join('/')
169
+ violations.add(`${relativeSource} -> ${relativeTarget}`)
170
+ }
171
+ }
172
+
173
+ return Array.from(violations).sort((left, right) => left.localeCompare(right))
174
+ }
175
+
176
+ function findResolvedPackageRoot(
177
+ resolvedPath: string,
178
+ packageName: string,
179
+ ): string | null {
180
+ let currentPath = fs.statSync(resolvedPath).isDirectory()
181
+ ? resolvedPath
182
+ : path.dirname(resolvedPath)
183
+
184
+ while (currentPath !== path.dirname(currentPath)) {
185
+ const packageJsonPath = path.join(currentPath, 'package.json')
186
+ if (fs.existsSync(packageJsonPath)) {
187
+ const packageJson = readPackageJson(packageJsonPath)
188
+ if (packageJson.name === packageName) {
189
+ return currentPath
190
+ }
191
+ }
192
+ currentPath = path.dirname(currentPath)
193
+ }
194
+
195
+ return null
196
+ }
197
+
198
+ function resolveInstalledPackageRootWithRequire(packageName: string, appDir: string): string | null {
199
+ const specifiers = [`${packageName}/package.json`, packageName]
200
+
201
+ for (const specifier of specifiers) {
202
+ try {
203
+ const resolvedPath = requireFromCli.resolve(specifier, {
204
+ paths: [appDir],
205
+ })
206
+ const packageRoot =
207
+ specifier === packageName
208
+ ? findResolvedPackageRoot(resolvedPath, packageName)
209
+ : path.dirname(resolvedPath)
210
+
211
+ if (packageRoot) {
212
+ return packageRoot
213
+ }
214
+ } catch {
215
+ continue
216
+ }
217
+ }
218
+
219
+ return null
220
+ }
221
+
222
+ export function parsePackageNameFromSpec(packageSpec: string): string | null {
223
+ const trimmed = packageSpec.trim()
224
+ if (!trimmed) return null
225
+
226
+ if (trimmed.startsWith('@')) {
227
+ const slashIndex = trimmed.indexOf('/')
228
+ if (slashIndex < 0) return null
229
+ const versionSeparator = trimmed.indexOf('@', slashIndex + 1)
230
+ return versionSeparator < 0 ? trimmed : trimmed.slice(0, versionSeparator)
231
+ }
232
+
233
+ const versionSeparator = trimmed.indexOf('@')
234
+ return versionSeparator < 0 ? trimmed : trimmed.slice(0, versionSeparator)
235
+ }
236
+
237
+ export function resolveInstalledPackageRoot(
238
+ resolver: PackageResolver,
239
+ packageName: string,
240
+ ): string {
241
+ const resolvedWithRequire = resolveInstalledPackageRootWithRequire(packageName, resolver.getAppDir())
242
+ if (resolvedWithRequire) {
243
+ return resolvedWithRequire
244
+ }
245
+
246
+ const fallback = resolver.getPackageRoot(packageName)
247
+ if (fs.existsSync(path.join(fallback, 'package.json'))) {
248
+ return fallback
249
+ }
250
+
251
+ // In monorepo mode, getPackageRoot resolves to packages/<name> (workspace convention),
252
+ // but externally installed packages land in root node_modules — check there too.
253
+ const nodeModulesFallback = path.join(resolver.getRootDir(), 'node_modules', packageName)
254
+ if (fs.existsSync(path.join(nodeModulesFallback, 'package.json'))) {
255
+ return nodeModulesFallback
256
+ }
257
+
258
+ throw new Error(`Package "${packageName}" is not installed in ${resolver.getAppDir()}.`)
259
+ }
260
+
261
+ export function readOfficialModulePackageFromRoot(
262
+ packageRoot: string,
263
+ expectedPackageName?: string,
264
+ targetModuleId?: string,
265
+ ): ValidatedOfficialModulePackage {
266
+ const packageJsonPath = path.join(packageRoot, 'package.json')
267
+ if (!fs.existsSync(packageJsonPath)) {
268
+ throw new Error(`Package manifest not found at ${packageJsonPath}.`)
269
+ }
270
+
271
+ const packageJson = readPackageJson(packageJsonPath)
272
+ const packageName = packageJson.name
273
+ if (!packageName || !packageName.startsWith('@open-mercato/')) {
274
+ throw new Error(`Package at ${packageRoot} is not under the @open-mercato scope.`)
275
+ }
276
+
277
+ if (expectedPackageName && packageName !== expectedPackageName) {
278
+ throw new Error(`Resolved package "${packageName}" does not match requested package "${expectedPackageName}".`)
279
+ }
280
+
281
+ const discoveredModules = discoverModulesInPackage(packageRoot)
282
+
283
+ if (discoveredModules.length === 0) {
284
+ throw new Error(`Package "${packageName}" has no modules in src/modules/ or dist/modules/.`)
285
+ }
286
+
287
+ let selected: DiscoveredModule
288
+
289
+ if (targetModuleId) {
290
+ const found = discoveredModules.find((m) => m.moduleId === targetModuleId)
291
+ if (!found) {
292
+ const available = discoveredModules.map((m) => m.moduleId).join(', ')
293
+ throw new Error(
294
+ `Package "${packageName}" does not contain module "${targetModuleId}". Available: ${available}`,
295
+ )
296
+ }
297
+ selected = found
298
+ } else {
299
+ if (discoveredModules.length > 1) {
300
+ const available = discoveredModules.map((m) => m.moduleId).join(', ')
301
+ throw new Error(
302
+ `Package "${packageName}" contains multiple modules (${available}). Specify one with --module <moduleId>.`,
303
+ )
304
+ }
305
+ selected = discoveredModules[0]
306
+ }
307
+
308
+ const { moduleId } = selected
309
+ const sourceModuleDir = path.join(packageRoot, 'src', 'modules', moduleId)
310
+ const distModuleDir = path.join(packageRoot, 'dist', 'modules', moduleId)
311
+
312
+ if (!fs.existsSync(sourceModuleDir)) {
313
+ throw new Error(`Package "${packageName}" is missing src/modules/${moduleId}.`)
314
+ }
315
+
316
+ if (!fs.existsSync(distModuleDir)) {
317
+ throw new Error(`Package "${packageName}" is missing dist/modules/${moduleId}.`)
318
+ }
319
+
320
+ const info = parseModuleInfo(path.join(sourceModuleDir, 'index.ts'))
321
+
322
+ return {
323
+ packageName,
324
+ packageRoot,
325
+ packageJson,
326
+ metadata: { moduleId, ejectable: selected.ejectable },
327
+ moduleInfo: { title: info.title, description: info.description },
328
+ sourceModuleDir,
329
+ distModuleDir,
330
+ }
331
+ }
332
+
333
+ export function resolveInstalledOfficialModulePackage(
334
+ resolver: PackageResolver,
335
+ packageName: string,
336
+ moduleId?: string,
337
+ ): ValidatedOfficialModulePackage {
338
+ const packageRoot = resolveInstalledPackageRoot(resolver, packageName)
339
+ return readOfficialModulePackageFromRoot(packageRoot, packageName, moduleId)
340
+ }
341
+
342
+ export function validateEjectBoundaries(modulePackage: ValidatedOfficialModulePackage): void {
343
+ const violations = collectBoundaryViolations(modulePackage.sourceModuleDir)
344
+ if (violations.length === 0) {
345
+ return
346
+ }
347
+
348
+ throw new Error(
349
+ [
350
+ `Package "${modulePackage.packageName}" cannot be added with --eject because it imports files outside src/modules/${modulePackage.metadata.moduleId}.`,
351
+ 'Invalid imports:',
352
+ ...violations.map((violation) => `- ${violation}`),
353
+ ].join('\n'),
354
+ )
355
+ }