@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
@@ -12,11 +12,81 @@ export function generateContext(config: OpenSaasConfig): string {
12
12
  // Check if custom Prisma client constructor is provided
13
13
  const hasCustomConstructor = !!config.db.prismaClientConstructor
14
14
 
15
+ // Check if storage is configured
16
+ const hasStorage = !!config.storage && Object.keys(config.storage).length > 0
17
+
15
18
  // Generate the Prisma client instantiation code
16
19
  const prismaInstantiation = hasCustomConstructor
17
20
  ? `config.db.prismaClientConstructor!(PrismaClient)`
18
21
  : `new PrismaClient()`
19
22
 
23
+ // Generate storage utilities if storage is configured
24
+ const storageUtilities = hasStorage
25
+ ? `
26
+ /**
27
+ * Lazy-loaded storage runtime functions
28
+ * Prevents sharp and other storage dependencies from being bundled in client code
29
+ */
30
+ let storageRuntime: typeof import('@opensaas/stack-storage/runtime') | null = null
31
+
32
+ async function getStorageRuntime() {
33
+ if (!storageRuntime) {
34
+ try {
35
+ storageRuntime = await import('@opensaas/stack-storage/runtime')
36
+ } catch (error) {
37
+ throw new Error(
38
+ 'Failed to load @opensaas/stack-storage/runtime. Make sure @opensaas/stack-storage is installed.'
39
+ )
40
+ }
41
+ }
42
+ return storageRuntime
43
+ }
44
+
45
+ /**
46
+ * Storage utilities for file/image uploads
47
+ */
48
+ const storage = {
49
+ uploadFile: async (providerName: string, file: File, buffer: Buffer, options?: unknown) => {
50
+ const runtime = await getStorageRuntime()
51
+ return runtime.uploadFile(config, providerName, { file, buffer }, options as any)
52
+ },
53
+
54
+ uploadImage: async (providerName: string, file: File, buffer: Buffer, options?: unknown) => {
55
+ const runtime = await getStorageRuntime()
56
+ return runtime.uploadImage(config, providerName, { file, buffer }, options as any)
57
+ },
58
+
59
+ deleteFile: async (providerName: string, filename: string) => {
60
+ const runtime = await getStorageRuntime()
61
+ return runtime.deleteFile(config, providerName, filename)
62
+ },
63
+
64
+ deleteImage: async (metadata: unknown) => {
65
+ const runtime = await getStorageRuntime()
66
+ return runtime.deleteImage(config, metadata as any)
67
+ },
68
+ }
69
+ `
70
+ : `
71
+ /**
72
+ * Storage utilities (not configured)
73
+ */
74
+ const storage = {
75
+ uploadFile: async () => {
76
+ throw new Error('Storage is not configured. Add storage providers to your opensaas.config.ts')
77
+ },
78
+ uploadImage: async () => {
79
+ throw new Error('Storage is not configured. Add storage providers to your opensaas.config.ts')
80
+ },
81
+ deleteFile: async () => {
82
+ throw new Error('Storage is not configured. Add storage providers to your opensaas.config.ts')
83
+ },
84
+ deleteImage: async () => {
85
+ throw new Error('Storage is not configured. Add storage providers to your opensaas.config.ts')
86
+ },
87
+ }
88
+ `
89
+
20
90
  return `/**
21
91
  * Auto-generated context factory
22
92
  *
@@ -27,14 +97,16 @@ export function generateContext(config: OpenSaasConfig): string {
27
97
  */
28
98
 
29
99
  import { getContext as getOpensaasContext } from '@opensaas/stack-core'
100
+ import type { Session as OpensaasSession } from '@opensaas/stack-core'
30
101
  import { PrismaClient } from './prisma-client'
102
+ import type { Context } from './types'
31
103
  import config from '../opensaas.config'
32
104
 
33
105
  // Internal Prisma singleton - managed automatically
34
106
  const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
35
107
  const prisma = globalForPrisma.prisma ?? ${prismaInstantiation}
36
108
  if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
37
-
109
+ ${storageUtilities}
38
110
  /**
39
111
  * Get OpenSaas context with optional session
40
112
  *
@@ -49,10 +121,15 @@ if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
49
121
  * // Authenticated access
50
122
  * const context = getContext({ userId: 'user-123' })
51
123
  * const myPosts = await context.db.post.findMany()
124
+ *
125
+ * // With custom session type
126
+ * type CustomSession = { userId: string; email: string; role: string } | null
127
+ * const context = getContext<CustomSession>({ userId: '123', email: 'user@example.com', role: 'admin' })
128
+ * // context.session is now typed as CustomSession
52
129
  * \`\`\`
53
130
  */
54
- export function getContext(session?: { userId?: string; [key: string]: unknown } | null) {
55
- return getOpensaasContext(config, prisma, session ?? null)
131
+ export function getContext<TSession extends OpensaasSession = OpensaasSession>(session?: TSession): Context<TSession> {
132
+ return getOpensaasContext(config, prisma, session ?? null, storage) as Context<TSession>
56
133
  }
57
134
 
58
135
  export const rawOpensaasContext = getContext()
@@ -2,3 +2,4 @@ export { generatePrismaSchema, writePrismaSchema } from './prisma.js'
2
2
  export { generateTypes, writeTypes } from './types.js'
3
3
  export { patchPrismaTypes } from './type-patcher.js'
4
4
  export { generateContext, writeContext } from './context.js'
5
+ export { generateMcp, writeMcp } from './mcp.js'
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { generateMcp } from './mcp.js'
3
+ import type { OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { text } from '@opensaas/stack-core/fields'
5
+ import * as fs from 'fs'
6
+ import * as path from 'path'
7
+ import * as os from 'os'
8
+
9
+ describe('MCP Generator', () => {
10
+ let tempDir: string
11
+
12
+ beforeEach(() => {
13
+ // Create a temporary directory for each test
14
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-'))
15
+ })
16
+
17
+ afterEach(() => {
18
+ // Clean up temporary directory
19
+ if (fs.existsSync(tempDir)) {
20
+ fs.rmSync(tempDir, { recursive: true, force: true })
21
+ }
22
+ })
23
+
24
+ describe('generateMcp', () => {
25
+ it('should return false when MCP is not enabled', () => {
26
+ const config: OpenSaasConfig = {
27
+ db: {
28
+ provider: 'sqlite',
29
+ url: 'file:./dev.db',
30
+ },
31
+ lists: {
32
+ User: {
33
+ fields: {
34
+ name: text(),
35
+ },
36
+ },
37
+ },
38
+ }
39
+
40
+ const generated = generateMcp(config, tempDir)
41
+
42
+ expect(generated).toBe(false)
43
+ })
44
+
45
+ it('should return true when MCP is enabled', () => {
46
+ const config: OpenSaasConfig = {
47
+ db: {
48
+ provider: 'sqlite',
49
+ url: 'file:./dev.db',
50
+ },
51
+ mcp: {
52
+ enabled: true,
53
+ },
54
+ lists: {
55
+ User: {
56
+ fields: {
57
+ name: text(),
58
+ },
59
+ },
60
+ },
61
+ }
62
+
63
+ const generated = generateMcp(config, tempDir)
64
+
65
+ expect(generated).toBe(true)
66
+ })
67
+
68
+ it('should create MCP directory when enabled', () => {
69
+ const config: OpenSaasConfig = {
70
+ db: {
71
+ provider: 'sqlite',
72
+ url: 'file:./dev.db',
73
+ },
74
+ mcp: {
75
+ enabled: true,
76
+ },
77
+ lists: {},
78
+ }
79
+
80
+ generateMcp(config, tempDir)
81
+
82
+ const mcpDir = path.join(tempDir, '.opensaas', 'mcp')
83
+ expect(fs.existsSync(mcpDir)).toBe(true)
84
+ })
85
+
86
+ it('should generate tools.json with default CRUD tools', () => {
87
+ const config: OpenSaasConfig = {
88
+ db: {
89
+ provider: 'sqlite',
90
+ url: 'file:./dev.db',
91
+ },
92
+ mcp: {
93
+ enabled: true,
94
+ },
95
+ lists: {
96
+ User: {
97
+ fields: {
98
+ name: text(),
99
+ },
100
+ },
101
+ },
102
+ }
103
+
104
+ generateMcp(config, tempDir)
105
+
106
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
107
+ expect(fs.existsSync(toolsPath)).toBe(true)
108
+
109
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
110
+ expect(tools).toHaveLength(4) // query, create, update, delete
111
+
112
+ const toolNames = tools.map((t: { name: string }) => t.name)
113
+ expect(toolNames).toContain('list_user_query')
114
+ expect(toolNames).toContain('list_user_create')
115
+ expect(toolNames).toContain('list_user_update')
116
+ expect(toolNames).toContain('list_user_delete')
117
+ })
118
+
119
+ it('should respect list-level MCP enabled flag', () => {
120
+ const config: OpenSaasConfig = {
121
+ db: {
122
+ provider: 'sqlite',
123
+ url: 'file:./dev.db',
124
+ },
125
+ mcp: {
126
+ enabled: true,
127
+ },
128
+ lists: {
129
+ User: {
130
+ fields: {
131
+ name: text(),
132
+ },
133
+ mcp: {
134
+ enabled: false,
135
+ },
136
+ },
137
+ Post: {
138
+ fields: {
139
+ title: text(),
140
+ },
141
+ },
142
+ },
143
+ }
144
+
145
+ generateMcp(config, tempDir)
146
+
147
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
148
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
149
+
150
+ // Only Post tools should be generated
151
+ const userTools = tools.filter((t: { listKey: string }) => t.listKey === 'User')
152
+ const postTools = tools.filter((t: { listKey: string }) => t.listKey === 'Post')
153
+
154
+ expect(userTools).toHaveLength(0)
155
+ expect(postTools).toHaveLength(4)
156
+ })
157
+
158
+ it('should respect custom tool configuration', () => {
159
+ const config: OpenSaasConfig = {
160
+ db: {
161
+ provider: 'sqlite',
162
+ url: 'file:./dev.db',
163
+ },
164
+ mcp: {
165
+ enabled: true,
166
+ },
167
+ lists: {
168
+ User: {
169
+ fields: {
170
+ name: text(),
171
+ },
172
+ mcp: {
173
+ tools: {
174
+ read: true,
175
+ create: false,
176
+ update: false,
177
+ delete: false,
178
+ },
179
+ },
180
+ },
181
+ },
182
+ }
183
+
184
+ generateMcp(config, tempDir)
185
+
186
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
187
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
188
+
189
+ expect(tools).toHaveLength(1)
190
+ expect(tools[0].name).toBe('list_user_query')
191
+ })
192
+
193
+ it('should include custom tools', () => {
194
+ const config: OpenSaasConfig = {
195
+ db: {
196
+ provider: 'sqlite',
197
+ url: 'file:./dev.db',
198
+ },
199
+ mcp: {
200
+ enabled: true,
201
+ },
202
+ lists: {
203
+ User: {
204
+ fields: {
205
+ name: text(),
206
+ },
207
+ mcp: {
208
+ tools: {
209
+ read: false,
210
+ create: false,
211
+ update: false,
212
+ delete: false,
213
+ },
214
+ customTools: [
215
+ {
216
+ name: 'user_verify_email',
217
+ description: 'Verify a user email address',
218
+ inputSchema: {},
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ handler: async () => ({}) as any,
221
+ },
222
+ ],
223
+ },
224
+ },
225
+ },
226
+ }
227
+
228
+ generateMcp(config, tempDir)
229
+
230
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
231
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
232
+
233
+ expect(tools).toHaveLength(1)
234
+ expect(tools[0].name).toBe('user_verify_email')
235
+ expect(tools[0].operation).toBe('custom')
236
+ })
237
+
238
+ it('should generate README.md with usage instructions', () => {
239
+ const config: OpenSaasConfig = {
240
+ db: {
241
+ provider: 'sqlite',
242
+ url: 'file:./dev.db',
243
+ },
244
+ mcp: {
245
+ enabled: true,
246
+ },
247
+ lists: {
248
+ User: {
249
+ fields: {
250
+ name: text(),
251
+ },
252
+ },
253
+ },
254
+ }
255
+
256
+ generateMcp(config, tempDir)
257
+
258
+ const readmePath = path.join(tempDir, '.opensaas', 'mcp', 'README.md')
259
+ expect(fs.existsSync(readmePath)).toBe(true)
260
+
261
+ const readme = fs.readFileSync(readmePath, 'utf-8')
262
+ expect(readme).toContain('# MCP Tools Reference')
263
+ expect(readme).toContain('Available Tools')
264
+ expect(readme).toContain('Usage')
265
+ expect(readme).toContain('createMcpHandlers')
266
+ expect(readme).toContain('Connecting to Claude Desktop')
267
+ })
268
+
269
+ it('should list tools in README', () => {
270
+ const config: OpenSaasConfig = {
271
+ db: {
272
+ provider: 'sqlite',
273
+ url: 'file:./dev.db',
274
+ },
275
+ mcp: {
276
+ enabled: true,
277
+ },
278
+ lists: {
279
+ User: {
280
+ fields: {
281
+ name: text(),
282
+ },
283
+ },
284
+ },
285
+ }
286
+
287
+ generateMcp(config, tempDir)
288
+
289
+ const readmePath = path.join(tempDir, '.opensaas', 'mcp', 'README.md')
290
+ const readme = fs.readFileSync(readmePath, 'utf-8')
291
+
292
+ expect(readme).toContain('list_user_query')
293
+ expect(readme).toContain('list_user_create')
294
+ expect(readme).toContain('list_user_update')
295
+ expect(readme).toContain('list_user_delete')
296
+ expect(readme).toContain('4 tool(s) available')
297
+ })
298
+
299
+ it('should handle multiple lists', () => {
300
+ const config: OpenSaasConfig = {
301
+ db: {
302
+ provider: 'sqlite',
303
+ url: 'file:./dev.db',
304
+ },
305
+ mcp: {
306
+ enabled: true,
307
+ },
308
+ lists: {
309
+ User: {
310
+ fields: {
311
+ name: text(),
312
+ },
313
+ },
314
+ Post: {
315
+ fields: {
316
+ title: text(),
317
+ },
318
+ },
319
+ },
320
+ }
321
+
322
+ generateMcp(config, tempDir)
323
+
324
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
325
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
326
+
327
+ expect(tools).toHaveLength(8) // 4 tools per list
328
+ })
329
+
330
+ it('should respect global defaultTools config', () => {
331
+ const config: OpenSaasConfig = {
332
+ db: {
333
+ provider: 'sqlite',
334
+ url: 'file:./dev.db',
335
+ },
336
+ mcp: {
337
+ enabled: true,
338
+ defaultTools: {
339
+ read: true,
340
+ create: true,
341
+ update: false,
342
+ delete: false,
343
+ },
344
+ },
345
+ lists: {
346
+ User: {
347
+ fields: {
348
+ name: text(),
349
+ },
350
+ },
351
+ },
352
+ }
353
+
354
+ generateMcp(config, tempDir)
355
+
356
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
357
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
358
+
359
+ expect(tools).toHaveLength(2)
360
+ const toolNames = tools.map((t: { name: string }) => t.name)
361
+ expect(toolNames).toContain('list_user_query')
362
+ expect(toolNames).toContain('list_user_create')
363
+ })
364
+
365
+ it('should use correct dbKey for tool names', () => {
366
+ const config: OpenSaasConfig = {
367
+ db: {
368
+ provider: 'sqlite',
369
+ url: 'file:./dev.db',
370
+ },
371
+ mcp: {
372
+ enabled: true,
373
+ },
374
+ lists: {
375
+ BlogPost: {
376
+ fields: {
377
+ title: text(),
378
+ },
379
+ },
380
+ },
381
+ }
382
+
383
+ generateMcp(config, tempDir)
384
+
385
+ const toolsPath = path.join(tempDir, '.opensaas', 'mcp', 'tools.json')
386
+ const tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
387
+
388
+ const toolNames = tools.map((t: { name: string }) => t.name)
389
+ expect(toolNames).toContain('list_blogPost_query')
390
+ expect(toolNames).toContain('list_blogPost_create')
391
+ })
392
+ })
393
+ })