Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/compose-drizzle-pglite-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@gemstack/ai-autopilot': minor
---

Teach the compose personas the opt-in real-persistence path (drizzle + pglite)

The composed stack (vike-auth + the universal-orm data layer) runs on the memory adapter, which resets on every server restart, so accounts and posts vanish on reboot. The `vike-data-modeler` persona now teaches the "make it real" swap: register the Drizzle adapter over an embedded pglite Postgres instead of the memory adapter, add the `vikeSchema()` Vite plugin to codegen `drizzle/schema.generated.ts`, and derive/apply migrations with drizzle-kit. Because auth and domain data ride the same one adapter, that single swap makes both durable at once; `defineSchema` tables and `db()` queries do not change. The `vike-auth-composer` persona points at the same step, and the memory adapter stays the zero-config dev default. Reference: the proven `examples/drizzle-pglite` twin. Part of #186. Closes #187.
3 changes: 3 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ export {
nextPageBuilder,
dataModeler,
uiIntentDesigner,
vikeAuthComposer,
vikeDataModeler,
sharedPersonas,
vikeExtensionPersonas,
stackPersonas,
type Persona,
type PersonaSpec,
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-autopilot/src/personas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export {
nextPageBuilder,
dataModeler,
uiIntentDesigner,
vikeAuthComposer,
vikeDataModeler,
sharedPersonas,
vikeExtensionPersonas,
stackPersonas,
} from './library.js'
export type { Persona, PersonaSpec } from './types.js'
43 changes: 43 additions & 0 deletions packages/ai-autopilot/src/personas/library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { strict as assert } from 'node:assert'
import { describe, it } from 'node:test'
import { personaInstructions } from './compose.js'
import { uiIntentDesigner, vikeAuthComposer, vikeDataModeler, vikeExtensionPersonas } from './library.js'

describe('vike extension personas', () => {
it('vikeAuthComposer teaches composing vike-auth instead of hand-rolling auth', () => {
assert.equal(vikeAuthComposer.name, 'vike-auth-composer')
const text = personaInstructions(vikeAuthComposer)
// Real install + wiring, so the agent composes rather than reinventing.
assert.match(text, /npm install vike-auth/)
assert.match(text, /vike-auth\/react/)
assert.match(text, /setAdapter\(createMemoryAdapter\(\)\)/)
assert.match(text, /useUser\(\)/)
// And it must forbid re-modeling users/sessions in the app's own ORM.
assert.match(text, /Do NOT write auth UI/)
assert.match(text, /do NOT model users or\s*\n?\s*sessions/i)
})

it('vikeDataModeler teaches the universal-orm data layer, not a hand-installed ORM', () => {
assert.equal(vikeDataModeler.name, 'vike-data-modeler')
const text = personaInstructions(vikeDataModeler)
assert.match(text, /@universal-orm\/core/)
assert.match(text, /defineSchema/)
assert.match(text, /createRepository/)
assert.match(text, /getAdapter\(\)/)
// The dev default: do NOT reach for Prisma/Drizzle/SQLite/migrations.
assert.match(text, /Do NOT add Prisma, Drizzle,\s*\n?\s*SQLite/i)
// But it must also teach the opt-in real-persistence path (drizzle + pglite),
// so a composed app can survive a restart without changing schema/queries.
assert.match(text, /Make it real/)
assert.match(text, /registerDrizzle/)
assert.match(text, /pglite/)
assert.match(text, /vikeSchema\(\)/)
assert.match(text, /drizzle-kit generate/)
})

it('vikeExtensionPersonas composes the data layer + auth (no Prisma, no hand-rolled auth)', () => {
const names = vikeExtensionPersonas.map(p => p.name)
assert.deepEqual(names, ['vike-data-modeler', 'vike-auth-composer', 'ui-intent-designer'])
assert.ok(vikeExtensionPersonas.includes(uiIntentDesigner))
})
})
147 changes: 147 additions & 0 deletions packages/ai-autopilot/src/personas/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,140 @@ Your output should read as a declaration of what the UI is, leaving how it looks
to the decoupled implementation.`,
})

/**
* Composes `vike-auth` for authentication instead of hand-rolling it. Auth is a
* solved, security-sensitive concern; the live-build failure mode is an agent
* reinventing sessions/cookies/CSRF (and getting them wrong). This persona hands
* that whole surface to the extension. Opt-in: vike-auth is Vike-specific, and
* (today) resolves only inside the vike-data workspace — see `vikeExtensionPersonas`.
*/
export const vikeAuthComposer: Persona = definePersona({
name: 'vike-auth-composer',
role: 'Composes vike-auth for auth instead of hand-rolling sessions, cookies, and login pages',
appliesTo: ['vike-auth'],
systemPrompt: `You compose vike-auth for authentication instead of hand-rolling it. Auth is a
solved, security-sensitive concern: a hand-rolled version (sessions, cookies,
password hashing, CSRF, rate limiting) is where apps get it wrong. vike-auth owns it.

What vike-auth gives you (passwordless, email magic-link):
- Its own tables: \`users\`, \`sessions\`, \`login_tokens\`. Do NOT model users or
sessions in your app's ORM, and do NOT write login / logout / session code.
- The \`/login\` and \`/account\` pages, shipped by the extension. Do NOT write auth UI.
- The current user on \`pageContext.user\` (server) and via \`useUser()\` (React).

Install and wire it (React + Vike):
- Add vike-auth and an ORM adapter to the app (vike-auth pulls the rest of its
stack transitively):
\`npm install vike-auth @universal-orm/core @universal-orm/memory\`
- In \`pages/+config.js\`, extend the React auth entry — this ONE line brings the
server tier AND the \`/login\` + \`/account\` pages:
\`\`\`js
import vikeReact from 'vike-react/config'
import authExt from 'vike-auth/react'
export default { extends: [vikeReact, authExt] } // loginRedirect: '/admin' to land signed-in users there
\`\`\`
- Register ONE universal-orm adapter, in \`pages/+onCreateGlobalContext.js\`, so
vike-auth's tables persist (memory is fine for dev; swap for a real DB later):
\`\`\`js
import { setAdapter, getAdapter } from '@universal-orm/core'
import { createMemoryAdapter } from '@universal-orm/memory'
export default async function onCreateGlobalContext() {
if (getAdapter()) return
setAdapter(createMemoryAdapter())
}
\`\`\`
Memory resets on restart, so accounts vanish on reboot. To persist accounts for
real, swap this ONE adapter for the Drizzle + pglite backend — see the data
persona's "Make it real" steps. Because auth AND your domain data ride this same
adapter, that single swap makes both durable at once.

Use it:
- Read the user server-side from \`pageContext.user\`; in a React component use
\`useUser()\` from \`vike-auth/react/hooks\` (returns \`{ id, email, name } | null\`).
- Protect a page by checking \`pageContext.user\` in a \`+guard\` (redirect to
\`/login\` when absent), or reuse vike-auth's own guard.

Your app's OWN domain data (posts, comments, etc.) still goes through the data
persona's ORM. vike-auth owns only identity and sessions — do not duplicate them.`,
})

/**
* Models domain data on the universal-orm data layer (the same one vike-auth
* uses) instead of a hand-installed ORM. The live-build failure mode is an agent
* burning time on ORM install/config/migrations (e.g. Prisma) for data that could
* ride the one adapter the app already registered. Opt-in, in-workspace only
* (the packages resolve inside the vike-data workspace).
*/
export const vikeDataModeler: Persona = definePersona({
name: 'vike-data-modeler',
role: 'Models domain data on the universal-orm data layer — one registered adapter, no ORM install',
appliesTo: ['@universal-orm/core', '@vike-data/vike-schema', '@vike-data/universal-schema'],
systemPrompt: `You model the app's domain data on the universal-orm data layer — the same layer
vike-auth uses — NOT on a hand-installed ORM. The app registers ONE adapter at
startup (memory in dev), and every table rides it, so there is nothing to install,
no database to provision, and no migrations to run. Do NOT add Prisma, Drizzle,
SQLite, or any ORM: that churn is exactly what this layer removes.

Define tables and build a repository over the already-registered adapter:
\`\`\`js
import { defineSchema } from '@vike-data/vike-schema/schema'
import { mergeSchemas } from '@vike-data/universal-schema'
import { createRepository, getAdapter } from '@universal-orm/core'

const posts = defineSchema('posts', (t) => {
t.integer('id').primary()
t.string('title')
t.text('content')
t.string('created_at')
})
let repo
// getAdapter() returns the adapter the app registered in +onCreateGlobalContext.
export const db = () => (repo ??= createRepository(mergeSchemas([posts]), getAdapter()))
\`\`\`

Read and write through the narrow repository:
- \`db().posts\`: \`insert(row)\`, \`find(filter, opts)\`, \`findOne(filter)\`,
\`upsert(row, { onConflict })\`, \`update(filter, patch)\`, \`delete(filter)\`.
- Filters are equality (\`{ post_id: 5 }\`) or membership (\`{ id: { in: [1, 2] } }\`)
ONLY — there are no joins or aggregates. For "a post with its comments", do two
finds and combine them in JS. \`opts\` is \`{ limit, offset, orderBy }\`.
- The memory adapter does NOT auto-assign ids — mint them yourself (a counter or uuid).
- Do NOT call \`setAdapter\` again; the app already registered one.

Column types: \`uuid\` / \`string\` / \`text\` / \`integer\` / \`boolean\` / \`timestamp\`,
each chainable with \`.nullable()\` / \`.unique()\` / \`.primary()\` /
\`.references('table.col', { onDelete })\`. Read data in Vike \`+data\` hooks on the
server.

Make it real (opt-in persistence). The memory adapter resets on every restart. To
persist for real, swap the ONE adapter the app registers in
\`pages/+onCreateGlobalContext.js\` from memory to the Drizzle adapter over an embedded
pglite Postgres (real Postgres as wasm; no server to run). Your \`defineSchema\` tables
and every \`db().posts\` query stay identical, and because vike-auth rides the SAME
adapter, this one swap makes accounts AND domain data survive a restart. This is NOT
"add an ORM to model with" — you still model with \`defineSchema\`; Drizzle is only the
persistence backend. The steps:
1. Install: \`npm install vike-drizzle @universal-orm/drizzle drizzle-orm @electric-sql/pglite\`
(and \`drizzle-kit\` as a dev dep).
2. In \`vite.config.js\`, add the \`vikeSchema()\` plugin (\`@vike-data/vike-schema/plugin\`)
AFTER \`vike()\`: it generates \`drizzle/schema.generated.ts\` from every installed
extension's tables (your posts/comments AND vike-auth's users/sessions). Also add
\`ssr: { external: ['@electric-sql/pglite', 'drizzle-orm'] }\` to keep pglite's wasm
out of the client bundle.
3. Add \`drizzle.config.js\` (\`{ schema: './drizzle/schema.generated.ts', out:
'./drizzle/migrations', dialect: 'postgresql' }\`) and run \`drizzle-kit generate\` to
derive the SQL migrations from that generated schema.
4. In \`pages/+onCreateGlobalContext.js\`, guard the DB setup with
\`if (!import.meta.env.SSR) return\`, open pglite, \`migrate(db, { migrationsFolder:
'drizzle/migrations' })\`, then \`registerDrizzle(db, schema)\` from \`vike-drizzle\`
instead of \`setAdapter(createMemoryAdapter())\`.
Reference: the proven \`examples/drizzle-pglite\` twin in the vike-data monorepo. Note:
\`integer('id').primary()\` is NOT auto-incremented (same as memory) — keep minting ids
yourself, or use \`uuid('id').primary()\` for collision-free ids on a persistent store,
and never re-insert fixed-id seed rows on every boot (that duplicates or crashes on a
real DB).`,
})

/**
* The framework-neutral personas shared by every preset — the data layer and the
* intent-based UI guardrail apply the same whether the app is on Vike or Next.
Expand All @@ -131,6 +265,19 @@ export const sharedPersonas: readonly Persona[] = Object.freeze([
uiIntentDesigner,
])

/**
* The opt-in vike-extension stack: compose `vike-auth` for authentication AND the
* universal-orm data layer for domain data (both ride one registered adapter),
* instead of hand-rolling auth or hand-installing an ORM. Swap this in for
* {@link sharedPersonas} when composing extensions (Vike only; the extensions
* currently resolve inside the vike-data workspace).
*/
export const vikeExtensionPersonas: readonly Persona[] = Object.freeze([
vikeDataModeler,
vikeAuthComposer,
uiIntentDesigner,
])

/** All built-in stack personas (the Vike stack), in a stable order. */
export const stackPersonas: readonly Persona[] = Object.freeze([
vikePageBuilder,
Expand Down
9 changes: 9 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Options:
--cwd <dir> Workspace the agent builds in (default: current directory).
--model <id> Model to pass through to the wrapped agent.
--scope <prototype|full> How much app to build (default: full).
--compose-extensions Compose the vike-* extensions (vike-auth for auth) instead
of hand-rolling them. Vike-only; extensions resolve in the
vike-data workspace (default: off, hand-rolled + Prisma).
--max-passes <n> Full-fledged loop pass budget (default: 5).
--permission-mode <mode> Claude Code permission mode: default | acceptEdits |
bypassPermissions | plan (default: acceptEdits).
Expand Down Expand Up @@ -85,6 +88,7 @@ export interface CliOptions {
servePath?: string | undefined
port?: number
dashboard: boolean
composeExtensions: boolean
sessionLink?: string | undefined
permissionMode?: PermissionMode | undefined
skipPermissions: boolean
Expand All @@ -102,6 +106,7 @@ export function parseArgs(argv: string[]): CliOptions {
intent: '',
scope: 'full',
dashboard: true,
composeExtensions: false,
skipPermissions: false,
}
const PERMISSION_MODES: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan']
Expand All @@ -123,6 +128,9 @@ export function parseArgs(argv: string[]): CliOptions {
case '--no-dashboard':
opts.dashboard = false
break
case '--compose-extensions':
opts.composeExtensions = true
break
case '--skip-preflight':
opts.skipPreflight = true
break
Expand Down Expand Up @@ -321,6 +329,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(deployTarget ? { deployTarget } : {}),
...(serve ? { serve } : {}),
...(fake ? { signals: FAKE_SIGNALS } : {}),
...(opts.composeExtensions ? { composeExtensions: true } : {}),
...(opts.sessionLink ? { sessionLink: opts.sessionLink } : {}),
}

Expand Down
42 changes: 42 additions & 0 deletions packages/framework/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,24 @@ import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { DEFAULT_MAX_PASSES, runFramework } from './run.js'
import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js'
import type { Driver } from './driver/index.js'
import type { FrameworkEvent } from './events.js'

/** A driver that records the `system` framing it is started with, delegating the run to the fake. */
function recordingDriver(): { driver: Driver; system: () => string } {
const fd = fakeDriver()
let captured = ''
// Name it 'fake' so the workspace-verify stays off (no fs access in this unit test).
const driver: Driver = {
name: 'fake',
start: opts => {
captured = opts.system ?? ''
return fd.start(opts)
},
}
return { driver, system: () => captured }
}

test('runFramework drives the whole flow through the driver, offline, to production-grade', async () => {
const events: FrameworkEvent[] = []
const { result, detection } = await runFramework({
Expand Down Expand Up @@ -97,6 +113,32 @@ test('runFramework shows a literal session link immediately (no template)', asyn
assert.equal(session.sessionLink, 'https://code.example.com/live')
})

test('--compose-extensions frames the agent with vike-auth, not hand-rolled auth (#186)', async () => {
const { driver, system } = recordingDriver()
await runFramework({
intent: FAKE_INTENT,
driver,
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
composeExtensions: true,
onEvent: () => {},
})
assert.match(system(), /vike-auth/)
assert.match(system(), /npm install vike-auth/)
})

test('without --compose-extensions the default framing has no vike-auth (publish-safe)', async () => {
const { driver, system } = recordingDriver()
await runFramework({
intent: FAKE_INTENT,
driver,
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
onEvent: () => {},
})
assert.doesNotMatch(system(), /vike-auth/)
})

test('the default pass budget is raised for from-scratch builds (#182)', () => {
// 3 was too low: the first passes go to bootstrapping an empty workspace.
assert.equal(DEFAULT_MAX_PASSES, 5)
Expand Down
14 changes: 13 additions & 1 deletion packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
personaInstructions,
presetPersonas,
serveCheck,
vikeExtensionPersonas,
type BootstrapEvent,
type BootstrapResult,
type BootstrapScope,
Expand Down Expand Up @@ -75,6 +76,13 @@ export interface RunFrameworkOptions {
model?: string
/** Signals for preset detection (deps/files). Default: none, so the flagship preset wins. */
signals?: FrameworkSignals
/**
* Compose the vike-* extensions (currently: vike-auth for authentication)
* instead of hand-rolling them. Frames the agent with the extension personas.
* Opt-in and Vike-only: the extensions resolve inside the vike-data workspace,
* so the default (hand-rolled + Prisma) path stays publish-safe.
*/
composeExtensions?: boolean
/** Max full-fledged passes. Default {@link DEFAULT_MAX_PASSES} (5). */
maxPasses?: number
/** A deploy decision to narrate at the end. Omit to skip the deploy phase. */
Expand Down Expand Up @@ -152,8 +160,12 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
}

// 1. Preset: detect the framework and turn its personas into prompt-framing.
// With --compose-extensions, swap the shared personas for the vike-extension
// set (compose vike-auth instead of hand-rolling auth); default keeps Prisma.
const { preset, detection } = builtinPresetRegistry().select(opts.signals ?? {})
const personas = presetPersonas(preset)
const personas = opts.composeExtensions
? presetPersonas(preset, vikeExtensionPersonas)
: presetPersonas(preset)
const system = personas.map(personaInstructions).join('\n\n')

// The session id is not known until the first driver turn returns, so a
Expand Down
Loading