@open-mercato/cli 0.4.2-canary-c02407ff85

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 (51) hide show
  1. package/bin/mercato +21 -0
  2. package/build.mjs +78 -0
  3. package/dist/bin.js +51 -0
  4. package/dist/bin.js.map +7 -0
  5. package/dist/index.js +5 -0
  6. package/dist/index.js.map +7 -0
  7. package/dist/lib/db/commands.js +350 -0
  8. package/dist/lib/db/commands.js.map +7 -0
  9. package/dist/lib/db/index.js +7 -0
  10. package/dist/lib/db/index.js.map +7 -0
  11. package/dist/lib/generators/entity-ids.js +257 -0
  12. package/dist/lib/generators/entity-ids.js.map +7 -0
  13. package/dist/lib/generators/index.js +12 -0
  14. package/dist/lib/generators/index.js.map +7 -0
  15. package/dist/lib/generators/module-di.js +73 -0
  16. package/dist/lib/generators/module-di.js.map +7 -0
  17. package/dist/lib/generators/module-entities.js +104 -0
  18. package/dist/lib/generators/module-entities.js.map +7 -0
  19. package/dist/lib/generators/module-registry.js +1081 -0
  20. package/dist/lib/generators/module-registry.js.map +7 -0
  21. package/dist/lib/resolver.js +205 -0
  22. package/dist/lib/resolver.js.map +7 -0
  23. package/dist/lib/utils.js +161 -0
  24. package/dist/lib/utils.js.map +7 -0
  25. package/dist/mercato.js +1045 -0
  26. package/dist/mercato.js.map +7 -0
  27. package/dist/registry.js +7 -0
  28. package/dist/registry.js.map +7 -0
  29. package/jest.config.cjs +19 -0
  30. package/package.json +71 -0
  31. package/src/__tests__/mercato.test.ts +90 -0
  32. package/src/bin.ts +74 -0
  33. package/src/index.ts +2 -0
  34. package/src/lib/__tests__/resolver.test.ts +101 -0
  35. package/src/lib/__tests__/utils.test.ts +270 -0
  36. package/src/lib/db/__tests__/commands.test.ts +131 -0
  37. package/src/lib/db/commands.ts +431 -0
  38. package/src/lib/db/index.ts +1 -0
  39. package/src/lib/generators/__tests__/generators.test.ts +197 -0
  40. package/src/lib/generators/entity-ids.ts +336 -0
  41. package/src/lib/generators/index.ts +4 -0
  42. package/src/lib/generators/module-di.ts +89 -0
  43. package/src/lib/generators/module-entities.ts +124 -0
  44. package/src/lib/generators/module-registry.ts +1222 -0
  45. package/src/lib/resolver.ts +308 -0
  46. package/src/lib/utils.ts +200 -0
  47. package/src/mercato.ts +1106 -0
  48. package/src/registry.ts +2 -0
  49. package/tsconfig.build.json +4 -0
  50. package/tsconfig.json +12 -0
  51. package/watch.mjs +6 -0
@@ -0,0 +1,308 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+
4
+ export type ModuleEntry = {
5
+ id: string
6
+ from?: '@open-mercato/core' | '@app' | string
7
+ }
8
+
9
+ export type PackageInfo = {
10
+ name: string
11
+ path: string
12
+ modulesPath: string
13
+ }
14
+
15
+ export interface PackageResolver {
16
+ isMonorepo(): boolean
17
+ getRootDir(): string
18
+ getAppDir(): string
19
+ getOutputDir(): string
20
+ getModulesConfigPath(): string
21
+ discoverPackages(): PackageInfo[]
22
+ loadEnabledModules(): ModuleEntry[]
23
+ getModulePaths(entry: ModuleEntry): { appBase: string; pkgBase: string }
24
+ getModuleImportBase(entry: ModuleEntry): { appBase: string; pkgBase: string }
25
+ getPackageOutputDir(packageName: string): string
26
+ getPackageRoot(from?: string): string
27
+ }
28
+
29
+ function pkgDirFor(rootDir: string, from?: string, isMonorepo = true): string {
30
+ if (!isMonorepo) {
31
+ // Production mode: look in node_modules
32
+ // Packages include source TypeScript files in src/modules
33
+ const pkgName = from || '@open-mercato/core'
34
+ return path.join(rootDir, 'node_modules', pkgName, 'src', 'modules')
35
+ }
36
+
37
+ // Monorepo mode
38
+ if (!from || from === '@open-mercato/core') {
39
+ return path.resolve(rootDir, 'packages/core/src/modules')
40
+ }
41
+ // Support other local packages like '@open-mercato/onboarding' => packages/onboarding/src/modules
42
+ const m = from.match(/^@open-mercato\/(.+)$/)
43
+ if (m) {
44
+ return path.resolve(rootDir, `packages/${m[1]}/src/modules`)
45
+ }
46
+ // Fallback to core modules path
47
+ return path.resolve(rootDir, 'packages/core/src/modules')
48
+ }
49
+
50
+ function pkgRootFor(rootDir: string, from?: string, isMonorepo = true): string {
51
+ if (!isMonorepo) {
52
+ const pkgName = from || '@open-mercato/core'
53
+ return path.join(rootDir, 'node_modules', pkgName)
54
+ }
55
+
56
+ if (!from || from === '@open-mercato/core') {
57
+ return path.resolve(rootDir, 'packages/core')
58
+ }
59
+ const m = from.match(/^@open-mercato\/(.+)$/)
60
+ if (m) {
61
+ return path.resolve(rootDir, `packages/${m[1]}`)
62
+ }
63
+ return path.resolve(rootDir, 'packages/core')
64
+ }
65
+
66
+ function parseModulesFromSource(source: string): ModuleEntry[] {
67
+ // Parse the enabledModules array from TypeScript source
68
+ // This is more reliable than trying to require() a .ts file
69
+ const match = source.match(/export\s+const\s+enabledModules[^=]*=\s*\[([\s\S]*?)\]/)
70
+ if (!match) return []
71
+
72
+ const arrayContent = match[1]
73
+ const modules: ModuleEntry[] = []
74
+
75
+ // Match each object in the array: { id: '...', from: '...' }
76
+ const objectRegex = /\{\s*id:\s*['"]([^'"]+)['"]\s*(?:,\s*from:\s*['"]([^'"]+)['"])?\s*\}/g
77
+ let objMatch
78
+ while ((objMatch = objectRegex.exec(arrayContent)) !== null) {
79
+ const [, id, from] = objMatch
80
+ modules.push({ id, from: from || '@open-mercato/core' })
81
+ }
82
+
83
+ return modules
84
+ }
85
+
86
+ function loadEnabledModulesFromConfig(appDir: string): ModuleEntry[] {
87
+ const cfgPath = path.resolve(appDir, 'src/modules.ts')
88
+ if (fs.existsSync(cfgPath)) {
89
+ try {
90
+ const source = fs.readFileSync(cfgPath, 'utf8')
91
+ const list = parseModulesFromSource(source)
92
+ if (list.length) return list
93
+ } catch {
94
+ // Fall through to fallback
95
+ }
96
+ }
97
+ // Fallback: scan src/modules/* to keep backward compatibility
98
+ const modulesRoot = path.resolve(appDir, 'src/modules')
99
+ if (!fs.existsSync(modulesRoot)) return []
100
+ return fs
101
+ .readdirSync(modulesRoot, { withFileTypes: true })
102
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
103
+ .map((e) => ({ id: e.name, from: '@app' as const }))
104
+ }
105
+
106
+ function discoverPackagesInMonorepo(rootDir: string): PackageInfo[] {
107
+ const packagesDir = path.join(rootDir, 'packages')
108
+ if (!fs.existsSync(packagesDir)) return []
109
+
110
+ const packages: PackageInfo[] = []
111
+ const entries = fs.readdirSync(packagesDir, { withFileTypes: true })
112
+
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory()) continue
115
+ const pkgPath = path.join(packagesDir, entry.name)
116
+ const pkgJsonPath = path.join(pkgPath, 'package.json')
117
+
118
+ if (!fs.existsSync(pkgJsonPath)) continue
119
+
120
+ try {
121
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
122
+ const modulesPath = path.join(pkgPath, 'src', 'modules')
123
+
124
+ if (fs.existsSync(modulesPath)) {
125
+ packages.push({
126
+ name: pkgJson.name || `@open-mercato/${entry.name}`,
127
+ path: pkgPath,
128
+ modulesPath,
129
+ })
130
+ }
131
+ } catch {
132
+ // Skip invalid packages
133
+ }
134
+ }
135
+
136
+ return packages
137
+ }
138
+
139
+ function discoverPackagesInNodeModules(rootDir: string): PackageInfo[] {
140
+ const nodeModulesPath = path.join(rootDir, 'node_modules', '@open-mercato')
141
+ if (!fs.existsSync(nodeModulesPath)) return []
142
+
143
+ const packages: PackageInfo[] = []
144
+ const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true })
145
+
146
+ for (const entry of entries) {
147
+ if (!entry.isDirectory()) continue
148
+ const pkgPath = path.join(nodeModulesPath, entry.name)
149
+ const pkgJsonPath = path.join(pkgPath, 'package.json')
150
+
151
+ if (!fs.existsSync(pkgJsonPath)) continue
152
+
153
+ try {
154
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
155
+ // Packages include source TypeScript files in src/modules
156
+ const modulesPath = path.join(pkgPath, 'src', 'modules')
157
+
158
+ if (fs.existsSync(modulesPath)) {
159
+ packages.push({
160
+ name: pkgJson.name || `@open-mercato/${entry.name}`,
161
+ path: pkgPath,
162
+ modulesPath,
163
+ })
164
+ }
165
+ } catch {
166
+ // Skip invalid packages
167
+ }
168
+ }
169
+
170
+ return packages
171
+ }
172
+
173
+ function detectAppDir(rootDir: string, isMonorepo: boolean): string {
174
+ if (!isMonorepo) {
175
+ // Production mode: app is at root
176
+ return rootDir
177
+ }
178
+
179
+ // Monorepo mode: look for app in apps/mercato/ or apps/app/
180
+ const mercatoApp = path.join(rootDir, 'apps', 'mercato')
181
+ if (fs.existsSync(mercatoApp)) {
182
+ return mercatoApp
183
+ }
184
+
185
+ const defaultApp = path.join(rootDir, 'apps', 'app')
186
+ if (fs.existsSync(defaultApp)) {
187
+ return defaultApp
188
+ }
189
+
190
+ // Fallback: check if apps directory exists and has any app
191
+ const appsDir = path.join(rootDir, 'apps')
192
+ if (fs.existsSync(appsDir)) {
193
+ const entries = fs.readdirSync(appsDir, { withFileTypes: true })
194
+ const appEntry = entries.find(
195
+ (e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'docs'
196
+ )
197
+ if (appEntry) {
198
+ return path.join(appsDir, appEntry.name)
199
+ }
200
+ }
201
+
202
+ // Final fallback for legacy structure: root is the app
203
+ return rootDir
204
+ }
205
+
206
+ function findNodeModulesRoot(startDir: string): string | null {
207
+ // Walk up to find node_modules/@open-mercato/core
208
+ let dir = startDir
209
+ while (dir !== path.dirname(dir)) {
210
+ const corePkgPath = path.join(dir, 'node_modules', '@open-mercato', 'core')
211
+ if (fs.existsSync(corePkgPath)) {
212
+ return dir
213
+ }
214
+ dir = path.dirname(dir)
215
+ }
216
+ return null
217
+ }
218
+
219
+ function detectMonorepoFromNodeModules(appDir: string): { isMonorepo: boolean; monorepoRoot: string | null; nodeModulesRoot: string | null } {
220
+ // Find where node_modules/@open-mercato/core is located (may be hoisted)
221
+ const nodeModulesRoot = findNodeModulesRoot(appDir)
222
+ if (!nodeModulesRoot) {
223
+ return { isMonorepo: false, monorepoRoot: null, nodeModulesRoot: null }
224
+ }
225
+
226
+ const corePkgPath = path.join(nodeModulesRoot, 'node_modules', '@open-mercato', 'core')
227
+
228
+ try {
229
+ const stat = fs.lstatSync(corePkgPath)
230
+ if (stat.isSymbolicLink()) {
231
+ // It's a symlink - we're in monorepo dev mode
232
+ // Resolve the symlink to find the monorepo root
233
+ const realPath = fs.realpathSync(corePkgPath)
234
+ // realPath is something like /path/to/monorepo/packages/core
235
+ // monorepo root is 2 levels up
236
+ const monorepoRoot = path.dirname(path.dirname(realPath))
237
+ return { isMonorepo: true, monorepoRoot, nodeModulesRoot }
238
+ }
239
+ // It's a real directory - production mode
240
+ return { isMonorepo: false, monorepoRoot: null, nodeModulesRoot }
241
+ } catch {
242
+ // Package doesn't exist yet or error reading - assume production mode
243
+ return { isMonorepo: false, monorepoRoot: null, nodeModulesRoot }
244
+ }
245
+ }
246
+
247
+ export function createResolver(cwd: string = process.cwd()): PackageResolver {
248
+ // First detect if we're in a monorepo by checking if node_modules packages are symlinks
249
+ const { isMonorepo: _isMonorepo, monorepoRoot } = detectMonorepoFromNodeModules(cwd)
250
+ const rootDir = monorepoRoot ?? cwd
251
+
252
+ // The app directory depends on context:
253
+ // - In monorepo: use detectAppDir to find apps/mercato or similar
254
+ // - In production: app is at cwd
255
+ const appDir = _isMonorepo ? detectAppDir(rootDir, true) : cwd
256
+
257
+ return {
258
+ isMonorepo: () => _isMonorepo,
259
+
260
+ getRootDir: () => rootDir,
261
+
262
+ getAppDir: () => appDir,
263
+
264
+ getOutputDir: () => {
265
+ // Output is ALWAYS .mercato/generated relative to app directory
266
+ return path.join(appDir, '.mercato', 'generated')
267
+ },
268
+
269
+ getModulesConfigPath: () => path.join(appDir, 'src', 'modules.ts'),
270
+
271
+ discoverPackages: () => {
272
+ return _isMonorepo
273
+ ? discoverPackagesInMonorepo(rootDir)
274
+ : discoverPackagesInNodeModules(rootDir)
275
+ },
276
+
277
+ loadEnabledModules: () => loadEnabledModulesFromConfig(appDir),
278
+
279
+ getModulePaths: (entry: ModuleEntry) => {
280
+ const appBase = path.resolve(appDir, 'src/modules', entry.id)
281
+ const pkgModulesRoot = pkgDirFor(rootDir, entry.from, _isMonorepo)
282
+ const pkgBase = path.join(pkgModulesRoot, entry.id)
283
+ return { appBase, pkgBase }
284
+ },
285
+
286
+ getModuleImportBase: (entry: ModuleEntry) => {
287
+ // Prefer @app overrides at import-time; fall back to provided package alias
288
+ const from = entry.from || '@open-mercato/core'
289
+ return {
290
+ appBase: `@/modules/${entry.id}`,
291
+ pkgBase: `${from}/modules/${entry.id}`,
292
+ }
293
+ },
294
+
295
+ getPackageOutputDir: (packageName: string) => {
296
+ if (packageName === '@app') {
297
+ // App output goes to .mercato/generated
298
+ return path.join(appDir, '.mercato', 'generated')
299
+ }
300
+ const pkgRoot = pkgRootFor(rootDir, packageName, _isMonorepo)
301
+ return path.join(pkgRoot, 'generated')
302
+ },
303
+
304
+ getPackageRoot: (from?: string) => {
305
+ return pkgRootFor(rootDir, from, _isMonorepo)
306
+ },
307
+ }
308
+ }
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ export type ChecksumRecord = {
6
+ content: string
7
+ structure: string
8
+ }
9
+
10
+ export interface GeneratorResult {
11
+ filesWritten: string[]
12
+ filesUnchanged: string[]
13
+ errors: string[]
14
+ }
15
+
16
+ export function calculateChecksum(content: string): string {
17
+ return crypto.createHash('md5').update(content).digest('hex')
18
+ }
19
+
20
+ export function readChecksumRecord(filePath: string): ChecksumRecord | null {
21
+ if (!fs.existsSync(filePath)) {
22
+ return null
23
+ }
24
+ try {
25
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial<ChecksumRecord>
26
+ if (parsed && typeof parsed.content === 'string' && typeof parsed.structure === 'string') {
27
+ return { content: parsed.content, structure: parsed.structure }
28
+ }
29
+ } catch {
30
+ // Invalid checksum file
31
+ }
32
+ return null
33
+ }
34
+
35
+ export function writeChecksumRecord(filePath: string, record: ChecksumRecord): void {
36
+ fs.writeFileSync(filePath, JSON.stringify(record) + '\n')
37
+ }
38
+
39
+ function collectStructureEntries(target: string, base: string, acc: string[]): void {
40
+ let entries: fs.Dirent[]
41
+ try {
42
+ entries = fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
43
+ } catch (err) {
44
+ acc.push(`error:${path.relative(base, target)}:${(err as Error).message}`)
45
+ return
46
+ }
47
+
48
+ for (const entry of entries) {
49
+ const fullPath = path.join(target, entry.name)
50
+ const rel = path.relative(base, fullPath)
51
+ try {
52
+ const stat = fs.statSync(fullPath)
53
+ if (entry.isDirectory()) {
54
+ acc.push(`dir:${rel}:${stat.mtimeMs}`)
55
+ collectStructureEntries(fullPath, base, acc)
56
+ } else if (entry.isFile()) {
57
+ acc.push(`file:${rel}:${stat.size}:${stat.mtimeMs}`)
58
+ } else {
59
+ acc.push(`other:${rel}:${stat.mtimeMs}`)
60
+ }
61
+ } catch {
62
+ // File was deleted between readdir and stat - skip it
63
+ continue
64
+ }
65
+ }
66
+ }
67
+
68
+ export function calculateStructureChecksum(paths: string[]): string {
69
+ const normalized = Array.from(new Set(paths.map((p) => path.resolve(p)))).sort()
70
+ const entries: string[] = []
71
+ for (const target of normalized) {
72
+ if (!fs.existsSync(target)) {
73
+ entries.push(`missing:${target}`)
74
+ continue
75
+ }
76
+ const stat = fs.statSync(target)
77
+ entries.push(`${stat.isDirectory() ? 'dir' : 'file'}:${target}:${stat.mtimeMs}`)
78
+ if (stat.isDirectory()) collectStructureEntries(target, target, entries)
79
+ }
80
+ return calculateChecksum(entries.join('\n'))
81
+ }
82
+
83
+ export function writeIfChanged(
84
+ filePath: string,
85
+ content: string,
86
+ checksumPath?: string,
87
+ structureChecksum?: string
88
+ ): boolean {
89
+ const newChecksum = calculateChecksum(content)
90
+
91
+ if (checksumPath) {
92
+ const existingRecord = readChecksumRecord(checksumPath)
93
+ const newRecord: ChecksumRecord = {
94
+ content: newChecksum,
95
+ structure: structureChecksum || '',
96
+ }
97
+
98
+ const shouldWrite =
99
+ !existingRecord ||
100
+ existingRecord.content !== newRecord.content ||
101
+ (structureChecksum && existingRecord.structure !== newRecord.structure)
102
+
103
+ if (shouldWrite) {
104
+ ensureDir(filePath)
105
+ fs.writeFileSync(filePath, content)
106
+ writeChecksumRecord(checksumPath, newRecord)
107
+ return true
108
+ }
109
+ return false
110
+ }
111
+
112
+ // Simple comparison without checksum file
113
+ if (fs.existsSync(filePath)) {
114
+ const existing = fs.readFileSync(filePath, 'utf8')
115
+ if (existing === content) {
116
+ return false
117
+ }
118
+ }
119
+
120
+ ensureDir(filePath)
121
+ fs.writeFileSync(filePath, content)
122
+ return true
123
+ }
124
+
125
+ export function ensureDir(filePath: string): void {
126
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
127
+ }
128
+
129
+ // Allowed path substrings for safe deletion. Include both POSIX and Windows separators.
130
+ const ALLOWED_RIMRAF_PATTERNS = [
131
+ '/generated/',
132
+ '/dist/',
133
+ '/.mercato/',
134
+ '/entities/',
135
+ '\\generated\\',
136
+ '\\dist\\',
137
+ '\\.mercato\\',
138
+ '\\entities\\',
139
+ ]
140
+
141
+ export function rimrafDir(dir: string, opts?: { allowedPatterns?: string[] }): void {
142
+ if (!fs.existsSync(dir)) return
143
+
144
+ // Safety check: only allow deletion within known safe directories
145
+ const resolved = path.resolve(dir)
146
+ const allowed = opts?.allowedPatterns ?? ALLOWED_RIMRAF_PATTERNS
147
+
148
+ // Normalize resolved path to support matching against both POSIX and Windows patterns
149
+ const normalized = {
150
+ posix: resolved.replace(/\\/g, '/'),
151
+ win: resolved.replace(/\//g, '\\'),
152
+ }
153
+
154
+ if (!allowed.some((pattern) => normalized.posix.includes(pattern) || normalized.win.includes(pattern))) {
155
+ throw new Error(`Refusing to delete directory outside allowed paths: ${resolved}. Allowed patterns: ${allowed.join(', ')}`)
156
+ }
157
+
158
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
159
+ const p = path.join(dir, entry.name)
160
+ if (entry.isDirectory()) rimrafDir(p, opts)
161
+ else fs.unlinkSync(p)
162
+ }
163
+ fs.rmdirSync(dir)
164
+ }
165
+
166
+ export function toVar(s: string): string {
167
+ return s.replace(/[^a-zA-Z0-9_]/g, '_')
168
+ }
169
+
170
+ export function toSnake(s: string): string {
171
+ return s
172
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
173
+ .replace(/\W+/g, '_')
174
+ .replace(/_{2,}/g, '_')
175
+ .replace(/^_+|_+$/g, '')
176
+ .toLowerCase()
177
+ }
178
+
179
+ export async function moduleHasExport(filePath: string, exportName: string): Promise<boolean> {
180
+ try {
181
+ const mod = await import(filePath)
182
+ return mod != null && Object.prototype.hasOwnProperty.call(mod, exportName)
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ export function logGenerationResult(label: string, changed: boolean): void {
189
+ if (changed) {
190
+ console.log(`Generated ${label}`)
191
+ }
192
+ }
193
+
194
+ export function createGeneratorResult(): GeneratorResult {
195
+ return {
196
+ filesWritten: [],
197
+ filesUnchanged: [],
198
+ errors: [],
199
+ }
200
+ }