@opensaas/stack-cli 0.3.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +193 -0
  3. package/dist/commands/generate.d.ts.map +1 -1
  4. package/dist/commands/generate.js +4 -13
  5. package/dist/commands/generate.js.map +1 -1
  6. package/dist/commands/migrate.d.ts +9 -0
  7. package/dist/commands/migrate.d.ts.map +1 -0
  8. package/dist/commands/migrate.js +473 -0
  9. package/dist/commands/migrate.js.map +1 -0
  10. package/dist/generator/context.d.ts.map +1 -1
  11. package/dist/generator/context.js +20 -5
  12. package/dist/generator/context.js.map +1 -1
  13. package/dist/generator/index.d.ts +1 -1
  14. package/dist/generator/index.d.ts.map +1 -1
  15. package/dist/generator/index.js +1 -1
  16. package/dist/generator/index.js.map +1 -1
  17. package/dist/generator/lists.d.ts.map +1 -1
  18. package/dist/generator/lists.js +33 -1
  19. package/dist/generator/lists.js.map +1 -1
  20. package/dist/generator/prisma-extensions.d.ts +11 -0
  21. package/dist/generator/prisma-extensions.d.ts.map +1 -0
  22. package/dist/generator/prisma-extensions.js +134 -0
  23. package/dist/generator/prisma-extensions.js.map +1 -0
  24. package/dist/generator/prisma.d.ts.map +1 -1
  25. package/dist/generator/prisma.js +4 -0
  26. package/dist/generator/prisma.js.map +1 -1
  27. package/dist/generator/types.d.ts.map +1 -1
  28. package/dist/generator/types.js +151 -17
  29. package/dist/generator/types.js.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp/lib/documentation-provider.d.ts +23 -0
  33. package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
  34. package/dist/mcp/lib/documentation-provider.js +471 -0
  35. package/dist/mcp/lib/documentation-provider.js.map +1 -1
  36. package/dist/mcp/lib/wizards/migration-wizard.d.ts +80 -0
  37. package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -0
  38. package/dist/mcp/lib/wizards/migration-wizard.js +499 -0
  39. package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -0
  40. package/dist/mcp/server/index.d.ts.map +1 -1
  41. package/dist/mcp/server/index.js +103 -0
  42. package/dist/mcp/server/index.js.map +1 -1
  43. package/dist/mcp/server/stack-mcp-server.d.ts +85 -0
  44. package/dist/mcp/server/stack-mcp-server.d.ts.map +1 -1
  45. package/dist/mcp/server/stack-mcp-server.js +219 -0
  46. package/dist/mcp/server/stack-mcp-server.js.map +1 -1
  47. package/dist/migration/generators/migration-generator.d.ts +60 -0
  48. package/dist/migration/generators/migration-generator.d.ts.map +1 -0
  49. package/dist/migration/generators/migration-generator.js +510 -0
  50. package/dist/migration/generators/migration-generator.js.map +1 -0
  51. package/dist/migration/introspectors/index.d.ts +12 -0
  52. package/dist/migration/introspectors/index.d.ts.map +1 -0
  53. package/dist/migration/introspectors/index.js +10 -0
  54. package/dist/migration/introspectors/index.js.map +1 -0
  55. package/dist/migration/introspectors/keystone-introspector.d.ts +59 -0
  56. package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -0
  57. package/dist/migration/introspectors/keystone-introspector.js +229 -0
  58. package/dist/migration/introspectors/keystone-introspector.js.map +1 -0
  59. package/dist/migration/introspectors/nextjs-introspector.d.ts +59 -0
  60. package/dist/migration/introspectors/nextjs-introspector.d.ts.map +1 -0
  61. package/dist/migration/introspectors/nextjs-introspector.js +159 -0
  62. package/dist/migration/introspectors/nextjs-introspector.js.map +1 -0
  63. package/dist/migration/introspectors/prisma-introspector.d.ts +45 -0
  64. package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -0
  65. package/dist/migration/introspectors/prisma-introspector.js +190 -0
  66. package/dist/migration/introspectors/prisma-introspector.js.map +1 -0
  67. package/dist/migration/types.d.ts +86 -0
  68. package/dist/migration/types.d.ts.map +1 -0
  69. package/dist/migration/types.js +5 -0
  70. package/dist/migration/types.js.map +1 -0
  71. package/package.json +12 -9
  72. package/src/commands/__snapshots__/generate.test.ts.snap +92 -21
  73. package/src/commands/generate.ts +8 -19
  74. package/src/commands/migrate.ts +534 -0
  75. package/src/generator/__snapshots__/context.test.ts.snap +60 -15
  76. package/src/generator/__snapshots__/types.test.ts.snap +689 -95
  77. package/src/generator/context.test.ts +3 -1
  78. package/src/generator/context.ts +20 -5
  79. package/src/generator/index.ts +1 -1
  80. package/src/generator/lists.ts +39 -1
  81. package/src/generator/prisma-extensions.ts +159 -0
  82. package/src/generator/prisma.ts +5 -0
  83. package/src/generator/types.ts +204 -17
  84. package/src/index.ts +4 -0
  85. package/src/mcp/lib/documentation-provider.ts +507 -0
  86. package/src/mcp/lib/wizards/migration-wizard.ts +584 -0
  87. package/src/mcp/server/index.ts +121 -0
  88. package/src/mcp/server/stack-mcp-server.ts +243 -0
  89. package/src/migration/generators/migration-generator.ts +675 -0
  90. package/src/migration/introspectors/index.ts +12 -0
  91. package/src/migration/introspectors/keystone-introspector.ts +296 -0
  92. package/src/migration/introspectors/nextjs-introspector.ts +209 -0
  93. package/src/migration/introspectors/prisma-introspector.ts +233 -0
  94. package/src/migration/types.ts +92 -0
  95. package/tests/introspectors/keystone-introspector.test.ts +255 -0
  96. package/tests/introspectors/nextjs-introspector.test.ts +302 -0
  97. package/tests/introspectors/prisma-introspector.test.ts +268 -0
  98. package/tests/migration-generator.test.ts +592 -0
  99. package/tests/migration-wizard.test.ts +442 -0
  100. package/tsconfig.tsbuildinfo +1 -1
  101. package/dist/generator/type-patcher.d.ts +0 -13
  102. package/dist/generator/type-patcher.d.ts.map +0 -1
  103. package/dist/generator/type-patcher.js +0 -68
  104. package/dist/generator/type-patcher.js.map +0 -1
  105. package/src/generator/type-patcher.ts +0 -93
@@ -2,6 +2,9 @@
2
2
  * Documentation provider - Fetches documentation from the hosted docs site
3
3
  */
4
4
 
5
+ import fs from 'fs-extra'
6
+ import path from 'path'
7
+ import { glob } from 'glob'
5
8
  import type { DocumentationLookup } from './types.js'
6
9
 
7
10
  interface SearchResult {
@@ -20,6 +23,18 @@ interface SearchResponse {
20
23
  count: number
21
24
  }
22
25
 
26
+ interface LocalDocResult {
27
+ content: string
28
+ files: string[]
29
+ }
30
+
31
+ interface ExampleConfig {
32
+ description: string
33
+ code: string
34
+ notes?: string
35
+ sourcePath: string
36
+ }
37
+
23
38
  export class OpenSaasDocumentationProvider {
24
39
  private readonly DOCS_API = 'https://stack.opensaas.au/api/search'
25
40
  private cache = new Map<string, { data: DocumentationLookup; timestamp: number }>()
@@ -182,6 +197,498 @@ For ${query}, you can also check:
182
197
  }
183
198
  }
184
199
 
200
+ /**
201
+ * Search local CLAUDE.md files in the monorepo
202
+ */
203
+ async searchLocalDocs(query: string): Promise<LocalDocResult> {
204
+ const cwd = process.cwd()
205
+
206
+ // Find CLAUDE.md files
207
+ const claudeFiles = await glob('**/CLAUDE.md', {
208
+ cwd,
209
+ ignore: ['node_modules/**', '.next/**', 'dist/**', 'build/**'],
210
+ absolute: true,
211
+ })
212
+
213
+ const results: Array<{ file: string; content: string; score: number }> = []
214
+ const queryLower = query.toLowerCase()
215
+ const queryTerms = queryLower.split(/\s+/)
216
+
217
+ for (const file of claudeFiles) {
218
+ try {
219
+ const content = await fs.readFile(file, 'utf-8')
220
+ const contentLower = content.toLowerCase()
221
+
222
+ // Simple relevance scoring
223
+ let score = 0
224
+ for (const term of queryTerms) {
225
+ if (contentLower.includes(term)) {
226
+ score += (contentLower.match(new RegExp(term, 'g')) || []).length
227
+ }
228
+ }
229
+
230
+ if (score > 0) {
231
+ // Extract relevant section
232
+ const lines = content.split('\n')
233
+ const relevantLines: string[] = []
234
+ let inRelevantSection = false
235
+
236
+ for (const line of lines) {
237
+ const lineLower = line.toLowerCase()
238
+
239
+ // Check if line starts a section
240
+ if (line.startsWith('#')) {
241
+ // Check if this section title is relevant
242
+ const titleRelevant = queryTerms.some((term) => lineLower.includes(term))
243
+ if (titleRelevant) {
244
+ inRelevantSection = true
245
+ relevantLines.push(line)
246
+ } else if (inRelevantSection) {
247
+ // We've left the relevant section
248
+ break
249
+ }
250
+ } else if (inRelevantSection) {
251
+ relevantLines.push(line)
252
+ } else if (queryTerms.some((term) => lineLower.includes(term))) {
253
+ // Found relevant content outside a section
254
+ relevantLines.push(line)
255
+ }
256
+ }
257
+
258
+ if (relevantLines.length > 0) {
259
+ results.push({
260
+ file: path.relative(cwd, file),
261
+ content: relevantLines.slice(0, 50).join('\n'),
262
+ score,
263
+ })
264
+ }
265
+ }
266
+ } catch {
267
+ // Skip files that can't be read
268
+ }
269
+ }
270
+
271
+ // Sort by score
272
+ results.sort((a, b) => b.score - a.score)
273
+
274
+ if (results.length === 0) {
275
+ return { content: '', files: [] }
276
+ }
277
+
278
+ const content = results
279
+ .slice(0, 3)
280
+ .map((r) => `### From \`${r.file}\`\n\n${r.content}`)
281
+ .join('\n\n---\n\n')
282
+
283
+ return {
284
+ content,
285
+ files: results.map((r) => r.file),
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Get example config code for a feature
291
+ */
292
+ async getExampleConfig(feature: string): Promise<ExampleConfig | null> {
293
+ const examples: Record<string, ExampleConfig> = {
294
+ 'blog-with-auth': {
295
+ description: 'A blog application with user authentication and post management',
296
+ code: `import { config, list } from '@opensaas/stack-core'
297
+ import { text, relationship, timestamp, select } from '@opensaas/stack-core/fields'
298
+ import { withAuth, authConfig } from '@opensaas/stack-auth'
299
+
300
+ const isAuthor = ({ session, item }) =>
301
+ session?.userId === item?.authorId
302
+
303
+ export default withAuth(
304
+ config({
305
+ db: {
306
+ provider: 'sqlite',
307
+ url: 'file:./dev.db',
308
+ prismaClientConstructor: (PrismaClient) => {
309
+ // ... adapter setup
310
+ },
311
+ },
312
+ lists: {
313
+ Post: list({
314
+ fields: {
315
+ title: text({ validation: { isRequired: true } }),
316
+ content: text({ ui: { displayMode: 'textarea' } }),
317
+ status: select({
318
+ options: [
319
+ { label: 'Draft', value: 'draft' },
320
+ { label: 'Published', value: 'published' },
321
+ ],
322
+ defaultValue: 'draft',
323
+ }),
324
+ author: relationship({ ref: 'User.posts' }),
325
+ publishedAt: timestamp(),
326
+ },
327
+ access: {
328
+ operation: {
329
+ query: () => true,
330
+ create: ({ session }) => !!session,
331
+ update: isAuthor,
332
+ delete: isAuthor,
333
+ },
334
+ },
335
+ }),
336
+ },
337
+ }),
338
+ authConfig({ emailAndPassword: { enabled: true } })
339
+ )`,
340
+ notes:
341
+ 'This example uses the auth plugin for user management. The `isAuthor` helper restricts updates to the post creator.',
342
+ sourcePath: 'examples/auth-demo/opensaas.config.ts',
343
+ },
344
+
345
+ 'access-control': {
346
+ description: 'Common access control patterns for different scenarios',
347
+ code: `// Public read, authenticated write
348
+ access: {
349
+ operation: {
350
+ query: () => true,
351
+ create: ({ session }) => !!session,
352
+ update: ({ session }) => !!session,
353
+ delete: ({ session }) => !!session,
354
+ },
355
+ }
356
+
357
+ // Only owners can access
358
+ const isOwner = ({ session, item }) =>
359
+ session?.userId === item?.userId
360
+
361
+ access: {
362
+ operation: {
363
+ query: isOwner,
364
+ update: isOwner,
365
+ delete: isOwner,
366
+ },
367
+ filter: {
368
+ query: ({ session }) => ({ userId: { equals: session?.userId } }),
369
+ },
370
+ }
371
+
372
+ // Role-based access
373
+ const isAdmin = ({ session }) => session?.role === 'admin'
374
+ const isOwnerOrAdmin = (args) => isOwner(args) || isAdmin(args)
375
+
376
+ access: {
377
+ operation: {
378
+ query: () => true,
379
+ create: ({ session }) => !!session,
380
+ update: isOwnerOrAdmin,
381
+ delete: isAdmin,
382
+ },
383
+ }`,
384
+ notes:
385
+ 'Access control functions receive session and item context. Use filter access to automatically scope queries.',
386
+ sourcePath: 'packages/core/CLAUDE.md',
387
+ },
388
+
389
+ relationships: {
390
+ description: 'How to define relationships between models',
391
+ code: `lists: {
392
+ User: list({
393
+ fields: {
394
+ name: text(),
395
+ email: text({ isIndexed: 'unique' }),
396
+ posts: relationship({ ref: 'Post.author', many: true }),
397
+ comments: relationship({ ref: 'Comment.author', many: true }),
398
+ },
399
+ }),
400
+
401
+ Post: list({
402
+ fields: {
403
+ title: text(),
404
+ content: text(),
405
+ author: relationship({ ref: 'User.posts' }),
406
+ comments: relationship({ ref: 'Comment.post', many: true }),
407
+ tags: relationship({ ref: 'Tag.posts', many: true }),
408
+ },
409
+ }),
410
+
411
+ Comment: list({
412
+ fields: {
413
+ content: text(),
414
+ author: relationship({ ref: 'User.comments' }),
415
+ post: relationship({ ref: 'Post.comments' }),
416
+ },
417
+ }),
418
+
419
+ Tag: list({
420
+ fields: {
421
+ name: text(),
422
+ posts: relationship({ ref: 'Post.tags', many: true }),
423
+ },
424
+ }),
425
+ }`,
426
+ notes:
427
+ 'Relationships use `ref: "ListName.fieldName"` format. Set `many: true` for one-to-many or many-to-many.',
428
+ sourcePath: 'examples/blog/opensaas.config.ts',
429
+ },
430
+
431
+ hooks: {
432
+ description: 'Data transformation and side effect hooks',
433
+ code: `Post: list({
434
+ fields: {
435
+ title: text(),
436
+ slug: text(),
437
+ status: select({
438
+ options: [
439
+ { label: 'Draft', value: 'draft' },
440
+ { label: 'Published', value: 'published' },
441
+ ],
442
+ }),
443
+ publishedAt: timestamp(),
444
+ },
445
+ hooks: {
446
+ resolveInput: async ({ resolvedData, operation }) => {
447
+ // Auto-generate slug from title
448
+ if (resolvedData.title && !resolvedData.slug) {
449
+ resolvedData.slug = resolvedData.title
450
+ .toLowerCase()
451
+ .replace(/[^a-z0-9]+/g, '-')
452
+ }
453
+
454
+ // Set publishedAt when status changes to published
455
+ if (operation === 'update' && resolvedData.status === 'published') {
456
+ resolvedData.publishedAt = new Date()
457
+ }
458
+
459
+ return resolvedData
460
+ },
461
+ afterOperation: async ({ operation, item }) => {
462
+ // Send notification after publish
463
+ if (operation === 'update' && item?.status === 'published') {
464
+ console.log(\`Post published: \${item.title}\`)
465
+ // await sendNotification(item)
466
+ }
467
+ },
468
+ },
469
+ })`,
470
+ notes:
471
+ 'resolveInput transforms data before save. afterOperation runs side effects. Use beforeOperation for validation.',
472
+ sourcePath: 'packages/core/CLAUDE.md',
473
+ },
474
+
475
+ 'custom-fields': {
476
+ description: 'Creating custom field types',
477
+ code: `// In your field definition file
478
+ import type { BaseFieldConfig } from '@opensaas/stack-core'
479
+ import { z } from 'zod'
480
+
481
+ export type SlugField = BaseFieldConfig & {
482
+ type: 'slug'
483
+ sourceField?: string
484
+ }
485
+
486
+ export function slug(options?: Omit<SlugField, 'type'>): SlugField {
487
+ return {
488
+ type: 'slug',
489
+ ...options,
490
+
491
+ getZodSchema: (fieldName, operation) => {
492
+ if (operation === 'create') {
493
+ return z.string().regex(/^[a-z0-9-]+$/).optional()
494
+ }
495
+ return z.string().regex(/^[a-z0-9-]+$/).optional()
496
+ },
497
+
498
+ getPrismaType: (fieldName) => ({
499
+ type: 'String',
500
+ modifiers: '?',
501
+ attributes: ['@unique'],
502
+ }),
503
+
504
+ getTypeScriptType: () => ({
505
+ type: 'string',
506
+ optional: true,
507
+ }),
508
+ }
509
+ }
510
+
511
+ // Usage in config
512
+ fields: {
513
+ title: text({ validation: { isRequired: true } }),
514
+ urlSlug: slug({ sourceField: 'title' }),
515
+ }`,
516
+ notes:
517
+ 'Custom fields implement getZodSchema, getPrismaType, and getTypeScriptType methods. See packages/tiptap for a full example.',
518
+ sourcePath: 'examples/custom-field',
519
+ },
520
+ }
521
+
522
+ const normalizedFeature = feature.toLowerCase().replace(/\s+/g, '-')
523
+ return examples[normalizedFeature] || null
524
+ }
525
+
526
+ /**
527
+ * Get migration-specific documentation for a project type
528
+ */
529
+ async findMigrationGuide(projectType: string): Promise<string> {
530
+ const guides: Record<string, string> = {
531
+ prisma: `# Prisma to OpenSaaS Migration
532
+
533
+ ## Type Mapping
534
+
535
+ | Prisma Type | OpenSaaS Field |
536
+ |-------------|----------------|
537
+ | String | text() |
538
+ | Int | integer() |
539
+ | Boolean | checkbox() |
540
+ | DateTime | timestamp() |
541
+ | Json | text() with custom handling |
542
+ | Enum | select() |
543
+ | @relation | relationship() |
544
+
545
+ ## Key Changes
546
+
547
+ 1. **Models → Lists**: Prisma models become OpenSaaS lists
548
+ 2. **Access Control**: Add operation-level and field-level access
549
+ 3. **Database URL**: Now provided via prismaClientConstructor
550
+ 4. **Relationships**: Use \`ref: 'ListName.fieldName'\` format
551
+
552
+ ## Common Patterns
553
+
554
+ ### Before (Prisma)
555
+ \`\`\`prisma
556
+ model Post {
557
+ id String @id @default(cuid())
558
+ title String
559
+ author User @relation(fields: [authorId], references: [id])
560
+ authorId String
561
+ }
562
+ \`\`\`
563
+
564
+ ### After (OpenSaaS)
565
+ \`\`\`typescript
566
+ Post: list({
567
+ fields: {
568
+ title: text({ validation: { isRequired: true } }),
569
+ author: relationship({ ref: 'User.posts' }),
570
+ },
571
+ access: {
572
+ operation: {
573
+ query: () => true,
574
+ create: ({ session }) => !!session,
575
+ },
576
+ },
577
+ })
578
+ \`\`\`
579
+ `,
580
+
581
+ keystone: `# KeystoneJS to OpenSaaS Migration
582
+
583
+ ## Good News!
584
+
585
+ KeystoneJS and OpenSaaS Stack are very similar. Migration is mostly:
586
+ 1. Update import paths
587
+ 2. Minor syntax adjustments
588
+ 3. Add prismaClientConstructor for Prisma 7
589
+
590
+ ## Key Changes
591
+
592
+ ### Imports
593
+ \`\`\`typescript
594
+ // Before (KeystoneJS)
595
+ import { config, list } from '@keystone-6/core'
596
+ import { text, relationship } from '@keystone-6/core/fields'
597
+
598
+ // After (OpenSaaS)
599
+ import { config, list } from '@opensaas/stack-core'
600
+ import { text, relationship } from '@opensaas/stack-core/fields'
601
+ \`\`\`
602
+
603
+ ### Database Config
604
+ \`\`\`typescript
605
+ // Before
606
+ db: {
607
+ provider: 'sqlite',
608
+ url: 'file:./dev.db',
609
+ }
610
+
611
+ // After (Prisma 7 requires adapters)
612
+ db: {
613
+ provider: 'sqlite',
614
+ url: 'file:./dev.db',
615
+ prismaClientConstructor: (PrismaClient) => {
616
+ const db = new Database('./dev.db')
617
+ const adapter = new PrismaBetterSQLite3(db)
618
+ return new PrismaClient({ adapter })
619
+ },
620
+ }
621
+ \`\`\`
622
+
623
+ ## Field Types (1:1 mapping)
624
+
625
+ Most field types work identically:
626
+ - text() → text()
627
+ - integer() → integer()
628
+ - checkbox() → checkbox()
629
+ - timestamp() → timestamp()
630
+ - select() → select()
631
+ - relationship() → relationship()
632
+
633
+ ## Access Control (same patterns)
634
+
635
+ Access control functions work the same way. Just copy them over!
636
+ `,
637
+
638
+ nextjs: `# Next.js to OpenSaaS Migration
639
+
640
+ ## Overview
641
+
642
+ If you have a Next.js project without Prisma, you'll need to:
643
+ 1. Define your data models in opensaas.config.ts
644
+ 2. Run the generator to create Prisma schema
645
+ 3. Set up your database
646
+
647
+ ## Steps
648
+
649
+ 1. **Install Dependencies**
650
+ \`\`\`bash
651
+ pnpm add @opensaas/stack-core @prisma/client prisma
652
+ pnpm add -D @prisma/adapter-better-sqlite3 better-sqlite3
653
+ \`\`\`
654
+
655
+ 2. **Create opensaas.config.ts**
656
+ \`\`\`typescript
657
+ import { config, list } from '@opensaas/stack-core'
658
+ import { text, integer } from '@opensaas/stack-core/fields'
659
+
660
+ export default config({
661
+ db: {
662
+ provider: 'sqlite',
663
+ url: 'file:./dev.db',
664
+ prismaClientConstructor: (PrismaClient) => {
665
+ // See examples for adapter setup
666
+ },
667
+ },
668
+ lists: {
669
+ // Define your models here
670
+ },
671
+ })
672
+ \`\`\`
673
+
674
+ 3. **Generate and Push**
675
+ \`\`\`bash
676
+ pnpm opensaas generate
677
+ npx prisma generate
678
+ npx prisma db push
679
+ \`\`\`
680
+
681
+ ## If You're Using an Auth Library
682
+
683
+ - **next-auth**: Consider migrating to Better-auth via @opensaas/stack-auth
684
+ - **clerk**: Can continue using Clerk alongside OpenSaaS
685
+ - **better-auth**: Use @opensaas/stack-auth plugin
686
+ `,
687
+ }
688
+
689
+ return guides[projectType.toLowerCase()] || guides.prisma
690
+ }
691
+
185
692
  /**
186
693
  * Clear expired cache entries
187
694
  */