@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,270 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import {
4
+ calculateChecksum,
5
+ readChecksumRecord,
6
+ writeChecksumRecord,
7
+ writeIfChanged,
8
+ ensureDir,
9
+ rimrafDir,
10
+ toVar,
11
+ toSnake,
12
+ createGeneratorResult,
13
+ type ChecksumRecord,
14
+ } from '../utils'
15
+
16
+ // Mock fs module
17
+ jest.mock('node:fs')
18
+
19
+ const mockFs = fs as jest.Mocked<typeof fs>
20
+
21
+ describe('utils', () => {
22
+ beforeEach(() => {
23
+ jest.clearAllMocks()
24
+ })
25
+
26
+ describe('calculateChecksum', () => {
27
+ it('returns MD5 hash of content', () => {
28
+ const content = 'test content'
29
+ const checksum = calculateChecksum(content)
30
+
31
+ expect(checksum).toMatch(/^[a-f0-9]{32}$/)
32
+ })
33
+
34
+ it('returns same hash for same content', () => {
35
+ const content = 'test content'
36
+
37
+ expect(calculateChecksum(content)).toBe(calculateChecksum(content))
38
+ })
39
+
40
+ it('returns different hash for different content', () => {
41
+ expect(calculateChecksum('content1')).not.toBe(calculateChecksum('content2'))
42
+ })
43
+
44
+ it('handles empty string', () => {
45
+ const checksum = calculateChecksum('')
46
+
47
+ expect(checksum).toMatch(/^[a-f0-9]{32}$/)
48
+ })
49
+ })
50
+
51
+ describe('readChecksumRecord', () => {
52
+ it('returns null when file does not exist', () => {
53
+ mockFs.existsSync.mockReturnValue(false)
54
+
55
+ const result = readChecksumRecord('/path/to/checksum')
56
+
57
+ expect(result).toBeNull()
58
+ })
59
+
60
+ it('returns parsed record when file exists and is valid', () => {
61
+ const record: ChecksumRecord = { content: 'abc123', structure: 'def456' }
62
+ mockFs.existsSync.mockReturnValue(true)
63
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(record))
64
+
65
+ const result = readChecksumRecord('/path/to/checksum')
66
+
67
+ expect(result).toEqual(record)
68
+ })
69
+
70
+ it('returns null when file contains invalid JSON', () => {
71
+ mockFs.existsSync.mockReturnValue(true)
72
+ mockFs.readFileSync.mockReturnValue('not valid json')
73
+
74
+ const result = readChecksumRecord('/path/to/checksum')
75
+
76
+ expect(result).toBeNull()
77
+ })
78
+
79
+ it('returns null when record is missing required fields', () => {
80
+ mockFs.existsSync.mockReturnValue(true)
81
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({ content: 'abc' }))
82
+
83
+ const result = readChecksumRecord('/path/to/checksum')
84
+
85
+ expect(result).toBeNull()
86
+ })
87
+ })
88
+
89
+ describe('writeChecksumRecord', () => {
90
+ it('writes JSON record with newline', () => {
91
+ const record: ChecksumRecord = { content: 'abc123', structure: 'def456' }
92
+
93
+ writeChecksumRecord('/path/to/checksum', record)
94
+
95
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
96
+ '/path/to/checksum',
97
+ JSON.stringify(record) + '\n'
98
+ )
99
+ })
100
+ })
101
+
102
+ describe('ensureDir', () => {
103
+ it('creates parent directory recursively', () => {
104
+ ensureDir('/path/to/file.txt')
105
+
106
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/path/to', { recursive: true })
107
+ })
108
+ })
109
+
110
+ describe('rimrafDir', () => {
111
+ it('does nothing when directory does not exist', () => {
112
+ mockFs.existsSync.mockReturnValue(false)
113
+
114
+ rimrafDir('/some/path/generated/test')
115
+
116
+ expect(mockFs.readdirSync).not.toHaveBeenCalled()
117
+ })
118
+
119
+ it('throws error for paths outside allowed patterns', () => {
120
+ mockFs.existsSync.mockReturnValue(true)
121
+
122
+ expect(() => rimrafDir('/path/to/important-data')).toThrow(
123
+ /Refusing to delete directory outside allowed paths/
124
+ )
125
+ })
126
+
127
+ it('allows deletion within /generated/ pattern', () => {
128
+ mockFs.existsSync.mockReturnValue(true)
129
+ mockFs.readdirSync.mockReturnValue([])
130
+
131
+ // Path must contain /generated/ (with slashes)
132
+ expect(() => rimrafDir('/project/generated/entities')).not.toThrow()
133
+ })
134
+
135
+ it('allows deletion within /dist/ pattern', () => {
136
+ mockFs.existsSync.mockReturnValue(true)
137
+ mockFs.readdirSync.mockReturnValue([])
138
+
139
+ expect(() => rimrafDir('/project/dist/output')).not.toThrow()
140
+ })
141
+
142
+ it('allows deletion within /.mercato/ pattern', () => {
143
+ mockFs.existsSync.mockReturnValue(true)
144
+ mockFs.readdirSync.mockReturnValue([])
145
+
146
+ expect(() => rimrafDir('/project/.mercato/generated')).not.toThrow()
147
+ })
148
+
149
+ it('allows deletion within /entities/ pattern', () => {
150
+ mockFs.existsSync.mockReturnValue(true)
151
+ mockFs.readdirSync.mockReturnValue([])
152
+
153
+ expect(() => rimrafDir('/project/generated/entities/test')).not.toThrow()
154
+ })
155
+
156
+ it('allows custom allowed patterns', () => {
157
+ mockFs.existsSync.mockReturnValue(true)
158
+ mockFs.readdirSync.mockReturnValue([])
159
+
160
+ expect(() =>
161
+ rimrafDir('/custom/temp/files', { allowedPatterns: ['/temp/'] })
162
+ ).not.toThrow()
163
+ })
164
+
165
+ it('removes files and directories recursively', () => {
166
+ mockFs.existsSync.mockReturnValue(true)
167
+ mockFs.readdirSync.mockReturnValueOnce([
168
+ { name: 'file.txt', isDirectory: () => false, isFile: () => true },
169
+ { name: 'subdir', isDirectory: () => true, isFile: () => false },
170
+ ] as any)
171
+ mockFs.readdirSync.mockReturnValueOnce([])
172
+
173
+ rimrafDir('/project/generated/test')
174
+
175
+ expect(mockFs.unlinkSync).toHaveBeenCalledWith('/project/generated/test/file.txt')
176
+ expect(mockFs.rmdirSync).toHaveBeenCalled()
177
+ })
178
+ })
179
+
180
+ describe('toVar', () => {
181
+ it('replaces non-alphanumeric characters with underscores', () => {
182
+ expect(toVar('hello-world')).toBe('hello_world')
183
+ expect(toVar('foo.bar')).toBe('foo_bar')
184
+ expect(toVar('test@123')).toBe('test_123')
185
+ })
186
+
187
+ it('preserves alphanumeric and underscore characters', () => {
188
+ expect(toVar('hello_world123')).toBe('hello_world123')
189
+ })
190
+ })
191
+
192
+ describe('toSnake', () => {
193
+ it('converts camelCase to snake_case', () => {
194
+ expect(toSnake('helloWorld')).toBe('hello_world')
195
+ expect(toSnake('getUserById')).toBe('get_user_by_id')
196
+ })
197
+
198
+ it('converts PascalCase to snake_case', () => {
199
+ expect(toSnake('HelloWorld')).toBe('hello_world')
200
+ expect(toSnake('UserAccount')).toBe('user_account')
201
+ })
202
+
203
+ it('replaces non-word characters with underscores', () => {
204
+ expect(toSnake('hello-world')).toBe('hello_world')
205
+ expect(toSnake('foo.bar')).toBe('foo_bar')
206
+ })
207
+
208
+ it('removes leading and trailing underscores', () => {
209
+ expect(toSnake('_hello_')).toBe('hello')
210
+ })
211
+
212
+ it('collapses multiple underscores', () => {
213
+ expect(toSnake('hello__world')).toBe('hello_world')
214
+ })
215
+
216
+ it('handles simple words', () => {
217
+ expect(toSnake('hello')).toBe('hello')
218
+ expect(toSnake('HELLO')).toBe('hello')
219
+ })
220
+ })
221
+
222
+ describe('createGeneratorResult', () => {
223
+ it('creates empty result object', () => {
224
+ const result = createGeneratorResult()
225
+
226
+ expect(result).toEqual({
227
+ filesWritten: [],
228
+ filesUnchanged: [],
229
+ errors: [],
230
+ })
231
+ })
232
+
233
+ it('returns new object each time', () => {
234
+ const result1 = createGeneratorResult()
235
+ const result2 = createGeneratorResult()
236
+
237
+ expect(result1).not.toBe(result2)
238
+ })
239
+ })
240
+
241
+ describe('writeIfChanged', () => {
242
+ it('writes file when it does not exist (without checksum)', () => {
243
+ mockFs.existsSync.mockReturnValue(false)
244
+
245
+ const result = writeIfChanged('/path/to/file.ts', 'content')
246
+
247
+ expect(result).toBe(true)
248
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/path/to/file.ts', 'content')
249
+ })
250
+
251
+ it('does not write when content is unchanged (without checksum)', () => {
252
+ mockFs.existsSync.mockReturnValue(true)
253
+ mockFs.readFileSync.mockReturnValue('same content')
254
+
255
+ const result = writeIfChanged('/path/to/file.ts', 'same content')
256
+
257
+ expect(result).toBe(false)
258
+ })
259
+
260
+ it('writes when content has changed (without checksum)', () => {
261
+ mockFs.existsSync.mockReturnValue(true)
262
+ mockFs.readFileSync.mockReturnValue('old content')
263
+
264
+ const result = writeIfChanged('/path/to/file.ts', 'new content')
265
+
266
+ expect(result).toBe(true)
267
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/path/to/file.ts', 'new content')
268
+ })
269
+ })
270
+ })
@@ -0,0 +1,131 @@
1
+ import { sanitizeModuleId, validateTableName, dbGreenfield } from '../commands'
2
+
3
+ describe('db commands security', () => {
4
+ describe('sanitizeModuleId', () => {
5
+ it('should preserve valid module IDs', () => {
6
+ expect(sanitizeModuleId('customers')).toBe('customers')
7
+ expect(sanitizeModuleId('auth')).toBe('auth')
8
+ expect(sanitizeModuleId('api_keys')).toBe('api_keys')
9
+ expect(sanitizeModuleId('catalog123')).toBe('catalog123')
10
+ })
11
+
12
+ it('should replace hyphens with underscores', () => {
13
+ expect(sanitizeModuleId('my-module')).toBe('my_module')
14
+ expect(sanitizeModuleId('test-module-name')).toBe('test_module_name')
15
+ })
16
+
17
+ it('should replace special characters with underscores', () => {
18
+ expect(sanitizeModuleId('module.name')).toBe('module_name')
19
+ expect(sanitizeModuleId('module@name')).toBe('module_name')
20
+ expect(sanitizeModuleId('module/name')).toBe('module_name')
21
+ })
22
+
23
+ it('should sanitize SQL injection attempts', () => {
24
+ expect(sanitizeModuleId('module; DROP TABLE users;--')).toBe('module__DROP_TABLE_users___')
25
+ expect(sanitizeModuleId("test' OR '1'='1")).toBe('test__OR__1___1')
26
+ expect(sanitizeModuleId('test" OR "1"="1')).toBe('test__OR__1___1')
27
+ })
28
+
29
+ it('should handle newlines and special whitespace', () => {
30
+ expect(sanitizeModuleId('module\ntest')).toBe('module_test')
31
+ expect(sanitizeModuleId('module\rtest')).toBe('module_test')
32
+ expect(sanitizeModuleId('module\ttest')).toBe('module_test')
33
+ })
34
+
35
+ it('should preserve uppercase letters', () => {
36
+ expect(sanitizeModuleId('MyModule')).toBe('MyModule')
37
+ expect(sanitizeModuleId('API_Keys')).toBe('API_Keys')
38
+ })
39
+
40
+ it('should handle empty string', () => {
41
+ expect(sanitizeModuleId('')).toBe('')
42
+ })
43
+ })
44
+
45
+ describe('validateTableName', () => {
46
+ it('should accept valid table names', () => {
47
+ expect(() => validateTableName('mikro_orm_migrations_customers')).not.toThrow()
48
+ expect(() => validateTableName('mikro_orm_migrations_auth')).not.toThrow()
49
+ expect(() => validateTableName('mikro_orm_migrations_api_keys')).not.toThrow()
50
+ expect(() => validateTableName('_private_table')).not.toThrow()
51
+ expect(() => validateTableName('Table123')).not.toThrow()
52
+ expect(() => validateTableName('a')).not.toThrow()
53
+ })
54
+
55
+ it('should reject names starting with numbers', () => {
56
+ expect(() => validateTableName('123_table')).toThrow(/Invalid table name/)
57
+ expect(() => validateTableName('1table')).toThrow(/Invalid table name/)
58
+ })
59
+
60
+ it('should reject names with hyphens', () => {
61
+ expect(() => validateTableName('table-name')).toThrow(/Invalid table name/)
62
+ })
63
+
64
+ it('should reject names with spaces', () => {
65
+ expect(() => validateTableName('table name')).toThrow(/Invalid table name/)
66
+ })
67
+
68
+ it('should reject names with dots', () => {
69
+ expect(() => validateTableName('schema.table')).toThrow(/Invalid table name/)
70
+ })
71
+
72
+ it('should reject names with semicolons', () => {
73
+ expect(() => validateTableName('table;drop')).toThrow(/Invalid table name/)
74
+ })
75
+
76
+ it('should reject empty string', () => {
77
+ expect(() => validateTableName('')).toThrow(/Invalid table name/)
78
+ })
79
+
80
+ it('should include the invalid table name in error message', () => {
81
+ expect(() => validateTableName('invalid-name')).toThrow(/invalid-name/)
82
+ })
83
+ })
84
+ })
85
+
86
+ describe('db commands', () => {
87
+ describe('dbGreenfield', () => {
88
+ it('should require --yes flag', async () => {
89
+ // Mock console.error and process.exit
90
+ const mockConsoleError = jest.spyOn(console, 'error').mockImplementation()
91
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
92
+ throw new Error('process.exit called')
93
+ })
94
+
95
+ const mockResolver = {
96
+ loadEnabledModules: () => [],
97
+ getOutputDir: () => '/tmp/test',
98
+ getRootDir: () => '/tmp',
99
+ getModulePaths: () => ({ appBase: '', pkgBase: '' }),
100
+ } as any
101
+
102
+ await expect(dbGreenfield(mockResolver, { yes: false })).rejects.toThrow('process.exit called')
103
+
104
+ expect(mockConsoleError).toHaveBeenCalledWith(
105
+ 'This command will DELETE all data. Use --yes to confirm.'
106
+ )
107
+
108
+ mockConsoleError.mockRestore()
109
+ mockExit.mockRestore()
110
+ })
111
+ })
112
+
113
+ describe('integration with sanitization', () => {
114
+ it('should create safe table names from any module ID', () => {
115
+ const dangerousIds = [
116
+ 'module; DROP TABLE users;--',
117
+ "test' OR '1'='1",
118
+ 'module\ninjection',
119
+ '../../../etc/passwd',
120
+ ]
121
+
122
+ dangerousIds.forEach(id => {
123
+ const sanitized = sanitizeModuleId(id)
124
+ const tableName = `mikro_orm_migrations_${sanitized}`
125
+
126
+ // The resulting table name should be valid
127
+ expect(() => validateTableName(tableName)).not.toThrow()
128
+ })
129
+ })
130
+ })
131
+ })