@opensaas/stack-cli 0.4.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +52 -0
- package/dist/commands/migrate.d.ts +9 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +473 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/lib/documentation-provider.d.ts +23 -0
- package/dist/mcp/lib/documentation-provider.d.ts.map +1 -1
- package/dist/mcp/lib/documentation-provider.js +471 -0
- package/dist/mcp/lib/documentation-provider.js.map +1 -1
- package/dist/mcp/lib/wizards/migration-wizard.d.ts +80 -0
- package/dist/mcp/lib/wizards/migration-wizard.d.ts.map +1 -0
- package/dist/mcp/lib/wizards/migration-wizard.js +499 -0
- package/dist/mcp/lib/wizards/migration-wizard.js.map +1 -0
- package/dist/mcp/server/index.d.ts.map +1 -1
- package/dist/mcp/server/index.js +103 -0
- package/dist/mcp/server/index.js.map +1 -1
- package/dist/mcp/server/stack-mcp-server.d.ts +85 -0
- package/dist/mcp/server/stack-mcp-server.d.ts.map +1 -1
- package/dist/mcp/server/stack-mcp-server.js +219 -0
- package/dist/mcp/server/stack-mcp-server.js.map +1 -1
- package/dist/migration/generators/migration-generator.d.ts +60 -0
- package/dist/migration/generators/migration-generator.d.ts.map +1 -0
- package/dist/migration/generators/migration-generator.js +510 -0
- package/dist/migration/generators/migration-generator.js.map +1 -0
- package/dist/migration/introspectors/index.d.ts +12 -0
- package/dist/migration/introspectors/index.d.ts.map +1 -0
- package/dist/migration/introspectors/index.js +10 -0
- package/dist/migration/introspectors/index.js.map +1 -0
- package/dist/migration/introspectors/keystone-introspector.d.ts +59 -0
- package/dist/migration/introspectors/keystone-introspector.d.ts.map +1 -0
- package/dist/migration/introspectors/keystone-introspector.js +229 -0
- package/dist/migration/introspectors/keystone-introspector.js.map +1 -0
- package/dist/migration/introspectors/nextjs-introspector.d.ts +59 -0
- package/dist/migration/introspectors/nextjs-introspector.d.ts.map +1 -0
- package/dist/migration/introspectors/nextjs-introspector.js +159 -0
- package/dist/migration/introspectors/nextjs-introspector.js.map +1 -0
- package/dist/migration/introspectors/prisma-introspector.d.ts +45 -0
- package/dist/migration/introspectors/prisma-introspector.d.ts.map +1 -0
- package/dist/migration/introspectors/prisma-introspector.js +190 -0
- package/dist/migration/introspectors/prisma-introspector.js.map +1 -0
- package/dist/migration/types.d.ts +86 -0
- package/dist/migration/types.d.ts.map +1 -0
- package/dist/migration/types.js +5 -0
- package/dist/migration/types.js.map +1 -0
- package/package.json +5 -2
- package/src/commands/migrate.ts +534 -0
- package/src/index.ts +4 -0
- package/src/mcp/lib/documentation-provider.ts +507 -0
- package/src/mcp/lib/wizards/migration-wizard.ts +584 -0
- package/src/mcp/server/index.ts +121 -0
- package/src/mcp/server/stack-mcp-server.ts +243 -0
- package/src/migration/generators/migration-generator.ts +675 -0
- package/src/migration/introspectors/index.ts +12 -0
- package/src/migration/introspectors/keystone-introspector.ts +296 -0
- package/src/migration/introspectors/nextjs-introspector.ts +209 -0
- package/src/migration/introspectors/prisma-introspector.ts +233 -0
- package/src/migration/types.ts +92 -0
- package/tests/introspectors/keystone-introspector.test.ts +255 -0
- package/tests/introspectors/nextjs-introspector.test.ts +302 -0
- package/tests/introspectors/prisma-introspector.test.ts +268 -0
- package/tests/migration-generator.test.ts +592 -0
- package/tests/migration-wizard.test.ts +442 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeystoneJS Config Introspector
|
|
3
|
+
*
|
|
4
|
+
* Loads keystone.config.ts using jiti and extracts list definitions.
|
|
5
|
+
* KeystoneJS → OpenSaaS migration is mostly 1:1.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import fs from 'fs-extra'
|
|
10
|
+
import { createJiti } from 'jiti'
|
|
11
|
+
import type { IntrospectedSchema, IntrospectedModel, IntrospectedField } from '../types.js'
|
|
12
|
+
|
|
13
|
+
export interface KeystoneList {
|
|
14
|
+
name: string
|
|
15
|
+
fields: KeystoneField[]
|
|
16
|
+
access?: Record<string, unknown>
|
|
17
|
+
hooks?: Record<string, unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KeystoneField {
|
|
21
|
+
name: string
|
|
22
|
+
type: string
|
|
23
|
+
options?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface KeystoneSchema {
|
|
27
|
+
lists: KeystoneList[]
|
|
28
|
+
db?: {
|
|
29
|
+
provider: string
|
|
30
|
+
url?: string
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class KeystoneIntrospector {
|
|
35
|
+
/**
|
|
36
|
+
* Introspect a KeystoneJS config file
|
|
37
|
+
*/
|
|
38
|
+
async introspect(
|
|
39
|
+
cwd: string,
|
|
40
|
+
configPath: string = 'keystone.config.ts',
|
|
41
|
+
): Promise<IntrospectedSchema> {
|
|
42
|
+
const fullPath = path.isAbsolute(configPath) ? configPath : path.join(cwd, configPath)
|
|
43
|
+
|
|
44
|
+
// Try alternative paths
|
|
45
|
+
const paths = [fullPath, path.join(cwd, 'keystone.ts'), path.join(cwd, 'keystone.config.js')]
|
|
46
|
+
|
|
47
|
+
let foundPath: string | undefined
|
|
48
|
+
for (const p of paths) {
|
|
49
|
+
if (await fs.pathExists(p)) {
|
|
50
|
+
foundPath = p
|
|
51
|
+
break
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!foundPath) {
|
|
56
|
+
throw new Error(`KeystoneJS config not found. Tried: ${paths.join(', ')}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Use jiti to load TypeScript config
|
|
61
|
+
const jiti = createJiti(import.meta.url, {
|
|
62
|
+
interopDefault: true,
|
|
63
|
+
moduleCache: false,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const configModule = (await jiti.import(foundPath)) as { default?: unknown } | unknown
|
|
67
|
+
const config =
|
|
68
|
+
typeof configModule === 'object' && configModule !== null && 'default' in configModule
|
|
69
|
+
? configModule.default
|
|
70
|
+
: configModule
|
|
71
|
+
|
|
72
|
+
const keystoneSchema = this.parseConfig(config)
|
|
73
|
+
|
|
74
|
+
// Convert KeystoneSchema to IntrospectedSchema
|
|
75
|
+
return this.convertToIntrospectedSchema(keystoneSchema)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
78
|
+
throw new Error(`Failed to load KeystoneJS config: ${message}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse the loaded KeystoneJS config object
|
|
84
|
+
*/
|
|
85
|
+
private parseConfig(config: unknown): KeystoneSchema {
|
|
86
|
+
const result: KeystoneSchema = {
|
|
87
|
+
lists: [],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof config !== 'object' || config === null) {
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const configObj = config as Record<string, unknown>
|
|
95
|
+
|
|
96
|
+
// Extract database config
|
|
97
|
+
if (configObj.db && typeof configObj.db === 'object' && configObj.db !== null) {
|
|
98
|
+
const db = configObj.db as Record<string, unknown>
|
|
99
|
+
result.db = {
|
|
100
|
+
provider: typeof db.provider === 'string' ? db.provider : 'unknown',
|
|
101
|
+
url: typeof db.url === 'string' ? db.url : undefined,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract lists
|
|
106
|
+
if (configObj.lists && typeof configObj.lists === 'object' && configObj.lists !== null) {
|
|
107
|
+
for (const [name, listDef] of Object.entries(configObj.lists)) {
|
|
108
|
+
const list = this.parseList(name, listDef)
|
|
109
|
+
result.lists.push(list)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse a single list definition
|
|
118
|
+
*/
|
|
119
|
+
private parseList(name: string, listDef: unknown): KeystoneList {
|
|
120
|
+
const list: KeystoneList = {
|
|
121
|
+
name,
|
|
122
|
+
fields: [],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof listDef !== 'object' || listDef === null) {
|
|
126
|
+
return list
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const listDefObj = listDef as Record<string, unknown>
|
|
130
|
+
|
|
131
|
+
// Extract fields
|
|
132
|
+
if (listDefObj.fields && typeof listDefObj.fields === 'object' && listDefObj.fields !== null) {
|
|
133
|
+
for (const [fieldName, fieldDef] of Object.entries(listDefObj.fields)) {
|
|
134
|
+
const field = this.parseField(fieldName, fieldDef)
|
|
135
|
+
list.fields.push(field)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Store access and hooks for reference (not used in migration but useful)
|
|
140
|
+
if (listDefObj.access && typeof listDefObj.access === 'object') {
|
|
141
|
+
list.access = listDefObj.access as Record<string, unknown>
|
|
142
|
+
}
|
|
143
|
+
if (listDefObj.hooks && typeof listDefObj.hooks === 'object') {
|
|
144
|
+
list.hooks = listDefObj.hooks as Record<string, unknown>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return list
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse a single field definition
|
|
152
|
+
*/
|
|
153
|
+
private parseField(name: string, fieldDef: unknown): KeystoneField {
|
|
154
|
+
// KeystoneJS fields are objects with a type property or function results
|
|
155
|
+
let type = 'unknown'
|
|
156
|
+
let options: Record<string, unknown> = {}
|
|
157
|
+
|
|
158
|
+
if (typeof fieldDef === 'object' && fieldDef !== null) {
|
|
159
|
+
const fieldDefObj = fieldDef as Record<string, unknown>
|
|
160
|
+
|
|
161
|
+
// Check for common field type patterns
|
|
162
|
+
if (typeof fieldDefObj.type === 'string') {
|
|
163
|
+
type = fieldDefObj.type
|
|
164
|
+
} else if (typeof fieldDefObj._type === 'string') {
|
|
165
|
+
type = fieldDefObj._type
|
|
166
|
+
} else if (
|
|
167
|
+
fieldDefObj.constructor &&
|
|
168
|
+
typeof fieldDefObj.constructor === 'object' &&
|
|
169
|
+
fieldDefObj.constructor !== null
|
|
170
|
+
) {
|
|
171
|
+
const constructor = fieldDefObj.constructor as Record<string, unknown>
|
|
172
|
+
if (typeof constructor.name === 'string') {
|
|
173
|
+
type = constructor.name
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Extract common options
|
|
178
|
+
if (fieldDefObj.validation !== undefined) options.validation = fieldDefObj.validation
|
|
179
|
+
if (fieldDefObj.defaultValue !== undefined) options.defaultValue = fieldDefObj.defaultValue
|
|
180
|
+
if (fieldDefObj.isRequired !== undefined) options.isRequired = fieldDefObj.isRequired
|
|
181
|
+
if (fieldDefObj.ref !== undefined) options.ref = fieldDefObj.ref
|
|
182
|
+
if (fieldDefObj.many !== undefined) options.many = fieldDefObj.many
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { name, type, options }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Convert KeystoneSchema to IntrospectedSchema format
|
|
190
|
+
*/
|
|
191
|
+
private convertToIntrospectedSchema(keystoneSchema: KeystoneSchema): IntrospectedSchema {
|
|
192
|
+
const models: IntrospectedModel[] = keystoneSchema.lists.map((list) => {
|
|
193
|
+
const fields: IntrospectedField[] = list.fields.map((field) => {
|
|
194
|
+
const isRelationship = field.type.toLowerCase() === 'relationship'
|
|
195
|
+
const isRequired = field.options?.isRequired === true
|
|
196
|
+
|
|
197
|
+
const introspectedField: IntrospectedField = {
|
|
198
|
+
name: field.name,
|
|
199
|
+
type: field.type,
|
|
200
|
+
isRequired,
|
|
201
|
+
isUnique: false, // KeystoneJS doesn't expose this easily
|
|
202
|
+
isId: field.name === 'id',
|
|
203
|
+
isList: field.options?.many === true,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (field.options?.defaultValue !== undefined) {
|
|
207
|
+
introspectedField.defaultValue = String(field.options.defaultValue)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (isRelationship && field.options?.ref) {
|
|
211
|
+
const ref = String(field.options.ref)
|
|
212
|
+
const [model, fieldName] = ref.split('.')
|
|
213
|
+
|
|
214
|
+
introspectedField.relation = {
|
|
215
|
+
name: '',
|
|
216
|
+
model,
|
|
217
|
+
fields: [],
|
|
218
|
+
references: fieldName ? [fieldName] : [],
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return introspectedField
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
name: list.name,
|
|
227
|
+
fields,
|
|
228
|
+
hasRelations: fields.some((f) => f.relation !== undefined),
|
|
229
|
+
primaryKey: 'id',
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
provider: keystoneSchema.db?.provider || 'unknown',
|
|
235
|
+
models,
|
|
236
|
+
enums: [], // KeystoneJS doesn't have enums in the same way
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Map KeystoneJS field type to OpenSaaS equivalent
|
|
242
|
+
*/
|
|
243
|
+
mapKeystoneTypeToOpenSaas(keystoneType: string): { type: string; import: string } {
|
|
244
|
+
// KeystoneJS → OpenSaaS is mostly 1:1
|
|
245
|
+
const mappings: Record<string, { type: string; import: string }> = {
|
|
246
|
+
text: { type: 'text', import: 'text' },
|
|
247
|
+
integer: { type: 'integer', import: 'integer' },
|
|
248
|
+
float: { type: 'float', import: 'float' },
|
|
249
|
+
checkbox: { type: 'checkbox', import: 'checkbox' },
|
|
250
|
+
timestamp: { type: 'timestamp', import: 'timestamp' },
|
|
251
|
+
select: { type: 'select', import: 'select' },
|
|
252
|
+
relationship: { type: 'relationship', import: 'relationship' },
|
|
253
|
+
password: { type: 'password', import: 'password' },
|
|
254
|
+
json: { type: 'json', import: 'json' },
|
|
255
|
+
// Storage field types (from @opensaas/stack-storage)
|
|
256
|
+
image: { type: 'image', import: 'image' },
|
|
257
|
+
file: { type: 'file', import: 'file' },
|
|
258
|
+
// Virtual/computed fields
|
|
259
|
+
virtual: { type: 'virtual', import: 'virtual' },
|
|
260
|
+
// Other field types
|
|
261
|
+
calendarDay: { type: 'timestamp', import: 'timestamp' },
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const lower = keystoneType.toLowerCase()
|
|
265
|
+
return mappings[lower] || { type: 'text', import: 'text' }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get warnings for unsupported features
|
|
270
|
+
*/
|
|
271
|
+
getWarnings(schema: IntrospectedSchema): string[] {
|
|
272
|
+
const warnings: string[] = []
|
|
273
|
+
const hasFileOrImageFields = schema.models.some((model) =>
|
|
274
|
+
model.fields.some((field) => ['image', 'file'].includes(field.type.toLowerCase())),
|
|
275
|
+
)
|
|
276
|
+
const hasVirtualFields = schema.models.some((model) =>
|
|
277
|
+
model.fields.some((field) => field.type.toLowerCase() === 'virtual'),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
// Add storage configuration reminder if file/image fields are present
|
|
281
|
+
if (hasFileOrImageFields) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
"Your schema uses file/image fields - you'll need to configure storage providers in your OpenSaaS config",
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Add virtual field migration reminder
|
|
288
|
+
if (hasVirtualFields) {
|
|
289
|
+
warnings.push(
|
|
290
|
+
"Your schema uses virtual fields - you'll need to manually migrate the resolveOutput hooks to compute field values",
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return warnings
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js Project Introspector
|
|
3
|
+
*
|
|
4
|
+
* Detects Next.js version, auth libraries, database libraries,
|
|
5
|
+
* and other project characteristics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import fs from 'fs-extra'
|
|
10
|
+
|
|
11
|
+
export interface NextjsAnalysis {
|
|
12
|
+
version: string
|
|
13
|
+
routerType: 'app' | 'pages' | 'both' | 'unknown'
|
|
14
|
+
typescript: boolean
|
|
15
|
+
authLibrary?: string
|
|
16
|
+
databaseLibrary?: string
|
|
17
|
+
hasPrisma: boolean
|
|
18
|
+
hasEnvFile: boolean
|
|
19
|
+
existingDependencies: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class NextjsIntrospector {
|
|
23
|
+
/**
|
|
24
|
+
* Analyze a Next.js project
|
|
25
|
+
*/
|
|
26
|
+
async introspect(cwd: string): Promise<NextjsAnalysis> {
|
|
27
|
+
const packageJsonPath = path.join(cwd, 'package.json')
|
|
28
|
+
|
|
29
|
+
if (!(await fs.pathExists(packageJsonPath))) {
|
|
30
|
+
throw new Error('package.json not found')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pkg = await fs.readJSON(packageJsonPath)
|
|
34
|
+
|
|
35
|
+
const analysis: NextjsAnalysis = {
|
|
36
|
+
version: this.getNextVersion(pkg),
|
|
37
|
+
routerType: await this.detectRouterType(cwd),
|
|
38
|
+
typescript: await this.hasTypeScript(cwd),
|
|
39
|
+
hasPrisma:
|
|
40
|
+
this.hasDependency(pkg, '@prisma/client') ||
|
|
41
|
+
(await fs.pathExists(path.join(cwd, 'prisma'))),
|
|
42
|
+
hasEnvFile:
|
|
43
|
+
(await fs.pathExists(path.join(cwd, '.env'))) ||
|
|
44
|
+
(await fs.pathExists(path.join(cwd, '.env.local'))),
|
|
45
|
+
existingDependencies: this.getAllDependencies(pkg),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Detect auth library
|
|
49
|
+
analysis.authLibrary = this.detectAuthLibrary(pkg)
|
|
50
|
+
|
|
51
|
+
// Detect database library
|
|
52
|
+
analysis.databaseLibrary = this.detectDatabaseLibrary(pkg)
|
|
53
|
+
|
|
54
|
+
return analysis
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get Next.js version from package.json
|
|
59
|
+
*/
|
|
60
|
+
private getNextVersion(pkg: Record<string, unknown>): string {
|
|
61
|
+
const deps = pkg.dependencies as Record<string, string> | undefined
|
|
62
|
+
const devDeps = pkg.devDependencies as Record<string, string> | undefined
|
|
63
|
+
const version = deps?.next || devDeps?.next || 'unknown'
|
|
64
|
+
// Strip semver prefixes like ^ or ~
|
|
65
|
+
return version.replace(/^[\^~]/, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detect if project uses app router, pages router, or both
|
|
70
|
+
*/
|
|
71
|
+
private async detectRouterType(cwd: string): Promise<'app' | 'pages' | 'both' | 'unknown'> {
|
|
72
|
+
const hasApp =
|
|
73
|
+
(await fs.pathExists(path.join(cwd, 'app'))) ||
|
|
74
|
+
(await fs.pathExists(path.join(cwd, 'src', 'app')))
|
|
75
|
+
const hasPages =
|
|
76
|
+
(await fs.pathExists(path.join(cwd, 'pages'))) ||
|
|
77
|
+
(await fs.pathExists(path.join(cwd, 'src', 'pages')))
|
|
78
|
+
|
|
79
|
+
if (hasApp && hasPages) return 'both'
|
|
80
|
+
if (hasApp) return 'app'
|
|
81
|
+
if (hasPages) return 'pages'
|
|
82
|
+
return 'unknown'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if project uses TypeScript
|
|
87
|
+
*/
|
|
88
|
+
private async hasTypeScript(cwd: string): Promise<boolean> {
|
|
89
|
+
return await fs.pathExists(path.join(cwd, 'tsconfig.json'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if package.json has a dependency
|
|
94
|
+
*/
|
|
95
|
+
private hasDependency(pkg: Record<string, unknown>, name: string): boolean {
|
|
96
|
+
const deps = pkg.dependencies as Record<string, string> | undefined
|
|
97
|
+
const devDeps = pkg.devDependencies as Record<string, string> | undefined
|
|
98
|
+
return !!(deps?.[name] || devDeps?.[name])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get all dependencies
|
|
103
|
+
*/
|
|
104
|
+
private getAllDependencies(pkg: Record<string, unknown>): string[] {
|
|
105
|
+
const deps = pkg.dependencies as Record<string, string> | undefined
|
|
106
|
+
const devDeps = pkg.devDependencies as Record<string, string> | undefined
|
|
107
|
+
return [...Object.keys(deps || {}), ...Object.keys(devDeps || {})]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Detect auth library being used
|
|
112
|
+
*/
|
|
113
|
+
private detectAuthLibrary(pkg: Record<string, unknown>): string | undefined {
|
|
114
|
+
const authLibraries = [
|
|
115
|
+
{ name: 'next-auth', dep: 'next-auth' },
|
|
116
|
+
{ name: 'better-auth', dep: 'better-auth' },
|
|
117
|
+
{ name: 'clerk', dep: '@clerk/nextjs' },
|
|
118
|
+
{ name: 'auth0', dep: '@auth0/nextjs-auth0' },
|
|
119
|
+
{ name: 'supabase', dep: '@supabase/auth-helpers-nextjs' },
|
|
120
|
+
{ name: 'lucia', dep: 'lucia' },
|
|
121
|
+
{ name: 'kinde', dep: '@kinde-oss/kinde-auth-nextjs' },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
for (const lib of authLibraries) {
|
|
125
|
+
if (this.hasDependency(pkg, lib.dep)) {
|
|
126
|
+
return lib.name
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Detect database library being used
|
|
135
|
+
*/
|
|
136
|
+
private detectDatabaseLibrary(pkg: Record<string, unknown>): string | undefined {
|
|
137
|
+
const dbLibraries = [
|
|
138
|
+
{ name: 'prisma', dep: '@prisma/client' },
|
|
139
|
+
{ name: 'drizzle', dep: 'drizzle-orm' },
|
|
140
|
+
{ name: 'typeorm', dep: 'typeorm' },
|
|
141
|
+
{ name: 'mongoose', dep: 'mongoose' },
|
|
142
|
+
{ name: 'knex', dep: 'knex' },
|
|
143
|
+
{ name: 'sequelize', dep: 'sequelize' },
|
|
144
|
+
{ name: 'kysely', dep: 'kysely' },
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
for (const lib of dbLibraries) {
|
|
148
|
+
if (this.hasDependency(pkg, lib.dep)) {
|
|
149
|
+
return lib.name
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get migration recommendations based on analysis
|
|
158
|
+
*/
|
|
159
|
+
getRecommendations(analysis: NextjsAnalysis): string[] {
|
|
160
|
+
const recommendations: string[] = []
|
|
161
|
+
|
|
162
|
+
if (analysis.routerType === 'pages') {
|
|
163
|
+
recommendations.push('Consider migrating to App Router for best OpenSaaS Stack integration')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (analysis.authLibrary && analysis.authLibrary !== 'better-auth') {
|
|
167
|
+
recommendations.push(
|
|
168
|
+
`Consider migrating from ${analysis.authLibrary} to Better-auth (used by OpenSaaS Stack auth plugin)`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!analysis.hasPrisma) {
|
|
173
|
+
recommendations.push("OpenSaaS Stack uses Prisma - you'll need to set up your data models")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (analysis.databaseLibrary && analysis.databaseLibrary !== 'prisma') {
|
|
177
|
+
recommendations.push(
|
|
178
|
+
`You're using ${analysis.databaseLibrary} - you may need to migrate to Prisma or run both`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!analysis.hasEnvFile) {
|
|
183
|
+
recommendations.push('Create a .env file with DATABASE_URL for your database connection')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return recommendations
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get warnings for potential issues
|
|
191
|
+
*/
|
|
192
|
+
getWarnings(analysis: NextjsAnalysis): string[] {
|
|
193
|
+
const warnings: string[] = []
|
|
194
|
+
|
|
195
|
+
if (analysis.version.startsWith('12') || analysis.version.startsWith('11')) {
|
|
196
|
+
warnings.push(
|
|
197
|
+
`Next.js ${analysis.version} is quite old - consider upgrading to 14+ for best results`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (analysis.databaseLibrary === 'mongoose') {
|
|
202
|
+
warnings.push(
|
|
203
|
+
'MongoDB/Mongoose is not fully supported by Prisma - migration may require database change',
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return warnings
|
|
208
|
+
}
|
|
209
|
+
}
|