@opensaas/stack-cli 0.4.0 → 0.5.0

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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/dist/commands/migrate.d.ts +9 -0
  4. package/dist/commands/migrate.d.ts.map +1 -0
  5. package/dist/commands/migrate.js +473 -0
  6. package/dist/commands/migrate.js.map +1 -0
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp/lib/documentation-provider.d.ts +23 -0
  10. package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
  11. package/dist/mcp/lib/documentation-provider.js +471 -0
  12. package/dist/mcp/lib/documentation-provider.js.map +1 -1
  13. package/dist/mcp/lib/wizards/migration-wizard.d.ts +80 -0
  14. package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -0
  15. package/dist/mcp/lib/wizards/migration-wizard.js +499 -0
  16. package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -0
  17. package/dist/mcp/server/index.d.ts.map +1 -1
  18. package/dist/mcp/server/index.js +103 -0
  19. package/dist/mcp/server/index.js.map +1 -1
  20. package/dist/mcp/server/stack-mcp-server.d.ts +85 -0
  21. package/dist/mcp/server/stack-mcp-server.d.ts.map +1 -1
  22. package/dist/mcp/server/stack-mcp-server.js +219 -0
  23. package/dist/mcp/server/stack-mcp-server.js.map +1 -1
  24. package/dist/migration/generators/migration-generator.d.ts +60 -0
  25. package/dist/migration/generators/migration-generator.d.ts.map +1 -0
  26. package/dist/migration/generators/migration-generator.js +510 -0
  27. package/dist/migration/generators/migration-generator.js.map +1 -0
  28. package/dist/migration/introspectors/index.d.ts +12 -0
  29. package/dist/migration/introspectors/index.d.ts.map +1 -0
  30. package/dist/migration/introspectors/index.js +10 -0
  31. package/dist/migration/introspectors/index.js.map +1 -0
  32. package/dist/migration/introspectors/keystone-introspector.d.ts +59 -0
  33. package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -0
  34. package/dist/migration/introspectors/keystone-introspector.js +229 -0
  35. package/dist/migration/introspectors/keystone-introspector.js.map +1 -0
  36. package/dist/migration/introspectors/nextjs-introspector.d.ts +59 -0
  37. package/dist/migration/introspectors/nextjs-introspector.d.ts.map +1 -0
  38. package/dist/migration/introspectors/nextjs-introspector.js +159 -0
  39. package/dist/migration/introspectors/nextjs-introspector.js.map +1 -0
  40. package/dist/migration/introspectors/prisma-introspector.d.ts +45 -0
  41. package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -0
  42. package/dist/migration/introspectors/prisma-introspector.js +190 -0
  43. package/dist/migration/introspectors/prisma-introspector.js.map +1 -0
  44. package/dist/migration/types.d.ts +86 -0
  45. package/dist/migration/types.d.ts.map +1 -0
  46. package/dist/migration/types.js +5 -0
  47. package/dist/migration/types.js.map +1 -0
  48. package/package.json +5 -2
  49. package/src/commands/migrate.ts +534 -0
  50. package/src/index.ts +4 -0
  51. package/src/mcp/lib/documentation-provider.ts +507 -0
  52. package/src/mcp/lib/wizards/migration-wizard.ts +584 -0
  53. package/src/mcp/server/index.ts +121 -0
  54. package/src/mcp/server/stack-mcp-server.ts +243 -0
  55. package/src/migration/generators/migration-generator.ts +675 -0
  56. package/src/migration/introspectors/index.ts +12 -0
  57. package/src/migration/introspectors/keystone-introspector.ts +296 -0
  58. package/src/migration/introspectors/nextjs-introspector.ts +209 -0
  59. package/src/migration/introspectors/prisma-introspector.ts +233 -0
  60. package/src/migration/types.ts +92 -0
  61. package/tests/introspectors/keystone-introspector.test.ts +255 -0
  62. package/tests/introspectors/nextjs-introspector.test.ts +302 -0
  63. package/tests/introspectors/prisma-introspector.test.ts +268 -0
  64. package/tests/migration-generator.test.ts +592 -0
  65. package/tests/migration-wizard.test.ts +442 -0
  66. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Prisma Schema Introspector
3
+ *
4
+ * Parses prisma/schema.prisma and extracts structured information
5
+ * about models, fields, relationships, and enums.
6
+ */
7
+
8
+ import fs from 'fs-extra'
9
+ import path from 'path'
10
+ import type { IntrospectedSchema, IntrospectedModel, IntrospectedField } from '../types.js'
11
+
12
+ export class PrismaIntrospector {
13
+ /**
14
+ * Introspect a Prisma schema file
15
+ */
16
+ async introspect(
17
+ cwd: string,
18
+ schemaPath: string = 'prisma/schema.prisma',
19
+ ): Promise<IntrospectedSchema> {
20
+ const fullPath = path.isAbsolute(schemaPath) ? schemaPath : path.join(cwd, schemaPath)
21
+
22
+ if (!(await fs.pathExists(fullPath))) {
23
+ throw new Error(`Schema file not found: ${fullPath}`)
24
+ }
25
+
26
+ const schema = await fs.readFile(fullPath, 'utf-8')
27
+
28
+ return {
29
+ provider: this.extractProvider(schema),
30
+ models: this.extractModels(schema),
31
+ enums: this.extractEnums(schema),
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Extract database provider from datasource block
37
+ */
38
+ private extractProvider(schema: string): string {
39
+ const match = schema.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/)
40
+ return match ? match[1] : 'unknown'
41
+ }
42
+
43
+ /**
44
+ * Extract all model definitions
45
+ */
46
+ private extractModels(schema: string): IntrospectedModel[] {
47
+ const models: IntrospectedModel[] = []
48
+
49
+ // Match model blocks
50
+ const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g
51
+ let match
52
+
53
+ while ((match = modelRegex.exec(schema)) !== null) {
54
+ const name = match[1]
55
+ const body = match[2]
56
+
57
+ const fields = this.extractFields(body)
58
+ const primaryKey = fields.find((f) => f.isId)?.name || 'id'
59
+
60
+ models.push({
61
+ name,
62
+ fields,
63
+ hasRelations: fields.some((f) => f.relation !== undefined),
64
+ primaryKey,
65
+ })
66
+ }
67
+
68
+ return models
69
+ }
70
+
71
+ /**
72
+ * Extract fields from a model body
73
+ */
74
+ private extractFields(body: string): IntrospectedField[] {
75
+ const fields: IntrospectedField[] = []
76
+ const lines = body.split('\n')
77
+
78
+ for (const line of lines) {
79
+ const trimmed = line.trim()
80
+
81
+ // Skip empty lines, comments, and model-level attributes
82
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) {
83
+ continue
84
+ }
85
+
86
+ const field = this.parseFieldLine(trimmed)
87
+ if (field) {
88
+ fields.push(field)
89
+ }
90
+ }
91
+
92
+ return fields
93
+ }
94
+
95
+ /**
96
+ * Parse a single field line
97
+ */
98
+ private parseFieldLine(line: string): IntrospectedField | null {
99
+ // Basic field pattern: name Type modifiers attributes
100
+ // Examples:
101
+ // id String @id @default(cuid())
102
+ // title String
103
+ // isActive Boolean? @default(true)
104
+ // posts Post[]
105
+ // author User @relation(fields: [authorId], references: [id])
106
+
107
+ // Remove comments
108
+ const withoutComment = line.split('//')[0].trim()
109
+
110
+ // Match field name and type
111
+ const fieldMatch = withoutComment.match(/^(\w+)\s+(\w+)(\?)?(\[\])?(.*)$/)
112
+ if (!fieldMatch) return null
113
+
114
+ const [, name, rawType, optional, isList, rest] = fieldMatch
115
+
116
+ // Skip if this looks like an index or other non-field line
117
+ if (['@@', 'index', 'unique'].some((kw) => name.startsWith(kw))) {
118
+ return null
119
+ }
120
+
121
+ const field: IntrospectedField = {
122
+ name,
123
+ type: rawType,
124
+ isRequired: !optional,
125
+ isUnique: rest.includes('@unique'),
126
+ isId: rest.includes('@id'),
127
+ isList: !!isList,
128
+ }
129
+
130
+ // Extract default value (handle nested parentheses)
131
+ const defaultMatch = rest.match(/@default\(/)
132
+ if (defaultMatch) {
133
+ const startIdx = rest.indexOf('@default(') + '@default('.length
134
+ let depth = 1
135
+ let endIdx = startIdx
136
+
137
+ while (depth > 0 && endIdx < rest.length) {
138
+ if (rest[endIdx] === '(') depth++
139
+ else if (rest[endIdx] === ')') depth--
140
+ if (depth > 0) endIdx++
141
+ }
142
+
143
+ field.defaultValue = rest.substring(startIdx, endIdx)
144
+ }
145
+
146
+ // Extract relation
147
+ const relationMatch = rest.match(/@relation\(([^)]+)\)/)
148
+ if (relationMatch) {
149
+ const relationBody = relationMatch[1]
150
+
151
+ // Parse relation parts
152
+ const fieldsMatch = relationBody.match(/fields:\s*\[([^\]]+)\]/)
153
+ const referencesMatch = relationBody.match(/references:\s*\[([^\]]+)\]/)
154
+ const nameMatch = relationBody.match(/name:\s*"([^"]+)"/) || relationBody.match(/"([^"]+)"/)
155
+
156
+ field.relation = {
157
+ name: nameMatch ? nameMatch[1] : '',
158
+ model: rawType,
159
+ fields: fieldsMatch ? fieldsMatch[1].split(',').map((f) => f.trim()) : [],
160
+ references: referencesMatch ? referencesMatch[1].split(',').map((r) => r.trim()) : [],
161
+ }
162
+ }
163
+
164
+ return field
165
+ }
166
+
167
+ /**
168
+ * Extract enum definitions
169
+ */
170
+ private extractEnums(schema: string): Array<{ name: string; values: string[] }> {
171
+ const enums: Array<{ name: string; values: string[] }> = []
172
+
173
+ // Match enum blocks
174
+ const enumRegex = /enum\s+(\w+)\s*\{([^}]+)\}/g
175
+ let match
176
+
177
+ while ((match = enumRegex.exec(schema)) !== null) {
178
+ const name = match[1]
179
+ const body = match[2]
180
+
181
+ const values = body
182
+ .split('\n')
183
+ .map((line) => line.trim())
184
+ .filter((line) => line && !line.startsWith('//'))
185
+
186
+ enums.push({ name, values })
187
+ }
188
+
189
+ return enums
190
+ }
191
+
192
+ /**
193
+ * Map Prisma type to OpenSaaS field type
194
+ */
195
+ mapPrismaTypeToOpenSaas(prismaType: string): { type: string; import: string } {
196
+ const mappings: Record<string, { type: string; import: string }> = {
197
+ String: { type: 'text', import: 'text' },
198
+ Int: { type: 'integer', import: 'integer' },
199
+ Float: { type: 'float', import: 'float' },
200
+ Boolean: { type: 'checkbox', import: 'checkbox' },
201
+ DateTime: { type: 'timestamp', import: 'timestamp' },
202
+ Json: { type: 'json', import: 'json' },
203
+ BigInt: { type: 'text', import: 'text' }, // No native support
204
+ Decimal: { type: 'text', import: 'text' }, // No native support
205
+ Bytes: { type: 'text', import: 'text' }, // No native support
206
+ }
207
+
208
+ return mappings[prismaType] || { type: 'text', import: 'text' }
209
+ }
210
+
211
+ /**
212
+ * Get warnings for unsupported features
213
+ */
214
+ getWarnings(schema: IntrospectedSchema): string[] {
215
+ const warnings: string[] = []
216
+
217
+ // Check for unsupported types
218
+ for (const model of schema.models) {
219
+ for (const field of model.fields) {
220
+ if (['BigInt', 'Decimal', 'Bytes'].includes(field.type)) {
221
+ warnings.push(
222
+ `Field "${model.name}.${field.name}" uses unsupported type "${field.type}" - will be mapped to text()`,
223
+ )
224
+ }
225
+ }
226
+ }
227
+
228
+ // Check for composite IDs
229
+ // This would require checking for @@id in the original schema
230
+
231
+ return warnings
232
+ }
233
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Migration types - Shared types for the migration system
3
+ */
4
+
5
+ export type ProjectType = 'prisma' | 'nextjs' | 'keystone'
6
+
7
+ export interface ModelInfo {
8
+ name: string
9
+ fieldCount: number
10
+ }
11
+
12
+ export interface ProjectAnalysis {
13
+ projectTypes: ProjectType[]
14
+ cwd: string
15
+ models?: ModelInfo[]
16
+ provider?: string
17
+ hasAuth?: boolean
18
+ authLibrary?: string
19
+ }
20
+
21
+ export interface FieldMapping {
22
+ prismaType: string
23
+ opensaasType: string
24
+ opensaasImport: string
25
+ }
26
+
27
+ export interface MigrationQuestion {
28
+ id: string
29
+ text: string
30
+ type: 'text' | 'select' | 'boolean' | 'multiselect'
31
+ options?: string[]
32
+ defaultValue?: string | boolean | string[]
33
+ required?: boolean
34
+ dependsOn?: {
35
+ questionId: string
36
+ value: string | boolean
37
+ }
38
+ }
39
+
40
+ export interface MigrationSession {
41
+ id: string
42
+ projectType: ProjectType
43
+ analysis: ProjectAnalysis
44
+ currentQuestionIndex: number
45
+ answers: Record<string, string | boolean | string[]>
46
+ generatedConfig?: string
47
+ isComplete: boolean
48
+ createdAt: Date
49
+ updatedAt: Date
50
+ }
51
+
52
+ export interface MigrationOutput {
53
+ configContent: string
54
+ dependencies: string[]
55
+ files: Array<{
56
+ path: string
57
+ content: string
58
+ language: string
59
+ description: string
60
+ }>
61
+ steps: string[]
62
+ warnings: string[]
63
+ }
64
+
65
+ export interface IntrospectedModel {
66
+ name: string
67
+ fields: IntrospectedField[]
68
+ hasRelations: boolean
69
+ primaryKey: string
70
+ }
71
+
72
+ export interface IntrospectedField {
73
+ name: string
74
+ type: string
75
+ isRequired: boolean
76
+ isUnique: boolean
77
+ isId: boolean
78
+ isList: boolean
79
+ defaultValue?: string
80
+ relation?: {
81
+ name: string
82
+ model: string
83
+ fields: string[]
84
+ references: string[]
85
+ }
86
+ }
87
+
88
+ export interface IntrospectedSchema {
89
+ provider: string
90
+ models: IntrospectedModel[]
91
+ enums: Array<{ name: string; values: string[] }>
92
+ }
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { KeystoneIntrospector } from '../../src/migration/introspectors/keystone-introspector.js'
3
+ import fs from 'fs-extra'
4
+ import path from 'path'
5
+ import os from 'os'
6
+
7
+ describe('KeystoneIntrospector', () => {
8
+ let introspector: KeystoneIntrospector
9
+ let tempDir: string
10
+
11
+ beforeEach(async () => {
12
+ introspector = new KeystoneIntrospector()
13
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'keystone-test-'))
14
+ })
15
+
16
+ afterEach(async () => {
17
+ await fs.remove(tempDir)
18
+ })
19
+
20
+ it('should load and parse a KeystoneJS config', async () => {
21
+ const config = `
22
+ export default {
23
+ db: {
24
+ provider: 'sqlite',
25
+ url: 'file:./keystone.db',
26
+ },
27
+ lists: {
28
+ User: {
29
+ fields: {
30
+ name: {
31
+ type: 'text',
32
+ validation: { isRequired: true },
33
+ },
34
+ email: {
35
+ type: 'text',
36
+ validation: { isRequired: true },
37
+ },
38
+ posts: {
39
+ type: 'relationship',
40
+ ref: 'Post.author',
41
+ many: true,
42
+ },
43
+ },
44
+ },
45
+ Post: {
46
+ fields: {
47
+ title: {
48
+ type: 'text',
49
+ validation: { isRequired: true },
50
+ },
51
+ author: {
52
+ type: 'relationship',
53
+ ref: 'User.posts',
54
+ },
55
+ },
56
+ },
57
+ },
58
+ }
59
+ `
60
+ await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
61
+
62
+ const result = await introspector.introspect(tempDir, 'keystone.config.js')
63
+
64
+ expect(result.provider).toBe('sqlite')
65
+ expect(result.models).toHaveLength(2)
66
+
67
+ const user = result.models.find((m) => m.name === 'User')
68
+ expect(user).toBeDefined()
69
+ expect(user!.fields).toHaveLength(3)
70
+
71
+ const post = result.models.find((m) => m.name === 'Post')
72
+ expect(post).toBeDefined()
73
+ expect(post!.hasRelations).toBe(true)
74
+ })
75
+
76
+ it('should map KeystoneJS types to OpenSaaS types', () => {
77
+ expect(introspector.mapKeystoneTypeToOpenSaas('text')).toEqual({ type: 'text', import: 'text' })
78
+ expect(introspector.mapKeystoneTypeToOpenSaas('integer')).toEqual({
79
+ type: 'integer',
80
+ import: 'integer',
81
+ })
82
+ expect(introspector.mapKeystoneTypeToOpenSaas('checkbox')).toEqual({
83
+ type: 'checkbox',
84
+ import: 'checkbox',
85
+ })
86
+ expect(introspector.mapKeystoneTypeToOpenSaas('timestamp')).toEqual({
87
+ type: 'timestamp',
88
+ import: 'timestamp',
89
+ })
90
+ expect(introspector.mapKeystoneTypeToOpenSaas('relationship')).toEqual({
91
+ type: 'relationship',
92
+ import: 'relationship',
93
+ })
94
+ })
95
+
96
+ it('should handle file and image fields', () => {
97
+ expect(introspector.mapKeystoneTypeToOpenSaas('image')).toEqual({
98
+ type: 'image',
99
+ import: 'image',
100
+ })
101
+ expect(introspector.mapKeystoneTypeToOpenSaas('file')).toEqual({ type: 'file', import: 'file' })
102
+ })
103
+
104
+ it('should handle virtual fields', () => {
105
+ expect(introspector.mapKeystoneTypeToOpenSaas('virtual')).toEqual({
106
+ type: 'virtual',
107
+ import: 'virtual',
108
+ })
109
+ })
110
+
111
+ it('should generate warnings for migration reminders', async () => {
112
+ const config = `
113
+ export default {
114
+ db: {
115
+ provider: 'postgresql',
116
+ },
117
+ lists: {
118
+ Media: {
119
+ fields: {
120
+ image: {
121
+ type: 'image',
122
+ },
123
+ document: {
124
+ type: 'file',
125
+ },
126
+ computed: {
127
+ type: 'virtual',
128
+ },
129
+ },
130
+ },
131
+ },
132
+ }
133
+ `
134
+ await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
135
+
136
+ const result = await introspector.introspect(tempDir, 'keystone.config.js')
137
+ const warnings = introspector.getWarnings(result)
138
+
139
+ expect(warnings.length).toBeGreaterThan(0)
140
+ // Should warn about storage configuration (helpful reminder)
141
+ expect(warnings.some((w) => w.includes('storage'))).toBe(true)
142
+ // Should remind about manual migration for virtual field hooks
143
+ expect(warnings.some((w) => w.includes('virtual') && w.includes('manually migrate'))).toBe(true)
144
+ })
145
+
146
+ it('should throw for missing config', async () => {
147
+ await expect(introspector.introspect(tempDir, 'nonexistent.ts')).rejects.toThrow(
148
+ 'KeystoneJS config not found',
149
+ )
150
+ })
151
+
152
+ it('should try alternative config paths', async () => {
153
+ const config = `
154
+ export default {
155
+ db: {
156
+ provider: 'sqlite',
157
+ },
158
+ lists: {
159
+ User: {
160
+ fields: {
161
+ name: {
162
+ type: 'text',
163
+ },
164
+ },
165
+ },
166
+ },
167
+ }
168
+ `
169
+ // Create config at alternative path
170
+ await fs.writeFile(path.join(tempDir, 'keystone.ts'), config)
171
+
172
+ // Should find it even if we specify the default path
173
+ const result = await introspector.introspect(tempDir)
174
+
175
+ expect(result.models).toHaveLength(1)
176
+ expect(result.models[0].name).toBe('User')
177
+ })
178
+
179
+ it('should parse relationship fields correctly', async () => {
180
+ const config = `
181
+ export default {
182
+ db: {
183
+ provider: 'sqlite',
184
+ },
185
+ lists: {
186
+ Post: {
187
+ fields: {
188
+ author: {
189
+ type: 'relationship',
190
+ ref: 'User.posts',
191
+ },
192
+ },
193
+ },
194
+ User: {
195
+ fields: {
196
+ posts: {
197
+ type: 'relationship',
198
+ ref: 'Post.author',
199
+ many: true,
200
+ },
201
+ },
202
+ },
203
+ },
204
+ }
205
+ `
206
+ await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
207
+
208
+ const result = await introspector.introspect(tempDir, 'keystone.config.js')
209
+
210
+ const post = result.models.find((m) => m.name === 'Post')
211
+ const authorField = post!.fields.find((f) => f.name === 'author')
212
+ expect(authorField!.relation).toBeDefined()
213
+ expect(authorField!.relation!.model).toBe('User')
214
+ expect(authorField!.relation!.references).toEqual(['posts'])
215
+
216
+ const user = result.models.find((m) => m.name === 'User')
217
+ const postsField = user!.fields.find((f) => f.name === 'posts')
218
+ expect(postsField!.isList).toBe(true)
219
+ })
220
+
221
+ it('should handle field validation options', async () => {
222
+ const config = `
223
+ export default {
224
+ db: {
225
+ provider: 'sqlite',
226
+ },
227
+ lists: {
228
+ User: {
229
+ fields: {
230
+ email: {
231
+ type: 'text',
232
+ validation: { isRequired: true },
233
+ isRequired: true,
234
+ },
235
+ age: {
236
+ type: 'integer',
237
+ defaultValue: 0,
238
+ },
239
+ },
240
+ },
241
+ },
242
+ }
243
+ `
244
+ await fs.writeFile(path.join(tempDir, 'keystone.config.js'), config)
245
+
246
+ const result = await introspector.introspect(tempDir, 'keystone.config.js')
247
+ const user = result.models[0]
248
+
249
+ const emailField = user.fields.find((f) => f.name === 'email')
250
+ expect(emailField!.isRequired).toBe(true)
251
+
252
+ const ageField = user.fields.find((f) => f.name === 'age')
253
+ expect(ageField!.defaultValue).toBe('0')
254
+ })
255
+ })