@opensaas/stack-cli 0.29.0 → 0.31.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.
@@ -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 hasOAuth = authMethods.some((m) => ['Google OAuth', 'GitHub OAuth'].includes(m));
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
- const hasMagicLink = authMethods.includes('Magic Links');
49
- // Build User list fields
50
- const fields = ['email: text({ validation: { isRequired: true } })'];
51
- if (hasPassword) {
52
- fields.push('password: password({ validation: { isRequired: true } })');
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
- fields.push(`role: select({ options: [${roles.map((r) => `'${r}'`).join(', ')}], defaultValue: '${roles[roles.length - 1]}' })`);
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
- fields.push('avatar: text()');
80
+ extendFields.push(`avatar: text() // or an image() field — see the file-upload feature`);
60
81
  }
61
82
  if (userFields.includes('Bio')) {
62
- fields.push('bio: text({ ui: { displayMode: "textarea" } })');
83
+ extendFields.push(`bio: text({ ui: { displayMode: 'textarea' } })`);
63
84
  }
64
85
  if (userFields.includes('Phone')) {
65
- fields.push('phone: text()');
86
+ extendFields.push(`phone: text()`);
66
87
  }
67
88
  if (userFields.includes('Location')) {
68
- fields.push('location: text()');
89
+ extendFields.push(`location: text()`);
69
90
  }
70
91
  if (userFields.includes('Website')) {
71
- fields.push('website: text()');
92
+ extendFields.push(`website: text()`);
72
93
  }
73
- // Config updates
74
- const configUpdates = `import { config, list } from '@opensaas/stack-core';
75
- import { text, password, select } from '@opensaas/stack-core/fields';
76
- import { authPlugin } from '@opensaas/stack-auth';
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
- db: {
94
- provider: 'postgresql', // or 'sqlite'
95
- url: process.env.DATABASE_URL,
96
- },
97
- lists: {
98
- User: list({
99
- fields: {
100
- ${fields.join(',\n ')}
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
- operation: {
104
- query: () => true,
105
- create: () => true, // Public sign-up
106
- update: ({ session, item }) => session?.userId === item.id,
107
- delete: ({ session }) => session?.role === 'admin',
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
- // Add your other lists here
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
- if (hasPassword || hasOAuth) {
118
- files.push({
119
- path: 'app/sign-in/page.tsx',
120
- language: 'tsx',
121
- description: 'Sign-in page with form and OAuth buttons',
122
- content: `import { SignInForm } from '@opensaas/stack-auth/ui';
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
- ${hasPassword ? 'emailAndPassword' : ''}
131
- ${hasOAuth ? `oauth={[${authMethods.includes('Google OAuth') ? "'google'" : ''}${authMethods.includes('GitHub OAuth') ? ", 'github'" : ''}]}` : ''}
132
- redirectTo="/dashboard"
215
+ authClient={authClient}
216
+ redirectTo="/admin"
217
+ showSocialProviders={${hasOAuth}}
133
218
  />
219
+ ${hasPassword
220
+ ? `<div className="mt-4 text-center text-sm">
221
+ Don&apos;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 with registration form',
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
- ? `export const isAdmin: AccessControl = ({ session }) => {
177
- return session?.role === 'admin';
178
- };
179
-
180
- export const isOwner: AccessControl = ({ session, item }) => {
181
- return session?.userId === item.id;
182
- };
183
-
184
- export const isAdminOrOwner: AccessControl = ({ session, item }) => {
185
- return session?.role === 'admin' || session?.userId === item.id;
186
- };`
187
- : ''}
188
-
189
- export const requireAuth: AccessControl = ({ session }) => {
190
- if (!session?.userId) {
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: 'postgresql://user:password@localhost:5432/mydb',
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 (authMethods.includes('Google OAuth')) {
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 (authMethods.includes('GitHub OAuth')) {
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
- 'Copy the config updates to your `opensaas.config.ts`',
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 for authentication with the following configuration:
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
- ### Access Control Helpers
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
- Use these functions in your list configurations:
315
+ ### Access Control Helpers
232
316
 
233
317
  \`\`\`typescript
234
- import { isAuthenticated${hasRoles ? ', isAdmin, isOwner' : ''} } from './lib/access-control';
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: isAuthenticated,
241
- update: isOwner,
242
- delete: ${hasRoles ? 'isAdmin' : 'isOwner'},
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
- To protect a route, check the session in your server components:
333
+ Check the session in server components:
250
334
 
251
335
  \`\`\`typescript
252
- import { auth } from '@/lib/auth';
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 auth();
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>Protected content for {session.user.name}</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 { getContext } from '@/.opensaas/context';
271
-
272
- const context = await getContext();
273
- const currentUser = await context.db.user.findUnique({
274
- where: { id: context.session?.userId }
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 useMarkdown = contentEditor === 'Markdown';
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
- 'slug: text({ validation: { isRequired: true } })',
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('content: text({ ui: { displayMode: "textarea" }, validation: { isRequired: true } })');
390
+ fields.push("content: text({ ui: { displayMode: 'textarea' }, validation: { isRequired: true } })");
309
391
  }
310
- fields.push('author: relationship({ ref: "User.posts" })');
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('excerpt: text({ ui: { displayMode: "textarea" } })');
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 configUpdates = `import { config, list } from '@opensaas/stack-core';
329
- import { text, select, relationship, timestamp${useTiptap ? '' : ', integer'} } from '@opensaas/stack-core/fields';
330
- ${useTiptap ? "import { richText } from '@opensaas/stack-tiptap/fields';" : ''}
331
-
332
- export default config({
333
- lists: {
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
- query: ({ session }) => {
341
- ${hasStatus ? "if (!session) return { status: { equals: 'published' } };" : ''}
342
- return true;
343
- },
344
- create: ({ session }) => !!session?.userId,
345
- update: ({ session, item }) => session?.userId === item.authorId,
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.authorId,
439
+ session?.role === 'admin' || session?.userId === item?.authorId,
348
440
  },
349
441
  },
350
442
  hooks: {
351
- ${hasStatus
352
- ? `resolveInput: async ({ resolvedData, operation }) => {
353
- // Auto-set publishedAt when publishing
354
- if (operation === 'update' && resolvedData.status === 'published' && !resolvedData.publishedAt) {
355
- resolvedData.publishedAt = new Date();
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
- return resolvedData;
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
- ${taxonomy.includes('Categories')
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
- ${taxonomy.includes('Tags')
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.name} · ${hasStatus ? '{post.publishedAt?.toLocaleDateString()}' : '{post.createdAt.toLocaleDateString()}'}
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 context = await getContext();
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: params.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.name} · ${hasStatus ? '{post.publishedAt?.toLocaleDateString()}' : '{post.createdAt.toLocaleDateString()}'}
576
+ By {post.author?.name ?? 'Unknown'} · ${hasStatus ? '{post.publishedAt?.toLocaleDateString()}' : '{post.createdAt.toLocaleDateString()}'}
453
577
  </div>
454
- ${useTiptap ? '<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: post.content }} />' : useMarkdown ? '<div className="prose max-w-none">{/* Render markdown here */}{post.content}</div>' : '<div className="prose max-w-none whitespace-pre-wrap">{post.content}</div>'}
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
- 'Copy the config updates to your `opensaas.config.ts`',
461
- 'Add the `posts` relationship field to your User list',
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: useTiptap ? {} : undefined,
619
+ envVars: undefined,
506
620
  nextSteps,
507
621
  };
508
622
  }
509
623
  /**
510
- * Generate comments feature (stub - to be implemented)
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: '// Comments feature implementation coming soon',
722
+ configUpdates,
515
723
  files: [],
516
- instructions: ['Feature implementation in progress'],
517
- devGuideSection: '## Comments Feature\n\nComing soon...',
518
- nextSteps: ['Feature implementation in progress'],
724
+ instructions: nextSteps,
725
+ devGuideSection,
726
+ nextSteps,
519
727
  };
520
728
  }
521
729
  /**
522
- * Generate file upload feature (stub - to be implemented)
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: '// File upload feature implementation coming soon',
843
+ configUpdates,
527
844
  files: [],
528
- instructions: ['Feature implementation in progress'],
529
- devGuideSection: '## File Upload Feature\n\nComing soon...',
530
- nextSteps: ['Feature implementation in progress'],
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 (stub - to be implemented)
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: '// Semantic search feature implementation coming soon',
933
+ configUpdates,
539
934
  files: [],
540
- instructions: ['Feature implementation in progress'],
541
- devGuideSection: '## Semantic Search Feature\n\nComing soon...',
542
- nextSteps: ['Feature implementation in progress'],
935
+ instructions: nextSteps,
936
+ devGuideSection,
937
+ envVars,
938
+ nextSteps,
543
939
  };
544
940
  }
545
941
  }