@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.
- package/bin/mercato +21 -0
- package/build.mjs +78 -0
- package/dist/bin.js +51 -0
- package/dist/bin.js.map +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/db/commands.js +350 -0
- package/dist/lib/db/commands.js.map +7 -0
- package/dist/lib/db/index.js +7 -0
- package/dist/lib/db/index.js.map +7 -0
- package/dist/lib/generators/entity-ids.js +257 -0
- package/dist/lib/generators/entity-ids.js.map +7 -0
- package/dist/lib/generators/index.js +12 -0
- package/dist/lib/generators/index.js.map +7 -0
- package/dist/lib/generators/module-di.js +73 -0
- package/dist/lib/generators/module-di.js.map +7 -0
- package/dist/lib/generators/module-entities.js +104 -0
- package/dist/lib/generators/module-entities.js.map +7 -0
- package/dist/lib/generators/module-registry.js +1081 -0
- package/dist/lib/generators/module-registry.js.map +7 -0
- package/dist/lib/resolver.js +205 -0
- package/dist/lib/resolver.js.map +7 -0
- package/dist/lib/utils.js +161 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/mercato.js +1045 -0
- package/dist/mercato.js.map +7 -0
- package/dist/registry.js +7 -0
- package/dist/registry.js.map +7 -0
- package/jest.config.cjs +19 -0
- package/package.json +71 -0
- package/src/__tests__/mercato.test.ts +90 -0
- package/src/bin.ts +74 -0
- package/src/index.ts +2 -0
- package/src/lib/__tests__/resolver.test.ts +101 -0
- package/src/lib/__tests__/utils.test.ts +270 -0
- package/src/lib/db/__tests__/commands.test.ts +131 -0
- package/src/lib/db/commands.ts +431 -0
- package/src/lib/db/index.ts +1 -0
- package/src/lib/generators/__tests__/generators.test.ts +197 -0
- package/src/lib/generators/entity-ids.ts +336 -0
- package/src/lib/generators/index.ts +4 -0
- package/src/lib/generators/module-di.ts +89 -0
- package/src/lib/generators/module-entities.ts +124 -0
- package/src/lib/generators/module-registry.ts +1222 -0
- package/src/lib/resolver.ts +308 -0
- package/src/lib/utils.ts +200 -0
- package/src/mercato.ts +1106 -0
- package/src/registry.ts +2 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +12 -0
- 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
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
}
|