@opensaas/stack-cli 0.29.0 → 0.30.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/README.md +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +11 -4
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +1 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/mcp/lib/features/catalog.js +2 -2
- package/dist/mcp/lib/features/catalog.js.map +1 -1
- package/dist/mcp/lib/generators/feature-generator.d.ts +8 -3
- package/dist/mcp/lib/generators/feature-generator.d.ts.map +1 -1
- package/dist/mcp/lib/generators/feature-generator.js +613 -217
- package/dist/mcp/lib/generators/feature-generator.js.map +1 -1
- package/package.json +3 -4
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Feature generator - Generates code, config, and documentation for features
|
|
3
|
+
*
|
|
4
|
+
* Emitted code must match the current stack APIs. Canonical references:
|
|
5
|
+
* - examples/starter-auth (authPlugin, access helpers, lib/auth wiring)
|
|
6
|
+
* - examples/file-upload-demo (storage config, file/image fields)
|
|
7
|
+
* - examples/rag-openai-chatbot and examples/rag-ollama-demo (ragPlugin, searchable)
|
|
3
8
|
*/
|
|
9
|
+
const SQLITE_DB_BLOCK = `db: {
|
|
10
|
+
provider: 'sqlite',
|
|
11
|
+
prismaClientConstructor: (PrismaClient) => {
|
|
12
|
+
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || 'file:./dev.db' })
|
|
13
|
+
return new PrismaClient({ adapter })
|
|
14
|
+
},
|
|
15
|
+
}`;
|
|
16
|
+
const POSTGRES_DB_BLOCK = `db: {
|
|
17
|
+
provider: 'postgresql',
|
|
18
|
+
prismaClientConstructor: (PrismaClient) => {
|
|
19
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
|
20
|
+
const adapter = new PrismaPg(pool)
|
|
21
|
+
return new PrismaClient({ adapter })
|
|
22
|
+
},
|
|
23
|
+
}`;
|
|
4
24
|
export class FeatureGenerator {
|
|
5
25
|
feature;
|
|
6
26
|
answers;
|
|
@@ -43,121 +63,188 @@ export class FeatureGenerator {
|
|
|
43
63
|
: null;
|
|
44
64
|
const userFields = this.answers['user-fields'] || [];
|
|
45
65
|
const emailVerification = this.answers['email-verification'];
|
|
46
|
-
const
|
|
66
|
+
const hasGoogle = authMethods.includes('Google OAuth');
|
|
67
|
+
const hasGithub = authMethods.includes('GitHub OAuth');
|
|
68
|
+
const hasOAuth = hasGoogle || hasGithub;
|
|
47
69
|
const hasPassword = authMethods.includes('Email & Password');
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
fields.push('name: text()');
|
|
70
|
+
// Custom User fields go through authPlugin's extendUserList — the plugin
|
|
71
|
+
// auto-generates the User/Session/Account/Verification lists. Passwords
|
|
72
|
+
// live on the Account list managed by Better-auth; never add a password
|
|
73
|
+
// field to User.
|
|
74
|
+
const extendFields = [];
|
|
55
75
|
if (hasRoles && roles) {
|
|
56
|
-
|
|
76
|
+
const options = roles.map((r) => `{ label: '${r[0].toUpperCase()}${r.slice(1)}', value: '${r}' }`);
|
|
77
|
+
extendFields.push(`role: select({ options: [${options.join(', ')}], defaultValue: '${roles[roles.length - 1]}' })`);
|
|
57
78
|
}
|
|
58
79
|
if (userFields.includes('Avatar')) {
|
|
59
|
-
|
|
80
|
+
extendFields.push(`avatar: text() // or an image() field — see the file-upload feature`);
|
|
60
81
|
}
|
|
61
82
|
if (userFields.includes('Bio')) {
|
|
62
|
-
|
|
83
|
+
extendFields.push(`bio: text({ ui: { displayMode: 'textarea' } })`);
|
|
63
84
|
}
|
|
64
85
|
if (userFields.includes('Phone')) {
|
|
65
|
-
|
|
86
|
+
extendFields.push(`phone: text()`);
|
|
66
87
|
}
|
|
67
88
|
if (userFields.includes('Location')) {
|
|
68
|
-
|
|
89
|
+
extendFields.push(`location: text()`);
|
|
69
90
|
}
|
|
70
91
|
if (userFields.includes('Website')) {
|
|
71
|
-
|
|
92
|
+
extendFields.push(`website: text()`);
|
|
72
93
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
const fieldImports = ['text'];
|
|
95
|
+
if (hasRoles)
|
|
96
|
+
fieldImports.push('select');
|
|
97
|
+
const socialProvidersBlock = hasOAuth
|
|
98
|
+
? `
|
|
99
|
+
socialProviders: {
|
|
100
|
+
${hasGoogle ? `google: {\n clientId: process.env.GOOGLE_CLIENT_ID!,\n clientSecret: process.env.GOOGLE_CLIENT_SECRET!,\n },` : ''}
|
|
101
|
+
${hasGithub ? `github: {\n clientId: process.env.GITHUB_CLIENT_ID!,\n clientSecret: process.env.GITHUB_CLIENT_SECRET!,\n },` : ''}
|
|
102
|
+
},`
|
|
103
|
+
: '';
|
|
104
|
+
const configUpdates = `import { config, list } from '@opensaas/stack-core'
|
|
105
|
+
import type { AccessControl } from '@opensaas/stack-core'
|
|
106
|
+
import { ${fieldImports.join(', ')} } from '@opensaas/stack-core/fields'
|
|
107
|
+
import { authPlugin } from '@opensaas/stack-auth'
|
|
108
|
+
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
|
109
|
+
|
|
110
|
+
// Access control helpers (see lib/access-control.ts for the full set)
|
|
111
|
+
const isSignedIn: AccessControl = ({ session }) => !!session
|
|
77
112
|
|
|
78
113
|
export default config({
|
|
79
114
|
plugins: [
|
|
80
115
|
authPlugin({
|
|
81
|
-
emailAndPassword: { enabled: ${hasPassword} }
|
|
82
|
-
${hasOAuth
|
|
83
|
-
? `oauth: {
|
|
84
|
-
google: { enabled: ${authMethods.includes('Google OAuth')} },
|
|
85
|
-
github: { enabled: ${authMethods.includes('GitHub OAuth')} },
|
|
86
|
-
},`
|
|
87
|
-
: ''}
|
|
88
|
-
${hasMagicLink ? `magicLink: { enabled: true },` : ''}
|
|
116
|
+
emailAndPassword: { enabled: ${hasPassword} },${socialProvidersBlock}
|
|
89
117
|
${emailVerification ? `emailVerification: { enabled: true },` : ''}
|
|
90
118
|
sessionFields: ['userId', 'email', 'name'${hasRoles ? ", 'role'" : ''}],
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
},
|
|
119
|
+
${extendFields.length > 0
|
|
120
|
+
? `// Custom fields on the auto-generated User list
|
|
121
|
+
extendUserList: {
|
|
122
|
+
fields: {
|
|
123
|
+
${extendFields.join(',\n ')},
|
|
124
|
+
},
|
|
125
|
+
},`
|
|
126
|
+
: ''}
|
|
127
|
+
// The auth lists ship closed (deny-by-default) — grant the access your
|
|
128
|
+
// app needs here (ADR-0013).
|
|
102
129
|
access: {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
user: {
|
|
131
|
+
operation: {
|
|
132
|
+
query: isSignedIn,
|
|
133
|
+
update: ({ session, item }) => session?.userId === item?.id,
|
|
134
|
+
delete: ({ session, item }) => ${hasRoles ? `session?.role === 'admin' || session?.userId === item?.id` : `session?.userId === item?.id`},
|
|
135
|
+
},
|
|
108
136
|
},
|
|
109
137
|
},
|
|
110
138
|
}),
|
|
111
|
-
|
|
139
|
+
],
|
|
140
|
+
${SQLITE_DB_BLOCK},
|
|
141
|
+
// For PostgreSQL use @prisma/adapter-pg instead:
|
|
142
|
+
// ${POSTGRES_DB_BLOCK.replace(/\n/g, '\n // ')}
|
|
143
|
+
lists: {
|
|
144
|
+
// Your app lists go here. User, Session, Account, and Verification are
|
|
145
|
+
// added automatically by authPlugin.
|
|
146
|
+
},
|
|
147
|
+
ui: {
|
|
148
|
+
basePath: '/admin',
|
|
112
149
|
},
|
|
113
|
-
})
|
|
114
|
-
// Generated files
|
|
150
|
+
})`;
|
|
115
151
|
const files = [];
|
|
152
|
+
// Better-auth server instance
|
|
153
|
+
files.push({
|
|
154
|
+
path: 'lib/auth.ts',
|
|
155
|
+
language: 'typescript',
|
|
156
|
+
description: 'Better-auth server instance and session helper',
|
|
157
|
+
content: `import { createAuth } from '@opensaas/stack-auth/server'
|
|
158
|
+
import { headers } from 'next/headers'
|
|
159
|
+
import config from '../opensaas.config'
|
|
160
|
+
import { rawOpensaasContext } from '@/.opensaas/context'
|
|
161
|
+
|
|
162
|
+
export const auth = createAuth(config, rawOpensaasContext)
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get the current session in OpenSaas format (the configured sessionFields).
|
|
166
|
+
*/
|
|
167
|
+
export async function getSession() {
|
|
168
|
+
const session = await auth.api.getSession({ headers: await headers() })
|
|
169
|
+
if (!session || !session.user) return null
|
|
170
|
+
return {
|
|
171
|
+
userId: session.user.id,
|
|
172
|
+
email: session.user.email,
|
|
173
|
+
name: session.user.name,${hasRoles ? `\n role: (session.user as { role?: string }).role,` : ''}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const GET = auth.handler
|
|
178
|
+
export const POST = auth.handler`,
|
|
179
|
+
});
|
|
180
|
+
// Auth API route
|
|
181
|
+
files.push({
|
|
182
|
+
path: 'app/api/auth/[...all]/route.ts',
|
|
183
|
+
language: 'typescript',
|
|
184
|
+
description: 'Better-auth API route (handles all /api/auth/* endpoints)',
|
|
185
|
+
content: `export { GET, POST } from '@/lib/auth'`,
|
|
186
|
+
});
|
|
187
|
+
// Auth client
|
|
188
|
+
files.push({
|
|
189
|
+
path: 'lib/auth-client.ts',
|
|
190
|
+
language: 'typescript',
|
|
191
|
+
description: 'Client-side Better-auth instance for React components',
|
|
192
|
+
content: `'use client'
|
|
193
|
+
|
|
194
|
+
import { createClient } from '@opensaas/stack-auth/client'
|
|
195
|
+
|
|
196
|
+
export const authClient = createClient({
|
|
197
|
+
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
|
198
|
+
})`,
|
|
199
|
+
});
|
|
116
200
|
// Sign-in page
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
201
|
+
files.push({
|
|
202
|
+
path: 'app/sign-in/page.tsx',
|
|
203
|
+
language: 'tsx',
|
|
204
|
+
description: 'Sign-in page using the pre-built SignInForm',
|
|
205
|
+
content: `import { SignInForm } from '@opensaas/stack-auth/ui'
|
|
206
|
+
import { authClient } from '@/lib/auth-client'
|
|
207
|
+
import Link from 'next/link'
|
|
123
208
|
|
|
124
209
|
export default function SignInPage() {
|
|
125
210
|
return (
|
|
126
|
-
<div className="min-h-screen flex items-center justify-center">
|
|
211
|
+
<div className="min-h-screen flex items-center justify-center p-4">
|
|
127
212
|
<div className="w-full max-w-md">
|
|
128
213
|
<h1 className="text-2xl font-bold mb-6">Sign In</h1>
|
|
129
214
|
<SignInForm
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
215
|
+
authClient={authClient}
|
|
216
|
+
redirectTo="/admin"
|
|
217
|
+
showSocialProviders={${hasOAuth}}
|
|
133
218
|
/>
|
|
219
|
+
${hasPassword
|
|
220
|
+
? `<div className="mt-4 text-center text-sm">
|
|
221
|
+
Don't have an account?{' '}
|
|
222
|
+
<Link href="/sign-up" className="underline">Sign up</Link>
|
|
223
|
+
</div>`
|
|
224
|
+
: ''}
|
|
134
225
|
</div>
|
|
135
226
|
</div>
|
|
136
|
-
)
|
|
227
|
+
)
|
|
137
228
|
}`,
|
|
138
|
-
|
|
139
|
-
}
|
|
229
|
+
});
|
|
140
230
|
// Sign-up page
|
|
141
231
|
if (hasPassword) {
|
|
142
232
|
files.push({
|
|
143
233
|
path: 'app/sign-up/page.tsx',
|
|
144
234
|
language: 'tsx',
|
|
145
|
-
description: 'Sign-up page
|
|
146
|
-
content: `import { SignUpForm } from '@opensaas/stack-auth/ui'
|
|
235
|
+
description: 'Sign-up page using the pre-built SignUpForm',
|
|
236
|
+
content: `import { SignUpForm } from '@opensaas/stack-auth/ui'
|
|
237
|
+
import { authClient } from '@/lib/auth-client'
|
|
147
238
|
|
|
148
239
|
export default function SignUpPage() {
|
|
149
240
|
return (
|
|
150
|
-
<div className="min-h-screen flex items-center justify-center">
|
|
241
|
+
<div className="min-h-screen flex items-center justify-center p-4">
|
|
151
242
|
<div className="w-full max-w-md">
|
|
152
243
|
<h1 className="text-2xl font-bold mb-6">Create Account</h1>
|
|
153
|
-
<SignUpForm
|
|
154
|
-
fields={['email', 'password', 'name']}
|
|
155
|
-
redirectTo="/dashboard"
|
|
156
|
-
${emailVerification ? 'requireEmailVerification' : ''}
|
|
157
|
-
/>
|
|
244
|
+
<SignUpForm authClient={authClient} redirectTo="/admin" />
|
|
158
245
|
</div>
|
|
159
246
|
</div>
|
|
160
|
-
)
|
|
247
|
+
)
|
|
161
248
|
}`,
|
|
162
249
|
});
|
|
163
250
|
}
|
|
@@ -166,54 +253,49 @@ export default function SignUpPage() {
|
|
|
166
253
|
path: 'lib/access-control.ts',
|
|
167
254
|
language: 'typescript',
|
|
168
255
|
description: 'Reusable access control functions',
|
|
169
|
-
content: `import type { AccessControl } from '@opensaas/stack-core'
|
|
170
|
-
|
|
171
|
-
export const isAuthenticated: AccessControl = ({ session }) => {
|
|
172
|
-
return !!session?.userId;
|
|
173
|
-
};
|
|
256
|
+
content: `import type { AccessControl } from '@opensaas/stack-core'
|
|
174
257
|
|
|
258
|
+
export const isSignedIn: AccessControl = ({ session }) => !!session
|
|
175
259
|
${hasRoles
|
|
176
|
-
? `
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
export const
|
|
190
|
-
|
|
191
|
-
throw new Error('Authentication required');
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
};`,
|
|
260
|
+
? `
|
|
261
|
+
export const isAdmin: AccessControl = ({ session }) => session?.role === 'admin'
|
|
262
|
+
|
|
263
|
+
export const isSelf: AccessControl = ({ session, item }) => session?.userId === item?.id
|
|
264
|
+
|
|
265
|
+
export const isAdminOrSelf: AccessControl = ({ session, item }) =>
|
|
266
|
+
session?.role === 'admin' || session?.userId === item?.id
|
|
267
|
+
`
|
|
268
|
+
: `
|
|
269
|
+
export const isSelf: AccessControl = ({ session, item }) => session?.userId === item?.id
|
|
270
|
+
`}
|
|
271
|
+
// Filter-based access: scope queries to rows the user owns.
|
|
272
|
+
// Returning a Prisma filter (instead of a boolean) narrows results silently.
|
|
273
|
+
export const ownRecordsOnly: AccessControl = ({ session }) =>
|
|
274
|
+
session ? { userId: { equals: session.userId } } : false`,
|
|
195
275
|
});
|
|
196
276
|
// Environment variables
|
|
197
277
|
const envVars = {
|
|
198
|
-
DATABASE_URL: '
|
|
278
|
+
DATABASE_URL: 'file:./dev.db',
|
|
199
279
|
BETTER_AUTH_SECRET: '<generate-with-openssl-rand-base64-32>',
|
|
200
280
|
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
281
|
+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
|
201
282
|
};
|
|
202
|
-
if (
|
|
283
|
+
if (hasGoogle) {
|
|
203
284
|
envVars.GOOGLE_CLIENT_ID = '<your-google-client-id>';
|
|
204
285
|
envVars.GOOGLE_CLIENT_SECRET = '<your-google-client-secret>';
|
|
205
286
|
}
|
|
206
|
-
if (
|
|
287
|
+
if (hasGithub) {
|
|
207
288
|
envVars.GITHUB_CLIENT_ID = '<your-github-client-id>';
|
|
208
289
|
envVars.GITHUB_CLIENT_SECRET = '<your-github-client-secret>';
|
|
209
290
|
}
|
|
210
291
|
// Next steps
|
|
211
292
|
const nextSteps = [
|
|
212
|
-
'
|
|
293
|
+
'Install dependencies: `pnpm add @opensaas/stack-auth @prisma/adapter-better-sqlite3`',
|
|
294
|
+
'Merge the config updates into your `opensaas.config.ts`',
|
|
213
295
|
'Create the files shown above in your project',
|
|
214
296
|
'Add environment variables to your `.env` file',
|
|
215
297
|
hasOAuth ? 'Set up OAuth applications in Google/GitHub developer consoles' : null,
|
|
216
|
-
'Run `pnpm generate` to update Prisma schema',
|
|
298
|
+
'Run `pnpm generate` to update the Prisma schema and generated context',
|
|
217
299
|
'Run `pnpm db:push` to update your database',
|
|
218
300
|
'Start your dev server: `pnpm dev`',
|
|
219
301
|
`Visit http://localhost:3000/${hasPassword ? 'sign-up' : 'sign-in'} to test authentication`,
|
|
@@ -221,44 +303,44 @@ export const requireAuth: AccessControl = ({ session }) => {
|
|
|
221
303
|
// Dev guide section
|
|
222
304
|
const devGuideSection = `## Authentication Feature
|
|
223
305
|
|
|
224
|
-
This project uses Better-auth
|
|
306
|
+
This project uses Better-auth (via \`@opensaas/stack-auth\`) with:
|
|
225
307
|
|
|
226
308
|
${authMethods.map((m) => `- ${m}`).join('\n')}
|
|
227
309
|
${hasRoles ? `\n**User Roles**: ${roles?.join(', ')}` : ''}
|
|
228
310
|
|
|
229
|
-
|
|
311
|
+
The User, Session, Account, and Verification lists are auto-generated by
|
|
312
|
+
\`authPlugin\`. They ship closed (deny-by-default) — access is granted through
|
|
313
|
+
the plugin's \`access\` option in \`opensaas.config.ts\`.
|
|
230
314
|
|
|
231
|
-
|
|
315
|
+
### Access Control Helpers
|
|
232
316
|
|
|
233
317
|
\`\`\`typescript
|
|
234
|
-
import {
|
|
318
|
+
import { isSignedIn${hasRoles ? ', isAdmin, isSelf' : ', isSelf'} } from './lib/access-control'
|
|
235
319
|
|
|
236
320
|
// In your list config:
|
|
237
321
|
access: {
|
|
238
322
|
operation: {
|
|
239
323
|
query: () => true,
|
|
240
|
-
create:
|
|
241
|
-
update:
|
|
242
|
-
delete: ${hasRoles ? 'isAdmin' : '
|
|
243
|
-
}
|
|
324
|
+
create: isSignedIn,
|
|
325
|
+
update: ({ session, item }) => session?.userId === item?.authorId,
|
|
326
|
+
delete: ${hasRoles ? 'isAdmin' : '({ session, item }) => session?.userId === item?.authorId'},
|
|
327
|
+
},
|
|
244
328
|
}
|
|
245
329
|
\`\`\`
|
|
246
330
|
|
|
247
331
|
### Protected Routes
|
|
248
332
|
|
|
249
|
-
|
|
333
|
+
Check the session in server components:
|
|
250
334
|
|
|
251
335
|
\`\`\`typescript
|
|
252
|
-
import {
|
|
336
|
+
import { redirect } from 'next/navigation'
|
|
337
|
+
import { getSession } from '@/lib/auth'
|
|
253
338
|
|
|
254
339
|
export default async function ProtectedPage() {
|
|
255
|
-
const session = await
|
|
256
|
-
|
|
257
|
-
if (!session) {
|
|
258
|
-
redirect('/sign-in');
|
|
259
|
-
}
|
|
340
|
+
const session = await getSession()
|
|
341
|
+
if (!session) redirect('/sign-in')
|
|
260
342
|
|
|
261
|
-
return <div>
|
|
343
|
+
return <div>Signed in as {session.name}</div>
|
|
262
344
|
}
|
|
263
345
|
\`\`\`
|
|
264
346
|
|
|
@@ -267,12 +349,14 @@ export default async function ProtectedPage() {
|
|
|
267
349
|
In server actions or API routes:
|
|
268
350
|
|
|
269
351
|
\`\`\`typescript
|
|
270
|
-
import {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
352
|
+
import { getSession } from '@/lib/auth'
|
|
353
|
+
import { getContext } from '@/.opensaas/context'
|
|
354
|
+
|
|
355
|
+
const session = await getSession()
|
|
356
|
+
const context = await getContext(session)
|
|
357
|
+
const currentUser = session
|
|
358
|
+
? await context.db.user.findUnique({ where: { id: session.userId } })
|
|
359
|
+
: null
|
|
276
360
|
\`\`\``;
|
|
277
361
|
return {
|
|
278
362
|
configUpdates,
|
|
@@ -292,31 +376,35 @@ const currentUser = await context.db.user.findUnique({
|
|
|
292
376
|
const taxonomy = this.answers['taxonomy'] || [];
|
|
293
377
|
const postFields = this.answers['post-fields'] || [];
|
|
294
378
|
const useTiptap = contentEditor === 'Rich text editor (Tiptap)';
|
|
295
|
-
const
|
|
379
|
+
const hasCategories = taxonomy.includes('Categories');
|
|
380
|
+
const hasTags = taxonomy.includes('Tags');
|
|
296
381
|
// Build Post fields
|
|
297
382
|
const fields = [
|
|
298
383
|
'title: text({ validation: { isRequired: true } })',
|
|
299
|
-
|
|
384
|
+
"slug: text({ validation: { isRequired: true }, isIndexed: 'unique' })",
|
|
300
385
|
];
|
|
301
386
|
if (useTiptap) {
|
|
302
387
|
fields.push('content: richText({ validation: { isRequired: true } })');
|
|
303
388
|
}
|
|
304
|
-
else if (useMarkdown) {
|
|
305
|
-
fields.push('content: text({ ui: { displayMode: "textarea" }, validation: { isRequired: true } })');
|
|
306
|
-
}
|
|
307
389
|
else {
|
|
308
|
-
fields.push(
|
|
390
|
+
fields.push("content: text({ ui: { displayMode: 'textarea' }, validation: { isRequired: true } })");
|
|
309
391
|
}
|
|
310
|
-
fields.push(
|
|
392
|
+
fields.push("author: relationship({ ref: 'User.posts' })");
|
|
311
393
|
if (hasStatus) {
|
|
312
|
-
fields.push("status: select({ options: ['draft', 'published'], defaultValue: 'draft' })");
|
|
394
|
+
fields.push("status: select({ options: [{ label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }], defaultValue: 'draft' })");
|
|
313
395
|
fields.push('publishedAt: timestamp()');
|
|
314
396
|
}
|
|
397
|
+
if (hasCategories) {
|
|
398
|
+
fields.push("category: relationship({ ref: 'Category.posts' })");
|
|
399
|
+
}
|
|
400
|
+
if (hasTags) {
|
|
401
|
+
fields.push("tags: relationship({ ref: 'Tag.posts', many: true })");
|
|
402
|
+
}
|
|
315
403
|
if (postFields.includes('Featured image')) {
|
|
316
|
-
fields.push('featuredImage: text()');
|
|
404
|
+
fields.push('featuredImage: text() // URL — or an image() field, see the file-upload feature');
|
|
317
405
|
}
|
|
318
406
|
if (postFields.includes('Excerpt/summary')) {
|
|
319
|
-
fields.push(
|
|
407
|
+
fields.push("excerpt: text({ ui: { displayMode: 'textarea' } })");
|
|
320
408
|
}
|
|
321
409
|
if (postFields.includes('SEO metadata (title, description)')) {
|
|
322
410
|
fields.push('seoTitle: text()');
|
|
@@ -325,76 +413,113 @@ const currentUser = await context.db.user.findUnique({
|
|
|
325
413
|
if (postFields.includes('Reading time estimate')) {
|
|
326
414
|
fields.push('readingTime: integer()');
|
|
327
415
|
}
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
416
|
+
const fieldImports = ['text', 'relationship'];
|
|
417
|
+
if (hasStatus)
|
|
418
|
+
fieldImports.push('select', 'timestamp');
|
|
419
|
+
if (postFields.includes('Reading time estimate'))
|
|
420
|
+
fieldImports.push('integer');
|
|
421
|
+
const configUpdates = `// Add these imports and lists to your existing opensaas.config.ts
|
|
422
|
+
import { ${fieldImports.join(', ')} } from '@opensaas/stack-core/fields'
|
|
423
|
+
${useTiptap ? "import { richText } from '@opensaas/stack-tiptap/fields'" : ''}
|
|
424
|
+
|
|
425
|
+
// Inside config({ lists: { ... } }):
|
|
334
426
|
Post: list({
|
|
335
427
|
fields: {
|
|
336
428
|
${fields.join(',\n ')},
|
|
337
429
|
},
|
|
338
430
|
access: {
|
|
339
431
|
operation: {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
create: ({ session }) => !!session
|
|
345
|
-
update: ({ session, item }) => session?.userId === item
|
|
432
|
+
${hasStatus
|
|
433
|
+
? `// Anonymous visitors only see published posts (filter-based access)
|
|
434
|
+
query: ({ session }) => (session ? true : { status: { equals: 'published' } }),`
|
|
435
|
+
: 'query: () => true,'}
|
|
436
|
+
create: ({ session }) => !!session,
|
|
437
|
+
update: ({ session, item }) => session?.userId === item?.authorId,
|
|
346
438
|
delete: ({ session, item }) =>
|
|
347
|
-
session?.role === 'admin' || session?.userId === item
|
|
439
|
+
session?.role === 'admin' || session?.userId === item?.authorId,
|
|
348
440
|
},
|
|
349
441
|
},
|
|
350
442
|
hooks: {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Auto-
|
|
354
|
-
if (operation === '
|
|
355
|
-
|
|
443
|
+
resolveInput: async ({ operation, resolvedData, item, context }) => {
|
|
444
|
+
const data = { ...resolvedData }
|
|
445
|
+
// Auto-connect the author on create
|
|
446
|
+
if (operation === 'create' && !data.author && context.session?.userId) {
|
|
447
|
+
data.author = { connect: { id: context.session.userId } }
|
|
356
448
|
}
|
|
357
|
-
|
|
358
|
-
|
|
449
|
+
${hasStatus
|
|
450
|
+
? `// Auto-set publishedAt when publishing
|
|
451
|
+
if (operation === 'create' && data.status === 'published') {
|
|
452
|
+
data.publishedAt = new Date()
|
|
453
|
+
} else if (operation === 'update' && data.status === 'published' && !item?.publishedAt) {
|
|
454
|
+
data.publishedAt = new Date()
|
|
455
|
+
}`
|
|
359
456
|
: ''}
|
|
457
|
+
return data
|
|
458
|
+
},
|
|
360
459
|
},
|
|
361
460
|
}),
|
|
362
|
-
${
|
|
461
|
+
${hasCategories
|
|
363
462
|
? `Category: list({
|
|
364
463
|
fields: {
|
|
365
464
|
name: text({ validation: { isRequired: true } }),
|
|
366
|
-
slug: text({ validation: { isRequired: true } }),
|
|
465
|
+
slug: text({ validation: { isRequired: true }, isIndexed: 'unique' }),
|
|
367
466
|
posts: relationship({ ref: 'Post.category', many: true }),
|
|
368
467
|
},
|
|
468
|
+
access: {
|
|
469
|
+
operation: {
|
|
470
|
+
query: () => true,
|
|
471
|
+
create: ({ session }) => session?.role === 'admin',
|
|
472
|
+
update: ({ session }) => session?.role === 'admin',
|
|
473
|
+
delete: ({ session }) => session?.role === 'admin',
|
|
474
|
+
},
|
|
475
|
+
},
|
|
369
476
|
}),`
|
|
370
477
|
: ''}
|
|
371
|
-
${
|
|
478
|
+
${hasTags
|
|
372
479
|
? `Tag: list({
|
|
373
480
|
fields: {
|
|
374
481
|
name: text({ validation: { isRequired: true } }),
|
|
375
482
|
posts: relationship({ ref: 'Post.tags', many: true }),
|
|
376
483
|
},
|
|
484
|
+
access: {
|
|
485
|
+
operation: {
|
|
486
|
+
query: () => true,
|
|
487
|
+
create: ({ session }) => !!session,
|
|
488
|
+
update: ({ session }) => session?.role === 'admin',
|
|
489
|
+
delete: ({ session }) => session?.role === 'admin',
|
|
490
|
+
},
|
|
491
|
+
},
|
|
377
492
|
}),`
|
|
378
493
|
: ''}
|
|
379
|
-
|
|
380
|
-
|
|
494
|
+
|
|
495
|
+
// If you use authPlugin, give the auto-generated User list the other side of
|
|
496
|
+
// the author relationship via extendUserList:
|
|
497
|
+
//
|
|
498
|
+
// authPlugin({
|
|
499
|
+
// ...,
|
|
500
|
+
// extendUserList: {
|
|
501
|
+
// fields: {
|
|
502
|
+
// posts: relationship({ ref: 'Post.author', many: true }),
|
|
503
|
+
// },
|
|
504
|
+
// },
|
|
505
|
+
// })`;
|
|
381
506
|
const files = [];
|
|
382
507
|
// Blog list page
|
|
383
508
|
files.push({
|
|
384
509
|
path: 'app/blog/page.tsx',
|
|
385
510
|
language: 'tsx',
|
|
386
511
|
description: 'Blog listing page',
|
|
387
|
-
content: `import { getContext } from '@/.opensaas/context'
|
|
388
|
-
import Link from 'next/link'
|
|
512
|
+
content: `import { getContext } from '@/.opensaas/context'
|
|
513
|
+
import Link from 'next/link'
|
|
389
514
|
|
|
390
515
|
export default async function BlogPage() {
|
|
391
|
-
const context = await getContext()
|
|
516
|
+
const context = await getContext()
|
|
392
517
|
|
|
393
518
|
const posts = await context.db.post.findMany({
|
|
394
|
-
${hasStatus ? "where: { status: 'published' }," : ''}
|
|
519
|
+
${hasStatus ? "where: { status: { equals: 'published' } }," : ''}
|
|
395
520
|
orderBy: { ${hasStatus ? 'publishedAt' : 'createdAt'}: 'desc' },
|
|
396
521
|
include: { author: true },
|
|
397
|
-
})
|
|
522
|
+
})
|
|
398
523
|
|
|
399
524
|
return (
|
|
400
525
|
<div className="container mx-auto py-8">
|
|
@@ -403,19 +528,17 @@ export default async function BlogPage() {
|
|
|
403
528
|
{posts.map((post) => (
|
|
404
529
|
<article key={post.id} className="border rounded-lg p-6">
|
|
405
530
|
<Link href={\`/blog/\${post.slug}\`}>
|
|
406
|
-
<h2 className="text-2xl font-bold hover:underline">
|
|
407
|
-
{post.title}
|
|
408
|
-
</h2>
|
|
531
|
+
<h2 className="text-2xl font-bold hover:underline">{post.title}</h2>
|
|
409
532
|
</Link>
|
|
410
533
|
${postFields.includes('Excerpt/summary') ? '<p className="mt-2 text-gray-600">{post.excerpt}</p>' : ''}
|
|
411
534
|
<div className="mt-4 text-sm text-gray-500">
|
|
412
|
-
By {post.author
|
|
535
|
+
By {post.author?.name ?? 'Unknown'} · ${hasStatus ? '{post.publishedAt?.toLocaleDateString()}' : '{post.createdAt.toLocaleDateString()}'}
|
|
413
536
|
</div>
|
|
414
537
|
</article>
|
|
415
538
|
))}
|
|
416
539
|
</div>
|
|
417
540
|
</div>
|
|
418
|
-
)
|
|
541
|
+
)
|
|
419
542
|
}`,
|
|
420
543
|
});
|
|
421
544
|
// Blog post page
|
|
@@ -423,78 +546,69 @@ export default async function BlogPage() {
|
|
|
423
546
|
path: 'app/blog/[slug]/page.tsx',
|
|
424
547
|
language: 'tsx',
|
|
425
548
|
description: 'Individual blog post page',
|
|
426
|
-
content: `import { getContext } from '@/.opensaas/context'
|
|
427
|
-
import { notFound } from 'next/navigation'
|
|
549
|
+
content: `import { getContext } from '@/.opensaas/context'
|
|
550
|
+
import { notFound } from 'next/navigation'
|
|
428
551
|
|
|
429
552
|
export default async function BlogPostPage({
|
|
430
553
|
params,
|
|
431
554
|
}: {
|
|
432
|
-
params: { slug: string }
|
|
555
|
+
params: Promise<{ slug: string }>
|
|
433
556
|
}) {
|
|
434
|
-
const
|
|
557
|
+
const { slug } = await params
|
|
558
|
+
const context = await getContext()
|
|
435
559
|
|
|
436
560
|
const post = await context.db.post.findFirst({
|
|
437
561
|
where: {
|
|
438
|
-
slug:
|
|
439
|
-
${hasStatus ? "status: 'published'," : ''}
|
|
562
|
+
slug: { equals: slug },
|
|
563
|
+
${hasStatus ? "status: { equals: 'published' }," : ''}
|
|
440
564
|
},
|
|
441
565
|
include: { author: true },
|
|
442
|
-
})
|
|
566
|
+
})
|
|
443
567
|
|
|
444
568
|
if (!post) {
|
|
445
|
-
notFound()
|
|
569
|
+
notFound()
|
|
446
570
|
}
|
|
447
571
|
|
|
448
572
|
return (
|
|
449
573
|
<article className="container mx-auto py-8 max-w-3xl">
|
|
450
574
|
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
|
|
451
575
|
<div className="text-gray-600 mb-8">
|
|
452
|
-
By {post.author
|
|
576
|
+
By {post.author?.name ?? 'Unknown'} · ${hasStatus ? '{post.publishedAt?.toLocaleDateString()}' : '{post.createdAt.toLocaleDateString()}'}
|
|
453
577
|
</div>
|
|
454
|
-
${useTiptap
|
|
578
|
+
${useTiptap
|
|
579
|
+
? `{/* richText stores Tiptap JSON. Render it to HTML with
|
|
580
|
+
generateHTML(post.content, [StarterKit]) from '@tiptap/html' +
|
|
581
|
+
'@tiptap/starter-kit', or build a custom renderer. */}
|
|
582
|
+
<div className="prose max-w-none">{JSON.stringify(post.content)}</div>`
|
|
583
|
+
: '<div className="prose max-w-none whitespace-pre-wrap">{post.content}</div>'}
|
|
455
584
|
</article>
|
|
456
|
-
)
|
|
585
|
+
)
|
|
457
586
|
}`,
|
|
458
587
|
});
|
|
459
588
|
const nextSteps = [
|
|
460
|
-
'
|
|
461
|
-
'Add the `posts` relationship
|
|
462
|
-
useTiptap ? 'Install Tiptap package: `pnpm add @opensaas/stack-tiptap`' : null,
|
|
589
|
+
'Merge the config updates into your `opensaas.config.ts`',
|
|
590
|
+
'Add the `posts` relationship to the User list (via authPlugin `extendUserList` shown above)',
|
|
591
|
+
useTiptap ? 'Install the Tiptap package: `pnpm add @opensaas/stack-tiptap`' : null,
|
|
592
|
+
useTiptap
|
|
593
|
+
? 'For public rendering of rich text, add `@tiptap/html` and `@tiptap/starter-kit`'
|
|
594
|
+
: null,
|
|
463
595
|
'Create the blog pages in your `app/` directory',
|
|
464
|
-
'Run `pnpm generate` to update Prisma schema',
|
|
465
|
-
'Run `pnpm db:push` to update database',
|
|
466
|
-
'Create your first blog post in the admin UI',
|
|
596
|
+
'Run `pnpm generate` to update the Prisma schema',
|
|
597
|
+
'Run `pnpm db:push` to update the database',
|
|
598
|
+
'Create your first blog post in the admin UI at /admin',
|
|
467
599
|
].filter(Boolean);
|
|
468
600
|
const devGuideSection = `## Blog Feature
|
|
469
601
|
|
|
470
602
|
This project includes a blog system with:
|
|
471
603
|
|
|
472
604
|
- ${contentEditor} for writing posts
|
|
473
|
-
${hasStatus ? '- Draft/publish workflow' : ''}
|
|
605
|
+
${hasStatus ? '- Draft/publish workflow (publishedAt is set automatically by a resolveInput hook)' : ''}
|
|
474
606
|
${taxonomy.length > 0 ? `- ${taxonomy.join(' and ')} for organization` : ''}
|
|
475
607
|
|
|
476
|
-
### Creating a Post
|
|
477
|
-
|
|
478
|
-
${hasStatus
|
|
479
|
-
? `Posts start as drafts and can be published when ready:
|
|
480
|
-
|
|
481
|
-
\`\`\`typescript
|
|
482
|
-
const post = await context.db.post.create({
|
|
483
|
-
data: {
|
|
484
|
-
title: 'My Post',
|
|
485
|
-
slug: 'my-post',
|
|
486
|
-
content: '...',
|
|
487
|
-
authorId: session.userId,
|
|
488
|
-
status: 'draft', // or 'published'
|
|
489
|
-
}
|
|
490
|
-
});
|
|
491
|
-
\`\`\``
|
|
492
|
-
: ''}
|
|
493
|
-
|
|
494
608
|
### Access Control
|
|
495
609
|
|
|
496
|
-
- Anyone can read ${hasStatus ? 'published posts' : 'posts'}
|
|
497
|
-
- Only authenticated users can create posts
|
|
610
|
+
- Anyone can read ${hasStatus ? 'published posts; signed-in users see drafts too (filter-based access)' : 'posts'}
|
|
611
|
+
- Only authenticated users can create posts (author is auto-connected from the session)
|
|
498
612
|
- Only authors can update their own posts
|
|
499
613
|
- Admins and authors can delete posts`;
|
|
500
614
|
return {
|
|
@@ -502,44 +616,326 @@ const post = await context.db.post.create({
|
|
|
502
616
|
files,
|
|
503
617
|
instructions: nextSteps,
|
|
504
618
|
devGuideSection,
|
|
505
|
-
envVars:
|
|
619
|
+
envVars: undefined,
|
|
506
620
|
nextSteps,
|
|
507
621
|
};
|
|
508
622
|
}
|
|
509
623
|
/**
|
|
510
|
-
* Generate comments feature
|
|
624
|
+
* Generate comments feature
|
|
511
625
|
*/
|
|
512
626
|
generateComments() {
|
|
627
|
+
const targets = this.answers['comment-targets'] || ['Posts'];
|
|
628
|
+
const nestedReplies = this.answers['nested-replies'];
|
|
629
|
+
const moderation = this.answers['moderation'];
|
|
630
|
+
const requiresApproval = moderation === 'Require admin approval';
|
|
631
|
+
// Build one relationship per commentable list (Posts → Post, Products → Product)
|
|
632
|
+
const targetLists = targets
|
|
633
|
+
.filter((t) => t !== 'Other')
|
|
634
|
+
.map((t) => (t.endsWith('s') ? t.slice(0, -1) : t));
|
|
635
|
+
const fields = [
|
|
636
|
+
"content: text({ validation: { isRequired: true }, ui: { displayMode: 'textarea' } })",
|
|
637
|
+
"author: relationship({ ref: 'User.comments' })",
|
|
638
|
+
...targetLists.map((target) => `${target.toLowerCase()}: relationship({ ref: '${target}.comments' })`),
|
|
639
|
+
];
|
|
640
|
+
if (nestedReplies) {
|
|
641
|
+
fields.push("parent: relationship({ ref: 'Comment.replies' })");
|
|
642
|
+
fields.push("replies: relationship({ ref: 'Comment.parent', many: true })");
|
|
643
|
+
}
|
|
644
|
+
if (requiresApproval) {
|
|
645
|
+
fields.push("status: select({ options: [{ label: 'Pending', value: 'pending' }, { label: 'Approved', value: 'approved' }], defaultValue: 'pending' })");
|
|
646
|
+
}
|
|
647
|
+
const fieldImports = ['text', 'relationship'];
|
|
648
|
+
if (requiresApproval)
|
|
649
|
+
fieldImports.push('select');
|
|
650
|
+
const configUpdates = `// Add these imports and lists to your existing opensaas.config.ts
|
|
651
|
+
import { ${fieldImports.join(', ')} } from '@opensaas/stack-core/fields'
|
|
652
|
+
|
|
653
|
+
// Inside config({ lists: { ... } }):
|
|
654
|
+
Comment: list({
|
|
655
|
+
fields: {
|
|
656
|
+
${fields.join(',\n ')},
|
|
657
|
+
},
|
|
658
|
+
access: {
|
|
659
|
+
operation: {
|
|
660
|
+
${requiresApproval
|
|
661
|
+
? `// Everyone sees approved comments; authors see their own pending
|
|
662
|
+
// ones; admins see everything (filter-based access).
|
|
663
|
+
query: ({ session }) => {
|
|
664
|
+
if (session?.role === 'admin') return true
|
|
665
|
+
if (session) {
|
|
666
|
+
return {
|
|
667
|
+
OR: [
|
|
668
|
+
{ status: { equals: 'approved' } },
|
|
669
|
+
{ authorId: { equals: session.userId } },
|
|
670
|
+
],
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return { status: { equals: 'approved' } }
|
|
674
|
+
},`
|
|
675
|
+
: 'query: () => true,'}
|
|
676
|
+
create: ({ session }) => !!session,
|
|
677
|
+
update: ({ session, item }) =>
|
|
678
|
+
session?.role === 'admin' || session?.userId === item?.authorId,
|
|
679
|
+
delete: ({ session, item }) =>
|
|
680
|
+
session?.role === 'admin' || session?.userId === item?.authorId,
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
hooks: {
|
|
684
|
+
resolveInput: async ({ operation, resolvedData, context }) => {
|
|
685
|
+
const data = { ...resolvedData }
|
|
686
|
+
// Auto-connect the author on create
|
|
687
|
+
if (operation === 'create' && !data.author && context.session?.userId) {
|
|
688
|
+
data.author = { connect: { id: context.session.userId } }
|
|
689
|
+
}
|
|
690
|
+
return data
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
}),
|
|
694
|
+
|
|
695
|
+
// Add the other side of each relationship:
|
|
696
|
+
${targetLists
|
|
697
|
+
.map((target) => `// - On the ${target} list: comments: relationship({ ref: 'Comment.${target.toLowerCase()}', many: true })`)
|
|
698
|
+
.join('\n')}
|
|
699
|
+
// - On the User list (via authPlugin extendUserList):
|
|
700
|
+
// comments: relationship({ ref: 'Comment.author', many: true })`;
|
|
701
|
+
const nextSteps = [
|
|
702
|
+
'Merge the config updates into your `opensaas.config.ts`',
|
|
703
|
+
...targetLists.map((target) => `Add the \`comments\` relationship field to your ${target} list`),
|
|
704
|
+
'Add the `comments` relationship to the User list (via authPlugin `extendUserList`)',
|
|
705
|
+
'Run `pnpm generate` to update the Prisma schema',
|
|
706
|
+
'Run `pnpm db:push` to update the database',
|
|
707
|
+
requiresApproval
|
|
708
|
+
? 'Moderate comments in the admin UI at /admin/comment (flip status to approved)'
|
|
709
|
+
: 'View comments in the admin UI at /admin/comment',
|
|
710
|
+
];
|
|
711
|
+
const devGuideSection = `## Comments Feature
|
|
712
|
+
|
|
713
|
+
Threaded comments on: ${targetLists.join(', ')}
|
|
714
|
+
|
|
715
|
+
- ${nestedReplies ? 'Nested replies via a self-referential parent/replies relationship' : 'Flat comment threads'}
|
|
716
|
+
- ${moderation}
|
|
717
|
+
- Authors are auto-connected from the session by a resolveInput hook
|
|
718
|
+
- Authors can edit/delete their own comments; admins can moderate everything${requiresApproval
|
|
719
|
+
? '\n- Anonymous visitors only see approved comments (filter-based access)'
|
|
720
|
+
: ''}`;
|
|
513
721
|
return {
|
|
514
|
-
configUpdates
|
|
722
|
+
configUpdates,
|
|
515
723
|
files: [],
|
|
516
|
-
instructions:
|
|
517
|
-
devGuideSection
|
|
518
|
-
nextSteps
|
|
724
|
+
instructions: nextSteps,
|
|
725
|
+
devGuideSection,
|
|
726
|
+
nextSteps,
|
|
519
727
|
};
|
|
520
728
|
}
|
|
521
729
|
/**
|
|
522
|
-
* Generate file upload feature
|
|
730
|
+
* Generate file upload feature
|
|
523
731
|
*/
|
|
524
732
|
generateFileUpload() {
|
|
733
|
+
const provider = this.answers['storage-provider'];
|
|
734
|
+
const associations = this.answers['file-associations'] || [];
|
|
735
|
+
const fileTypes = this.answers['file-types'] || [];
|
|
736
|
+
const imagesOnly = fileTypes.length === 1 && fileTypes.includes('Images (jpg, png, webp)');
|
|
737
|
+
let storageImport;
|
|
738
|
+
let storageBlock;
|
|
739
|
+
let extraDependency = null;
|
|
740
|
+
const envVars = {};
|
|
741
|
+
if (provider === 'AWS S3' || provider === 'Cloudflare R2') {
|
|
742
|
+
const isR2 = provider === 'Cloudflare R2';
|
|
743
|
+
storageImport = "import { s3Storage } from '@opensaas/stack-storage-s3'";
|
|
744
|
+
extraDependency = '@opensaas/stack-storage-s3';
|
|
745
|
+
storageBlock = `storage: {
|
|
746
|
+
uploads: s3Storage({
|
|
747
|
+
bucket: process.env.S3_BUCKET!,
|
|
748
|
+
region: process.env.S3_REGION${isR2 ? " || 'auto'" : '!'},
|
|
749
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
|
750
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,${isR2
|
|
751
|
+
? `\n // R2 is S3-compatible — point the endpoint at your account
|
|
752
|
+
endpoint: process.env.S3_ENDPOINT, // https://<account-id>.r2.cloudflarestorage.com`
|
|
753
|
+
: ''}
|
|
754
|
+
}),
|
|
755
|
+
},`;
|
|
756
|
+
envVars.S3_BUCKET = '<your-bucket>';
|
|
757
|
+
envVars.S3_REGION = isR2 ? 'auto' : 'us-east-1';
|
|
758
|
+
envVars.S3_ACCESS_KEY_ID = '<access-key-id>';
|
|
759
|
+
envVars.S3_SECRET_ACCESS_KEY = '<secret-access-key>';
|
|
760
|
+
if (isR2)
|
|
761
|
+
envVars.S3_ENDPOINT = 'https://<account-id>.r2.cloudflarestorage.com';
|
|
762
|
+
}
|
|
763
|
+
else if (provider === 'Vercel Blob') {
|
|
764
|
+
storageImport = "import { vercelBlobStorage } from '@opensaas/stack-storage-vercel'";
|
|
765
|
+
extraDependency = '@opensaas/stack-storage-vercel';
|
|
766
|
+
storageBlock = `storage: {
|
|
767
|
+
uploads: vercelBlobStorage({
|
|
768
|
+
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
769
|
+
}),
|
|
770
|
+
},`;
|
|
771
|
+
envVars.BLOB_READ_WRITE_TOKEN = '<vercel-blob-read-write-token>';
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
storageImport = "import { localStorage } from '@opensaas/stack-storage'";
|
|
775
|
+
storageBlock = `storage: {
|
|
776
|
+
uploads: localStorage({
|
|
777
|
+
uploadDir: './public/uploads',
|
|
778
|
+
serveUrl: '/uploads',
|
|
779
|
+
}),
|
|
780
|
+
},`;
|
|
781
|
+
}
|
|
782
|
+
const imageFieldExample = `image({
|
|
783
|
+
storage: 'uploads',
|
|
784
|
+
transformations: {
|
|
785
|
+
thumbnail: { width: 100, height: 100, fit: 'cover', format: 'webp' },
|
|
786
|
+
},
|
|
787
|
+
validation: {
|
|
788
|
+
maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
789
|
+
acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
|
790
|
+
},
|
|
791
|
+
})`;
|
|
792
|
+
const fileFieldExample = `file({
|
|
793
|
+
storage: 'uploads',
|
|
794
|
+
validation: {
|
|
795
|
+
maxFileSize: 25 * 1024 * 1024, // 25MB
|
|
796
|
+
},
|
|
797
|
+
})`;
|
|
798
|
+
const fieldExamples = [];
|
|
799
|
+
if (associations.includes('User avatars')) {
|
|
800
|
+
fieldExamples.push(`// On the User list (via authPlugin extendUserList):
|
|
801
|
+
avatar: ${imageFieldExample},`);
|
|
802
|
+
}
|
|
803
|
+
if (associations.includes('Post featured images') || associations.includes('Product images')) {
|
|
804
|
+
const listName = associations.includes('Post featured images') ? 'Post' : 'Product';
|
|
805
|
+
fieldExamples.push(`// On the ${listName} list:
|
|
806
|
+
${associations.includes('Post featured images') ? 'featuredImage' : 'images'}: ${imageFieldExample},`);
|
|
807
|
+
}
|
|
808
|
+
if (associations.includes('General attachments') || !imagesOnly) {
|
|
809
|
+
fieldExamples.push(`// General attachments on any list:
|
|
810
|
+
attachment: ${fileFieldExample},`);
|
|
811
|
+
}
|
|
812
|
+
const configUpdates = `// Add these imports to your opensaas.config.ts
|
|
813
|
+
${storageImport}
|
|
814
|
+
import { file, image } from '@opensaas/stack-storage/fields'
|
|
815
|
+
|
|
816
|
+
// Add a top-level storage config alongside db and lists:
|
|
817
|
+
export default config({
|
|
818
|
+
${storageBlock}
|
|
819
|
+
// ...
|
|
820
|
+
lists: {
|
|
821
|
+
// Use file()/image() fields, referencing the storage key by name:
|
|
822
|
+
${fieldExamples.join('\n ')}
|
|
823
|
+
},
|
|
824
|
+
})`;
|
|
825
|
+
const nextSteps = [
|
|
826
|
+
`Install dependencies: \`pnpm add @opensaas/stack-storage${extraDependency ? ` ${extraDependency}` : ''}\``,
|
|
827
|
+
'Merge the storage config and fields into your `opensaas.config.ts`',
|
|
828
|
+
Object.keys(envVars).length > 0 ? 'Add environment variables to your `.env` file' : null,
|
|
829
|
+
'Run `pnpm generate` to update the Prisma schema',
|
|
830
|
+
'Run `pnpm db:push` to update the database',
|
|
831
|
+
'Upload files through the admin UI — the image/file fields render an upload widget automatically',
|
|
832
|
+
].filter(Boolean);
|
|
833
|
+
const devGuideSection = `## File Upload Feature
|
|
834
|
+
|
|
835
|
+
Storage provider: ${provider}
|
|
836
|
+
|
|
837
|
+
- Files are stored via the \`storage\` config block; fields reference a storage
|
|
838
|
+
key by name (\`storage: 'uploads'\`)
|
|
839
|
+
- \`image()\` fields support transformations (resize/format) and validation
|
|
840
|
+
- \`file()\` fields handle any file type with size validation
|
|
841
|
+
- Access control on the owning list applies to the field like any other field`;
|
|
525
842
|
return {
|
|
526
|
-
configUpdates
|
|
843
|
+
configUpdates,
|
|
527
844
|
files: [],
|
|
528
|
-
instructions:
|
|
529
|
-
devGuideSection
|
|
530
|
-
|
|
845
|
+
instructions: nextSteps,
|
|
846
|
+
devGuideSection,
|
|
847
|
+
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
|
|
848
|
+
nextSteps,
|
|
531
849
|
};
|
|
532
850
|
}
|
|
533
851
|
/**
|
|
534
|
-
* Generate semantic search feature
|
|
852
|
+
* Generate semantic search feature
|
|
535
853
|
*/
|
|
536
854
|
generateSemanticSearch() {
|
|
855
|
+
const searchableContent = this.answers['searchable-content'] || ['Posts'];
|
|
856
|
+
const embeddingProvider = this.answers['embedding-provider'];
|
|
857
|
+
const searchFields = this.answers['search-fields'] || ['Title', 'Content/body'];
|
|
858
|
+
const useOllama = embeddingProvider?.startsWith('Ollama');
|
|
859
|
+
const providerBlock = useOllama
|
|
860
|
+
? `provider: ollamaEmbeddings({
|
|
861
|
+
baseURL: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
|
862
|
+
model: 'nomic-embed-text',
|
|
863
|
+
}),
|
|
864
|
+
storage: sqliteVssStorage({
|
|
865
|
+
distanceFunction: 'cosine',
|
|
866
|
+
}),`
|
|
867
|
+
: `provider: openaiEmbeddings({
|
|
868
|
+
apiKey: process.env.OPENAI_API_KEY!,
|
|
869
|
+
model: 'text-embedding-3-small',
|
|
870
|
+
}),
|
|
871
|
+
// pgvectorStorage requires the postgresql db provider.
|
|
872
|
+
// On SQLite, use sqliteVssStorage from '@opensaas/stack-rag' instead.
|
|
873
|
+
storage: pgvectorStorage({
|
|
874
|
+
distanceFunction: 'cosine',
|
|
875
|
+
}),`;
|
|
876
|
+
const ragImports = useOllama
|
|
877
|
+
? "import { ragPlugin, ollamaEmbeddings, sqliteVssStorage } from '@opensaas/stack-rag'"
|
|
878
|
+
: "import { ragPlugin, openaiEmbeddings, pgvectorStorage } from '@opensaas/stack-rag'";
|
|
879
|
+
const targetList = searchableContent[0]?.endsWith('s')
|
|
880
|
+
? searchableContent[0].slice(0, -1)
|
|
881
|
+
: searchableContent[0] || 'Post';
|
|
882
|
+
const envVars = useOllama
|
|
883
|
+
? { OLLAMA_BASE_URL: 'http://localhost:11434' }
|
|
884
|
+
: { OPENAI_API_KEY: '<your-openai-api-key>' };
|
|
885
|
+
const configUpdates = `// Add these imports to your opensaas.config.ts
|
|
886
|
+
${ragImports}
|
|
887
|
+
import { searchable } from '@opensaas/stack-rag/fields'
|
|
888
|
+
|
|
889
|
+
// Add the RAG plugin alongside your other plugins:
|
|
890
|
+
export default config({
|
|
891
|
+
plugins: [
|
|
892
|
+
ragPlugin({
|
|
893
|
+
${providerBlock}
|
|
894
|
+
}),
|
|
895
|
+
// ...your other plugins
|
|
896
|
+
],
|
|
897
|
+
// ...
|
|
898
|
+
lists: {
|
|
899
|
+
${targetList}: list({
|
|
900
|
+
fields: {
|
|
901
|
+
// Wrap the fields you want indexed with searchable() —
|
|
902
|
+
// embeddings update automatically when the content changes.
|
|
903
|
+
${searchFields.includes('Title') ? `title: searchable(text({ validation: { isRequired: true } })),` : ''}
|
|
904
|
+
${searchFields.includes('Content/body')
|
|
905
|
+
? `content: searchable(
|
|
906
|
+
text({ validation: { isRequired: true }, ui: { displayMode: 'textarea' } }),
|
|
907
|
+
),`
|
|
908
|
+
: ''}
|
|
909
|
+
// ...your other fields
|
|
910
|
+
},
|
|
911
|
+
}),
|
|
912
|
+
},
|
|
913
|
+
})`;
|
|
914
|
+
const nextSteps = [
|
|
915
|
+
'Install the RAG package: `pnpm add @opensaas/stack-rag`',
|
|
916
|
+
useOllama
|
|
917
|
+
? 'Install and start Ollama, then pull the embedding model: `ollama pull nomic-embed-text`'
|
|
918
|
+
: 'Set OPENAI_API_KEY in your `.env` file (pgvector storage requires PostgreSQL)',
|
|
919
|
+
'Merge the plugin and searchable() fields into your `opensaas.config.ts`',
|
|
920
|
+
'Run `pnpm generate` to update the Prisma schema',
|
|
921
|
+
'Run `pnpm db:push` to update the database',
|
|
922
|
+
`Query with the RAG runtime — see the ${useOllama ? 'rag-ollama-demo' : 'rag-openai-chatbot'} example for a full search API route`,
|
|
923
|
+
];
|
|
924
|
+
const devGuideSection = `## Semantic Search Feature
|
|
925
|
+
|
|
926
|
+
AI-powered search over: ${searchableContent.join(', ')}
|
|
927
|
+
|
|
928
|
+
- Embedding provider: ${embeddingProvider}
|
|
929
|
+
- Fields wrapped in \`searchable()\` are embedded automatically on create/update
|
|
930
|
+
- Search respects the list's access control — users only find what they can read
|
|
931
|
+
- See the ${useOllama ? 'rag-ollama-demo' : 'rag-openai-chatbot'} example in the stack repo for a complete search UI`;
|
|
537
932
|
return {
|
|
538
|
-
configUpdates
|
|
933
|
+
configUpdates,
|
|
539
934
|
files: [],
|
|
540
|
-
instructions:
|
|
541
|
-
devGuideSection
|
|
542
|
-
|
|
935
|
+
instructions: nextSteps,
|
|
936
|
+
devGuideSection,
|
|
937
|
+
envVars,
|
|
938
|
+
nextSteps,
|
|
543
939
|
};
|
|
544
940
|
}
|
|
545
941
|
}
|