@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,710 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Documentation provider - Fetches documentation from the hosted docs site
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import fs from 'fs-extra'
|
|
6
|
-
import path from 'path'
|
|
7
|
-
import { glob } from 'glob'
|
|
8
|
-
import type { DocumentationLookup } from './types.js'
|
|
9
|
-
|
|
10
|
-
interface SearchResult {
|
|
11
|
-
content: string
|
|
12
|
-
metadata: {
|
|
13
|
-
title?: string
|
|
14
|
-
slug?: string
|
|
15
|
-
section?: string
|
|
16
|
-
}
|
|
17
|
-
score: number
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface SearchResponse {
|
|
21
|
-
results: SearchResult[]
|
|
22
|
-
query: string
|
|
23
|
-
count: number
|
|
24
|
-
}
|
|
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
|
-
|
|
38
|
-
export class OpenSaasDocumentationProvider {
|
|
39
|
-
private readonly DOCS_API = 'https://stack.opensaas.au/api/search'
|
|
40
|
-
private cache = new Map<string, { data: DocumentationLookup; timestamp: number }>()
|
|
41
|
-
private readonly CACHE_TTL = 1000 * 60 * 30 // 30 minutes
|
|
42
|
-
|
|
43
|
-
// Topic mappings for user-friendly queries
|
|
44
|
-
private topicMappings: Record<string, string> = {
|
|
45
|
-
fields: 'field-types',
|
|
46
|
-
'field types': 'field-types',
|
|
47
|
-
'field type': 'field-types',
|
|
48
|
-
access: 'access-control',
|
|
49
|
-
'access control': 'access-control',
|
|
50
|
-
permissions: 'access-control',
|
|
51
|
-
auth: 'authentication',
|
|
52
|
-
authentication: 'authentication',
|
|
53
|
-
login: 'authentication',
|
|
54
|
-
'sign in': 'authentication',
|
|
55
|
-
hooks: 'hooks',
|
|
56
|
-
hook: 'hooks',
|
|
57
|
-
lifecycle: 'hooks',
|
|
58
|
-
plugins: 'plugin-system',
|
|
59
|
-
plugin: 'plugin-system',
|
|
60
|
-
rag: 'rag',
|
|
61
|
-
search: 'semantic-search',
|
|
62
|
-
'semantic search': 'semantic-search',
|
|
63
|
-
storage: 'file-storage',
|
|
64
|
-
files: 'file-storage',
|
|
65
|
-
upload: 'file-storage',
|
|
66
|
-
config: 'configuration',
|
|
67
|
-
configuration: 'configuration',
|
|
68
|
-
prisma: 'prisma-integration',
|
|
69
|
-
database: 'database-setup',
|
|
70
|
-
deployment: 'deployment',
|
|
71
|
-
deploy: 'deployment',
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Search documentation by query
|
|
76
|
-
*/
|
|
77
|
-
async searchDocs(query: string, limit = 5, minScore = 0.7): Promise<DocumentationLookup> {
|
|
78
|
-
const cacheKey = `search:${query}:${limit}:${minScore}`
|
|
79
|
-
|
|
80
|
-
// Check cache
|
|
81
|
-
const cached = this.cache.get(cacheKey)
|
|
82
|
-
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
|
83
|
-
return cached.data
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const response = await fetch(this.DOCS_API, {
|
|
88
|
-
method: 'POST',
|
|
89
|
-
headers: {
|
|
90
|
-
'Content-Type': 'application/json',
|
|
91
|
-
},
|
|
92
|
-
body: JSON.stringify({ query, limit, minScore }),
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
if (!response.ok) {
|
|
96
|
-
throw new Error(`Docs API error: ${response.statusText}`)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const data = (await response.json()) as SearchResponse
|
|
100
|
-
|
|
101
|
-
const docLookup: DocumentationLookup = {
|
|
102
|
-
topic: query,
|
|
103
|
-
content: this.formatSearchResults(data.results),
|
|
104
|
-
url: 'https://stack.opensaas.au/',
|
|
105
|
-
codeExamples: this.extractCodeExamples(data.results),
|
|
106
|
-
relatedTopics: this.extractRelatedTopics(data.results),
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Cache the result
|
|
110
|
-
this.cache.set(cacheKey, { data: docLookup, timestamp: Date.now() })
|
|
111
|
-
|
|
112
|
-
return docLookup
|
|
113
|
-
} catch (error) {
|
|
114
|
-
console.error('Error fetching documentation:', error)
|
|
115
|
-
return this.getFallbackDocs(query)
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Get documentation for a specific topic
|
|
121
|
-
*/
|
|
122
|
-
async getTopicDocs(topic: string): Promise<DocumentationLookup> {
|
|
123
|
-
// Normalize topic using mappings
|
|
124
|
-
const normalizedTopic = this.topicMappings[topic.toLowerCase()] || topic
|
|
125
|
-
|
|
126
|
-
return this.searchDocs(normalizedTopic, 3, 0.8)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Format search results into readable content
|
|
131
|
-
*/
|
|
132
|
-
private formatSearchResults(results: SearchResult[]): string {
|
|
133
|
-
if (results.length === 0) {
|
|
134
|
-
return 'No documentation found for this query.'
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return results
|
|
138
|
-
.map((result, index) => {
|
|
139
|
-
const title = result.metadata.title || `Section ${index + 1}`
|
|
140
|
-
const section = result.metadata.section || ''
|
|
141
|
-
const score = (result.score * 100).toFixed(0)
|
|
142
|
-
|
|
143
|
-
return `### ${title}${section ? ` (${section})` : ''} [Relevance: ${score}%]\n\n${result.content}\n`
|
|
144
|
-
})
|
|
145
|
-
.join('\n---\n\n')
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Extract code examples from search results
|
|
150
|
-
*/
|
|
151
|
-
private extractCodeExamples(results: SearchResult[]): string[] {
|
|
152
|
-
const codeExamples: string[] = []
|
|
153
|
-
const codeBlockRegex = /```[\s\S]*?```/g
|
|
154
|
-
|
|
155
|
-
for (const result of results) {
|
|
156
|
-
const matches = result.content.match(codeBlockRegex)
|
|
157
|
-
if (matches) {
|
|
158
|
-
codeExamples.push(...matches)
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return codeExamples
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Extract related topics from search results
|
|
167
|
-
*/
|
|
168
|
-
private extractRelatedTopics(results: SearchResult[]): string[] {
|
|
169
|
-
const topics = new Set<string>()
|
|
170
|
-
|
|
171
|
-
for (const result of results) {
|
|
172
|
-
if (result.metadata.section) {
|
|
173
|
-
topics.add(result.metadata.section)
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return Array.from(topics)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Fallback documentation when API is unavailable
|
|
182
|
-
*/
|
|
183
|
-
private getFallbackDocs(query: string): DocumentationLookup {
|
|
184
|
-
return {
|
|
185
|
-
topic: query,
|
|
186
|
-
content: `Unable to fetch documentation from the docs site at this time.
|
|
187
|
-
|
|
188
|
-
Please visit the OpenSaaS Stack documentation directly:
|
|
189
|
-
https://stack.opensaas.au/
|
|
190
|
-
|
|
191
|
-
For ${query}, you can also check:
|
|
192
|
-
- GitHub repository: https://github.com/OpenSaasAU/stack
|
|
193
|
-
- Example projects in the examples/ directory`,
|
|
194
|
-
url: 'https://stack.opensaas.au/',
|
|
195
|
-
codeExamples: [],
|
|
196
|
-
relatedTopics: [],
|
|
197
|
-
}
|
|
198
|
-
}
|
|
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
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Clear expired cache entries
|
|
694
|
-
*/
|
|
695
|
-
clearExpiredCache(): void {
|
|
696
|
-
const now = Date.now()
|
|
697
|
-
for (const [key, value] of this.cache.entries()) {
|
|
698
|
-
if (now - value.timestamp >= this.CACHE_TTL) {
|
|
699
|
-
this.cache.delete(key)
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Clear all cache
|
|
706
|
-
*/
|
|
707
|
-
clearCache(): void {
|
|
708
|
-
this.cache.clear()
|
|
709
|
-
}
|
|
710
|
-
}
|