@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,584 @@
1
+ /**
2
+ * Migration Wizard - Interactive guide for migrating to OpenSaaS Stack
3
+ */
4
+
5
+ import type {
6
+ ProjectType,
7
+ MigrationSession,
8
+ IntrospectedSchema,
9
+ IntrospectedModel,
10
+ MigrationQuestion,
11
+ } from '../../../migration/types.js'
12
+ import { MigrationGenerator } from '../../../migration/generators/migration-generator.js'
13
+
14
+ interface MigrationSessionStorage {
15
+ [sessionId: string]: MigrationSession & { questions?: MigrationQuestion[] }
16
+ }
17
+
18
+ export class MigrationWizard {
19
+ private sessions: MigrationSessionStorage = {}
20
+ private generator: MigrationGenerator
21
+
22
+ constructor() {
23
+ this.generator = new MigrationGenerator()
24
+ }
25
+
26
+ /**
27
+ * Start a new migration wizard session
28
+ */
29
+ async startMigration(
30
+ projectType: ProjectType,
31
+ analysis?: IntrospectedSchema,
32
+ ): Promise<{ content: Array<{ type: 'text'; text: string }> }> {
33
+ const sessionId = this.generateSessionId()
34
+
35
+ // Generate questions based on project type and analysis
36
+ const questions = this.generateQuestions(projectType, analysis)
37
+
38
+ const session: MigrationSession & { questions?: MigrationQuestion[] } = {
39
+ id: sessionId,
40
+ projectType,
41
+ analysis: {
42
+ projectTypes: [projectType],
43
+ cwd: process.cwd(),
44
+ models: analysis?.models?.map((m) => ({
45
+ name: m.name,
46
+ fieldCount: m.fields.length,
47
+ })),
48
+ provider: analysis?.provider,
49
+ },
50
+ currentQuestionIndex: 0,
51
+ answers: {},
52
+ isComplete: false,
53
+ createdAt: new Date(),
54
+ updatedAt: new Date(),
55
+ questions,
56
+ }
57
+
58
+ this.sessions[sessionId] = session
59
+
60
+ const totalQuestions = questions.length
61
+ const firstQuestion = questions[0]
62
+ const progressBar = this.renderProgressBar(1, totalQuestions)
63
+
64
+ return {
65
+ content: [
66
+ {
67
+ type: 'text' as const,
68
+ text: `# 🚀 OpenSaaS Stack Migration Wizard
69
+
70
+ ## Project Analysis
71
+
72
+ - **Project Type:** ${projectType}
73
+ - **Models:** ${analysis?.models?.length || 0}
74
+ - **Database:** ${analysis?.provider || 'Not detected'}
75
+
76
+ ---
77
+
78
+ ## Let's Configure Your Migration
79
+
80
+ ${this.renderQuestion(firstQuestion, 1, totalQuestions)}
81
+
82
+ ---
83
+
84
+ **Progress:** ${progressBar} 1/${totalQuestions}
85
+
86
+ <details>
87
+ <summary>💡 **Instructions for Claude**</summary>
88
+
89
+ 1. Present this question naturally to the user
90
+ 2. When they answer, call \`opensaas_answer_migration\` with:
91
+ - \`sessionId\`: "${sessionId}"
92
+ - \`answer\`: their response (string, boolean, or array)
93
+ 3. Continue until the wizard is complete
94
+ 4. Do NOT mention session IDs to the user
95
+
96
+ </details>`,
97
+ },
98
+ ],
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Answer a wizard question
104
+ */
105
+ async answerQuestion(
106
+ sessionId: string,
107
+ answer: string | boolean | string[],
108
+ ): Promise<{ content: Array<{ type: 'text'; text: string }> }> {
109
+ const session = this.sessions[sessionId]
110
+ if (!session) {
111
+ return {
112
+ content: [
113
+ {
114
+ type: 'text' as const,
115
+ text: `❌ **Session not found:** ${sessionId}
116
+
117
+ Please start a new migration with \`opensaas_start_migration\`.`,
118
+ },
119
+ ],
120
+ }
121
+ }
122
+
123
+ const questions = session.questions!
124
+ const currentQuestion = questions[session.currentQuestionIndex]
125
+
126
+ // Validate answer
127
+ const validation = this.validateAnswer(answer, currentQuestion)
128
+ if (!validation.valid) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: 'text' as const,
133
+ text: `❌ **Invalid answer:** ${validation.message}
134
+
135
+ ${this.renderQuestion(currentQuestion, session.currentQuestionIndex + 1, questions.length)}`,
136
+ },
137
+ ],
138
+ }
139
+ }
140
+
141
+ // Normalize and store answer
142
+ let normalizedAnswer = answer
143
+ if (currentQuestion.type === 'boolean') {
144
+ const boolValue = this.normalizeBoolean(answer)
145
+ if (boolValue !== null) {
146
+ normalizedAnswer = boolValue
147
+ }
148
+ }
149
+ session.answers[currentQuestion.id] = normalizedAnswer
150
+ session.updatedAt = new Date()
151
+
152
+ // Move to next question, skipping conditional questions
153
+ session.currentQuestionIndex++
154
+ while (session.currentQuestionIndex < questions.length) {
155
+ const nextQ = questions[session.currentQuestionIndex]
156
+ // Skip if this question depends on a previous answer that doesn't match
157
+ if (
158
+ nextQ.dependsOn &&
159
+ session.answers[nextQ.dependsOn.questionId] !== nextQ.dependsOn.value
160
+ ) {
161
+ session.currentQuestionIndex++
162
+ } else {
163
+ break
164
+ }
165
+ }
166
+
167
+ // Check if complete
168
+ if (session.currentQuestionIndex >= questions.length) {
169
+ session.isComplete = true
170
+ return this.generateMigrationConfig(session)
171
+ }
172
+
173
+ // Render next question
174
+ const nextQuestion = questions[session.currentQuestionIndex]
175
+ const questionNum = session.currentQuestionIndex + 1
176
+ const progressBar = this.renderProgressBar(questionNum, questions.length)
177
+
178
+ return {
179
+ content: [
180
+ {
181
+ type: 'text' as const,
182
+ text: `✅ **Recorded:** ${this.formatAnswer(normalizedAnswer)}
183
+
184
+ ---
185
+
186
+ ${this.renderQuestion(nextQuestion, questionNum, questions.length)}
187
+
188
+ ---
189
+
190
+ **Progress:** ${progressBar} ${questionNum}/${questions.length}`,
191
+ },
192
+ ],
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Generate questions based on project type and analysis
198
+ */
199
+ private generateQuestions(
200
+ projectType: ProjectType,
201
+ analysis?: IntrospectedSchema,
202
+ ): MigrationQuestion[] {
203
+ const questions: MigrationQuestion[] = []
204
+
205
+ // 1. Database configuration
206
+ questions.push({
207
+ id: 'preserve_database',
208
+ text: 'Do you want to keep your existing database?',
209
+ type: 'boolean',
210
+ defaultValue: true,
211
+ })
212
+
213
+ questions.push({
214
+ id: 'db_provider',
215
+ text: 'Which database provider are you using?',
216
+ type: 'select',
217
+ options: ['sqlite', 'postgresql', 'mysql'],
218
+ defaultValue: analysis?.provider || 'sqlite',
219
+ })
220
+
221
+ // 2. Authentication
222
+ questions.push({
223
+ id: 'enable_auth',
224
+ text: 'Do you want to add authentication?',
225
+ type: 'boolean',
226
+ defaultValue: this.hasAuthModels(analysis),
227
+ })
228
+
229
+ questions.push({
230
+ id: 'auth_methods',
231
+ text: 'Which authentication methods do you want?',
232
+ type: 'multiselect',
233
+ options: ['email-password', 'google', 'github', 'magic-link'],
234
+ defaultValue: ['email-password'],
235
+ dependsOn: {
236
+ questionId: 'enable_auth',
237
+ value: true,
238
+ },
239
+ })
240
+
241
+ // 3. Access control strategy
242
+ questions.push({
243
+ id: 'default_access',
244
+ text: 'What should be the default access control strategy?',
245
+ type: 'select',
246
+ options: ['public-read-auth-write', 'authenticated-only', 'owner-only', 'admin-only'],
247
+ defaultValue: 'public-read-auth-write',
248
+ })
249
+
250
+ // 4. Per-model configuration (if models detected)
251
+ if (analysis?.models && analysis.models.length > 0) {
252
+ // Ask about special models
253
+ const modelNames = analysis.models.map((m) => m.name)
254
+
255
+ if (modelNames.some((n) => ['User', 'Account', 'Session'].includes(n))) {
256
+ questions.push({
257
+ id: 'skip_auth_models',
258
+ text: "We detected User/Account/Session models. Should we skip these (they'll be managed by the auth plugin)?",
259
+ type: 'boolean',
260
+ defaultValue: true,
261
+ })
262
+ }
263
+
264
+ // Ask about models that need special access control
265
+ const nonAuthModels = modelNames.filter(
266
+ (n) => !['User', 'Account', 'Session', 'Verification'].includes(n),
267
+ )
268
+
269
+ if (nonAuthModels.length > 0) {
270
+ questions.push({
271
+ id: 'models_with_owner',
272
+ text: `Which models should have owner-based access control? (User can only access their own)`,
273
+ type: 'multiselect',
274
+ options: nonAuthModels,
275
+ defaultValue: this.guessOwnerModels(analysis.models, nonAuthModels),
276
+ })
277
+ }
278
+ }
279
+
280
+ // 5. Admin UI
281
+ questions.push({
282
+ id: 'admin_base_path',
283
+ text: 'What base path should the admin UI use?',
284
+ type: 'text',
285
+ defaultValue: '/admin',
286
+ })
287
+
288
+ // 6. Additional features
289
+ questions.push({
290
+ id: 'additional_features',
291
+ text: 'Do you want to add any additional features?',
292
+ type: 'multiselect',
293
+ options: ['file-storage', 'semantic-search', 'audit-logging'],
294
+ defaultValue: [],
295
+ })
296
+
297
+ // 7. Final confirmation
298
+ questions.push({
299
+ id: 'confirm',
300
+ text: 'Ready to generate your opensaas.config.ts?',
301
+ type: 'boolean',
302
+ defaultValue: true,
303
+ })
304
+
305
+ return questions
306
+ }
307
+
308
+ /**
309
+ * Generate the final migration config
310
+ */
311
+ private async generateMigrationConfig(
312
+ session: MigrationSession,
313
+ ): Promise<{ content: Array<{ type: 'text'; text: string }> }> {
314
+ try {
315
+ const output = await this.generator.generate(session)
316
+
317
+ // Clean up session
318
+ delete this.sessions[session.id]
319
+
320
+ return {
321
+ content: [
322
+ {
323
+ type: 'text' as const,
324
+ text: `# ✅ Migration Complete!
325
+
326
+ ## Generated opensaas.config.ts
327
+
328
+ \`\`\`typescript
329
+ ${output.configContent}
330
+ \`\`\`
331
+
332
+ ---
333
+
334
+ ## Install Dependencies
335
+
336
+ \`\`\`bash
337
+ ${output.dependencies.map((d) => `pnpm add ${d}`).join('\n')}
338
+ \`\`\`
339
+
340
+ ---
341
+
342
+ ${
343
+ output.files.length > 0
344
+ ? `## Additional Files
345
+
346
+ ${output.files
347
+ .map(
348
+ (f) => `### ${f.path}
349
+
350
+ *${f.description}*
351
+
352
+ \`\`\`${f.language}
353
+ ${f.content}
354
+ \`\`\``,
355
+ )
356
+ .join('\n\n')}
357
+
358
+ ---
359
+
360
+ `
361
+ : ''
362
+ }
363
+
364
+ ${
365
+ output.warnings.length > 0
366
+ ? `## ⚠️ Warnings
367
+
368
+ ${output.warnings.map((w) => `- ${w}`).join('\n')}
369
+
370
+ ---
371
+
372
+ `
373
+ : ''
374
+ }
375
+
376
+ ## Next Steps
377
+
378
+ ${output.steps.map((step, i) => `${i + 1}. ${step}`).join('\n')}
379
+
380
+ ---
381
+
382
+ 🎉 **Your migration is ready!**
383
+
384
+ The generated config creates an OpenSaaS Stack application that matches your existing schema.
385
+
386
+ 📚 **Documentation:** https://stack.opensaas.au/`,
387
+ },
388
+ ],
389
+ }
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error)
392
+ return {
393
+ content: [
394
+ {
395
+ type: 'text' as const,
396
+ text: `❌ **Failed to generate config:** ${message}
397
+
398
+ Please try again or create the config manually.
399
+
400
+ 📚 See: https://stack.opensaas.au/guides/migration`,
401
+ },
402
+ ],
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Check if analysis includes auth-related models
409
+ */
410
+ private hasAuthModels(analysis?: IntrospectedSchema): boolean {
411
+ if (!analysis?.models) return false
412
+ const authModelNames = ['User', 'Account', 'Session']
413
+ return analysis.models.some((m) => authModelNames.includes(m.name))
414
+ }
415
+
416
+ /**
417
+ * Guess which models should have owner-based access
418
+ */
419
+ private guessOwnerModels(models: IntrospectedModel[], modelNames: string[]): string[] {
420
+ const ownerModels: string[] = []
421
+
422
+ for (const name of modelNames) {
423
+ const model = models.find((m) => m.name === name)
424
+ if (!model) continue
425
+
426
+ // Check if model has a relationship to User
427
+ const hasUserRelation = model.fields.some(
428
+ (f) =>
429
+ f.relation &&
430
+ (f.relation.model === 'User' ||
431
+ f.name.toLowerCase().includes('author') ||
432
+ f.name.toLowerCase().includes('owner')),
433
+ )
434
+
435
+ if (hasUserRelation) {
436
+ ownerModels.push(name)
437
+ }
438
+ }
439
+
440
+ return ownerModels
441
+ }
442
+
443
+ /**
444
+ * Render a question for display
445
+ */
446
+ private renderQuestion(
447
+ question: MigrationQuestion,
448
+ questionNum: number,
449
+ totalQuestions: number,
450
+ ): string {
451
+ let rendered = `### Question ${questionNum}/${totalQuestions}\n\n**${question.text}**\n\n`
452
+
453
+ if (question.type === 'select') {
454
+ rendered += question.options!.map((opt) => `- \`${opt}\``).join('\n')
455
+ } else if (question.type === 'multiselect') {
456
+ rendered += question.options!.map((opt) => `- \`${opt}\``).join('\n')
457
+ rendered += '\n\n*Select multiple (comma-separated) or empty for none*'
458
+ } else if (question.type === 'boolean') {
459
+ rendered += '*Answer: yes or no*'
460
+ } else if (question.type === 'text') {
461
+ rendered += '*Enter your response*'
462
+ }
463
+
464
+ if (question.defaultValue !== undefined) {
465
+ rendered += `\n\n*Default: ${this.formatAnswer(question.defaultValue)}*`
466
+ }
467
+
468
+ return rendered
469
+ }
470
+
471
+ /**
472
+ * Validate an answer
473
+ */
474
+ private validateAnswer(
475
+ answer: string | boolean | string[],
476
+ question: MigrationQuestion,
477
+ ): { valid: boolean; message?: string } {
478
+ if (question.required && !answer) {
479
+ return { valid: false, message: 'This question requires an answer.' }
480
+ }
481
+
482
+ if (question.type === 'boolean') {
483
+ const normalized = this.normalizeBoolean(answer)
484
+ if (normalized === null) {
485
+ return { valid: false, message: 'Please answer with yes/no or true/false.' }
486
+ }
487
+ }
488
+
489
+ if (question.type === 'select' && question.options) {
490
+ if (!question.options.includes(answer as string)) {
491
+ return {
492
+ valid: false,
493
+ message: `Please select one of: ${question.options.join(', ')}`,
494
+ }
495
+ }
496
+ }
497
+
498
+ if (question.type === 'multiselect' && question.options) {
499
+ const answers = Array.isArray(answer)
500
+ ? answer
501
+ : (answer as string)
502
+ .split(',')
503
+ .map((a) => a.trim())
504
+ .filter(Boolean)
505
+ const invalid = answers.filter((a) => !question.options!.includes(a))
506
+ if (invalid.length > 0) {
507
+ return {
508
+ valid: false,
509
+ message: `Invalid options: ${invalid.join(', ')}. Valid: ${question.options.join(', ')}`,
510
+ }
511
+ }
512
+ }
513
+
514
+ return { valid: true }
515
+ }
516
+
517
+ /**
518
+ * Normalize boolean answer
519
+ */
520
+ private normalizeBoolean(answer: string | boolean | string[]): boolean | null {
521
+ if (typeof answer === 'boolean') return answer
522
+ if (typeof answer !== 'string') return null
523
+
524
+ const lower = answer.toLowerCase().trim()
525
+ if (['yes', 'y', 'true', '1'].includes(lower)) return true
526
+ if (['no', 'n', 'false', '0'].includes(lower)) return false
527
+ return null
528
+ }
529
+
530
+ /**
531
+ * Format an answer for display
532
+ */
533
+ private formatAnswer(answer: string | boolean | string[]): string {
534
+ if (typeof answer === 'boolean') return answer ? 'Yes' : 'No'
535
+ if (Array.isArray(answer)) return answer.length > 0 ? answer.join(', ') : '(none)'
536
+ return String(answer)
537
+ }
538
+
539
+ /**
540
+ * Generate a unique session ID
541
+ */
542
+ private generateSessionId(): string {
543
+ return `migration_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
544
+ }
545
+
546
+ /**
547
+ * Render a progress bar
548
+ */
549
+ private renderProgressBar(current: number, total: number): string {
550
+ const filled = Math.round((current / total) * 10)
551
+ const empty = 10 - filled
552
+ return '▓'.repeat(filled) + '░'.repeat(empty)
553
+ }
554
+
555
+ /**
556
+ * Get a session by ID (for testing/debugging)
557
+ */
558
+ getSession(sessionId: string): MigrationSession | undefined {
559
+ return this.sessions[sessionId]
560
+ }
561
+
562
+ /**
563
+ * Clear completed sessions
564
+ */
565
+ clearCompletedSessions(): void {
566
+ Object.keys(this.sessions).forEach((id) => {
567
+ if (this.sessions[id].isComplete) {
568
+ delete this.sessions[id]
569
+ }
570
+ })
571
+ }
572
+
573
+ /**
574
+ * Clear old sessions (older than 1 hour)
575
+ */
576
+ clearOldSessions(): void {
577
+ const oneHourAgo = Date.now() - 60 * 60 * 1000
578
+ Object.keys(this.sessions).forEach((id) => {
579
+ if (this.sessions[id].createdAt.getTime() < oneHourAgo) {
580
+ delete this.sessions[id]
581
+ }
582
+ })
583
+ }
584
+ }
@@ -131,6 +131,105 @@ const TOOLS: Tool[] = [
131
131
  required: ['feature'],
132
132
  },
133
133
  },
134
+ {
135
+ name: 'opensaas_start_migration',
136
+ description:
137
+ 'Start the migration wizard for an existing project. Returns the first question to begin the migration process.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ projectType: {
142
+ type: 'string',
143
+ description: 'Type of project being migrated',
144
+ enum: ['prisma', 'keystone', 'nextjs'],
145
+ },
146
+ },
147
+ required: ['projectType'],
148
+ },
149
+ },
150
+ {
151
+ name: 'opensaas_answer_migration',
152
+ description:
153
+ 'Answer a question in the migration wizard. Use this after opensaas_start_migration to progress through the migration.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ sessionId: {
158
+ type: 'string',
159
+ description: 'Migration session ID from the wizard',
160
+ },
161
+ answer: {
162
+ description: 'Answer to the current question',
163
+ oneOf: [
164
+ { type: 'string' },
165
+ { type: 'boolean' },
166
+ { type: 'array', items: { type: 'string' } },
167
+ ],
168
+ },
169
+ },
170
+ required: ['sessionId', 'answer'],
171
+ },
172
+ },
173
+ {
174
+ name: 'opensaas_introspect_prisma',
175
+ description:
176
+ 'Analyze a Prisma schema file and return detailed information about models, fields, and relationships. This helps you understand your existing schema before migration.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ schemaPath: {
181
+ type: 'string',
182
+ description: 'Path to schema.prisma (defaults to prisma/schema.prisma)',
183
+ },
184
+ },
185
+ },
186
+ },
187
+ {
188
+ name: 'opensaas_introspect_keystone',
189
+ description:
190
+ 'Analyze a KeystoneJS config file and return information about lists and fields. This helps you understand your existing schema before migration.',
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ configPath: {
195
+ type: 'string',
196
+ description: 'Path to keystone.config.ts (defaults to keystone.config.ts)',
197
+ },
198
+ },
199
+ },
200
+ },
201
+ {
202
+ name: 'opensaas_search_migration_docs',
203
+ description:
204
+ 'Search OpenSaaS Stack documentation for migration-related topics. Searches both local CLAUDE.md files and online documentation.',
205
+ inputSchema: {
206
+ type: 'object',
207
+ properties: {
208
+ query: {
209
+ type: 'string',
210
+ description:
211
+ 'Search query (e.g., "prisma to opensaas", "access control patterns", "field types")',
212
+ },
213
+ },
214
+ required: ['query'],
215
+ },
216
+ },
217
+ {
218
+ name: 'opensaas_get_example',
219
+ description:
220
+ 'Get example code for a specific feature or pattern. Returns code snippets from the examples directory.',
221
+ inputSchema: {
222
+ type: 'object',
223
+ properties: {
224
+ feature: {
225
+ type: 'string',
226
+ description:
227
+ 'Feature to get example for (e.g., "blog-with-auth", "access-control", "relationships", "hooks", "custom-fields")',
228
+ },
229
+ },
230
+ required: ['feature'],
231
+ },
232
+ },
134
233
  ]
135
234
 
136
235
  /**
@@ -189,6 +288,28 @@ export async function startMCPServer() {
189
288
  case 'opensaas_validate_feature':
190
289
  return await stackServer.validateFeature(args as { feature: string; configPath?: string })
191
290
 
291
+ case 'opensaas_start_migration':
292
+ return await stackServer.startMigration(
293
+ args as { projectType: 'prisma' | 'keystone' | 'nextjs' },
294
+ )
295
+
296
+ case 'opensaas_answer_migration':
297
+ return await stackServer.answerMigration(
298
+ args as { sessionId: string; answer: string | boolean | string[] },
299
+ )
300
+
301
+ case 'opensaas_introspect_prisma':
302
+ return await stackServer.introspectPrisma(args as { schemaPath?: string })
303
+
304
+ case 'opensaas_introspect_keystone':
305
+ return await stackServer.introspectKeystone(args as { configPath?: string })
306
+
307
+ case 'opensaas_search_migration_docs':
308
+ return await stackServer.searchMigrationDocs(args as { query: string })
309
+
310
+ case 'opensaas_get_example':
311
+ return await stackServer.getExample(args as { feature: string })
312
+
192
313
  default:
193
314
  return {
194
315
  content: [