@opensaas/stack-cli 0.1.0 → 0.1.2

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 (49) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/CLAUDE.md +249 -0
  4. package/LICENSE +21 -0
  5. package/README.md +65 -5
  6. package/dist/commands/generate.d.ts.map +1 -1
  7. package/dist/commands/generate.js +6 -1
  8. package/dist/commands/generate.js.map +1 -1
  9. package/dist/commands/init.d.ts +9 -1
  10. package/dist/commands/init.d.ts.map +1 -1
  11. package/dist/commands/init.js +27 -338
  12. package/dist/commands/init.js.map +1 -1
  13. package/dist/generator/context.d.ts.map +1 -1
  14. package/dist/generator/context.js +78 -3
  15. package/dist/generator/context.js.map +1 -1
  16. package/dist/generator/index.d.ts +1 -0
  17. package/dist/generator/index.d.ts.map +1 -1
  18. package/dist/generator/index.js +1 -0
  19. package/dist/generator/index.js.map +1 -1
  20. package/dist/generator/mcp.d.ts +14 -0
  21. package/dist/generator/mcp.d.ts.map +1 -0
  22. package/dist/generator/mcp.js +193 -0
  23. package/dist/generator/mcp.js.map +1 -0
  24. package/dist/generator/types.d.ts.map +1 -1
  25. package/dist/generator/types.js +11 -33
  26. package/dist/generator/types.js.map +1 -1
  27. package/dist/index.js +10 -4
  28. package/dist/index.js.map +1 -1
  29. package/package.json +9 -3
  30. package/src/commands/__snapshots__/generate.test.ts.snap +265 -0
  31. package/src/commands/dev.test.ts +216 -0
  32. package/src/commands/generate.test.ts +272 -0
  33. package/src/commands/generate.ts +7 -0
  34. package/src/commands/init.ts +28 -361
  35. package/src/generator/__snapshots__/context.test.ts.snap +137 -0
  36. package/src/generator/__snapshots__/prisma.test.ts.snap +182 -0
  37. package/src/generator/__snapshots__/types.test.ts.snap +512 -0
  38. package/src/generator/context.test.ts +145 -0
  39. package/src/generator/context.ts +80 -3
  40. package/src/generator/index.ts +1 -0
  41. package/src/generator/mcp.test.ts +393 -0
  42. package/src/generator/mcp.ts +221 -0
  43. package/src/generator/prisma.test.ts +221 -0
  44. package/src/generator/types.test.ts +280 -0
  45. package/src/generator/types.ts +14 -36
  46. package/src/index.ts +8 -4
  47. package/tsconfig.json +1 -1
  48. package/tsconfig.tsbuildinfo +1 -1
  49. package/vitest.config.ts +26 -0
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+ import * as os from 'os'
5
+
6
+ // Mock chokidar
7
+ const mockWatcherOn = vi.fn()
8
+ const mockWatcherClose = vi.fn()
9
+ const mockWatch = vi.fn(() => ({
10
+ on: mockWatcherOn,
11
+ close: mockWatcherClose,
12
+ }))
13
+
14
+ vi.mock('chokidar', () => ({
15
+ default: {
16
+ watch: mockWatch,
17
+ },
18
+ }))
19
+
20
+ // Mock the generate command
21
+ vi.mock('./generate.js', () => ({
22
+ generateCommand: vi.fn().mockResolvedValue(undefined),
23
+ }))
24
+
25
+ // Mock ora
26
+ vi.mock('ora', () => ({
27
+ default: vi.fn(() => ({
28
+ start: vi.fn().mockReturnThis(),
29
+ succeed: vi.fn().mockReturnThis(),
30
+ fail: vi.fn().mockReturnThis(),
31
+ text: '',
32
+ })),
33
+ }))
34
+
35
+ // Mock chalk
36
+ vi.mock('chalk', () => ({
37
+ default: {
38
+ bold: {
39
+ cyan: vi.fn((str) => str),
40
+ },
41
+ cyan: vi.fn((str) => str),
42
+ gray: vi.fn((str) => str),
43
+ red: vi.fn((str) => str),
44
+ yellow: vi.fn((str) => str),
45
+ },
46
+ }))
47
+
48
+ describe('Dev Command', () => {
49
+ let tempDir: string
50
+ let originalCwd: string
51
+ let originalExit: typeof process.exit
52
+ let exitCode: number | undefined
53
+
54
+ beforeEach(() => {
55
+ vi.clearAllMocks()
56
+
57
+ // Create temp directory
58
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-test-'))
59
+ originalCwd = process.cwd()
60
+ process.chdir(tempDir)
61
+
62
+ // Mock process.exit
63
+ originalExit = process.exit
64
+ exitCode = undefined
65
+ process.exit = vi.fn((code?: number) => {
66
+ exitCode = code
67
+ throw new Error(`process.exit(${code})`)
68
+ }) as never
69
+
70
+ // Create opensaas.config.ts file
71
+ fs.writeFileSync(
72
+ path.join(tempDir, 'opensaas.config.ts'),
73
+ `
74
+ import { config } from '@opensaas/stack-core'
75
+ export default config({
76
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
77
+ lists: {}
78
+ })
79
+ `,
80
+ )
81
+ })
82
+
83
+ afterEach(() => {
84
+ // Restore
85
+ process.chdir(originalCwd)
86
+ process.exit = originalExit
87
+
88
+ // Clean up
89
+ if (fs.existsSync(tempDir)) {
90
+ fs.rmSync(tempDir, { recursive: true, force: true })
91
+ }
92
+ })
93
+
94
+ describe('devCommand', () => {
95
+ it('should fail if config file does not exist', async () => {
96
+ // Remove config file
97
+ fs.unlinkSync(path.join(tempDir, 'opensaas.config.ts'))
98
+
99
+ const { devCommand } = await import('./dev.js')
100
+
101
+ try {
102
+ await devCommand()
103
+ } catch {
104
+ // Expected to throw
105
+ }
106
+
107
+ expect(exitCode).toBe(1)
108
+ })
109
+
110
+ it('should call generateCommand initially', async () => {
111
+ const { generateCommand } = await import('./generate.js')
112
+ const { devCommand } = await import('./dev.js')
113
+
114
+ // Run dev command in background (don't await)
115
+ devCommand().catch(() => {
116
+ // Ignore errors
117
+ })
118
+
119
+ // Wait a bit for initial generation
120
+ await new Promise((resolve) => setTimeout(resolve, 100))
121
+
122
+ expect(generateCommand).toHaveBeenCalled()
123
+ })
124
+
125
+ it('should set up file watcher for config file', async () => {
126
+ const { devCommand } = await import('./dev.js')
127
+
128
+ // Run dev command
129
+ devCommand().catch(() => {
130
+ // Ignore errors
131
+ })
132
+
133
+ await new Promise((resolve) => setTimeout(resolve, 100))
134
+
135
+ // Verify watcher was set up
136
+ expect(mockWatch).toHaveBeenCalled()
137
+ expect(mockWatch.mock.calls.length).toBeGreaterThan(0)
138
+
139
+ const watchPath = mockWatch.mock.calls[0]![0]
140
+ expect(watchPath).toContain('opensaas.config.ts')
141
+
142
+ const watchOptions = mockWatch.mock.calls[0]![1]
143
+ expect(watchOptions).toMatchObject({
144
+ persistent: true,
145
+ ignoreInitial: true,
146
+ })
147
+ })
148
+
149
+ it('should register change event handler', async () => {
150
+ const { devCommand } = await import('./dev.js')
151
+
152
+ devCommand().catch(() => {
153
+ // Ignore errors
154
+ })
155
+
156
+ await new Promise((resolve) => setTimeout(resolve, 100))
157
+
158
+ expect(mockWatcherOn).toHaveBeenCalledWith('change', expect.any(Function))
159
+ })
160
+
161
+ it('should register error event handler', async () => {
162
+ const { devCommand } = await import('./dev.js')
163
+
164
+ devCommand().catch(() => {
165
+ // Ignore errors
166
+ })
167
+
168
+ await new Promise((resolve) => setTimeout(resolve, 100))
169
+
170
+ expect(mockWatcherOn).toHaveBeenCalledWith('error', expect.any(Function))
171
+ })
172
+
173
+ it('should regenerate on config file change', async () => {
174
+ const { generateCommand } = await import('./generate.js')
175
+ const { devCommand } = await import('./dev.js')
176
+
177
+ devCommand().catch(() => {
178
+ // Ignore errors
179
+ })
180
+
181
+ await new Promise((resolve) => setTimeout(resolve, 100))
182
+
183
+ // Simulate file change
184
+ const changeHandler = mockWatcherOn.mock.calls.find((call) => call[0] === 'change')?.[1]
185
+ expect(changeHandler).toBeDefined()
186
+
187
+ if (changeHandler) {
188
+ await changeHandler()
189
+ }
190
+
191
+ // generateCommand should be called again
192
+ expect(vi.mocked(generateCommand).mock.calls.length).toBeGreaterThan(1)
193
+ })
194
+
195
+ it('should close watcher on SIGINT', async () => {
196
+ const { devCommand } = await import('./dev.js')
197
+
198
+ devCommand().catch(() => {
199
+ // Ignore errors
200
+ })
201
+
202
+ await new Promise((resolve) => setTimeout(resolve, 100))
203
+
204
+ // Simulate SIGINT - catch the error from process.exit
205
+ try {
206
+ process.emit('SIGINT', 'SIGINT')
207
+ } catch {
208
+ // Expected to throw from process.exit mock
209
+ }
210
+
211
+ await new Promise((resolve) => setTimeout(resolve, 100))
212
+
213
+ expect(mockWatcherClose).toHaveBeenCalled()
214
+ })
215
+ })
216
+ })
@@ -0,0 +1,272 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+ import * as os from 'os'
5
+ import type { OpenSaasConfig } from '@opensaas/stack-core'
6
+ import { text } from '@opensaas/stack-core/fields'
7
+ import { writePrismaSchema, writeTypes, writeContext } from '../generator/index.js'
8
+
9
+ // Mock ora module
10
+ vi.mock('ora', () => ({
11
+ default: vi.fn(() => ({
12
+ start: vi.fn().mockReturnThis(),
13
+ succeed: vi.fn().mockReturnThis(),
14
+ fail: vi.fn().mockReturnThis(),
15
+ text: '',
16
+ })),
17
+ }))
18
+
19
+ // Mock chalk module
20
+ vi.mock('chalk', () => ({
21
+ default: {
22
+ bold: vi.fn((str) => str),
23
+ cyan: vi.fn((str) => str),
24
+ gray: vi.fn((str) => str),
25
+ red: vi.fn((str) => str),
26
+ yellow: vi.fn((str) => str),
27
+ green: vi.fn((str) => str),
28
+ },
29
+ }))
30
+
31
+ describe('Generate Command Integration', () => {
32
+ let tempDir: string
33
+
34
+ beforeEach(() => {
35
+ // Create temp directory for testing
36
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'generate-test-'))
37
+ })
38
+
39
+ afterEach(() => {
40
+ // Clean up
41
+ if (fs.existsSync(tempDir)) {
42
+ fs.rmSync(tempDir, { recursive: true, force: true })
43
+ }
44
+ })
45
+
46
+ describe('Generator Integration', () => {
47
+ it('should generate all files for a basic config', () => {
48
+ const config: OpenSaasConfig = {
49
+ db: {
50
+ provider: 'sqlite',
51
+ url: 'file:./dev.db',
52
+ },
53
+ lists: {
54
+ User: {
55
+ fields: {
56
+ name: text({ validation: { isRequired: true } }),
57
+ email: text({ validation: { isRequired: true } }),
58
+ },
59
+ },
60
+ },
61
+ }
62
+
63
+ // Generate files
64
+ const prismaPath = path.join(tempDir, 'prisma', 'schema.prisma')
65
+ const typesPath = path.join(tempDir, '.opensaas', 'types.ts')
66
+ const contextPath = path.join(tempDir, '.opensaas', 'context.ts')
67
+
68
+ writePrismaSchema(config, prismaPath)
69
+ writeTypes(config, typesPath)
70
+ writeContext(config, contextPath)
71
+
72
+ // Verify all files exist
73
+ expect(fs.existsSync(prismaPath)).toBe(true)
74
+ expect(fs.existsSync(typesPath)).toBe(true)
75
+ expect(fs.existsSync(contextPath)).toBe(true)
76
+
77
+ // Verify file contents with snapshots
78
+ const prismaSchema = fs.readFileSync(prismaPath, 'utf-8')
79
+ expect(prismaSchema).toMatchSnapshot('prisma-schema')
80
+
81
+ const types = fs.readFileSync(typesPath, 'utf-8')
82
+ expect(types).toMatchSnapshot('types')
83
+
84
+ const context = fs.readFileSync(contextPath, 'utf-8')
85
+ expect(context).toMatchSnapshot('context')
86
+ })
87
+
88
+ it('should create directories if they do not exist', () => {
89
+ const config: OpenSaasConfig = {
90
+ db: {
91
+ provider: 'sqlite',
92
+ url: 'file:./dev.db',
93
+ },
94
+ lists: {},
95
+ }
96
+
97
+ const prismaPath = path.join(tempDir, 'prisma', 'schema.prisma')
98
+
99
+ writePrismaSchema(config, prismaPath)
100
+
101
+ expect(fs.existsSync(path.join(tempDir, 'prisma'))).toBe(true)
102
+ expect(fs.existsSync(prismaPath)).toBe(true)
103
+ })
104
+
105
+ it('should overwrite existing files', () => {
106
+ const config1: OpenSaasConfig = {
107
+ db: {
108
+ provider: 'sqlite',
109
+ url: 'file:./dev.db',
110
+ },
111
+ lists: {
112
+ User: {
113
+ fields: {
114
+ name: text(),
115
+ },
116
+ },
117
+ },
118
+ }
119
+
120
+ const config2: OpenSaasConfig = {
121
+ db: {
122
+ provider: 'sqlite',
123
+ url: 'file:./dev.db',
124
+ },
125
+ lists: {
126
+ Post: {
127
+ fields: {
128
+ title: text(),
129
+ },
130
+ },
131
+ },
132
+ }
133
+
134
+ const prismaPath = path.join(tempDir, 'prisma', 'schema.prisma')
135
+
136
+ // Generate first config
137
+ writePrismaSchema(config1, prismaPath)
138
+ let schema = fs.readFileSync(prismaPath, 'utf-8')
139
+ expect(schema).toMatchSnapshot('overwrite-before')
140
+
141
+ // Generate second config (should overwrite)
142
+ writePrismaSchema(config2, prismaPath)
143
+ schema = fs.readFileSync(prismaPath, 'utf-8')
144
+ expect(schema).toMatchSnapshot('overwrite-after')
145
+ })
146
+
147
+ it('should handle custom opensaasPath', () => {
148
+ const config: OpenSaasConfig = {
149
+ db: {
150
+ provider: 'sqlite',
151
+ url: 'file:./dev.db',
152
+ },
153
+ opensaasPath: '.custom',
154
+ lists: {},
155
+ }
156
+
157
+ const typesPath = path.join(tempDir, '.custom', 'types.ts')
158
+ const contextPath = path.join(tempDir, '.custom', 'context.ts')
159
+
160
+ writeTypes(config, typesPath)
161
+ writeContext(config, contextPath)
162
+
163
+ expect(fs.existsSync(path.join(tempDir, '.custom'))).toBe(true)
164
+ expect(fs.existsSync(typesPath)).toBe(true)
165
+ expect(fs.existsSync(contextPath)).toBe(true)
166
+ })
167
+
168
+ it('should generate consistent output across multiple runs', () => {
169
+ const config: OpenSaasConfig = {
170
+ db: {
171
+ provider: 'sqlite',
172
+ url: 'file:./dev.db',
173
+ },
174
+ lists: {
175
+ User: {
176
+ fields: {
177
+ name: text(),
178
+ },
179
+ },
180
+ },
181
+ }
182
+
183
+ const prismaPath = path.join(tempDir, 'prisma', 'schema.prisma')
184
+
185
+ // Generate twice
186
+ writePrismaSchema(config, prismaPath)
187
+ const schema1 = fs.readFileSync(prismaPath, 'utf-8')
188
+
189
+ writePrismaSchema(config, prismaPath)
190
+ const schema2 = fs.readFileSync(prismaPath, 'utf-8')
191
+
192
+ // Should be identical
193
+ expect(schema1).toBe(schema2)
194
+ expect(schema1).toMatchSnapshot('consistent-output')
195
+ })
196
+
197
+ it('should handle empty lists config', () => {
198
+ const config: OpenSaasConfig = {
199
+ db: {
200
+ provider: 'sqlite',
201
+ url: 'file:./dev.db',
202
+ },
203
+ lists: {},
204
+ }
205
+
206
+ const prismaPath = path.join(tempDir, 'prisma', 'schema.prisma')
207
+ const typesPath = path.join(tempDir, '.opensaas', 'types.ts')
208
+
209
+ writePrismaSchema(config, prismaPath)
210
+ writeTypes(config, typesPath)
211
+
212
+ expect(fs.existsSync(prismaPath)).toBe(true)
213
+ expect(fs.existsSync(typesPath)).toBe(true)
214
+
215
+ const schema = fs.readFileSync(prismaPath, 'utf-8')
216
+ expect(schema).toMatchSnapshot('empty-lists-schema')
217
+
218
+ const types = fs.readFileSync(typesPath, 'utf-8')
219
+ expect(types).toMatchSnapshot('empty-lists-types')
220
+ })
221
+
222
+ it('should handle different database providers', () => {
223
+ const providers = ['sqlite', 'postgresql', 'mysql'] as const
224
+
225
+ providers.forEach((provider) => {
226
+ const config: OpenSaasConfig = {
227
+ db: {
228
+ provider,
229
+ url: provider === 'sqlite' ? 'file:./dev.db' : 'postgresql://localhost:5432/db',
230
+ },
231
+ lists: {},
232
+ }
233
+
234
+ const prismaPath = path.join(tempDir, `${provider}-schema.prisma`)
235
+ writePrismaSchema(config, prismaPath)
236
+
237
+ const schema = fs.readFileSync(prismaPath, 'utf-8')
238
+ expect(schema).toMatchSnapshot(`${provider}-provider`)
239
+ })
240
+ })
241
+
242
+ it('should generate files in correct locations', () => {
243
+ const config: OpenSaasConfig = {
244
+ db: {
245
+ provider: 'sqlite',
246
+ url: 'file:./dev.db',
247
+ },
248
+ lists: {
249
+ User: {
250
+ fields: {
251
+ name: text(),
252
+ },
253
+ },
254
+ },
255
+ }
256
+
257
+ writePrismaSchema(config, path.join(tempDir, 'prisma', 'schema.prisma'))
258
+ writeTypes(config, path.join(tempDir, '.opensaas', 'types.ts'))
259
+ writeContext(config, path.join(tempDir, '.opensaas', 'context.ts'))
260
+
261
+ // Verify directory structure
262
+ const prismaDir = path.join(tempDir, 'prisma')
263
+ const opensaasDir = path.join(tempDir, '.opensaas')
264
+
265
+ expect(fs.existsSync(prismaDir)).toBe(true)
266
+ expect(fs.existsSync(opensaasDir)).toBe(true)
267
+ expect(fs.readdirSync(prismaDir)).toContain('schema.prisma')
268
+ expect(fs.readdirSync(opensaasDir)).toContain('types.ts')
269
+ expect(fs.readdirSync(opensaasDir)).toContain('context.ts')
270
+ })
271
+ })
272
+ })
@@ -9,6 +9,7 @@ import {
9
9
  writeTypes,
10
10
  writeContext,
11
11
  patchPrismaTypes,
12
+ writeMcp,
12
13
  } from '../generator/index.js'
13
14
  import type { OpenSaasConfig } from '@opensaas/stack-core'
14
15
 
@@ -44,10 +45,16 @@ export async function generateCommand() {
44
45
  writeTypes(config, path.join(cwd, '.opensaas', 'types.ts'))
45
46
  writeContext(config, path.join(cwd, '.opensaas', 'context.ts'))
46
47
 
48
+ // Generate MCP metadata if enabled
49
+ const mcpGenerated = writeMcp(config, cwd)
50
+
47
51
  generatorSpinner.succeed(chalk.green('Schema generation complete'))
48
52
  console.log(chalk.green('✅ Prisma schema generated'))
49
53
  console.log(chalk.green('✅ TypeScript types generated'))
50
54
  console.log(chalk.green('✅ Context factory generated'))
55
+ if (mcpGenerated) {
56
+ console.log(chalk.green('✅ MCP metadata generated'))
57
+ }
51
58
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
59
  } catch (err: any) {
53
60
  generatorSpinner.fail(chalk.red('Failed to generate'))