@opensaas/stack-cli 0.1.7 → 0.4.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 (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +348 -0
  3. package/CLAUDE.md +60 -12
  4. package/dist/commands/generate.d.ts.map +1 -1
  5. package/dist/commands/generate.js +13 -13
  6. package/dist/commands/generate.js.map +1 -1
  7. package/dist/commands/mcp.d.ts +6 -0
  8. package/dist/commands/mcp.d.ts.map +1 -0
  9. package/dist/commands/mcp.js +116 -0
  10. package/dist/commands/mcp.js.map +1 -0
  11. package/dist/generator/context.d.ts.map +1 -1
  12. package/dist/generator/context.js +40 -7
  13. package/dist/generator/context.js.map +1 -1
  14. package/dist/generator/index.d.ts +4 -1
  15. package/dist/generator/index.d.ts.map +1 -1
  16. package/dist/generator/index.js +4 -1
  17. package/dist/generator/index.js.map +1 -1
  18. package/dist/generator/lists.d.ts +31 -0
  19. package/dist/generator/lists.d.ts.map +1 -0
  20. package/dist/generator/lists.js +123 -0
  21. package/dist/generator/lists.js.map +1 -0
  22. package/dist/generator/plugin-types.d.ts +10 -0
  23. package/dist/generator/plugin-types.d.ts.map +1 -0
  24. package/dist/generator/plugin-types.js +122 -0
  25. package/dist/generator/plugin-types.js.map +1 -0
  26. package/dist/generator/prisma-config.d.ts +17 -0
  27. package/dist/generator/prisma-config.d.ts.map +1 -0
  28. package/dist/generator/prisma-config.js +40 -0
  29. package/dist/generator/prisma-config.js.map +1 -0
  30. package/dist/generator/prisma-extensions.d.ts +11 -0
  31. package/dist/generator/prisma-extensions.d.ts.map +1 -0
  32. package/dist/generator/prisma-extensions.js +134 -0
  33. package/dist/generator/prisma-extensions.js.map +1 -0
  34. package/dist/generator/prisma.d.ts.map +1 -1
  35. package/dist/generator/prisma.js +5 -2
  36. package/dist/generator/prisma.js.map +1 -1
  37. package/dist/generator/types.d.ts.map +1 -1
  38. package/dist/generator/types.js +201 -17
  39. package/dist/generator/types.js.map +1 -1
  40. package/dist/index.js +3 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/mcp/lib/documentation-provider.d.ts +43 -0
  43. package/dist/mcp/lib/documentation-provider.d.ts.map +1 -0
  44. package/dist/mcp/lib/documentation-provider.js +163 -0
  45. package/dist/mcp/lib/documentation-provider.js.map +1 -0
  46. package/dist/mcp/lib/features/catalog.d.ts +26 -0
  47. package/dist/mcp/lib/features/catalog.d.ts.map +1 -0
  48. package/dist/mcp/lib/features/catalog.js +291 -0
  49. package/dist/mcp/lib/features/catalog.js.map +1 -0
  50. package/dist/mcp/lib/generators/feature-generator.d.ts +35 -0
  51. package/dist/mcp/lib/generators/feature-generator.d.ts.map +1 -0
  52. package/dist/mcp/lib/generators/feature-generator.js +546 -0
  53. package/dist/mcp/lib/generators/feature-generator.js.map +1 -0
  54. package/dist/mcp/lib/types.d.ts +80 -0
  55. package/dist/mcp/lib/types.d.ts.map +1 -0
  56. package/dist/mcp/lib/types.js +5 -0
  57. package/dist/mcp/lib/types.js.map +1 -0
  58. package/dist/mcp/lib/wizards/wizard-engine.d.ts +71 -0
  59. package/dist/mcp/lib/wizards/wizard-engine.d.ts.map +1 -0
  60. package/dist/mcp/lib/wizards/wizard-engine.js +356 -0
  61. package/dist/mcp/lib/wizards/wizard-engine.js.map +1 -0
  62. package/dist/mcp/server/index.d.ts +8 -0
  63. package/dist/mcp/server/index.d.ts.map +1 -0
  64. package/dist/mcp/server/index.js +202 -0
  65. package/dist/mcp/server/index.js.map +1 -0
  66. package/dist/mcp/server/stack-mcp-server.d.ts +92 -0
  67. package/dist/mcp/server/stack-mcp-server.d.ts.map +1 -0
  68. package/dist/mcp/server/stack-mcp-server.js +265 -0
  69. package/dist/mcp/server/stack-mcp-server.js.map +1 -0
  70. package/package.json +10 -8
  71. package/src/commands/__snapshots__/generate.test.ts.snap +145 -38
  72. package/src/commands/dev.test.ts +0 -1
  73. package/src/commands/generate.test.ts +18 -8
  74. package/src/commands/generate.ts +20 -19
  75. package/src/commands/mcp.ts +135 -0
  76. package/src/generator/__snapshots__/context.test.ts.snap +63 -18
  77. package/src/generator/__snapshots__/prisma.test.ts.snap +8 -16
  78. package/src/generator/__snapshots__/types.test.ts.snap +1267 -95
  79. package/src/generator/context.test.ts +15 -8
  80. package/src/generator/context.ts +40 -7
  81. package/src/generator/index.ts +4 -1
  82. package/src/generator/lists.test.ts +335 -0
  83. package/src/generator/lists.ts +140 -0
  84. package/src/generator/plugin-types.ts +147 -0
  85. package/src/generator/prisma-config.ts +46 -0
  86. package/src/generator/prisma-extensions.ts +159 -0
  87. package/src/generator/prisma.test.ts +0 -10
  88. package/src/generator/prisma.ts +6 -2
  89. package/src/generator/types.test.ts +0 -12
  90. package/src/generator/types.ts +257 -17
  91. package/src/index.ts +4 -0
  92. package/src/mcp/lib/documentation-provider.ts +203 -0
  93. package/src/mcp/lib/features/catalog.ts +301 -0
  94. package/src/mcp/lib/generators/feature-generator.ts +598 -0
  95. package/src/mcp/lib/types.ts +89 -0
  96. package/src/mcp/lib/wizards/wizard-engine.ts +427 -0
  97. package/src/mcp/server/index.ts +240 -0
  98. package/src/mcp/server/stack-mcp-server.ts +301 -0
  99. package/tsconfig.tsbuildinfo +1 -1
  100. package/dist/generator/type-patcher.d.ts +0 -13
  101. package/dist/generator/type-patcher.d.ts.map +0 -1
  102. package/dist/generator/type-patcher.js +0 -68
  103. package/dist/generator/type-patcher.js.map +0 -1
  104. package/src/generator/type-patcher.ts +0 -93
@@ -41,24 +41,96 @@ function isFieldOptional(field: FieldConfig): boolean {
41
41
  }
42
42
 
43
43
  /**
44
- * Generate TypeScript interface for a model
44
+ * Generate virtual fields type - only contains virtual fields
45
+ * This is intersected with Prisma's GetPayload to add virtual fields to query results
45
46
  */
46
- function generateModelType(listName: string, fields: Record<string, FieldConfig>): string {
47
+ function generateVirtualFieldsType(listName: string, fields: Record<string, FieldConfig>): string {
47
48
  const lines: string[] = []
49
+ const virtualFields = Object.entries(fields).filter(([_, config]) => config.type === 'virtual')
48
50
 
49
- lines.push(`export type ${listName} = {`)
51
+ lines.push(`/**`)
52
+ lines.push(` * Virtual fields for ${listName} - computed fields not in database`)
53
+ lines.push(` * These are added to query results via resolveOutput hooks`)
54
+ lines.push(` */`)
55
+ lines.push(`export type ${listName}VirtualFields = {`)
56
+
57
+ for (const [fieldName, fieldConfig] of virtualFields) {
58
+ const tsType = mapFieldTypeToTypeScript(fieldConfig)
59
+ if (!tsType) continue
60
+
61
+ const optional = isFieldOptional(fieldConfig)
62
+ const nullability = optional ? ' | null' : ''
63
+ lines.push(` ${fieldName}: ${tsType}${nullability}`)
64
+ }
65
+
66
+ // If no virtual fields, make it an empty object
67
+ if (virtualFields.length === 0) {
68
+ lines.push(' // No virtual fields defined')
69
+ }
70
+
71
+ lines.push('}')
72
+
73
+ return lines.join('\n')
74
+ }
75
+
76
+ /**
77
+ * Generate transformed fields type - fields with resultExtension transformations
78
+ * This replaces Prisma's base types with transformed types (e.g., string -> HashedPassword)
79
+ */
80
+ function generateTransformedFieldsType(
81
+ listName: string,
82
+ fields: Record<string, FieldConfig>,
83
+ ): string {
84
+ const lines: string[] = []
85
+ const transformedFields = Object.entries(fields).filter(([_, config]) => config.resultExtension)
86
+
87
+ lines.push(`/**`)
88
+ lines.push(` * Transformed fields for ${listName} - fields with resultExtension transformations`)
89
+ lines.push(` * These override Prisma's base types with transformed types via result extensions`)
90
+ lines.push(` */`)
91
+ lines.push(`export type ${listName}TransformedFields = {`)
92
+
93
+ for (const [fieldName, fieldConfig] of transformedFields) {
94
+ if (fieldConfig.resultExtension) {
95
+ const optional = isFieldOptional(fieldConfig)
96
+ const nullability = optional ? ' | undefined' : ''
97
+ lines.push(` ${fieldName}: ${fieldConfig.resultExtension.outputType}${nullability}`)
98
+ }
99
+ }
100
+
101
+ // If no transformed fields, make it an empty object
102
+ if (transformedFields.length === 0) {
103
+ lines.push(' // No transformed fields defined')
104
+ }
105
+
106
+ lines.push('}')
107
+
108
+ return lines.join('\n')
109
+ }
110
+
111
+ /**
112
+ * Generate TypeScript Output type for a model (includes virtual fields)
113
+ * This is kept for backwards compatibility but CustomDB uses Prisma's GetPayload + VirtualFields
114
+ */
115
+ function generateModelOutputType(listName: string, fields: Record<string, FieldConfig>): string {
116
+ const lines: string[] = []
117
+
118
+ lines.push(`export type ${listName}Output = {`)
50
119
  lines.push(' id: string')
51
120
 
52
121
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
122
+ // Skip virtual fields - they're in VirtualFields type
123
+ if (fieldConfig.type === 'virtual') continue
124
+
53
125
  if (fieldConfig.type === 'relationship') {
54
126
  const relField = fieldConfig as RelationshipField
55
127
  const [targetList] = relField.ref.split('.')
56
128
 
57
129
  if (relField.many) {
58
- lines.push(` ${fieldName}: ${targetList}[]`)
130
+ lines.push(` ${fieldName}?: ${targetList}Output[]`) // Optional since only present with include
59
131
  } else {
60
132
  lines.push(` ${fieldName}Id: string | null`)
61
- lines.push(` ${fieldName}: ${targetList} | null`)
133
+ lines.push(` ${fieldName}?: ${targetList}Output | null`) // Optional since only present with include
62
134
  }
63
135
  } else {
64
136
  const tsType = mapFieldTypeToTypeScript(fieldConfig)
@@ -72,11 +144,18 @@ function generateModelType(listName: string, fields: Record<string, FieldConfig>
72
144
 
73
145
  lines.push(' createdAt: Date')
74
146
  lines.push(' updatedAt: Date')
75
- lines.push('}')
147
+ lines.push('} & ' + listName + 'VirtualFields') // Include virtual fields
76
148
 
77
149
  return lines.join('\n')
78
150
  }
79
151
 
152
+ /**
153
+ * Generate convenience type alias (List = ListOutput)
154
+ */
155
+ function generateModelTypeAlias(listName: string): string {
156
+ return `export type ${listName} = ${listName}Output`
157
+ }
158
+
80
159
  /**
81
160
  * Generate CreateInput type
82
161
  */
@@ -86,6 +165,12 @@ function generateCreateInputType(listName: string, fields: Record<string, FieldC
86
165
  lines.push(`export type ${listName}CreateInput = {`)
87
166
 
88
167
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
168
+ // Skip virtual fields - they don't accept input in create operations
169
+ // Virtual fields with resolveInput hooks handle side effects but don't store data
170
+ if (fieldConfig.virtual) {
171
+ continue
172
+ }
173
+
89
174
  if (fieldConfig.type === 'relationship') {
90
175
  const relField = fieldConfig as RelationshipField
91
176
 
@@ -118,6 +203,12 @@ function generateUpdateInputType(listName: string, fields: Record<string, FieldC
118
203
  lines.push(`export type ${listName}UpdateInput = {`)
119
204
 
120
205
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
206
+ // Virtual fields with resolveInput hooks can accept input for side effects
207
+ // but we still skip them in the input type since they don't store data
208
+ if (fieldConfig.virtual) {
209
+ continue
210
+ }
211
+
121
212
  if (fieldConfig.type === 'relationship') {
122
213
  const relField = fieldConfig as RelationshipField
123
214
 
@@ -170,19 +261,153 @@ function generateWhereInputType(listName: string, fields: Record<string, FieldCo
170
261
  }
171
262
 
172
263
  /**
173
- * Generate Context type with all operations
264
+ * Generate hook types that reference Prisma input types
174
265
  */
175
- function generateContextType(): string {
266
+ function generateHookTypes(listName: string): string {
176
267
  const lines: string[] = []
177
268
 
178
- lines.push('export type Context<TSession extends OpensaasSession = OpensaasSession> = {')
179
- lines.push(' db: AccessControlledDB<PrismaClient>')
269
+ lines.push(`/**`)
270
+ lines.push(` * Hook types for ${listName} list`)
271
+ lines.push(` * Properly typed to use Prisma's generated input types`)
272
+ lines.push(` */`)
273
+ lines.push(`export type ${listName}Hooks = {`)
274
+ lines.push(` resolveInput?: (args:`)
275
+ lines.push(` | {`)
276
+ lines.push(` operation: 'create'`)
277
+ lines.push(` resolvedData: Prisma.${listName}CreateInput`)
278
+ lines.push(` item: undefined`)
279
+ lines.push(` context: import('@opensaas/stack-core').AccessContext`)
280
+ lines.push(` }`)
281
+ lines.push(` | {`)
282
+ lines.push(` operation: 'update'`)
283
+ lines.push(` resolvedData: Prisma.${listName}UpdateInput`)
284
+ lines.push(` item: ${listName}`)
285
+ lines.push(` context: import('@opensaas/stack-core').AccessContext`)
286
+ lines.push(` }`)
287
+ lines.push(` ) => Promise<Prisma.${listName}CreateInput | Prisma.${listName}UpdateInput>`)
288
+ lines.push(` validateInput?: (args: {`)
289
+ lines.push(` operation: 'create' | 'update'`)
290
+ lines.push(` resolvedData: Prisma.${listName}CreateInput | Prisma.${listName}UpdateInput`)
291
+ lines.push(` item?: ${listName}`)
292
+ lines.push(` context: import('@opensaas/stack-core').AccessContext`)
293
+ lines.push(` addValidationError: (msg: string) => void`)
294
+ lines.push(` }) => Promise<void>`)
295
+ lines.push(` beforeOperation?: (args: {`)
296
+ lines.push(` operation: 'create' | 'update' | 'delete'`)
297
+ lines.push(` resolvedData?: Prisma.${listName}CreateInput | Prisma.${listName}UpdateInput`)
298
+ lines.push(` item?: ${listName}`)
299
+ lines.push(` context: import('@opensaas/stack-core').AccessContext`)
300
+ lines.push(` }) => Promise<void>`)
301
+ lines.push(` afterOperation?: (args: {`)
302
+ lines.push(` operation: 'create' | 'update' | 'delete'`)
303
+ lines.push(` resolvedData?: Prisma.${listName}CreateInput | Prisma.${listName}UpdateInput`)
304
+ lines.push(` item?: ${listName}`)
305
+ lines.push(` context: import('@opensaas/stack-core').AccessContext`)
306
+ lines.push(` }) => Promise<void>`)
307
+ lines.push(`}`)
308
+
309
+ return lines.join('\n')
310
+ }
311
+
312
+ /**
313
+ * Generate custom DB interface that uses Prisma's conditional types with virtual and transformed fields
314
+ * This leverages Prisma's GetPayload utility to get correct types based on select/include
315
+ */
316
+ function generateCustomDBType(config: OpenSaasConfig): string {
317
+ const lines: string[] = []
318
+
319
+ // Generate list of db keys to omit from AccessControlledDB
320
+ const dbKeys = Object.keys(config.lists).map((listName) => {
321
+ const dbKey = listName.charAt(0).toLowerCase() + listName.slice(1)
322
+ return `'${dbKey}'`
323
+ })
324
+
325
+ lines.push('/**')
326
+ lines.push(
327
+ " * Custom DB type that uses Prisma's conditional types with virtual and transformed field support",
328
+ )
329
+ lines.push(
330
+ ' * Types change based on select/include - relationships only present when explicitly included',
331
+ )
332
+ lines.push(' * Virtual fields and transformed fields are added to the base model type')
333
+ lines.push(' */')
334
+ lines.push('export type CustomDB = Omit<AccessControlledDB<PrismaClient>, ')
335
+ lines.push(` ${dbKeys.join(' | ')}`)
336
+ lines.push('> & {')
337
+
338
+ // For each list, create strongly-typed methods using Prisma's conditional types
339
+ for (const listName of Object.keys(config.lists)) {
340
+ const dbKey = listName.charAt(0).toLowerCase() + listName.slice(1) // camelCase
341
+
342
+ lines.push(` ${dbKey}: {`)
343
+
344
+ // findUnique - generic to preserve Prisma's conditional return type
345
+ lines.push(` findUnique: <T extends Prisma.${listName}FindUniqueArgs>(`)
346
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}FindUniqueArgs>`)
347
+ lines.push(
348
+ ` ) => Promise<(Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields) | null>`,
349
+ )
350
+
351
+ // findMany - generic to preserve Prisma's conditional return type
352
+ lines.push(` findMany: <T extends Prisma.${listName}FindManyArgs>(`)
353
+ lines.push(` args?: Prisma.SelectSubset<T, Prisma.${listName}FindManyArgs>`)
354
+ lines.push(
355
+ ` ) => Promise<Array<Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields>>`,
356
+ )
357
+
358
+ // create - generic to preserve Prisma's conditional return type
359
+ lines.push(` create: <T extends Prisma.${listName}CreateArgs>(`)
360
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}CreateArgs>`)
361
+ lines.push(
362
+ ` ) => Promise<Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields>`,
363
+ )
364
+
365
+ // update - generic to preserve Prisma's conditional return type
366
+ lines.push(` update: <T extends Prisma.${listName}UpdateArgs>(`)
367
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}UpdateArgs>`)
368
+ lines.push(
369
+ ` ) => Promise<(Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields) | null>`,
370
+ )
371
+
372
+ // delete - generic to preserve Prisma's conditional return type
373
+ lines.push(` delete: <T extends Prisma.${listName}DeleteArgs>(`)
374
+ lines.push(` args: Prisma.SelectSubset<T, Prisma.${listName}DeleteArgs>`)
375
+ lines.push(
376
+ ` ) => Promise<(Omit<Prisma.${listName}GetPayload<T>, keyof ${listName}TransformedFields> & ${listName}TransformedFields & ${listName}VirtualFields) | null>`,
377
+ )
378
+
379
+ // count - no changes to return type
380
+ lines.push(` count: (args?: Prisma.${listName}CountArgs) => Promise<number>`)
381
+
382
+ lines.push(` }`)
383
+ }
384
+
385
+ lines.push('}')
386
+
387
+ return lines.join('\n')
388
+ }
389
+
390
+ /**
391
+ * Generate Context type that is compatible with AccessContext
392
+ */
393
+ function generateContextType(_config: OpenSaasConfig): string {
394
+ const lines: string[] = []
395
+
396
+ lines.push('/**')
397
+ lines.push(
398
+ ' * Context type compatible with AccessContext but with CustomDB for virtual field typing',
399
+ )
400
+ lines.push(
401
+ ' * Extends AccessContext and overrides db property to include virtual fields in output types',
402
+ )
403
+ lines.push(' */')
404
+ lines.push(
405
+ "export type Context<TSession extends OpensaasSession = OpensaasSession> = Omit<AccessContext<PrismaClient>, 'db' | 'session'> & {",
406
+ )
407
+ lines.push(' db: CustomDB')
180
408
  lines.push(' session: TSession')
181
- lines.push(' prisma: PrismaClient')
182
- lines.push(' storage: StorageUtils')
183
409
  lines.push(' serverAction: (props: ServerActionProps) => Promise<unknown>')
184
410
  lines.push(' sudo: () => Context<TSession>')
185
- lines.push(' _isSudo: boolean')
186
411
  lines.push('}')
187
412
 
188
413
  return lines.join('\n')
@@ -249,9 +474,10 @@ export function generateTypes(config: OpenSaasConfig): string {
249
474
  // Add necessary imports
250
475
  // Use alias for Session to avoid conflicts if user has a list named "Session"
251
476
  lines.push(
252
- "import type { Session as OpensaasSession, StorageUtils, ServerActionProps, AccessControlledDB } from '@opensaas/stack-core'",
477
+ "import type { Session as OpensaasSession, StorageUtils, ServerActionProps, AccessControlledDB, AccessContext } from '@opensaas/stack-core'",
253
478
  )
254
- lines.push("import type { PrismaClient } from './prisma-client'")
479
+ lines.push("import type { PrismaClient, Prisma } from './prisma-client/client'")
480
+ lines.push("import type { PluginServices } from './plugin-types'")
255
481
 
256
482
  // Add field-specific imports
257
483
  const fieldImports = collectFieldImports(config)
@@ -265,7 +491,15 @@ export function generateTypes(config: OpenSaasConfig): string {
265
491
 
266
492
  // Generate types for each list
267
493
  for (const [listName, listConfig] of Object.entries(config.lists)) {
268
- lines.push(generateModelType(listName, listConfig.fields))
494
+ // Generate VirtualFields type first (needed by Output type and CustomDB)
495
+ lines.push(generateVirtualFieldsType(listName, listConfig.fields))
496
+ lines.push('')
497
+ // Generate TransformedFields type (needed by CustomDB)
498
+ lines.push(generateTransformedFieldsType(listName, listConfig.fields))
499
+ lines.push('')
500
+ lines.push(generateModelOutputType(listName, listConfig.fields))
501
+ lines.push('')
502
+ lines.push(generateModelTypeAlias(listName))
269
503
  lines.push('')
270
504
  lines.push(generateCreateInputType(listName, listConfig.fields))
271
505
  lines.push('')
@@ -273,10 +507,16 @@ export function generateTypes(config: OpenSaasConfig): string {
273
507
  lines.push('')
274
508
  lines.push(generateWhereInputType(listName, listConfig.fields))
275
509
  lines.push('')
510
+ lines.push(generateHookTypes(listName))
511
+ lines.push('')
276
512
  }
277
513
 
514
+ // Generate CustomDB interface
515
+ lines.push(generateCustomDBType(config))
516
+ lines.push('')
517
+
278
518
  // Generate Context type
279
- lines.push(generateContextType())
519
+ lines.push(generateContextType(config))
280
520
 
281
521
  return lines.join('\n')
282
522
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { Command } from 'commander'
4
4
  import { generateCommand } from './commands/generate.js'
5
5
  import { initCommand } from './commands/init.js'
6
6
  import { devCommand } from './commands/dev.js'
7
+ import { createMCPCommand } from './commands/mcp.js'
7
8
 
8
9
  const program = new Command()
9
10
 
@@ -35,4 +36,7 @@ program
35
36
  await devCommand()
36
37
  })
37
38
 
39
+ // Add MCP command group
40
+ program.addCommand(createMCPCommand())
41
+
38
42
  program.parse()
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Documentation provider - Fetches documentation from the hosted docs site
3
+ */
4
+
5
+ import type { DocumentationLookup } from './types.js'
6
+
7
+ interface SearchResult {
8
+ content: string
9
+ metadata: {
10
+ title?: string
11
+ slug?: string
12
+ section?: string
13
+ }
14
+ score: number
15
+ }
16
+
17
+ interface SearchResponse {
18
+ results: SearchResult[]
19
+ query: string
20
+ count: number
21
+ }
22
+
23
+ export class OpenSaasDocumentationProvider {
24
+ private readonly DOCS_API = 'https://stack.opensaas.au/api/search'
25
+ private cache = new Map<string, { data: DocumentationLookup; timestamp: number }>()
26
+ private readonly CACHE_TTL = 1000 * 60 * 30 // 30 minutes
27
+
28
+ // Topic mappings for user-friendly queries
29
+ private topicMappings: Record<string, string> = {
30
+ fields: 'field-types',
31
+ 'field types': 'field-types',
32
+ 'field type': 'field-types',
33
+ access: 'access-control',
34
+ 'access control': 'access-control',
35
+ permissions: 'access-control',
36
+ auth: 'authentication',
37
+ authentication: 'authentication',
38
+ login: 'authentication',
39
+ 'sign in': 'authentication',
40
+ hooks: 'hooks',
41
+ hook: 'hooks',
42
+ lifecycle: 'hooks',
43
+ plugins: 'plugin-system',
44
+ plugin: 'plugin-system',
45
+ rag: 'rag',
46
+ search: 'semantic-search',
47
+ 'semantic search': 'semantic-search',
48
+ storage: 'file-storage',
49
+ files: 'file-storage',
50
+ upload: 'file-storage',
51
+ config: 'configuration',
52
+ configuration: 'configuration',
53
+ prisma: 'prisma-integration',
54
+ database: 'database-setup',
55
+ deployment: 'deployment',
56
+ deploy: 'deployment',
57
+ }
58
+
59
+ /**
60
+ * Search documentation by query
61
+ */
62
+ async searchDocs(query: string, limit = 5, minScore = 0.7): Promise<DocumentationLookup> {
63
+ const cacheKey = `search:${query}:${limit}:${minScore}`
64
+
65
+ // Check cache
66
+ const cached = this.cache.get(cacheKey)
67
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
68
+ return cached.data
69
+ }
70
+
71
+ try {
72
+ const response = await fetch(this.DOCS_API, {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ },
77
+ body: JSON.stringify({ query, limit, minScore }),
78
+ })
79
+
80
+ if (!response.ok) {
81
+ throw new Error(`Docs API error: ${response.statusText}`)
82
+ }
83
+
84
+ const data = (await response.json()) as SearchResponse
85
+
86
+ const docLookup: DocumentationLookup = {
87
+ topic: query,
88
+ content: this.formatSearchResults(data.results),
89
+ url: 'https://stack.opensaas.au/',
90
+ codeExamples: this.extractCodeExamples(data.results),
91
+ relatedTopics: this.extractRelatedTopics(data.results),
92
+ }
93
+
94
+ // Cache the result
95
+ this.cache.set(cacheKey, { data: docLookup, timestamp: Date.now() })
96
+
97
+ return docLookup
98
+ } catch (error) {
99
+ console.error('Error fetching documentation:', error)
100
+ return this.getFallbackDocs(query)
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get documentation for a specific topic
106
+ */
107
+ async getTopicDocs(topic: string): Promise<DocumentationLookup> {
108
+ // Normalize topic using mappings
109
+ const normalizedTopic = this.topicMappings[topic.toLowerCase()] || topic
110
+
111
+ return this.searchDocs(normalizedTopic, 3, 0.8)
112
+ }
113
+
114
+ /**
115
+ * Format search results into readable content
116
+ */
117
+ private formatSearchResults(results: SearchResult[]): string {
118
+ if (results.length === 0) {
119
+ return 'No documentation found for this query.'
120
+ }
121
+
122
+ return results
123
+ .map((result, index) => {
124
+ const title = result.metadata.title || `Section ${index + 1}`
125
+ const section = result.metadata.section || ''
126
+ const score = (result.score * 100).toFixed(0)
127
+
128
+ return `### ${title}${section ? ` (${section})` : ''} [Relevance: ${score}%]\n\n${result.content}\n`
129
+ })
130
+ .join('\n---\n\n')
131
+ }
132
+
133
+ /**
134
+ * Extract code examples from search results
135
+ */
136
+ private extractCodeExamples(results: SearchResult[]): string[] {
137
+ const codeExamples: string[] = []
138
+ const codeBlockRegex = /```[\s\S]*?```/g
139
+
140
+ for (const result of results) {
141
+ const matches = result.content.match(codeBlockRegex)
142
+ if (matches) {
143
+ codeExamples.push(...matches)
144
+ }
145
+ }
146
+
147
+ return codeExamples
148
+ }
149
+
150
+ /**
151
+ * Extract related topics from search results
152
+ */
153
+ private extractRelatedTopics(results: SearchResult[]): string[] {
154
+ const topics = new Set<string>()
155
+
156
+ for (const result of results) {
157
+ if (result.metadata.section) {
158
+ topics.add(result.metadata.section)
159
+ }
160
+ }
161
+
162
+ return Array.from(topics)
163
+ }
164
+
165
+ /**
166
+ * Fallback documentation when API is unavailable
167
+ */
168
+ private getFallbackDocs(query: string): DocumentationLookup {
169
+ return {
170
+ topic: query,
171
+ content: `Unable to fetch documentation from the docs site at this time.
172
+
173
+ Please visit the OpenSaaS Stack documentation directly:
174
+ https://stack.opensaas.au/
175
+
176
+ For ${query}, you can also check:
177
+ - GitHub repository: https://github.com/OpenSaasAU/stack
178
+ - Example projects in the examples/ directory`,
179
+ url: 'https://stack.opensaas.au/',
180
+ codeExamples: [],
181
+ relatedTopics: [],
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Clear expired cache entries
187
+ */
188
+ clearExpiredCache(): void {
189
+ const now = Date.now()
190
+ for (const [key, value] of this.cache.entries()) {
191
+ if (now - value.timestamp >= this.CACHE_TTL) {
192
+ this.cache.delete(key)
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Clear all cache
199
+ */
200
+ clearCache(): void {
201
+ this.cache.clear()
202
+ }
203
+ }