@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.
- package/README.md +76 -0
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +91 -265
- package/dist/commands/migrate.js.map +1 -1
- package/package.json +7 -2
- package/plugin/.claude-plugin/plugin.json +15 -0
- package/plugin/README.md +112 -0
- package/plugin/agents/migration-assistant.md +150 -0
- package/plugin/commands/analyze-schema.md +34 -0
- package/plugin/commands/generate-config.md +33 -0
- package/plugin/commands/validate-migration.md +34 -0
- package/plugin/skills/opensaas-migration/SKILL.md +192 -0
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -462
- package/CLAUDE.md +0 -298
- package/src/commands/__snapshots__/generate.test.ts.snap +0 -413
- package/src/commands/dev.test.ts +0 -215
- package/src/commands/dev.ts +0 -48
- package/src/commands/generate.test.ts +0 -282
- package/src/commands/generate.ts +0 -182
- package/src/commands/init.ts +0 -34
- package/src/commands/mcp.ts +0 -135
- package/src/commands/migrate.ts +0 -534
- package/src/generator/__snapshots__/context.test.ts.snap +0 -361
- package/src/generator/__snapshots__/prisma.test.ts.snap +0 -174
- package/src/generator/__snapshots__/types.test.ts.snap +0 -1702
- package/src/generator/context.test.ts +0 -139
- package/src/generator/context.ts +0 -227
- package/src/generator/index.ts +0 -7
- package/src/generator/lists.test.ts +0 -335
- package/src/generator/lists.ts +0 -140
- package/src/generator/plugin-types.ts +0 -147
- package/src/generator/prisma-config.ts +0 -46
- package/src/generator/prisma-extensions.ts +0 -159
- package/src/generator/prisma.test.ts +0 -211
- package/src/generator/prisma.ts +0 -161
- package/src/generator/types.test.ts +0 -268
- package/src/generator/types.ts +0 -537
- package/src/index.ts +0 -46
- package/src/mcp/lib/documentation-provider.ts +0 -710
- package/src/mcp/lib/features/catalog.ts +0 -301
- package/src/mcp/lib/generators/feature-generator.ts +0 -598
- package/src/mcp/lib/types.ts +0 -89
- package/src/mcp/lib/wizards/migration-wizard.ts +0 -584
- package/src/mcp/lib/wizards/wizard-engine.ts +0 -427
- package/src/mcp/server/index.ts +0 -361
- package/src/mcp/server/stack-mcp-server.ts +0 -544
- package/src/migration/generators/migration-generator.ts +0 -675
- package/src/migration/introspectors/index.ts +0 -12
- package/src/migration/introspectors/keystone-introspector.ts +0 -296
- package/src/migration/introspectors/nextjs-introspector.ts +0 -209
- package/src/migration/introspectors/prisma-introspector.ts +0 -233
- package/src/migration/types.ts +0 -92
- package/tests/introspectors/keystone-introspector.test.ts +0 -255
- package/tests/introspectors/nextjs-introspector.test.ts +0 -302
- package/tests/introspectors/prisma-introspector.test.ts +0 -268
- package/tests/migration-generator.test.ts +0 -592
- package/tests/migration-wizard.test.ts +0 -442
- package/tsconfig.json +0 -13
- package/tsconfig.tsbuildinfo +0 -1
- 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
|
-
}
|