@opensaas/stack-cli 0.1.0 → 0.1.1

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 (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/CLAUDE.md +249 -0
  4. package/LICENSE +21 -0
  5. package/dist/commands/generate.d.ts.map +1 -1
  6. package/dist/commands/generate.js +6 -1
  7. package/dist/commands/generate.js.map +1 -1
  8. package/dist/commands/init.js +2 -2
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/generator/context.d.ts.map +1 -1
  11. package/dist/generator/context.js +78 -3
  12. package/dist/generator/context.js.map +1 -1
  13. package/dist/generator/index.d.ts +1 -0
  14. package/dist/generator/index.d.ts.map +1 -1
  15. package/dist/generator/index.js +1 -0
  16. package/dist/generator/index.js.map +1 -1
  17. package/dist/generator/mcp.d.ts +14 -0
  18. package/dist/generator/mcp.d.ts.map +1 -0
  19. package/dist/generator/mcp.js +193 -0
  20. package/dist/generator/mcp.js.map +1 -0
  21. package/dist/generator/types.d.ts.map +1 -1
  22. package/dist/generator/types.js +11 -33
  23. package/dist/generator/types.js.map +1 -1
  24. package/package.json +9 -3
  25. package/src/commands/__snapshots__/generate.test.ts.snap +265 -0
  26. package/src/commands/dev.test.ts +216 -0
  27. package/src/commands/generate.test.ts +272 -0
  28. package/src/commands/generate.ts +7 -0
  29. package/src/commands/init.test.ts +308 -0
  30. package/src/commands/init.ts +2 -2
  31. package/src/generator/__snapshots__/context.test.ts.snap +137 -0
  32. package/src/generator/__snapshots__/prisma.test.ts.snap +182 -0
  33. package/src/generator/__snapshots__/types.test.ts.snap +512 -0
  34. package/src/generator/context.test.ts +145 -0
  35. package/src/generator/context.ts +80 -3
  36. package/src/generator/index.ts +1 -0
  37. package/src/generator/mcp.test.ts +393 -0
  38. package/src/generator/mcp.ts +221 -0
  39. package/src/generator/prisma.test.ts +221 -0
  40. package/src/generator/types.test.ts +280 -0
  41. package/src/generator/types.ts +14 -36
  42. package/tsconfig.json +1 -1
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/vitest.config.ts +26 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * MCP metadata generation
3
+ * Generates reference files for MCP configuration
4
+ */
5
+
6
+ import * as fs from 'fs'
7
+ import * as path from 'path'
8
+ import type { OpenSaasConfig } from '@opensaas/stack-core'
9
+ import { getDbKey } from '@opensaas/stack-core'
10
+
11
+ /**
12
+ * Generate MCP metadata if MCP is enabled
13
+ */
14
+ export function generateMcp(config: OpenSaasConfig, outputPath: string): boolean {
15
+ // Skip if MCP is not enabled
16
+ if (!config.mcp?.enabled) {
17
+ return false
18
+ }
19
+
20
+ // Ensure output directory exists
21
+ const mcpDir = path.join(outputPath, '.opensaas', 'mcp')
22
+ if (!fs.existsSync(mcpDir)) {
23
+ fs.mkdirSync(mcpDir, { recursive: true })
24
+ }
25
+
26
+ // Generate tool metadata for reference
27
+ const tools: Array<{
28
+ name: string
29
+ description: string
30
+ listKey: string
31
+ operation: string
32
+ }> = []
33
+
34
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
35
+ if (listConfig.mcp?.enabled === false) continue
36
+
37
+ const dbKey = getDbKey(listKey)
38
+ const defaultTools = config.mcp?.defaultTools || {
39
+ read: true,
40
+ create: true,
41
+ update: true,
42
+ delete: true,
43
+ }
44
+
45
+ const enabledTools = {
46
+ read: listConfig.mcp?.tools?.read ?? defaultTools.read ?? true,
47
+ create: listConfig.mcp?.tools?.create ?? defaultTools.create ?? true,
48
+ update: listConfig.mcp?.tools?.update ?? defaultTools.update ?? true,
49
+ delete: listConfig.mcp?.tools?.delete ?? defaultTools.delete ?? true,
50
+ }
51
+
52
+ if (enabledTools.read) {
53
+ tools.push({
54
+ name: `list_${dbKey}_query`,
55
+ description: `Query ${listKey} records with optional filters`,
56
+ listKey,
57
+ operation: 'query',
58
+ })
59
+ }
60
+
61
+ if (enabledTools.create) {
62
+ tools.push({
63
+ name: `list_${dbKey}_create`,
64
+ description: `Create a new ${listKey} record`,
65
+ listKey,
66
+ operation: 'create',
67
+ })
68
+ }
69
+
70
+ if (enabledTools.update) {
71
+ tools.push({
72
+ name: `list_${dbKey}_update`,
73
+ description: `Update an existing ${listKey} record`,
74
+ listKey,
75
+ operation: 'update',
76
+ })
77
+ }
78
+
79
+ if (enabledTools.delete) {
80
+ tools.push({
81
+ name: `list_${dbKey}_delete`,
82
+ description: `Delete a ${listKey} record`,
83
+ listKey,
84
+ operation: 'delete',
85
+ })
86
+ }
87
+
88
+ // Custom tools
89
+ if (listConfig.mcp?.customTools) {
90
+ for (const customTool of listConfig.mcp.customTools) {
91
+ tools.push({
92
+ name: customTool.name,
93
+ description: customTool.description,
94
+ listKey,
95
+ operation: 'custom',
96
+ })
97
+ }
98
+ }
99
+ }
100
+
101
+ // Write tools.json for reference
102
+ const toolsPath = path.join(mcpDir, 'tools.json')
103
+ fs.writeFileSync(toolsPath, JSON.stringify(tools, null, 2), 'utf-8')
104
+
105
+ // Write README with usage instructions
106
+ const readmePath = path.join(mcpDir, 'README.md')
107
+ fs.writeFileSync(
108
+ readmePath,
109
+ `# MCP Tools Reference
110
+
111
+ This directory contains metadata about your MCP configuration.
112
+
113
+ ## Available Tools
114
+
115
+ ${tools.length} tool(s) available:
116
+
117
+ ${tools
118
+ .map(
119
+ (tool) => `- **${tool.name}** (${tool.operation}): ${tool.description}
120
+ - List: ${tool.listKey}`,
121
+ )
122
+ .join('\n\n')}
123
+
124
+ ## Usage
125
+
126
+ Create your MCP route handler:
127
+
128
+ \`\`\`typescript
129
+ // app/api/mcp/[[...transport]]/route.ts
130
+ import { createMcpHandlers } from '@opensaas/stack-mcp'
131
+ import config from '@/opensaas.config'
132
+ import { auth } from '@/lib/auth'
133
+ import { getContext } from '@/.opensaas/context'
134
+
135
+ const { GET, POST, DELETE } = createMcpHandlers({
136
+ config,
137
+ auth,
138
+ getContext
139
+ })
140
+
141
+ export { GET, POST, DELETE }
142
+ \`\`\`
143
+
144
+ ## Connecting to Claude Desktop
145
+
146
+ ### Option 1: Remote MCP Server (Recommended for Production)
147
+
148
+ For production use with OAuth authentication, add your server via **Claude Desktop > Settings > Connectors**.
149
+
150
+ Claude Desktop requires:
151
+ 1. Your server must be publicly accessible (use ngrok/cloudflare tunnel for local testing)
152
+ 2. OAuth authorization server at \`/.well-known/oauth-authorization-server\`
153
+ 3. Dynamic Client Registration (DCR) support - Better Auth MCP plugin provides this
154
+
155
+ **Note:** Remote MCP servers with OAuth cannot be configured via \`claude_desktop_config.json\` - they must be added through the Claude Desktop UI.
156
+
157
+ ### Option 2: Local Development (No OAuth)
158
+
159
+ For local development without OAuth, you can create a proxy MCP server script:
160
+
161
+ 1. Create \`mcp-server.js\` in your project root:
162
+
163
+ \`\`\`javascript
164
+ #!/usr/bin/env node
165
+ import { spawn } from 'child_process';
166
+
167
+ // Start Next.js dev server in background
168
+ const server = spawn('npm', ['run', 'dev'], { stdio: 'inherit' });
169
+
170
+ // Wait for server to be ready
171
+ setTimeout(() => {
172
+ console.log('MCP server ready at http://localhost:3000${config.mcp.basePath || '/api/mcp'}');
173
+ }, 3000);
174
+
175
+ process.on('SIGINT', () => {
176
+ server.kill();
177
+ process.exit();
178
+ });
179
+ \`\`\`
180
+
181
+ 2. Add to \`claude_desktop_config.json\`:
182
+
183
+ \`\`\`json
184
+ {
185
+ "mcpServers": {
186
+ "my-app": {
187
+ "command": "node",
188
+ "args": ["mcp-server.js"]
189
+ }
190
+ }
191
+ }
192
+ \`\`\`
193
+
194
+ ### Option 3: Using ngrok for Local OAuth Testing
195
+
196
+ 1. Start your dev server: \`npm run dev\`
197
+ 2. Expose with ngrok: \`ngrok http 3000\`
198
+ 3. Add to Claude Desktop via **Settings > Connectors** with your ngrok URL
199
+ `,
200
+ 'utf-8',
201
+ )
202
+
203
+ return true
204
+ }
205
+
206
+ /**
207
+ * Write MCP metadata to disk
208
+ */
209
+ export function writeMcp(config: OpenSaasConfig, outputPath: string) {
210
+ const generated = generateMcp(config, outputPath)
211
+
212
+ if (!generated) {
213
+ // Clean up MCP directory if MCP is disabled
214
+ const mcpDir = path.join(outputPath, '.opensaas', 'mcp')
215
+ if (fs.existsSync(mcpDir)) {
216
+ fs.rmSync(mcpDir, { recursive: true, force: true })
217
+ }
218
+ }
219
+
220
+ return generated
221
+ }
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { generatePrismaSchema } from './prisma.js'
3
+ import type { OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { text, integer, relationship, checkbox, timestamp } from '@opensaas/stack-core/fields'
5
+
6
+ describe('Prisma Schema Generator', () => {
7
+ describe('generatePrismaSchema', () => {
8
+ it('should generate basic schema with datasource and generator', () => {
9
+ const config: OpenSaasConfig = {
10
+ db: {
11
+ provider: 'sqlite',
12
+ url: 'file:./dev.db',
13
+ },
14
+ lists: {},
15
+ }
16
+
17
+ const schema = generatePrismaSchema(config)
18
+
19
+ expect(schema).toMatchSnapshot()
20
+ })
21
+
22
+ it('should use custom opensaasPath for generator output', () => {
23
+ const config: OpenSaasConfig = {
24
+ db: {
25
+ provider: 'sqlite',
26
+ url: 'file:./dev.db',
27
+ },
28
+ opensaasPath: '.custom-path',
29
+ lists: {},
30
+ }
31
+
32
+ const schema = generatePrismaSchema(config)
33
+
34
+ expect(schema).toMatchSnapshot()
35
+ })
36
+
37
+ it('should generate model with basic fields', () => {
38
+ const config: OpenSaasConfig = {
39
+ db: {
40
+ provider: 'sqlite',
41
+ url: 'file:./dev.db',
42
+ },
43
+ lists: {
44
+ User: {
45
+ fields: {
46
+ name: text({ validation: { isRequired: true } }),
47
+ email: text({ validation: { isRequired: true } }),
48
+ age: integer(),
49
+ },
50
+ },
51
+ },
52
+ }
53
+
54
+ const schema = generatePrismaSchema(config)
55
+
56
+ expect(schema).toMatchSnapshot()
57
+ })
58
+
59
+ it('should generate model with checkbox field', () => {
60
+ const config: OpenSaasConfig = {
61
+ db: {
62
+ provider: 'sqlite',
63
+ url: 'file:./dev.db',
64
+ },
65
+ lists: {
66
+ Post: {
67
+ fields: {
68
+ title: text(),
69
+ isPublished: checkbox({ defaultValue: false }),
70
+ },
71
+ },
72
+ },
73
+ }
74
+
75
+ const schema = generatePrismaSchema(config)
76
+
77
+ expect(schema).toMatchSnapshot()
78
+ })
79
+
80
+ it('should generate model with timestamp field', () => {
81
+ const config: OpenSaasConfig = {
82
+ db: {
83
+ provider: 'sqlite',
84
+ url: 'file:./dev.db',
85
+ },
86
+ lists: {
87
+ Post: {
88
+ fields: {
89
+ title: text(),
90
+ publishedAt: timestamp(),
91
+ },
92
+ },
93
+ },
94
+ }
95
+
96
+ const schema = generatePrismaSchema(config)
97
+
98
+ expect(schema).toMatchSnapshot()
99
+ })
100
+
101
+ it('should generate many-to-one relationship', () => {
102
+ const config: OpenSaasConfig = {
103
+ db: {
104
+ provider: 'sqlite',
105
+ url: 'file:./dev.db',
106
+ },
107
+ lists: {
108
+ User: {
109
+ fields: {
110
+ name: text(),
111
+ },
112
+ },
113
+ Post: {
114
+ fields: {
115
+ title: text(),
116
+ author: relationship({ ref: 'User.posts' }),
117
+ },
118
+ },
119
+ },
120
+ }
121
+
122
+ const schema = generatePrismaSchema(config)
123
+
124
+ expect(schema).toMatchSnapshot()
125
+ })
126
+
127
+ it('should generate one-to-many relationship', () => {
128
+ const config: OpenSaasConfig = {
129
+ db: {
130
+ provider: 'sqlite',
131
+ url: 'file:./dev.db',
132
+ },
133
+ lists: {
134
+ User: {
135
+ fields: {
136
+ name: text(),
137
+ posts: relationship({ ref: 'Post.author', many: true }),
138
+ },
139
+ },
140
+ Post: {
141
+ fields: {
142
+ title: text(),
143
+ },
144
+ },
145
+ },
146
+ }
147
+
148
+ const schema = generatePrismaSchema(config)
149
+
150
+ expect(schema).toMatchSnapshot()
151
+ })
152
+
153
+ it('should generate multiple models', () => {
154
+ const config: OpenSaasConfig = {
155
+ db: {
156
+ provider: 'postgresql',
157
+ url: process.env.DATABASE_URL || 'postgresql://localhost:5432/db',
158
+ },
159
+ lists: {
160
+ User: {
161
+ fields: {
162
+ name: text(),
163
+ },
164
+ },
165
+ Post: {
166
+ fields: {
167
+ title: text(),
168
+ },
169
+ },
170
+ Comment: {
171
+ fields: {
172
+ content: text(),
173
+ },
174
+ },
175
+ },
176
+ }
177
+
178
+ const schema = generatePrismaSchema(config)
179
+
180
+ expect(schema).toMatchSnapshot()
181
+ })
182
+
183
+ it('should always include system fields', () => {
184
+ const config: OpenSaasConfig = {
185
+ db: {
186
+ provider: 'sqlite',
187
+ url: 'file:./dev.db',
188
+ },
189
+ lists: {
190
+ User: {
191
+ fields: {
192
+ name: text(),
193
+ },
194
+ },
195
+ },
196
+ }
197
+
198
+ const schema = generatePrismaSchema(config)
199
+
200
+ expect(schema).toContain('id String @id @default(cuid())')
201
+ expect(schema).toContain('createdAt DateTime @default(now())')
202
+ expect(schema).toContain('updatedAt DateTime @updatedAt')
203
+ })
204
+
205
+ it('should handle empty lists config', () => {
206
+ const config: OpenSaasConfig = {
207
+ db: {
208
+ provider: 'sqlite',
209
+ url: 'file:./dev.db',
210
+ },
211
+ lists: {},
212
+ }
213
+
214
+ const schema = generatePrismaSchema(config)
215
+
216
+ expect(schema).toContain('generator client {')
217
+ expect(schema).toContain('datasource db {')
218
+ expect(schema).not.toContain('model')
219
+ })
220
+ })
221
+ })
@@ -0,0 +1,280 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { generateTypes } from './types.js'
3
+ import type { OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { text, integer, relationship, checkbox } from '@opensaas/stack-core/fields'
5
+
6
+ describe('Types Generator', () => {
7
+ describe('generateTypes', () => {
8
+ it('should generate type definitions for basic model', () => {
9
+ const config: OpenSaasConfig = {
10
+ db: {
11
+ provider: 'sqlite',
12
+ url: 'file:./dev.db',
13
+ },
14
+ lists: {
15
+ User: {
16
+ fields: {
17
+ name: text({ validation: { isRequired: true } }),
18
+ email: text({ validation: { isRequired: true } }),
19
+ },
20
+ },
21
+ },
22
+ }
23
+
24
+ const types = generateTypes(config)
25
+
26
+ expect(types).toMatchSnapshot()
27
+ })
28
+
29
+ it('should generate CreateInput type', () => {
30
+ const config: OpenSaasConfig = {
31
+ db: {
32
+ provider: 'sqlite',
33
+ url: 'file:./dev.db',
34
+ },
35
+ lists: {
36
+ Post: {
37
+ fields: {
38
+ title: text({ validation: { isRequired: true } }),
39
+ content: text(),
40
+ },
41
+ },
42
+ },
43
+ }
44
+
45
+ const types = generateTypes(config)
46
+
47
+ expect(types).toMatchSnapshot()
48
+ })
49
+
50
+ it('should generate UpdateInput type', () => {
51
+ const config: OpenSaasConfig = {
52
+ db: {
53
+ provider: 'sqlite',
54
+ url: 'file:./dev.db',
55
+ },
56
+ lists: {
57
+ Post: {
58
+ fields: {
59
+ title: text({ validation: { isRequired: true } }),
60
+ content: text(),
61
+ },
62
+ },
63
+ },
64
+ }
65
+
66
+ const types = generateTypes(config)
67
+
68
+ expect(types).toMatchSnapshot()
69
+ })
70
+
71
+ it('should generate WhereInput type', () => {
72
+ const config: OpenSaasConfig = {
73
+ db: {
74
+ provider: 'sqlite',
75
+ url: 'file:./dev.db',
76
+ },
77
+ lists: {
78
+ User: {
79
+ fields: {
80
+ name: text(),
81
+ },
82
+ },
83
+ },
84
+ }
85
+
86
+ const types = generateTypes(config)
87
+
88
+ expect(types).toMatchSnapshot()
89
+ })
90
+
91
+ it('should generate Context type with all operations', () => {
92
+ const config: OpenSaasConfig = {
93
+ db: {
94
+ provider: 'sqlite',
95
+ url: 'file:./dev.db',
96
+ },
97
+ lists: {
98
+ User: {
99
+ fields: {
100
+ name: text(),
101
+ },
102
+ },
103
+ },
104
+ }
105
+
106
+ const types = generateTypes(config)
107
+
108
+ expect(types).toMatchSnapshot()
109
+ })
110
+
111
+ it('should handle relationship fields in types', () => {
112
+ const config: OpenSaasConfig = {
113
+ db: {
114
+ provider: 'sqlite',
115
+ url: 'file:./dev.db',
116
+ },
117
+ lists: {
118
+ User: {
119
+ fields: {
120
+ name: text(),
121
+ posts: relationship({ ref: 'Post.author', many: true }),
122
+ },
123
+ },
124
+ Post: {
125
+ fields: {
126
+ title: text(),
127
+ author: relationship({ ref: 'User.posts' }),
128
+ },
129
+ },
130
+ },
131
+ }
132
+
133
+ const types = generateTypes(config)
134
+
135
+ expect(types).toMatchSnapshot()
136
+ })
137
+
138
+ it('should handle relationship fields in CreateInput', () => {
139
+ const config: OpenSaasConfig = {
140
+ db: {
141
+ provider: 'sqlite',
142
+ url: 'file:./dev.db',
143
+ },
144
+ lists: {
145
+ Post: {
146
+ fields: {
147
+ title: text(),
148
+ author: relationship({ ref: 'User.posts' }),
149
+ },
150
+ },
151
+ User: {
152
+ fields: {
153
+ name: text(),
154
+ },
155
+ },
156
+ },
157
+ }
158
+
159
+ const types = generateTypes(config)
160
+
161
+ expect(types).toMatchSnapshot()
162
+ })
163
+
164
+ it('should handle relationship fields in UpdateInput', () => {
165
+ const config: OpenSaasConfig = {
166
+ db: {
167
+ provider: 'sqlite',
168
+ url: 'file:./dev.db',
169
+ },
170
+ lists: {
171
+ Post: {
172
+ fields: {
173
+ title: text(),
174
+ author: relationship({ ref: 'User.posts' }),
175
+ },
176
+ },
177
+ User: {
178
+ fields: {
179
+ name: text(),
180
+ },
181
+ },
182
+ },
183
+ }
184
+
185
+ const types = generateTypes(config)
186
+
187
+ expect(types).toMatchSnapshot()
188
+ })
189
+
190
+ it('should generate types for multiple lists', () => {
191
+ const config: OpenSaasConfig = {
192
+ db: {
193
+ provider: 'sqlite',
194
+ url: 'file:./dev.db',
195
+ },
196
+ lists: {
197
+ User: {
198
+ fields: {
199
+ name: text(),
200
+ },
201
+ },
202
+ Post: {
203
+ fields: {
204
+ title: text(),
205
+ },
206
+ },
207
+ Comment: {
208
+ fields: {
209
+ content: text(),
210
+ },
211
+ },
212
+ },
213
+ }
214
+
215
+ const types = generateTypes(config)
216
+
217
+ expect(types).toMatchSnapshot()
218
+ })
219
+
220
+ it('should handle integer fields correctly', () => {
221
+ const config: OpenSaasConfig = {
222
+ db: {
223
+ provider: 'sqlite',
224
+ url: 'file:./dev.db',
225
+ },
226
+ lists: {
227
+ Product: {
228
+ fields: {
229
+ name: text(),
230
+ price: integer(),
231
+ },
232
+ },
233
+ },
234
+ }
235
+
236
+ const types = generateTypes(config)
237
+
238
+ expect(types).toContain('price:')
239
+ expect(types).toContain('number')
240
+ })
241
+
242
+ it('should handle checkbox fields correctly', () => {
243
+ const config: OpenSaasConfig = {
244
+ db: {
245
+ provider: 'sqlite',
246
+ url: 'file:./dev.db',
247
+ },
248
+ lists: {
249
+ Post: {
250
+ fields: {
251
+ title: text(),
252
+ isPublished: checkbox(),
253
+ },
254
+ },
255
+ },
256
+ }
257
+
258
+ const types = generateTypes(config)
259
+
260
+ expect(types).toContain('isPublished:')
261
+ expect(types).toContain('boolean')
262
+ })
263
+
264
+ it('should include header comment', () => {
265
+ const config: OpenSaasConfig = {
266
+ db: {
267
+ provider: 'sqlite',
268
+ url: 'file:./dev.db',
269
+ },
270
+ lists: {},
271
+ }
272
+
273
+ const types = generateTypes(config)
274
+
275
+ expect(types).toContain('/**')
276
+ expect(types).toContain('Generated types from OpenSaas configuration')
277
+ expect(types).toContain('DO NOT EDIT')
278
+ })
279
+ })
280
+ })