@opensaas/stack-cli 0.5.0 ā 0.6.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.
- package/README.md +76 -0
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +94 -268
- package/dist/commands/migrate.js.map +1 -1
- package/package.json +7 -2
- 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,427 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wizard engine for multi-step feature implementation flows
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { Feature, WizardSession, SessionStorage, FeatureQuestion } from '../types.js'
|
|
6
|
-
import { getFeature } from '../features/catalog.js'
|
|
7
|
-
import { FeatureGenerator } from '../generators/feature-generator.js'
|
|
8
|
-
|
|
9
|
-
export class WizardEngine {
|
|
10
|
-
private sessions: SessionStorage = {}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Start a new feature implementation wizard
|
|
14
|
-
*/
|
|
15
|
-
async startFeature(featureId: string): Promise<{
|
|
16
|
-
content: Array<{ type: string; text: string }>
|
|
17
|
-
}> {
|
|
18
|
-
const feature = getFeature(featureId)
|
|
19
|
-
if (!feature) {
|
|
20
|
-
return {
|
|
21
|
-
content: [
|
|
22
|
-
{
|
|
23
|
-
type: 'text',
|
|
24
|
-
text: `ā Unknown feature: "${featureId}"\n\nAvailable features: authentication, blog, comments, file-upload, semantic-search`,
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const sessionId = this.generateSessionId()
|
|
31
|
-
const session = this.createSession(sessionId, feature)
|
|
32
|
-
this.sessions[sessionId] = session
|
|
33
|
-
|
|
34
|
-
const progressBar = this.renderProgressBar(1, feature.questions.length)
|
|
35
|
-
const firstQuestion = this.renderQuestion(feature.questions[0], session, 1)
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
content: [
|
|
39
|
-
{
|
|
40
|
-
type: 'text',
|
|
41
|
-
text: `š **${feature.name} Implementation**
|
|
42
|
-
|
|
43
|
-
${feature.description}
|
|
44
|
-
|
|
45
|
-
**What's included**:
|
|
46
|
-
${feature.includes.map((item) => `- ${item}`).join('\n')}
|
|
47
|
-
|
|
48
|
-
${feature.dependsOn && feature.dependsOn.length > 0 ? `\nā ļø **Dependencies**: This feature requires: ${feature.dependsOn.join(', ')}\n` : ''}
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
## Let's configure this feature
|
|
53
|
-
|
|
54
|
-
${firstQuestion}
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
**Progress**: ${progressBar} 1/${feature.questions.length}
|
|
59
|
-
**Session ID**: \`${sessionId}\`
|
|
60
|
-
|
|
61
|
-
<details>
|
|
62
|
-
<summary>š” **Instructions for Claude Code**</summary>
|
|
63
|
-
|
|
64
|
-
1. Present this question to the user naturally in conversation
|
|
65
|
-
2. When they respond, call \`opensaas_answer_feature\` with:
|
|
66
|
-
- sessionId: "${sessionId}"
|
|
67
|
-
- answer: <their response>
|
|
68
|
-
3. Continue the conversation flow with the next question
|
|
69
|
-
4. Keep it natural - don't mention session IDs to the user
|
|
70
|
-
|
|
71
|
-
</details>`,
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Process an answer and move to next question or complete the wizard
|
|
79
|
-
*/
|
|
80
|
-
async answerQuestion(
|
|
81
|
-
sessionId: string,
|
|
82
|
-
answer: string | boolean | string[],
|
|
83
|
-
): Promise<{
|
|
84
|
-
content: Array<{ type: string; text: string }>
|
|
85
|
-
}> {
|
|
86
|
-
const session = this.sessions[sessionId]
|
|
87
|
-
if (!session) {
|
|
88
|
-
return {
|
|
89
|
-
content: [
|
|
90
|
-
{
|
|
91
|
-
type: 'text',
|
|
92
|
-
text: `ā Session not found: ${sessionId}\n\nPlease start a new feature implementation.`,
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const currentQ = session.feature.questions[session.currentQuestionIndex]
|
|
99
|
-
|
|
100
|
-
// Validate answer
|
|
101
|
-
const validation = this.validateAnswer(answer, currentQ)
|
|
102
|
-
if (!validation.valid) {
|
|
103
|
-
return {
|
|
104
|
-
content: [
|
|
105
|
-
{
|
|
106
|
-
type: 'text',
|
|
107
|
-
text: `ā **Invalid answer**: ${validation.message}\n\n${this.renderQuestion(currentQ, session, session.currentQuestionIndex + 1)}`,
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Store answer
|
|
114
|
-
session.answers[currentQ.id] = answer
|
|
115
|
-
session.updatedAt = new Date()
|
|
116
|
-
|
|
117
|
-
// Check for follow-up questions
|
|
118
|
-
if (currentQ.followUp) {
|
|
119
|
-
const shouldAskFollowUp =
|
|
120
|
-
currentQ.followUp.if === answer ||
|
|
121
|
-
(typeof currentQ.followUp.if === 'boolean' && currentQ.followUp.if === answer)
|
|
122
|
-
|
|
123
|
-
if (shouldAskFollowUp) {
|
|
124
|
-
const followUpText = `**Follow-up**: ${currentQ.followUp.ask}`
|
|
125
|
-
return {
|
|
126
|
-
content: [
|
|
127
|
-
{
|
|
128
|
-
type: 'text',
|
|
129
|
-
text: `ā Recorded: ${this.formatAnswer(answer)}\n\n${followUpText}\n\n---\n\nš” **Claude Code**: Ask this follow-up question and call \`opensaas_answer_followup\` with sessionId "${sessionId}" and their response.`,
|
|
130
|
-
},
|
|
131
|
-
],
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Move to next question
|
|
137
|
-
session.currentQuestionIndex++
|
|
138
|
-
|
|
139
|
-
// Check if complete
|
|
140
|
-
if (session.currentQuestionIndex >= session.feature.questions.length) {
|
|
141
|
-
session.isComplete = true
|
|
142
|
-
return this.generateFeatureImplementation(session)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Render next question
|
|
146
|
-
const nextQ = session.feature.questions[session.currentQuestionIndex]
|
|
147
|
-
const questionNum = session.currentQuestionIndex + 1
|
|
148
|
-
const progressBar = this.renderProgressBar(questionNum, session.feature.questions.length)
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
content: [
|
|
152
|
-
{
|
|
153
|
-
type: 'text',
|
|
154
|
-
text: `ā Recorded: ${this.formatAnswer(answer)}\n\n${this.renderQuestion(nextQ, session, questionNum)}\n\n---\n\n**Progress**: ${progressBar} ${questionNum}/${session.feature.questions.length}`,
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Handle follow-up question answers
|
|
162
|
-
*/
|
|
163
|
-
async answerFollowUp(
|
|
164
|
-
sessionId: string,
|
|
165
|
-
answer: string,
|
|
166
|
-
): Promise<{
|
|
167
|
-
content: Array<{ type: string; text: string }>
|
|
168
|
-
}> {
|
|
169
|
-
const session = this.sessions[sessionId]
|
|
170
|
-
if (!session) {
|
|
171
|
-
return {
|
|
172
|
-
content: [
|
|
173
|
-
{
|
|
174
|
-
type: 'text',
|
|
175
|
-
text: `ā Session not found: ${sessionId}`,
|
|
176
|
-
},
|
|
177
|
-
],
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const currentQ = session.feature.questions[session.currentQuestionIndex]
|
|
182
|
-
const followUpKey = `${currentQ.id}_followup`
|
|
183
|
-
|
|
184
|
-
// Store follow-up answer
|
|
185
|
-
session.followUpAnswers[followUpKey] = answer
|
|
186
|
-
session.updatedAt = new Date()
|
|
187
|
-
|
|
188
|
-
// Move to next question
|
|
189
|
-
session.currentQuestionIndex++
|
|
190
|
-
|
|
191
|
-
// Check if complete
|
|
192
|
-
if (session.currentQuestionIndex >= session.feature.questions.length) {
|
|
193
|
-
session.isComplete = true
|
|
194
|
-
return this.generateFeatureImplementation(session)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Render next question
|
|
198
|
-
const nextQ = session.feature.questions[session.currentQuestionIndex]
|
|
199
|
-
const questionNum = session.currentQuestionIndex + 1
|
|
200
|
-
const progressBar = this.renderProgressBar(questionNum, session.feature.questions.length)
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
content: [
|
|
204
|
-
{
|
|
205
|
-
type: 'text',
|
|
206
|
-
text: `ā Recorded: ${answer}\n\n${this.renderQuestion(nextQ, session, questionNum)}\n\n---\n\n**Progress**: ${progressBar} ${questionNum}/${session.feature.questions.length}`,
|
|
207
|
-
},
|
|
208
|
-
],
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Generate the complete feature implementation
|
|
214
|
-
*/
|
|
215
|
-
private async generateFeatureImplementation(session: WizardSession): Promise<{
|
|
216
|
-
content: Array<{ type: string; text: string }>
|
|
217
|
-
}> {
|
|
218
|
-
const generator = new FeatureGenerator(
|
|
219
|
-
session.feature,
|
|
220
|
-
session.answers,
|
|
221
|
-
session.followUpAnswers,
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
const implementation = generator.generate()
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
content: [
|
|
228
|
-
{
|
|
229
|
-
type: 'text',
|
|
230
|
-
text: `ā
**${session.feature.name} Implementation Complete!**
|
|
231
|
-
|
|
232
|
-
---
|
|
233
|
-
|
|
234
|
-
## š Update \`opensaas.config.ts\`
|
|
235
|
-
|
|
236
|
-
\`\`\`typescript
|
|
237
|
-
${implementation.configUpdates}
|
|
238
|
-
\`\`\`
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
## š Create these files:
|
|
243
|
-
|
|
244
|
-
${implementation.files.map((file) => `### ${file.path}\n*${file.description}*\n\n\`\`\`${file.language}\n${file.content}\n\`\`\``).join('\n\n')}
|
|
245
|
-
|
|
246
|
-
---
|
|
247
|
-
|
|
248
|
-
${
|
|
249
|
-
implementation.envVars && Object.keys(implementation.envVars).length > 0
|
|
250
|
-
? `## š Environment Variables\n\nAdd these to your \`.env\` file:\n\n\`\`\`bash\n${Object.entries(
|
|
251
|
-
implementation.envVars,
|
|
252
|
-
)
|
|
253
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
254
|
-
.join('\n')}\n\`\`\`\n\n---\n\n`
|
|
255
|
-
: ''
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
## š Next Steps
|
|
259
|
-
|
|
260
|
-
${implementation.nextSteps.map((step, i) => `${i + 1}. ${step}`).join('\n')}
|
|
261
|
-
|
|
262
|
-
---
|
|
263
|
-
|
|
264
|
-
## š Feature Documentation
|
|
265
|
-
|
|
266
|
-
Add this to your project's \`DEVELOPMENT.md\`:
|
|
267
|
-
|
|
268
|
-
\`\`\`markdown
|
|
269
|
-
${implementation.devGuideSection}
|
|
270
|
-
\`\`\`
|
|
271
|
-
|
|
272
|
-
---
|
|
273
|
-
|
|
274
|
-
š **Your ${session.feature.name} feature is ready to use!**
|
|
275
|
-
|
|
276
|
-
<details>
|
|
277
|
-
<summary>š” **Troubleshooting**</summary>
|
|
278
|
-
|
|
279
|
-
If you encounter issues:
|
|
280
|
-
1. Ensure all dependencies are installed: \`pnpm install\`
|
|
281
|
-
2. Check that environment variables are set correctly
|
|
282
|
-
3. Run \`pnpm generate\` to update Prisma schema
|
|
283
|
-
4. Run \`pnpm db:push\` to update database
|
|
284
|
-
|
|
285
|
-
For more help, see the docs at https://stack.opensaas.au/
|
|
286
|
-
|
|
287
|
-
</details>`,
|
|
288
|
-
},
|
|
289
|
-
],
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Create a new wizard session
|
|
295
|
-
*/
|
|
296
|
-
private createSession(sessionId: string, feature: Feature): WizardSession {
|
|
297
|
-
return {
|
|
298
|
-
id: sessionId,
|
|
299
|
-
featureId: feature.id,
|
|
300
|
-
feature,
|
|
301
|
-
currentQuestionIndex: 0,
|
|
302
|
-
answers: {},
|
|
303
|
-
followUpAnswers: {},
|
|
304
|
-
isComplete: false,
|
|
305
|
-
createdAt: new Date(),
|
|
306
|
-
updatedAt: new Date(),
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Generate a unique session ID
|
|
312
|
-
*/
|
|
313
|
-
private generateSessionId(): string {
|
|
314
|
-
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Render a question for the user
|
|
319
|
-
*/
|
|
320
|
-
private renderQuestion(
|
|
321
|
-
question: FeatureQuestion,
|
|
322
|
-
session: WizardSession,
|
|
323
|
-
questionNum: number,
|
|
324
|
-
): string {
|
|
325
|
-
let rendered = `**Question ${questionNum}**: ${question.text}\n\n`
|
|
326
|
-
|
|
327
|
-
if (question.type === 'select' || question.type === 'multiselect') {
|
|
328
|
-
rendered += question.options!.map((opt) => `- ${opt}`).join('\n')
|
|
329
|
-
if (question.type === 'multiselect') {
|
|
330
|
-
rendered += '\n\n*You can select multiple options (comma-separated)*'
|
|
331
|
-
}
|
|
332
|
-
} else if (question.type === 'boolean') {
|
|
333
|
-
rendered += '*Answer: yes or no*'
|
|
334
|
-
} else if (question.type === 'textarea') {
|
|
335
|
-
rendered += '*Provide a detailed response*'
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (question.defaultValue !== undefined) {
|
|
339
|
-
rendered += `\n\n*Default: ${this.formatAnswer(question.defaultValue)}*`
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return rendered
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Validate an answer against question requirements
|
|
347
|
-
*/
|
|
348
|
-
private validateAnswer(
|
|
349
|
-
answer: string | boolean | string[],
|
|
350
|
-
question: FeatureQuestion,
|
|
351
|
-
): { valid: boolean; message?: string } {
|
|
352
|
-
if (question.required && !answer) {
|
|
353
|
-
return { valid: false, message: 'This question is required' }
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
if (question.type === 'boolean' && typeof answer !== 'boolean') {
|
|
357
|
-
return {
|
|
358
|
-
valid: false,
|
|
359
|
-
message: 'Please answer with yes/no or true/false',
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
question.type === 'select' &&
|
|
365
|
-
question.options &&
|
|
366
|
-
!question.options.includes(answer as string)
|
|
367
|
-
) {
|
|
368
|
-
return {
|
|
369
|
-
valid: false,
|
|
370
|
-
message: `Please select one of: ${question.options.join(', ')}`,
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (question.type === 'multiselect' && question.options) {
|
|
375
|
-
const answers = Array.isArray(answer) ? answer : [answer]
|
|
376
|
-
const invalid = answers.filter((a) => typeof a === 'string' && !question.options!.includes(a))
|
|
377
|
-
if (invalid.length > 0) {
|
|
378
|
-
return {
|
|
379
|
-
valid: false,
|
|
380
|
-
message: `Invalid options: ${invalid.join(', ')}. Valid options: ${question.options.join(', ')}`,
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return { valid: true }
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Render a progress bar
|
|
390
|
-
*/
|
|
391
|
-
private renderProgressBar(current: number, total: number): string {
|
|
392
|
-
const filled = Math.round((current / total) * 10)
|
|
393
|
-
const empty = 10 - filled
|
|
394
|
-
return 'ā'.repeat(filled) + 'ā'.repeat(empty)
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Format an answer for display
|
|
399
|
-
*/
|
|
400
|
-
private formatAnswer(answer: string | boolean | string[]): string {
|
|
401
|
-
if (typeof answer === 'boolean') {
|
|
402
|
-
return answer ? 'Yes' : 'No'
|
|
403
|
-
}
|
|
404
|
-
if (Array.isArray(answer)) {
|
|
405
|
-
return answer.join(', ')
|
|
406
|
-
}
|
|
407
|
-
return answer
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Get session by ID (for debugging/testing)
|
|
412
|
-
*/
|
|
413
|
-
getSession(sessionId: string): WizardSession | undefined {
|
|
414
|
-
return this.sessions[sessionId]
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Clear completed sessions (cleanup)
|
|
419
|
-
*/
|
|
420
|
-
clearCompletedSessions(): void {
|
|
421
|
-
Object.keys(this.sessions).forEach((id) => {
|
|
422
|
-
if (this.sessions[id].isComplete) {
|
|
423
|
-
delete this.sessions[id]
|
|
424
|
-
}
|
|
425
|
-
})
|
|
426
|
-
}
|
|
427
|
-
}
|