@opensaas/stack-cli 0.5.0 → 0.6.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 (61) hide show
  1. package/README.md +76 -0
  2. package/dist/commands/migrate.d.ts.map +1 -1
  3. package/dist/commands/migrate.js +91 -265
  4. package/dist/commands/migrate.js.map +1 -1
  5. package/package.json +7 -2
  6. package/plugin/.claude-plugin/plugin.json +15 -0
  7. package/plugin/README.md +112 -0
  8. package/plugin/agents/migration-assistant.md +150 -0
  9. package/plugin/commands/analyze-schema.md +34 -0
  10. package/plugin/commands/generate-config.md +33 -0
  11. package/plugin/commands/validate-migration.md +34 -0
  12. package/plugin/skills/opensaas-migration/SKILL.md +192 -0
  13. package/.turbo/turbo-build.log +0 -4
  14. package/CHANGELOG.md +0 -462
  15. package/CLAUDE.md +0 -298
  16. package/src/commands/__snapshots__/generate.test.ts.snap +0 -413
  17. package/src/commands/dev.test.ts +0 -215
  18. package/src/commands/dev.ts +0 -48
  19. package/src/commands/generate.test.ts +0 -282
  20. package/src/commands/generate.ts +0 -182
  21. package/src/commands/init.ts +0 -34
  22. package/src/commands/mcp.ts +0 -135
  23. package/src/commands/migrate.ts +0 -534
  24. package/src/generator/__snapshots__/context.test.ts.snap +0 -361
  25. package/src/generator/__snapshots__/prisma.test.ts.snap +0 -174
  26. package/src/generator/__snapshots__/types.test.ts.snap +0 -1702
  27. package/src/generator/context.test.ts +0 -139
  28. package/src/generator/context.ts +0 -227
  29. package/src/generator/index.ts +0 -7
  30. package/src/generator/lists.test.ts +0 -335
  31. package/src/generator/lists.ts +0 -140
  32. package/src/generator/plugin-types.ts +0 -147
  33. package/src/generator/prisma-config.ts +0 -46
  34. package/src/generator/prisma-extensions.ts +0 -159
  35. package/src/generator/prisma.test.ts +0 -211
  36. package/src/generator/prisma.ts +0 -161
  37. package/src/generator/types.test.ts +0 -268
  38. package/src/generator/types.ts +0 -537
  39. package/src/index.ts +0 -46
  40. package/src/mcp/lib/documentation-provider.ts +0 -710
  41. package/src/mcp/lib/features/catalog.ts +0 -301
  42. package/src/mcp/lib/generators/feature-generator.ts +0 -598
  43. package/src/mcp/lib/types.ts +0 -89
  44. package/src/mcp/lib/wizards/migration-wizard.ts +0 -584
  45. package/src/mcp/lib/wizards/wizard-engine.ts +0 -427
  46. package/src/mcp/server/index.ts +0 -361
  47. package/src/mcp/server/stack-mcp-server.ts +0 -544
  48. package/src/migration/generators/migration-generator.ts +0 -675
  49. package/src/migration/introspectors/index.ts +0 -12
  50. package/src/migration/introspectors/keystone-introspector.ts +0 -296
  51. package/src/migration/introspectors/nextjs-introspector.ts +0 -209
  52. package/src/migration/introspectors/prisma-introspector.ts +0 -233
  53. package/src/migration/types.ts +0 -92
  54. package/tests/introspectors/keystone-introspector.test.ts +0 -255
  55. package/tests/introspectors/nextjs-introspector.test.ts +0 -302
  56. package/tests/introspectors/prisma-introspector.test.ts +0 -268
  57. package/tests/migration-generator.test.ts +0 -592
  58. package/tests/migration-wizard.test.ts +0 -442
  59. package/tsconfig.json +0 -13
  60. package/tsconfig.tsbuildinfo +0 -1
  61. package/vitest.config.ts +0 -26
@@ -1,584 +0,0 @@
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
- }