diff --git a/specs/migration-helper/README.md b/specs/migration-helper/README.md new file mode 100644 index 00000000..ff97df03 --- /dev/null +++ b/specs/migration-helper/README.md @@ -0,0 +1,111 @@ +# Migration Helper - Implementation Tasks + +This folder contains standalone task specifications for implementing the AI-powered migration system for OpenSaaS Stack. Each phase can be assigned to a separate coding agent. + +## Overview + +The migration helper enables users to migrate existing Prisma, Next.js, and KeystoneJS projects to OpenSaaS Stack with AI assistance through Claude Code. + +## Task Dependency Order + +Tasks must be completed in this order due to dependencies: + +``` +Phase 1: CLI Command (foundation) + ↓ +Phase 4: Introspectors (needed by Phase 2 & 3) + ↓ +Phase 2: MCP Tools (depends on introspectors) + ↓ +Phase 3: Migration Wizard (depends on MCP tools) + ↓ +Phase 5: Config Generator (depends on wizard) + ↓ +Phase 6: Claude Agent Templates (final integration) +``` + +**Recommended parallel work:** +- Phase 1 and Phase 4 can be done in parallel +- Phase 2 and Phase 3 can be started together once Phase 4 is complete + +## Task Files + +| Phase | File | Description | Estimated Effort | +|-------|------|-------------|------------------| +| 1 | `phase-1-cli-command.md` | CLI `migrate` command with project detection | 1 day | +| 2 | `phase-2-mcp-tools.md` | MCP server tools and documentation provider | 2 days | +| 3 | `phase-3-migration-wizard.md` | Interactive migration wizard engine | 2 days | +| 4 | `phase-4-introspectors.md` | Prisma, KeystoneJS, Next.js introspectors | 2 days | +| 5 | `phase-5-config-generator.md` | opensaas.config.ts generator | 2 days | +| 6 | `phase-6-claude-agent.md` | Claude Code agent templates | 1 day | + +## Repository Structure + +All files are created in `packages/cli/`: + +``` +packages/cli/src/ +├── commands/ +│ └── migrate.ts # Phase 1 +├── migration/ +│ ├── types.ts # Phase 1 (shared types) +│ ├── introspectors/ +│ │ ├── prisma-introspector.ts # Phase 4 +│ │ ├── keystone-introspector.ts # Phase 4 +│ │ └── nextjs-introspector.ts # Phase 4 +│ └── generators/ +│ └── migration-generator.ts # Phase 5 +└── mcp/ + ├── lib/ + │ ├── documentation-provider.ts # Phase 2 (modify) + │ └── wizards/ + │ └── migration-wizard.ts # Phase 3 + └── server/ + ├── index.ts # Phase 2 (modify) + └── stack-mcp-server.ts # Phase 2 (modify) +``` + +## How to Use These Tasks + +Each task file contains: + +1. **Context Section** - Background info and architecture overview +2. **Reference Code** - Existing patterns to follow +3. **Requirements** - Exact specifications +4. **File Paths** - What to create/modify +5. **Code Templates** - Starting points for implementation +6. **Acceptance Criteria** - How to verify completion +7. **Testing Instructions** - How to test the implementation + +To assign a task to a coding agent: +1. Copy the entire contents of the phase file +2. Paste into a new Claude Code context +3. The agent has all context needed to complete the task + +## Integration Points + +After each phase, verify integration: + +- **After Phase 1**: Run `opensaas migrate` and verify project detection +- **After Phase 4**: Run introspectors on test projects +- **After Phase 2**: Test MCP tools via `opensaas mcp start` +- **After Phase 3**: Test wizard flow with mock data +- **After Phase 5**: Generate config and run `opensaas generate` +- **After Phase 6**: Full E2E test with Claude Code + +## Key Patterns to Follow + +All implementations should: + +1. **Use existing dependencies** - chalk, ora, fs-extra, jiti, commander +2. **Follow CLI output style** - Emoji prefixes, spinner animations, colored output +3. **Use MCP response format** - `{ content: [{ type: 'text', text: '...' }] }` +4. **Implement proper TypeScript** - No `any` types, proper generics +5. **Handle errors gracefully** - User-friendly messages, recovery suggestions + +## Documentation + +After implementation, create: +- `docs/content/guides/migration.md` - User-facing migration guide +- Update `packages/cli/CLAUDE.md` - Add migrate command docs +- Update `packages/cli/README.md` - Add migrate command usage diff --git a/specs/migration-helper/phase-1-cli-command.md b/specs/migration-helper/phase-1-cli-command.md new file mode 100644 index 00000000..e121f9b6 --- /dev/null +++ b/specs/migration-helper/phase-1-cli-command.md @@ -0,0 +1,677 @@ +# Phase 1: CLI Migration Command + +## Task Overview + +Create the `opensaas migrate` CLI command that detects project types, analyzes existing schemas, and sets up Claude Code integration for AI-guided migration. + +## Context + +### OpenSaaS Stack CLI Architecture + +The CLI is built with Commander.js and located in `packages/cli/`. The main entry point is `packages/cli/src/index.ts`: + +```typescript +#!/usr/bin/env node + +import { Command } from 'commander' +import { generateCommand } from './commands/generate.js' +import { initCommand } from './commands/init.js' +import { devCommand } from './commands/dev.js' +import { createMCPCommand } from './commands/mcp.js' + +const program = new Command() + +program.name('opensaas').description('OpenSaas Stack CLI').version('0.1.0') + +program + .command('generate') + .description('Generate Prisma schema and TypeScript types from opensaas.config.ts') + .action(async () => { + await generateCommand() + }) + +program + .command('init [project-name]') + .description('Create a new OpenSaas project (delegates to create-opensaas-app)') + .option('--with-auth', 'Include authentication (Better-auth)') + .allowUnknownOption() + .action(async (projectName, options) => { + const args = [] + if (projectName) args.push(projectName) + if (options.withAuth) args.push('--with-auth') + await initCommand(args) + }) + +program + .command('dev') + .description('Watch opensaas.config.ts and regenerate on changes') + .action(async () => { + await devCommand() + }) + +// Add MCP command group +program.addCommand(createMCPCommand()) + +program.parse() +``` + +### Existing MCP Command Pattern + +Reference `packages/cli/src/commands/mcp.ts` for command structure: + +```typescript +import { Command } from 'commander' +import { spawn } from 'child_process' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +export function createMCPCommand(): Command { + const mcp = new Command('mcp') + mcp.description('MCP server for AI-assisted development with Claude Code') + + mcp + .command('install') + .description('Install MCP server in Claude Code') + .action(async () => { + try { + await installMCPServer() + process.exit(0) + } catch { + process.exit(1) + } + }) + + // ... more subcommands + + return mcp +} +``` + +### Output Styling + +Use `chalk` and `ora` for consistent styling: +- ✅ Green checkmarks for success +- ❌ Red X for errors +- 📦 📊 📁 🤖 Emoji prefixes +- Spinner animations during async operations + +--- + +## Requirements + +### 1. Create Migration Command + +**File to create:** `packages/cli/src/commands/migrate.ts` + +The command should: + +1. **Auto-detect project type** by checking for: + - `prisma/schema.prisma` → Prisma project + - `keystone.config.ts` or `keystone.ts` → KeystoneJS project + - `package.json` with `next` dependency → Next.js project + - Can be multiple (e.g., Prisma + Next.js) + +2. **Analyze project structure**: + - Count models/lists in schema + - Detect database provider + - Identify existing auth patterns + +3. **Setup Claude Code integration** (when `--with-ai` flag): + - Create `.claude/` directory structure + - Generate `settings.json` with MCP server registration + - Create migration assistant agent file + - Create command files + +4. **Display analysis and next steps** + +### 2. Create Migration Types + +**File to create:** `packages/cli/src/migration/types.ts` + +Define shared types for the migration system. + +### 3. Modify CLI Entry Point + +**File to modify:** `packages/cli/src/index.ts` + +Add the migrate command to the CLI. + +--- + +## File Templates + +### `packages/cli/src/commands/migrate.ts` + +```typescript +/** + * Migration command - Helps migrate existing projects to OpenSaaS Stack + */ + +import { Command } from 'commander' +import fs from 'fs-extra' +import path from 'path' +import chalk from 'chalk' +import ora from 'ora' +import { spawn } from 'child_process' +import type { ProjectAnalysis, ProjectType } from '../migration/types.js' + +interface MigrateOptions { + withAi?: boolean + type?: 'prisma' | 'nextjs' | 'keystone' +} + +/** + * Detect what type of project this is + */ +async function detectProjectType(cwd: string): Promise { + const types: ProjectType[] = [] + + // Check for Prisma + const prismaSchemaPath = path.join(cwd, 'prisma', 'schema.prisma') + if (await fs.pathExists(prismaSchemaPath)) { + types.push('prisma') + } + + // Check for KeystoneJS + const keystoneConfigPath = path.join(cwd, 'keystone.config.ts') + const keystoneAltPath = path.join(cwd, 'keystone.ts') + if (await fs.pathExists(keystoneConfigPath) || await fs.pathExists(keystoneAltPath)) { + types.push('keystone') + } + + // Check for Next.js + const packageJsonPath = path.join(cwd, 'package.json') + if (await fs.pathExists(packageJsonPath)) { + const pkg = await fs.readJSON(packageJsonPath) + if (pkg.dependencies?.next || pkg.devDependencies?.next) { + types.push('nextjs') + } + } + + return types +} + +/** + * Analyze a Prisma schema + */ +async function analyzePrismaSchema(cwd: string): Promise<{ + models: Array<{ name: string; fieldCount: number }> + provider: string +}> { + const schemaPath = path.join(cwd, 'prisma', 'schema.prisma') + const schema = await fs.readFile(schemaPath, 'utf-8') + + // Extract models + const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g + const models: Array<{ name: string; fieldCount: number }> = [] + let match + + while ((match = modelRegex.exec(schema)) !== null) { + const name = match[1] + const body = match[2] + const fieldCount = body.split('\n').filter(line => + line.trim() && !line.trim().startsWith('@@') && !line.trim().startsWith('//') + ).length + models.push({ name, fieldCount }) + } + + // Extract provider + const providerMatch = schema.match(/provider\s*=\s*"(\w+)"/) + const provider = providerMatch ? providerMatch[1] : 'unknown' + + return { models, provider } +} + +/** + * Setup Claude Code integration + */ +async function setupClaudeCode( + cwd: string, + analysis: ProjectAnalysis +): Promise { + const claudeDir = path.join(cwd, '.claude') + const agentsDir = path.join(claudeDir, 'agents') + const commandsDir = path.join(claudeDir, 'commands') + + // Create directories + await fs.ensureDir(agentsDir) + await fs.ensureDir(commandsDir) + + // Create settings.json + const settings = { + mcpServers: { + 'opensaas-migration': { + command: 'npx', + args: ['@opensaas/stack-cli', 'mcp', 'start'], + disabled: false, + }, + }, + } + await fs.writeJSON(path.join(claudeDir, 'settings.json'), settings, { spaces: 2 }) + + // Create README + const readme = `# OpenSaaS Stack Migration + +This project is being migrated to OpenSaaS Stack. + +## Quick Start + +Ask Claude: "Help me migrate to OpenSaaS Stack" + +## Project Analysis + +- **Project Type**: ${analysis.projectTypes.join(', ')} +- **Models Detected**: ${analysis.models?.length || 0} +- **Database Provider**: ${analysis.provider || 'unknown'} + +## Available Commands + +- \`/analyze-schema\` - Re-analyze current schema +- \`/generate-config\` - Generate opensaas.config.ts + +## Documentation + +- [OpenSaaS Stack Docs](https://stack.opensaas.au/) +- [Migration Guide](https://stack.opensaas.au/guides/migration) +` + await fs.writeFile(path.join(claudeDir, 'README.md'), readme) + + // Create migration assistant agent + const modelList = analysis.models?.map(m => `- ${m.name} (${m.fieldCount} fields)`).join('\n') || 'No models detected' + + const agentPrompt = `You are the OpenSaaS Stack Migration Assistant. + +## Project Analysis + +**Project Type:** ${analysis.projectTypes.join(', ')} +**Database Provider:** ${analysis.provider || 'Not detected'} +**Models Detected:** ${analysis.models?.length || 0} + +${modelList} + +## Your Role + +Guide the user through migrating this project to OpenSaaS Stack: + +1. Start with \`opensaas_start_migration\` to begin the wizard +2. Answer questions about access control, authentication, etc. +3. Generate the final config with \`opensaas_generate_config\` +4. Provide clear next steps + +## Available MCP Tools + +- \`opensaas_start_migration\` - Begin the migration wizard +- \`opensaas_answer_migration\` - Answer wizard questions +- \`opensaas_introspect_prisma\` - View current Prisma schema +- \`opensaas_introspect_keystone\` - View KeystoneJS config +- \`opensaas_search_migration_docs\` - Search migration documentation +- \`opensaas_get_example\` - Get example code patterns + +## Conversation Guidelines + +When the user asks to migrate: +1. Acknowledge their project details (already analyzed above) +2. Start the migration wizard +3. Guide them through each question naturally +4. Explain the implications of each choice +5. Generate the final config with clear explanations +6. Provide next steps (install deps, generate, db:push) + +Be encouraging and explain OpenSaaS Stack benefits as you go. +` + await fs.writeFile(path.join(agentsDir, 'migration-assistant.md'), agentPrompt) + + // Create command files + const analyzeCommand = `Analyze the current project schema and show details about: +- All models/tables and their fields +- Relationships between models +- Database provider and connection +- Potential access control patterns + +Use the \`opensaas_introspect_prisma\` or \`opensaas_introspect_keystone\` tools. +` + await fs.writeFile(path.join(commandsDir, 'analyze-schema.md'), analyzeCommand) + + const generateCommand = `Generate the opensaas.config.ts file based on the current schema analysis. + +This should: +1. Start the migration wizard if not already started +2. Use sensible defaults where possible +3. Generate a complete, working config +4. Show the user what was generated +5. Provide next steps + +Use the migration wizard tools to complete this. +` + await fs.writeFile(path.join(commandsDir, 'generate-config.md'), generateCommand) +} + +/** + * Main migrate command + */ +async function migrateCommand(options: MigrateOptions): Promise { + const cwd = process.cwd() + + console.log(chalk.bold.cyan('\n🚀 OpenSaaS Stack Migration\n')) + + // Step 1: Detect project type + const spinner = ora('Detecting project type...').start() + + let projectTypes: ProjectType[] + if (options.type) { + projectTypes = [options.type] + } else { + projectTypes = await detectProjectType(cwd) + } + + if (projectTypes.length === 0) { + spinner.fail(chalk.red('No recognizable project found')) + console.log(chalk.dim('\nThis command works with:')) + console.log(chalk.dim(' - Prisma projects (prisma/schema.prisma)')) + console.log(chalk.dim(' - KeystoneJS projects (keystone.config.ts)')) + console.log(chalk.dim(' - Next.js projects (package.json with next)')) + console.log(chalk.dim('\nUse --type to force a project type.')) + process.exit(1) + } + + spinner.succeed(chalk.green(`Detected: ${projectTypes.join(', ')}`)) + + // Step 2: Analyze schema + const analysisSpinner = ora('Analyzing schema...').start() + + const analysis: ProjectAnalysis = { + projectTypes, + cwd, + } + + if (projectTypes.includes('prisma')) { + try { + const prismaAnalysis = await analyzePrismaSchema(cwd) + analysis.models = prismaAnalysis.models + analysis.provider = prismaAnalysis.provider + } catch (error) { + // Prisma analysis failed, continue without it + } + } + + if (analysis.models && analysis.models.length > 0) { + analysisSpinner.succeed(chalk.green(`Found ${analysis.models.length} models`)) + + // Display model tree + const lastIndex = analysis.models.length - 1 + analysis.models.forEach((model, index) => { + const prefix = index === lastIndex ? '└─' : '├─' + console.log(chalk.dim(` ${prefix} ${model.name} (${model.fieldCount} fields)`)) + }) + } else { + analysisSpinner.succeed(chalk.yellow('No models found (will create from scratch)')) + } + + // Step 3: Setup Claude Code (if --with-ai) + if (options.withAi) { + const claudeSpinner = ora('Setting up Claude Code...').start() + + try { + await setupClaudeCode(cwd, analysis) + claudeSpinner.succeed(chalk.green('Claude Code ready')) + + console.log(chalk.dim(' ├─ Created .claude directory')) + console.log(chalk.dim(' ├─ Generated migration assistant')) + console.log(chalk.dim(' └─ Registered MCP server')) + } catch (error) { + claudeSpinner.fail(chalk.red('Failed to setup Claude Code')) + console.error(error) + } + } + + // Step 4: Display next steps + console.log(chalk.green('\n✅ Analysis complete!\n')) + + if (options.withAi) { + console.log(chalk.bold('🤖 Next Steps:\n')) + console.log(chalk.cyan(' 1. Open this project in Claude Code')) + console.log(chalk.cyan(' 2. Ask: "Help me migrate to OpenSaaS Stack"')) + console.log(chalk.cyan(' 3. Follow the interactive wizard')) + } else { + console.log(chalk.bold('📝 Next Steps:\n')) + console.log(chalk.cyan(' 1. Run with --with-ai for AI-guided migration')) + console.log(chalk.cyan(' 2. Or manually create opensaas.config.ts')) + console.log(chalk.dim('\n See: https://stack.opensaas.au/guides/migration')) + } + + console.log(chalk.dim(`\n📚 Documentation: https://stack.opensaas.au/guides/migration\n`)) +} + +/** + * Create the migrate command for Commander + */ +export function createMigrateCommand(): Command { + const migrate = new Command('migrate') + migrate.description('Migrate an existing project to OpenSaaS Stack') + + migrate + .option('--with-ai', 'Enable AI-guided migration with Claude Code') + .option('--type ', 'Force project type (prisma, nextjs, keystone)') + .action(async (options: MigrateOptions) => { + try { + await migrateCommand(options) + process.exit(0) + } catch (error) { + console.error(chalk.red('\n❌ Migration failed:'), error) + process.exit(1) + } + }) + + return migrate +} +``` + +### `packages/cli/src/migration/types.ts` + +```typescript +/** + * Migration types - Shared types for the migration system + */ + +export type ProjectType = 'prisma' | 'nextjs' | 'keystone' + +export interface ModelInfo { + name: string + fieldCount: number +} + +export interface ProjectAnalysis { + projectTypes: ProjectType[] + cwd: string + models?: ModelInfo[] + provider?: string + hasAuth?: boolean + authLibrary?: string +} + +export interface FieldMapping { + prismaType: string + opensaasType: string + opensaasImport: string +} + +export interface MigrationQuestion { + id: string + text: string + type: 'text' | 'select' | 'boolean' | 'multiselect' + options?: string[] + defaultValue?: string | boolean | string[] + required?: boolean + dependsOn?: { + questionId: string + value: string | boolean + } +} + +export interface MigrationSession { + id: string + projectType: ProjectType + analysis: ProjectAnalysis + currentQuestionIndex: number + answers: Record + generatedConfig?: string + isComplete: boolean + createdAt: Date + updatedAt: Date +} + +export interface MigrationOutput { + configContent: string + dependencies: string[] + files: Array<{ + path: string + content: string + language: string + description: string + }> + steps: string[] + warnings: string[] +} + +export interface IntrospectedModel { + name: string + fields: IntrospectedField[] + hasRelations: boolean + primaryKey: string +} + +export interface IntrospectedField { + name: string + type: string + isRequired: boolean + isUnique: boolean + isId: boolean + isList: boolean + defaultValue?: string + relation?: { + name: string + model: string + fields: string[] + references: string[] + } +} + +export interface IntrospectedSchema { + provider: string + models: IntrospectedModel[] + enums: Array<{ name: string; values: string[] }> +} +``` + +### Modify `packages/cli/src/index.ts` + +Add to imports: + +```typescript +import { createMigrateCommand } from './commands/migrate.js' +``` + +Add before `program.parse()`: + +```typescript +// Add migrate command +program.addCommand(createMigrateCommand()) +``` + +--- + +## Dependencies + +Add to `packages/cli/package.json` if not present: + +```json +{ + "dependencies": { + "fs-extra": "^11.2.0" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4" + } +} +``` + +Note: `chalk`, `ora`, and `commander` should already be present. + +--- + +## Acceptance Criteria + +1. **Command Registration** + - [ ] `opensaas migrate` command is available + - [ ] `opensaas migrate --help` shows options + - [ ] `--with-ai` and `--type` flags work + +2. **Project Detection** + - [ ] Detects Prisma projects (looks for `prisma/schema.prisma`) + - [ ] Detects KeystoneJS projects (looks for `keystone.config.ts`) + - [ ] Detects Next.js projects (checks package.json) + - [ ] Can detect multiple types in same project + - [ ] `--type` flag overrides detection + +3. **Schema Analysis** + - [ ] Counts models in Prisma schema + - [ ] Extracts field counts per model + - [ ] Detects database provider + - [ ] Handles missing/empty schemas gracefully + +4. **Claude Code Setup (with --with-ai)** + - [ ] Creates `.claude/` directory structure + - [ ] Generates valid `settings.json` with MCP server + - [ ] Creates `migration-assistant.md` agent with project details + - [ ] Creates command files (`analyze-schema.md`, `generate-config.md`) + - [ ] README includes project analysis summary + +5. **Output Quality** + - [ ] Uses emoji prefixes consistently + - [ ] Shows spinner during async operations + - [ ] Displays tree-style model list + - [ ] Provides clear next steps + - [ ] Links to documentation + +--- + +## Testing + +Create new vitest tests in `packages/cli/tests/commands/migrate.test.ts` to cover: +- Project type detection logic +- Schema analysis functions +- Claude Code setup functions + +### Manual Testing + +```bash +# Build the CLI +cd packages/cli +pnpm build + +# Test on a Prisma project +cd /path/to/prisma-project +npx @opensaas/stack-cli migrate + +# Test with AI setup +npx @opensaas/stack-cli migrate --with-ai + +# Test type override +npx @opensaas/stack-cli migrate --type prisma + +# Verify .claude directory was created correctly +ls -la .claude/ +cat .claude/settings.json +cat .claude/agents/migration-assistant.md +``` + +### Edge Cases to Test + +1. Empty directory (no project detected) +2. Project with only package.json (no Prisma) +3. Prisma project with empty schema +4. Project already has `.claude/` directory +5. Permission errors on file creation diff --git a/specs/migration-helper/phase-2-mcp-tools.md b/specs/migration-helper/phase-2-mcp-tools.md new file mode 100644 index 00000000..c2e859f1 --- /dev/null +++ b/specs/migration-helper/phase-2-mcp-tools.md @@ -0,0 +1,1150 @@ +# Phase 2: MCP Server Tools & Documentation Provider + +## Task Overview + +Extend the existing MCP server with migration-specific tools and enhance the documentation provider to search local docs and examples. + +## Context + +### Existing MCP Server Architecture + +The MCP server is located in `packages/cli/src/mcp/`. It uses the `@modelcontextprotocol/sdk` to provide tools that Claude Code can call. + +**Entry Point:** `packages/cli/src/mcp/server/index.ts` + +```typescript +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + ListToolsRequestSchema, + CallToolRequestSchema, + type Tool, +} from '@modelcontextprotocol/sdk/types.js' +import { StackMCPServer } from './stack-mcp-server.js' + +// Tool definitions +const TOOLS: Tool[] = [ + { + name: 'opensaas_implement_feature', + description: 'Start an interactive wizard...', + inputSchema: { + type: 'object', + properties: { + feature: { type: 'string', description: '...' }, + }, + required: ['feature'], + }, + }, + // ... more tools +] + +export async function startMCPServer() { + const server = new Server(/* ... */) + const stackServer = new StackMCPServer() + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS } + }) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + switch (name) { + case 'opensaas_implement_feature': + return await stackServer.implementFeature(args) + // ... more cases + } + }) + + const transport = new StdioServerTransport() + await server.connect(transport) +} +``` + +**Business Logic:** `packages/cli/src/mcp/server/stack-mcp-server.ts` + +```typescript +import { WizardEngine } from '../lib/wizards/wizard-engine.js' +import { OpenSaasDocumentationProvider } from '../lib/documentation-provider.js' + +export class StackMCPServer { + private wizardEngine: WizardEngine + private docsProvider: OpenSaasDocumentationProvider + + constructor() { + this.wizardEngine = new WizardEngine() + this.docsProvider = new OpenSaasDocumentationProvider() + } + + async implementFeature({ feature, description }) { /* ... */ } + async answerFeatureQuestion({ sessionId, answer }) { /* ... */ } + async searchFeatureDocs({ topic }) { /* ... */ } + // ... more methods +} +``` + +### Existing Documentation Provider + +Located at `packages/cli/src/mcp/lib/documentation-provider.ts`: + +```typescript +export class OpenSaasDocumentationProvider { + private readonly DOCS_API = 'https://stack.opensaas.au/api/search' + private cache = new Map() + + async searchDocs(query: string, limit = 5, minScore = 0.7): Promise { + // Fetches from hosted docs API + } + + async getTopicDocs(topic: string): Promise { + // Normalizes topic and searches + } +} +``` + +### MCP Response Format + +All tools return this format: + +```typescript +{ + content: [ + { + type: 'text' as const, + text: 'Response message here with **markdown** formatting', + }, + ], +} +``` + +--- + +## Requirements + +### 1. Add New Migration Tools + +Add these tools to the MCP server: + +| Tool Name | Description | Parameters | +|-----------|-------------|------------| +| `opensaas_start_migration` | Start migration wizard | `projectType: 'prisma' \| 'keystone' \| 'nextjs'` | +| `opensaas_answer_migration` | Answer migration question | `sessionId: string, answer: any` | +| `opensaas_introspect_prisma` | Analyze Prisma schema | `schemaPath?: string` (defaults to `prisma/schema.prisma`) | +| `opensaas_introspect_keystone` | Analyze KeystoneJS config | `configPath?: string` | +| `opensaas_search_migration_docs` | Search migration docs | `query: string` | +| `opensaas_get_example` | Get example config code | `feature: string` | + +### 2. Enhance Documentation Provider + +Add methods for local documentation: + +- `searchLocalDocs(query)` - Search CLAUDE.md files +- `getExampleConfig(feature)` - Get example opensaas.config.ts snippets +- `findMigrationGuide(projectType)` - Get migration-specific docs + +### 3. Wire Everything Together + +Update `stack-mcp-server.ts` to handle new tools. + +--- + +## File Changes + +### 1. Modify `packages/cli/src/mcp/server/index.ts` + +Add new tool definitions to the `TOOLS` array: + +```typescript +// Add to TOOLS array + +{ + name: 'opensaas_start_migration', + description: 'Start the migration wizard for an existing project. Returns the first question.', + inputSchema: { + type: 'object', + properties: { + projectType: { + type: 'string', + description: 'Type of project being migrated', + enum: ['prisma', 'keystone', 'nextjs'], + }, + }, + required: ['projectType'], + }, +}, +{ + name: 'opensaas_answer_migration', + description: 'Answer a question in the migration wizard', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Migration session ID', + }, + answer: { + description: 'Answer to the current question', + oneOf: [ + { type: 'string' }, + { type: 'boolean' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }, + required: ['sessionId', 'answer'], + }, +}, +{ + name: 'opensaas_introspect_prisma', + description: 'Analyze a Prisma schema file and return detailed information about models, fields, and relationships', + inputSchema: { + type: 'object', + properties: { + schemaPath: { + type: 'string', + description: 'Path to schema.prisma (defaults to prisma/schema.prisma)', + }, + }, + }, +}, +{ + name: 'opensaas_introspect_keystone', + description: 'Analyze a KeystoneJS config file and return information about lists and fields', + inputSchema: { + type: 'object', + properties: { + configPath: { + type: 'string', + description: 'Path to keystone.config.ts (defaults to keystone.config.ts)', + }, + }, + }, +}, +{ + name: 'opensaas_search_migration_docs', + description: 'Search OpenSaaS Stack documentation for migration-related topics', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query (e.g., "prisma to opensaas", "access control patterns")', + }, + }, + required: ['query'], + }, +}, +{ + name: 'opensaas_get_example', + description: 'Get example code for a specific feature or pattern', + inputSchema: { + type: 'object', + properties: { + feature: { + type: 'string', + description: 'Feature to get example for (e.g., "blog-with-auth", "access-control", "relationships")', + }, + }, + required: ['feature'], + }, +}, +``` + +Add to the switch statement in `CallToolRequestSchema` handler: + +```typescript +case 'opensaas_start_migration': + return await stackServer.startMigration( + args as { projectType: 'prisma' | 'keystone' | 'nextjs' } + ) + +case 'opensaas_answer_migration': + return await stackServer.answerMigration( + args as { sessionId: string; answer: string | boolean | string[] } + ) + +case 'opensaas_introspect_prisma': + return await stackServer.introspectPrisma( + args as { schemaPath?: string } + ) + +case 'opensaas_introspect_keystone': + return await stackServer.introspectKeystone( + args as { configPath?: string } + ) + +case 'opensaas_search_migration_docs': + return await stackServer.searchMigrationDocs( + args as { query: string } + ) + +case 'opensaas_get_example': + return await stackServer.getExample( + args as { feature: string } + ) +``` + +### 2. Modify `packages/cli/src/mcp/server/stack-mcp-server.ts` + +Add new methods to the `StackMCPServer` class: + +```typescript +import { MigrationWizard } from '../lib/wizards/migration-wizard.js' +import { PrismaIntrospector } from '../../migration/introspectors/prisma-introspector.js' +import { KeystoneIntrospector } from '../../migration/introspectors/keystone-introspector.js' +import type { ProjectType } from '../../migration/types.js' + +export class StackMCPServer { + private wizardEngine: WizardEngine + private migrationWizard: MigrationWizard + private docsProvider: OpenSaasDocumentationProvider + private prismaIntrospector: PrismaIntrospector + private keystoneIntrospector: KeystoneIntrospector + + constructor() { + this.wizardEngine = new WizardEngine() + this.migrationWizard = new MigrationWizard() + this.docsProvider = new OpenSaasDocumentationProvider() + this.prismaIntrospector = new PrismaIntrospector() + this.keystoneIntrospector = new KeystoneIntrospector() + } + + // ... existing methods ... + + /** + * Start a migration wizard session + */ + async startMigration({ projectType }: { projectType: ProjectType }) { + return this.migrationWizard.startMigration(projectType) + } + + /** + * Answer a migration wizard question + */ + async answerMigration({ + sessionId, + answer, + }: { + sessionId: string + answer: string | boolean | string[] + }) { + return this.migrationWizard.answerQuestion(sessionId, answer) + } + + /** + * Introspect a Prisma schema + */ + async introspectPrisma({ schemaPath }: { schemaPath?: string }) { + const cwd = process.cwd() + const path = schemaPath || 'prisma/schema.prisma' + + try { + const schema = await this.prismaIntrospector.introspect(cwd, path) + + const modelList = schema.models + .map(m => { + const fields = m.fields.map(f => { + let type = f.type + if (f.relation) type = `→ ${f.relation.model}` + if (f.isList) type = `${type}[]` + if (!f.isRequired) type = `${type}?` + return ` - ${f.name}: ${type}` + }).join('\n') + return `### ${m.name}\n${fields}` + }) + .join('\n\n') + + const enumList = schema.enums.length > 0 + ? `\n## Enums\n\n${schema.enums.map(e => `- **${e.name}**: ${e.values.join(', ')}`).join('\n')}` + : '' + + return { + content: [ + { + type: 'text' as const, + text: `# Prisma Schema Analysis + +**Provider:** ${schema.provider} +**Models:** ${schema.models.length} +**Enums:** ${schema.enums.length} + +## Models + +${modelList} +${enumList} + +--- + +**Ready to migrate?** Use \`opensaas_start_migration({ projectType: "prisma" })\` to begin the wizard.`, + }, + ], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + content: [ + { + type: 'text' as const, + text: `❌ Failed to introspect Prisma schema: ${message}\n\nMake sure the file exists at: ${path}`, + }, + ], + isError: true, + } + } + } + + /** + * Introspect a KeystoneJS config + */ + async introspectKeystone({ configPath }: { configPath?: string }) { + const cwd = process.cwd() + const path = configPath || 'keystone.config.ts' + + try { + const config = await this.keystoneIntrospector.introspect(cwd, path) + + return { + content: [ + { + type: 'text' as const, + text: `# KeystoneJS Config Analysis + +**Lists:** ${config.lists.length} + +## Lists + +${config.lists.map(l => `### ${l.name}\n${l.fields.map(f => `- ${f.name}: ${f.type}`).join('\n')}`).join('\n\n')} + +--- + +**Note:** KeystoneJS → OpenSaaS migration is mostly 1:1. Field types and access control patterns map directly. + +**Ready to migrate?** Use \`opensaas_start_migration({ projectType: "keystone" })\` to begin.`, + }, + ], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + content: [ + { + type: 'text' as const, + text: `❌ Failed to introspect KeystoneJS config: ${message}\n\nMake sure the file exists at: ${path}`, + }, + ], + isError: true, + } + } + } + + /** + * Search migration documentation + */ + async searchMigrationDocs({ query }: { query: string }) { + // First try local CLAUDE.md files + const localDocs = await this.docsProvider.searchLocalDocs(query) + + // Then try hosted docs + const hostedDocs = await this.docsProvider.searchDocs(query) + + const sections: string[] = [] + + if (localDocs.content) { + sections.push(`## Local Documentation\n\n${localDocs.content}`) + } + + if (hostedDocs.content && hostedDocs.content !== 'No documentation found for this query.') { + sections.push(`## Online Documentation\n\n${hostedDocs.content}`) + } + + if (sections.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: `No documentation found for "${query}". + +Try these searches: +- "access control" - How to restrict access to data +- "field types" - Available field types in OpenSaaS +- "authentication" - Setting up auth with Better-auth +- "hooks" - Data transformation and side effects + +Or visit: https://stack.opensaas.au/`, + }, + ], + } + } + + return { + content: [ + { + type: 'text' as const, + text: `# Documentation: ${query}\n\n${sections.join('\n\n---\n\n')}`, + }, + ], + } + } + + /** + * Get example code for a feature + */ + async getExample({ feature }: { feature: string }) { + const example = await this.docsProvider.getExampleConfig(feature) + + if (!example) { + return { + content: [ + { + type: 'text' as const, + text: `No example found for "${feature}". + +Available examples: +- **blog-with-auth** - Blog with user authentication +- **access-control** - Access control patterns +- **relationships** - Model relationships +- **hooks** - Data transformation hooks +- **custom-fields** - Custom field types + +Use: \`opensaas_get_example({ feature: "example-name" })\``, + }, + ], + } + } + + return { + content: [ + { + type: 'text' as const, + text: `# Example: ${feature} + +${example.description} + +\`\`\`typescript +${example.code} +\`\`\` + +${example.notes ? `\n## Notes\n\n${example.notes}` : ''} + +--- + +📚 Full example at: ${example.sourcePath}`, + }, + ], + } + } +} +``` + +### 3. Modify `packages/cli/src/mcp/lib/documentation-provider.ts` + +Add new methods for local documentation: + +```typescript +import fs from 'fs-extra' +import path from 'path' +import { glob } from 'glob' + +interface LocalDocResult { + content: string + files: string[] +} + +interface ExampleConfig { + description: string + code: string + notes?: string + sourcePath: string +} + +export class OpenSaasDocumentationProvider { + // ... existing code ... + + /** + * Search local CLAUDE.md files in the monorepo + */ + async searchLocalDocs(query: string): Promise { + const cwd = process.cwd() + + // Find CLAUDE.md files + const claudeFiles = await glob('**/CLAUDE.md', { + cwd, + ignore: ['node_modules/**', '.next/**', 'dist/**'], + absolute: true, + }) + + const results: Array<{ file: string; content: string; score: number }> = [] + const queryLower = query.toLowerCase() + const queryTerms = queryLower.split(/\s+/) + + for (const file of claudeFiles) { + try { + const content = await fs.readFile(file, 'utf-8') + const contentLower = content.toLowerCase() + + // Simple relevance scoring + let score = 0 + for (const term of queryTerms) { + if (contentLower.includes(term)) { + score += (contentLower.match(new RegExp(term, 'g')) || []).length + } + } + + if (score > 0) { + // Extract relevant section + const lines = content.split('\n') + const relevantLines: string[] = [] + let inRelevantSection = false + let sectionHeader = '' + + for (const line of lines) { + const lineLower = line.toLowerCase() + + // Check if line starts a section + if (line.startsWith('#')) { + // Check if this section title is relevant + const titleRelevant = queryTerms.some(term => lineLower.includes(term)) + if (titleRelevant) { + inRelevantSection = true + sectionHeader = line + relevantLines.push(line) + } else if (inRelevantSection) { + // We've left the relevant section + break + } + } else if (inRelevantSection) { + relevantLines.push(line) + } else if (queryTerms.some(term => lineLower.includes(term))) { + // Found relevant content outside a section + relevantLines.push(line) + } + } + + if (relevantLines.length > 0) { + results.push({ + file: path.relative(cwd, file), + content: relevantLines.slice(0, 50).join('\n'), + score, + }) + } + } + } catch { + // Skip files that can't be read + } + } + + // Sort by score + results.sort((a, b) => b.score - a.score) + + if (results.length === 0) { + return { content: '', files: [] } + } + + const content = results + .slice(0, 3) + .map(r => `### From \`${r.file}\`\n\n${r.content}`) + .join('\n\n---\n\n') + + return { + content, + files: results.map(r => r.file), + } + } + + /** + * Get example config code for a feature + */ + async getExampleConfig(feature: string): Promise { + const examples: Record = { + 'blog-with-auth': { + description: 'A blog application with user authentication and post management', + code: `import { config, list } from '@opensaas/stack-core' +import { text, relationship, timestamp, select } from '@opensaas/stack-core/fields' +import { withAuth, authConfig } from '@opensaas/stack-auth' + +const isAuthor = ({ session, item }) => + session?.userId === item?.authorId + +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: 'file:./dev.db', + prismaClientConstructor: (PrismaClient) => { + // ... adapter setup + }, + }, + lists: { + Post: list({ + fields: { + title: text({ validation: { isRequired: true } }), + content: text({ ui: { displayMode: 'textarea' } }), + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + defaultValue: 'draft', + }), + author: relationship({ ref: 'User.posts' }), + publishedAt: timestamp(), + }, + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: isAuthor, + delete: isAuthor, + }, + }, + }), + }, + }), + authConfig({ emailAndPassword: { enabled: true } }) +)`, + notes: 'This example uses the auth plugin for user management. The `isAuthor` helper restricts updates to the post creator.', + sourcePath: 'examples/auth-demo/opensaas.config.ts', + }, + + 'access-control': { + description: 'Common access control patterns for different scenarios', + code: `// Public read, authenticated write +access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: ({ session }) => !!session, + delete: ({ session }) => !!session, + }, +} + +// Only owners can access +const isOwner = ({ session, item }) => + session?.userId === item?.userId + +access: { + operation: { + query: isOwner, + update: isOwner, + delete: isOwner, + }, + filter: { + query: ({ session }) => ({ userId: { equals: session?.userId } }), + }, +} + +// Role-based access +const isAdmin = ({ session }) => session?.role === 'admin' +const isOwnerOrAdmin = (args) => isOwner(args) || isAdmin(args) + +access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: isOwnerOrAdmin, + delete: isAdmin, + }, +}`, + notes: 'Access control functions receive session and item context. Use filter access to automatically scope queries.', + sourcePath: 'packages/core/CLAUDE.md', + }, + + 'relationships': { + description: 'How to define relationships between models', + code: `lists: { + User: list({ + fields: { + name: text(), + email: text({ isIndexed: 'unique' }), + posts: relationship({ ref: 'Post.author', many: true }), + comments: relationship({ ref: 'Comment.author', many: true }), + }, + }), + + Post: list({ + fields: { + title: text(), + content: text(), + author: relationship({ ref: 'User.posts' }), + comments: relationship({ ref: 'Comment.post', many: true }), + tags: relationship({ ref: 'Tag.posts', many: true }), + }, + }), + + Comment: list({ + fields: { + content: text(), + author: relationship({ ref: 'User.comments' }), + post: relationship({ ref: 'Post.comments' }), + }, + }), + + Tag: list({ + fields: { + name: text(), + posts: relationship({ ref: 'Post.tags', many: true }), + }, + }), +}`, + notes: 'Relationships use `ref: "ListName.fieldName"` format. Set `many: true` for one-to-many or many-to-many.', + sourcePath: 'examples/blog/opensaas.config.ts', + }, + + 'hooks': { + description: 'Data transformation and side effect hooks', + code: `Post: list({ + fields: { + title: text(), + slug: text(), + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + }), + publishedAt: timestamp(), + }, + hooks: { + resolveInput: async ({ resolvedData, operation }) => { + // Auto-generate slug from title + if (resolvedData.title && !resolvedData.slug) { + resolvedData.slug = resolvedData.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + } + + // Set publishedAt when status changes to published + if (operation === 'update' && resolvedData.status === 'published') { + resolvedData.publishedAt = new Date() + } + + return resolvedData + }, + afterOperation: async ({ operation, item }) => { + // Send notification after publish + if (operation === 'update' && item?.status === 'published') { + console.log(\`Post published: \${item.title}\`) + // await sendNotification(item) + } + }, + }, +})`, + notes: 'resolveInput transforms data before save. afterOperation runs side effects. Use beforeOperation for validation.', + sourcePath: 'packages/core/CLAUDE.md', + }, + + 'custom-fields': { + description: 'Creating custom field types', + code: `// In your field definition file +import type { BaseFieldConfig } from '@opensaas/stack-core' +import { z } from 'zod' + +export type SlugField = BaseFieldConfig & { + type: 'slug' + sourceField?: string +} + +export function slug(options?: Omit): SlugField { + return { + type: 'slug', + ...options, + + getZodSchema: (fieldName, operation) => { + if (operation === 'create') { + return z.string().regex(/^[a-z0-9-]+$/).optional() + } + return z.string().regex(/^[a-z0-9-]+$/).optional() + }, + + getPrismaType: (fieldName) => ({ + type: 'String', + modifiers: '?', + attributes: ['@unique'], + }), + + getTypeScriptType: () => ({ + type: 'string', + optional: true, + }), + } +} + +// Usage in config +fields: { + title: text({ validation: { isRequired: true } }), + urlSlug: slug({ sourceField: 'title' }), +}`, + notes: 'Custom fields implement getZodSchema, getPrismaType, and getTypeScriptType methods. See packages/tiptap for a full example.', + sourcePath: 'examples/custom-field', + }, + } + + const normalizedFeature = feature.toLowerCase().replace(/\s+/g, '-') + return examples[normalizedFeature] || null + } + + /** + * Get migration-specific documentation for a project type + */ + async findMigrationGuide(projectType: string): Promise { + const guides: Record = { + prisma: `# Prisma to OpenSaaS Migration + +## Type Mapping + +| Prisma Type | OpenSaaS Field | +|-------------|----------------| +| String | text() | +| Int | integer() | +| Boolean | checkbox() | +| DateTime | timestamp() | +| Json | text() with custom handling | +| Enum | select() | +| @relation | relationship() | + +## Key Changes + +1. **Models → Lists**: Prisma models become OpenSaaS lists +2. **Access Control**: Add operation-level and field-level access +3. **Database URL**: Now provided via prismaClientConstructor +4. **Relationships**: Use \`ref: 'ListName.fieldName'\` format + +## Common Patterns + +### Before (Prisma) +\`\`\`prisma +model Post { + id String @id @default(cuid()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId String +} +\`\`\` + +### After (OpenSaaS) +\`\`\`typescript +Post: list({ + fields: { + title: text({ validation: { isRequired: true } }), + author: relationship({ ref: 'User.posts' }), + }, + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + }, + }, +}) +\`\`\` +`, + + keystone: `# KeystoneJS to OpenSaaS Migration + +## Good News! + +KeystoneJS and OpenSaaS Stack are very similar. Migration is mostly: +1. Update import paths +2. Minor syntax adjustments +3. Add prismaClientConstructor for Prisma 7 + +## Key Changes + +### Imports +\`\`\`typescript +// Before (KeystoneJS) +import { config, list } from '@keystone-6/core' +import { text, relationship } from '@keystone-6/core/fields' + +// After (OpenSaaS) +import { config, list } from '@opensaas/stack-core' +import { text, relationship } from '@opensaas/stack-core/fields' +\`\`\` + +### Database Config +\`\`\`typescript +// Before +db: { + provider: 'sqlite', + url: 'file:./dev.db', +} + +// After (Prisma 7 requires adapters) +db: { + provider: 'sqlite', + url: 'file:./dev.db', + prismaClientConstructor: (PrismaClient) => { + const db = new Database('./dev.db') + const adapter = new PrismaBetterSQLite3(db) + return new PrismaClient({ adapter }) + }, +} +\`\`\` + +## Field Types (1:1 mapping) + +Most field types work identically: +- text() → text() +- integer() → integer() +- checkbox() → checkbox() +- timestamp() → timestamp() +- select() → select() +- relationship() → relationship() + +## Access Control (same patterns) + +Access control functions work the same way. Just copy them over! +`, + + nextjs: `# Next.js to OpenSaaS Migration + +## Overview + +If you have a Next.js project without Prisma, you'll need to: +1. Define your data models in opensaas.config.ts +2. Run the generator to create Prisma schema +3. Set up your database + +## Steps + +1. **Install Dependencies** +\`\`\`bash +pnpm add @opensaas/stack-core @prisma/client prisma +pnpm add -D @prisma/adapter-better-sqlite3 better-sqlite3 +\`\`\` + +2. **Create opensaas.config.ts** +\`\`\`typescript +import { config, list } from '@opensaas/stack-core' +import { text, integer } from '@opensaas/stack-core/fields' + +export default config({ + db: { + provider: 'sqlite', + url: 'file:./dev.db', + prismaClientConstructor: (PrismaClient) => { + // See examples for adapter setup + }, + }, + lists: { + // Define your models here + }, +}) +\`\`\` + +3. **Generate and Push** +\`\`\`bash +pnpm opensaas generate +npx prisma generate +npx prisma db push +\`\`\` + +## If You're Using an Auth Library + +- **next-auth**: Consider migrating to Better-auth via @opensaas/stack-auth +- **clerk**: Can continue using Clerk alongside OpenSaaS +- **better-auth**: Use @opensaas/stack-auth plugin +`, + } + + return guides[projectType.toLowerCase()] || guides.prisma + } +} +``` + +--- + +## Dependencies + +The introspectors are imported from Phase 4. For now, create stub files: + +**`packages/cli/src/migration/introspectors/prisma-introspector.ts`** (stub) + +```typescript +import type { IntrospectedSchema } from '../types.js' + +export class PrismaIntrospector { + async introspect(cwd: string, schemaPath: string): Promise { + // Stub - will be implemented in Phase 4 + throw new Error('PrismaIntrospector not yet implemented') + } +} +``` + +**`packages/cli/src/migration/introspectors/keystone-introspector.ts`** (stub) + +```typescript +export class KeystoneIntrospector { + async introspect(cwd: string, configPath: string): Promise { + // Stub - will be implemented in Phase 4 + throw new Error('KeystoneIntrospector not yet implemented') + } +} +``` + +The `MigrationWizard` is from Phase 3, also create a stub: + +**`packages/cli/src/mcp/lib/wizards/migration-wizard.ts`** (stub) + +```typescript +import type { ProjectType } from '../../../migration/types.js' + +export class MigrationWizard { + async startMigration(projectType: ProjectType) { + // Stub - will be implemented in Phase 3 + return { + content: [{ type: 'text' as const, text: 'Migration wizard not yet implemented' }], + } + } + + async answerQuestion(sessionId: string, answer: any) { + // Stub - will be implemented in Phase 3 + return { + content: [{ type: 'text' as const, text: 'Migration wizard not yet implemented' }], + } + } +} +``` + +--- + +## Acceptance Criteria + +1. **New Tools Registered** + - [ ] All 6 new tools appear in `opensaas mcp` tool list + - [ ] Tool schemas are valid JSON Schema + - [ ] Tool descriptions are clear + +2. **Documentation Provider** + - [ ] `searchLocalDocs` finds content in CLAUDE.md files + - [ ] `getExampleConfig` returns code for known features + - [ ] `findMigrationGuide` returns guides for all project types + - [ ] Results are properly formatted markdown + +3. **Tool Handlers** + - [ ] Each tool returns proper MCP response format + - [ ] Error handling returns isError: true with message + - [ ] Tools work without session context + +4. **Integration** + - [ ] Stubs exist for Phase 3 & 4 dependencies + - [ ] Server starts without errors + - [ ] Existing tools still work + +--- + +## Testing + +```bash +# Build the CLI +cd packages/cli +pnpm build + +# Start MCP server directly +node dist/mcp/server/index.js + +# Test tools using MCP inspector or Claude Code +``` + +Test each tool with vitest tests: +1. `opensaas_introspect_prisma` - On a project with prisma/schema.prisma +2. `opensaas_search_migration_docs` - Search for "access control" +3. `opensaas_get_example` - Get "blog-with-auth" example diff --git a/specs/migration-helper/phase-3-migration-wizard.md b/specs/migration-helper/phase-3-migration-wizard.md new file mode 100644 index 00000000..999e7e0e --- /dev/null +++ b/specs/migration-helper/phase-3-migration-wizard.md @@ -0,0 +1,856 @@ +# Phase 3: Migration Wizard Engine + +## Task Overview + +Create an interactive migration wizard that guides users through converting their existing project to OpenSaaS Stack. The wizard collects information through a series of questions and generates the final configuration. + +## Context + +### Existing Wizard Pattern + +The codebase has an existing wizard engine at `packages/cli/src/mcp/lib/wizards/wizard-engine.ts`: + +```typescript +import type { Feature, WizardSession, SessionStorage, FeatureQuestion } from '../types.js' +import { getFeature } from '../features/catalog.js' +import { FeatureGenerator } from '../generators/feature-generator.js' + +export class WizardEngine { + private sessions: SessionStorage = {} + + async startFeature(featureId: string): Promise<{ + content: Array<{ type: string; text: string }> + }> { + const feature = getFeature(featureId) + if (!feature) { + return { content: [{ type: 'text', text: `❌ Unknown feature: "${featureId}"` }] } + } + + const sessionId = this.generateSessionId() + const session = this.createSession(sessionId, feature) + this.sessions[sessionId] = session + + const progressBar = this.renderProgressBar(1, feature.questions.length) + const firstQuestion = this.renderQuestion(feature.questions[0], session, 1) + + return { + content: [{ + type: 'text', + text: `🚀 **${feature.name} Implementation** + +${feature.description} + +--- + +## Let's configure this feature + +${firstQuestion} + +--- + +**Progress**: ${progressBar} 1/${feature.questions.length} +**Session ID**: \`${sessionId}\``, + }], + } + } + + async answerQuestion(sessionId: string, answer: string | boolean | string[]): Promise<{ + content: Array<{ type: string; text: string }> + }> { + const session = this.sessions[sessionId] + if (!session) { + return { content: [{ type: 'text', text: `❌ Session not found: ${sessionId}` }] } + } + + // Validate, store answer, move to next question or complete + // ... (see full file for implementation) + } + + // Helper methods + private generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + } + + private renderProgressBar(current: number, total: number): string { + const filled = Math.round((current / total) * 10) + const empty = 10 - filled + return '▓'.repeat(filled) + '░'.repeat(empty) + } +} +``` + +### Session & Question Types + +From `packages/cli/src/mcp/lib/types.ts`: + +```typescript +export type QuestionType = 'text' | 'textarea' | 'select' | 'multiselect' | 'boolean' + +export interface FeatureQuestion { + id: string + text: string + type: QuestionType + required?: boolean + options?: string[] + defaultValue?: string | boolean | string[] + dependsOn?: { questionId: string; value: string | boolean } + followUp?: { if: string | boolean; ask: string; type: QuestionType; options?: string[] } +} + +export interface WizardSession { + id: string + featureId: string + feature: Feature + currentQuestionIndex: number + answers: Record + followUpAnswers: Record + isComplete: boolean + createdAt: Date + updatedAt: Date +} +``` + +--- + +## Requirements + +### 1. Create Migration Wizard + +**File to create:** `packages/cli/src/mcp/lib/wizards/migration-wizard.ts` + +The wizard should: + +1. **Start with project analysis** - Use introspected schema data +2. **Generate dynamic questions** - Based on detected models +3. **Collect answers step-by-step** - Store in session +4. **Generate final config** - Call MigrationGenerator when complete +5. **Provide Claude Code instructions** - Guide AI through the flow + +### 2. Question Categories + +The wizard asks about: + +1. **Database** - Provider, adapter choice +2. **Authentication** - Enable auth plugin, methods +3. **Per-Model Access Control** - For each detected model +4. **Admin UI** - Base path, customizations +5. **Plugins** - Additional features to enable + +### 3. Session Management + +- Sessions persist for the MCP server lifetime +- Cleanup completed sessions periodically +- Handle invalid session IDs gracefully + +--- + +## File Template + +### `packages/cli/src/mcp/lib/wizards/migration-wizard.ts` + +```typescript +/** + * Migration Wizard - Interactive guide for migrating to OpenSaaS Stack + */ + +import type { ProjectType, MigrationSession, IntrospectedSchema, IntrospectedModel } from '../../../migration/types.js' +import { MigrationGenerator } from '../../../migration/generators/migration-generator.js' + +interface MigrationQuestion { + id: string + text: string + type: 'text' | 'select' | 'boolean' | 'multiselect' + options?: string[] + defaultValue?: string | boolean | string[] + required?: boolean + context?: string // Additional context shown with the question +} + +interface MigrationSessionStorage { + [sessionId: string]: MigrationSession +} + +export class MigrationWizard { + private sessions: MigrationSessionStorage = {} + private generator: MigrationGenerator + + constructor() { + this.generator = new MigrationGenerator() + } + + /** + * Start a new migration wizard session + */ + async startMigration( + projectType: ProjectType, + analysis?: IntrospectedSchema + ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + const sessionId = this.generateSessionId() + + // Generate questions based on project type and analysis + const questions = this.generateQuestions(projectType, analysis) + + const session: MigrationSession = { + id: sessionId, + projectType, + analysis: { + projectTypes: [projectType], + cwd: process.cwd(), + models: analysis?.models?.map(m => ({ + name: m.name, + fieldCount: m.fields.length, + })), + provider: analysis?.provider, + }, + currentQuestionIndex: 0, + answers: {}, + isComplete: false, + createdAt: new Date(), + updatedAt: new Date(), + } + + // Store questions in session for later reference + ;(session as any).questions = questions + + this.sessions[sessionId] = session + + const totalQuestions = questions.length + const firstQuestion = questions[0] + const progressBar = this.renderProgressBar(1, totalQuestions) + + return { + content: [ + { + type: 'text' as const, + text: `# 🚀 OpenSaaS Stack Migration Wizard + +## Project Analysis + +- **Project Type:** ${projectType} +- **Models:** ${analysis?.models?.length || 0} +- **Database:** ${analysis?.provider || 'Not detected'} + +--- + +## Let's Configure Your Migration + +${this.renderQuestion(firstQuestion, 1, totalQuestions)} + +--- + +**Progress:** ${progressBar} 1/${totalQuestions} + +
+💡 **Instructions for Claude** + +1. Present this question naturally to the user +2. When they answer, call \`opensaas_answer_migration\` with: + - \`sessionId\`: "${sessionId}" + - \`answer\`: their response (string, boolean, or array) +3. Continue until the wizard is complete +4. Do NOT mention session IDs to the user + +
`, + }, + ], + } + } + + /** + * Answer a wizard question + */ + async answerQuestion( + sessionId: string, + answer: string | boolean | string[] + ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + const session = this.sessions[sessionId] + if (!session) { + return { + content: [ + { + type: 'text' as const, + text: `❌ **Session not found:** ${sessionId} + +Please start a new migration with \`opensaas_start_migration\`.`, + }, + ], + } + } + + const questions = (session as any).questions as MigrationQuestion[] + const currentQuestion = questions[session.currentQuestionIndex] + + // Validate answer + const validation = this.validateAnswer(answer, currentQuestion) + if (!validation.valid) { + return { + content: [ + { + type: 'text' as const, + text: `❌ **Invalid answer:** ${validation.message} + +${this.renderQuestion(currentQuestion, session.currentQuestionIndex + 1, questions.length)}`, + }, + ], + } + } + + // Store answer + session.answers[currentQuestion.id] = answer + session.updatedAt = new Date() + + // Move to next question + session.currentQuestionIndex++ + + // Check if complete + if (session.currentQuestionIndex >= questions.length) { + session.isComplete = true + return this.generateMigrationConfig(session) + } + + // Render next question + const nextQuestion = questions[session.currentQuestionIndex] + const questionNum = session.currentQuestionIndex + 1 + const progressBar = this.renderProgressBar(questionNum, questions.length) + + return { + content: [ + { + type: 'text' as const, + text: `✅ **Recorded:** ${this.formatAnswer(answer)} + +--- + +${this.renderQuestion(nextQuestion, questionNum, questions.length)} + +--- + +**Progress:** ${progressBar} ${questionNum}/${questions.length}`, + }, + ], + } + } + + /** + * Generate questions based on project type and analysis + */ + private generateQuestions( + projectType: ProjectType, + analysis?: IntrospectedSchema + ): MigrationQuestion[] { + const questions: MigrationQuestion[] = [] + + // 1. Database configuration + questions.push({ + id: 'preserve_database', + text: 'Do you want to keep your existing database?', + type: 'boolean', + defaultValue: true, + context: 'If yes, we\'ll generate a config that matches your current schema.', + }) + + questions.push({ + id: 'db_provider', + text: 'Which database provider are you using?', + type: 'select', + options: ['sqlite', 'postgresql', 'mysql'], + defaultValue: analysis?.provider || 'sqlite', + }) + + // 2. Authentication + questions.push({ + id: 'enable_auth', + text: 'Do you want to add authentication?', + type: 'boolean', + defaultValue: this.hasAuthModels(analysis), + context: 'Uses Better-auth for user management and sessions.', + }) + + questions.push({ + id: 'auth_methods', + text: 'Which authentication methods do you want?', + type: 'multiselect', + options: ['email-password', 'google', 'github', 'magic-link'], + defaultValue: ['email-password'], + }) + + // 3. Access control strategy + questions.push({ + id: 'default_access', + text: 'What should be the default access control strategy?', + type: 'select', + options: [ + 'public-read-auth-write', + 'authenticated-only', + 'owner-only', + 'admin-only', + ], + defaultValue: 'public-read-auth-write', + context: ` +- **public-read-auth-write**: Anyone can read, only logged-in users can write +- **authenticated-only**: Only logged-in users can read or write +- **owner-only**: Users can only access their own data +- **admin-only**: Only admins can access`, + }) + + // 4. Per-model configuration (if models detected) + if (analysis?.models && analysis.models.length > 0) { + // Ask about special models + const modelNames = analysis.models.map(m => m.name) + + if (modelNames.some(n => ['User', 'Account', 'Session'].includes(n))) { + questions.push({ + id: 'skip_auth_models', + text: 'We detected User/Account/Session models. Should we skip these (they\'ll be managed by the auth plugin)?', + type: 'boolean', + defaultValue: true, + context: 'The auth plugin automatically creates these models.', + }) + } + + // Ask about models that need special access control + const nonAuthModels = modelNames.filter( + n => !['User', 'Account', 'Session', 'Verification'].includes(n) + ) + + if (nonAuthModels.length > 0) { + questions.push({ + id: 'models_with_owner', + text: `Which models should have owner-based access control? (User can only access their own)`, + type: 'multiselect', + options: nonAuthModels, + defaultValue: this.guessOwnerModels(analysis.models, nonAuthModels), + context: 'Models with a relationship to User are good candidates.', + }) + } + } + + // 5. Admin UI + questions.push({ + id: 'admin_base_path', + text: 'What base path should the admin UI use?', + type: 'text', + defaultValue: '/admin', + }) + + // 6. Additional features + questions.push({ + id: 'additional_features', + text: 'Do you want to add any additional features?', + type: 'multiselect', + options: ['file-storage', 'semantic-search', 'audit-logging'], + defaultValue: [], + context: 'These can be added later. Select any you want included now.', + }) + + // 7. Final confirmation + questions.push({ + id: 'confirm', + text: 'Ready to generate your opensaas.config.ts?', + type: 'boolean', + defaultValue: true, + context: 'You can always modify the generated config afterwards.', + }) + + return questions + } + + /** + * Generate the final migration config + */ + private async generateMigrationConfig( + session: MigrationSession + ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + try { + const output = await this.generator.generate(session) + + // Clean up session + delete this.sessions[session.id] + + return { + content: [ + { + type: 'text' as const, + text: `# ✅ Migration Complete! + +## Generated opensaas.config.ts + +\`\`\`typescript +${output.configContent} +\`\`\` + +--- + +## Install Dependencies + +\`\`\`bash +${output.dependencies.map(d => `pnpm add ${d}`).join('\n')} +\`\`\` + +--- + +${output.files.length > 0 ? `## Additional Files + +${output.files.map(f => `### ${f.path} + +*${f.description}* + +\`\`\`${f.language} +${f.content} +\`\`\``).join('\n\n')} + +--- + +` : ''} + +${output.warnings.length > 0 ? `## ⚠️ Warnings + +${output.warnings.map(w => `- ${w}`).join('\n')} + +--- + +` : ''} + +## Next Steps + +${output.steps.map((step, i) => `${i + 1}. ${step}`).join('\n')} + +--- + +🎉 **Your migration is ready!** + +The generated config creates an OpenSaaS Stack application that matches your existing schema. + +📚 **Documentation:** https://stack.opensaas.au/`, + }, + ], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + content: [ + { + type: 'text' as const, + text: `❌ **Failed to generate config:** ${message} + +Please try again or create the config manually. + +📚 See: https://stack.opensaas.au/guides/migration`, + }, + ], + } + } + } + + /** + * Check if analysis includes auth-related models + */ + private hasAuthModels(analysis?: IntrospectedSchema): boolean { + if (!analysis?.models) return false + const authModelNames = ['User', 'Account', 'Session'] + return analysis.models.some(m => authModelNames.includes(m.name)) + } + + /** + * Guess which models should have owner-based access + */ + private guessOwnerModels( + models: IntrospectedModel[], + modelNames: string[] + ): string[] { + const ownerModels: string[] = [] + + for (const name of modelNames) { + const model = models.find(m => m.name === name) + if (!model) continue + + // Check if model has a relationship to User + const hasUserRelation = model.fields.some( + f => f.relation && (f.relation.model === 'User' || f.name.toLowerCase().includes('author') || f.name.toLowerCase().includes('owner')) + ) + + if (hasUserRelation) { + ownerModels.push(name) + } + } + + return ownerModels + } + + /** + * Render a question for display + */ + private renderQuestion( + question: MigrationQuestion, + questionNum: number, + totalQuestions: number + ): string { + let rendered = `### Question ${questionNum}/${totalQuestions}\n\n**${question.text}**\n\n` + + if (question.context) { + rendered += `${question.context}\n\n` + } + + if (question.type === 'select') { + rendered += question.options!.map(opt => `- \`${opt}\``).join('\n') + } else if (question.type === 'multiselect') { + rendered += question.options!.map(opt => `- \`${opt}\``).join('\n') + rendered += '\n\n*Select multiple (comma-separated) or empty for none*' + } else if (question.type === 'boolean') { + rendered += '*Answer: yes or no*' + } else if (question.type === 'text') { + rendered += '*Enter your response*' + } + + if (question.defaultValue !== undefined) { + rendered += `\n\n*Default: ${this.formatAnswer(question.defaultValue)}*` + } + + return rendered + } + + /** + * Validate an answer + */ + private validateAnswer( + answer: string | boolean | string[], + question: MigrationQuestion + ): { valid: boolean; message?: string } { + if (question.required && !answer) { + return { valid: false, message: 'This question requires an answer.' } + } + + if (question.type === 'boolean') { + const normalized = this.normalizeBoolean(answer) + if (normalized === null) { + return { valid: false, message: 'Please answer with yes/no or true/false.' } + } + } + + if (question.type === 'select' && question.options) { + if (!question.options.includes(answer as string)) { + return { + valid: false, + message: `Please select one of: ${question.options.join(', ')}`, + } + } + } + + if (question.type === 'multiselect' && question.options) { + const answers = Array.isArray(answer) ? answer : (answer as string).split(',').map(a => a.trim()).filter(Boolean) + const invalid = answers.filter(a => !question.options!.includes(a)) + if (invalid.length > 0) { + return { + valid: false, + message: `Invalid options: ${invalid.join(', ')}. Valid: ${question.options.join(', ')}`, + } + } + } + + return { valid: true } + } + + /** + * Normalize boolean answer + */ + private normalizeBoolean(answer: string | boolean | string[]): boolean | null { + if (typeof answer === 'boolean') return answer + if (typeof answer !== 'string') return null + + const lower = answer.toLowerCase().trim() + if (['yes', 'y', 'true', '1'].includes(lower)) return true + if (['no', 'n', 'false', '0'].includes(lower)) return false + return null + } + + /** + * Format an answer for display + */ + private formatAnswer(answer: string | boolean | string[]): string { + if (typeof answer === 'boolean') return answer ? 'Yes' : 'No' + if (Array.isArray(answer)) return answer.length > 0 ? answer.join(', ') : '(none)' + return String(answer) + } + + /** + * Generate a unique session ID + */ + private generateSessionId(): string { + return `migration_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + } + + /** + * Render a progress bar + */ + private renderProgressBar(current: number, total: number): string { + const filled = Math.round((current / total) * 10) + const empty = 10 - filled + return '▓'.repeat(filled) + '░'.repeat(empty) + } + + /** + * Get a session by ID (for testing/debugging) + */ + getSession(sessionId: string): MigrationSession | undefined { + return this.sessions[sessionId] + } + + /** + * Clear completed sessions + */ + clearCompletedSessions(): void { + Object.keys(this.sessions).forEach(id => { + if (this.sessions[id].isComplete) { + delete this.sessions[id] + } + }) + } + + /** + * Clear old sessions (older than 1 hour) + */ + clearOldSessions(): void { + const oneHourAgo = Date.now() - 60 * 60 * 1000 + Object.keys(this.sessions).forEach(id => { + if (this.sessions[id].createdAt.getTime() < oneHourAgo) { + delete this.sessions[id] + } + }) + } +} +``` + +--- + +## Dependencies + +This phase depends on: + +1. **Phase 1 Types** - `MigrationSession` from `packages/cli/src/migration/types.ts` +2. **Phase 4 Introspectors** - `IntrospectedSchema`, `IntrospectedModel` types +3. **Phase 5 Generator** - `MigrationGenerator` class + +Create a stub for Phase 5: + +**`packages/cli/src/migration/generators/migration-generator.ts`** (stub) + +```typescript +import type { MigrationSession, MigrationOutput } from '../types.js' + +export class MigrationGenerator { + async generate(session: MigrationSession): Promise { + // Stub - will be implemented in Phase 5 + return { + configContent: '// Generated config will appear here', + dependencies: ['@opensaas/stack-core', '@prisma/client'], + files: [], + steps: ['Install dependencies', 'Run opensaas generate', 'Run prisma db push'], + warnings: [], + } + } +} +``` + +--- + +## Acceptance Criteria + +1. **Wizard Start** + - [ ] Creates session with unique ID + - [ ] Generates appropriate questions based on project type + - [ ] Shows project analysis summary + - [ ] Displays first question with proper formatting + +2. **Question Flow** + - [ ] Validates answers appropriately (boolean, select, multiselect) + - [ ] Stores answers in session + - [ ] Shows progress bar and question number + - [ ] Handles edge cases (empty answers, invalid options) + +3. **Dynamic Questions** + - [ ] Generates per-model questions for detected models + - [ ] Skips auth models when auth plugin is used + - [ ] Guesses owner models based on relationships + +4. **Completion** + - [ ] Calls generator when all questions answered + - [ ] Shows generated config in markdown + - [ ] Lists dependencies to install + - [ ] Shows warnings for unsupported features + - [ ] Provides clear next steps + +5. **Session Management** + - [ ] Sessions persist across MCP calls + - [ ] Handles invalid session IDs gracefully + - [ ] Cleanup methods work correctly + +--- + +## Testing + +### Unit Tests + +Create test file `packages/cli/tests/migration-wizard.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest' +import { MigrationWizard } from '../src/mcp/lib/wizards/migration-wizard' + +describe('MigrationWizard', () => { + let wizard: MigrationWizard + + beforeEach(() => { + wizard = new MigrationWizard() + }) + + it('should start a migration session', async () => { + const result = await wizard.startMigration('prisma') + expect(result.content[0].text).toContain('Migration Wizard') + expect(result.content[0].text).toContain('Session ID') + }) + + it('should answer questions and progress', async () => { + const startResult = await wizard.startMigration('prisma') + const sessionIdMatch = startResult.content[0].text.match(/sessionId.*?:\s*"([^"]+)"/) + const sessionId = sessionIdMatch?.[1] + + expect(sessionId).toBeDefined() + + const answerResult = await wizard.answerQuestion(sessionId!, true) + expect(answerResult.content[0].text).toContain('Recorded') + expect(answerResult.content[0].text).toContain('Question 2') + }) + + it('should handle invalid session ID', async () => { + const result = await wizard.answerQuestion('invalid-session', true) + expect(result.content[0].text).toContain('Session not found') + }) + + it('should validate select answers', async () => { + const startResult = await wizard.startMigration('prisma') + const sessionIdMatch = startResult.content[0].text.match(/sessionId.*?:\s*"([^"]+)"/) + const sessionId = sessionIdMatch?.[1] + + // Answer first question (boolean) + await wizard.answerQuestion(sessionId!, true) + + // Try invalid select answer + const invalidResult = await wizard.answerQuestion(sessionId!, 'invalid-provider') + expect(invalidResult.content[0].text).toContain('Invalid') + }) +}) +``` + +### Manual Testing + +```bash +# Build and start MCP server +cd packages/cli +pnpm build +node dist/mcp/server/index.js + +# In another terminal, use MCP inspector or Claude Code to: +# 1. Call opensaas_start_migration with projectType: "prisma" +# 2. Answer questions using opensaas_answer_migration +# 3. Verify final config is generated +``` diff --git a/specs/migration-helper/phase-4-introspectors.md b/specs/migration-helper/phase-4-introspectors.md new file mode 100644 index 00000000..12c85d9b --- /dev/null +++ b/specs/migration-helper/phase-4-introspectors.md @@ -0,0 +1,948 @@ +# Phase 4: Schema Introspectors + +## Task Overview + +Create introspectors that analyze existing Prisma schemas and KeystoneJS configs to extract model definitions, field types, relationships, and other metadata needed for migration. + +## Context + +### How Introspectors Are Used + +The introspectors are called by: +1. **MCP Tools** - `opensaas_introspect_prisma`, `opensaas_introspect_keystone` +2. **CLI Command** - `opensaas migrate` for initial analysis +3. **Migration Wizard** - To generate dynamic questions + +They parse source files and return structured data that other components use. + +### Shared Types + +From `packages/cli/src/migration/types.ts` (created in Phase 1): + +```typescript +export interface IntrospectedModel { + name: string + fields: IntrospectedField[] + hasRelations: boolean + primaryKey: string +} + +export interface IntrospectedField { + name: string + type: string + isRequired: boolean + isUnique: boolean + isId: boolean + isList: boolean + defaultValue?: string + relation?: { + name: string + model: string + fields: string[] + references: string[] + } +} + +export interface IntrospectedSchema { + provider: string + models: IntrospectedModel[] + enums: Array<{ name: string; values: string[] }> +} +``` + +### Existing Generator Pattern + +The codebase has a Prisma schema generator at `packages/cli/src/generator/prisma.ts` that can be referenced for parsing patterns. It uses regex to parse schema files. + +--- + +## Requirements + +### 1. Prisma Introspector + +**File to create:** `packages/cli/src/migration/introspectors/prisma-introspector.ts` + +Must parse: +- Datasource block (provider) +- Model definitions with all fields +- Field types and modifiers (`?`, `[]`, `@id`, `@unique`, `@default`) +- Relationships (`@relation`) +- Enums + +### 2. KeystoneJS Introspector + +**File to create:** `packages/cli/src/migration/introspectors/keystone-introspector.ts` + +Must parse: +- Load TypeScript config using jiti +- Extract lists and their fields +- Map KeystoneJS field types to OpenSaaS equivalents +- Extract access control patterns (for reference) + +### 3. Next.js Introspector + +**File to create:** `packages/cli/src/migration/introspectors/nextjs-introspector.ts` + +Must detect: +- Next.js version +- Auth libraries (next-auth, clerk, etc.) +- Database libraries (prisma, drizzle, etc.) +- App router vs pages router + +--- + +## File Templates + +### `packages/cli/src/migration/introspectors/prisma-introspector.ts` + +```typescript +/** + * Prisma Schema Introspector + * + * Parses prisma/schema.prisma and extracts structured information + * about models, fields, relationships, and enums. + */ + +import fs from 'fs-extra' +import path from 'path' +import type { IntrospectedSchema, IntrospectedModel, IntrospectedField } from '../types.js' + +export class PrismaIntrospector { + /** + * Introspect a Prisma schema file + */ + async introspect(cwd: string, schemaPath: string = 'prisma/schema.prisma'): Promise { + const fullPath = path.isAbsolute(schemaPath) ? schemaPath : path.join(cwd, schemaPath) + + if (!await fs.pathExists(fullPath)) { + throw new Error(`Schema file not found: ${fullPath}`) + } + + const schema = await fs.readFile(fullPath, 'utf-8') + + return { + provider: this.extractProvider(schema), + models: this.extractModels(schema), + enums: this.extractEnums(schema), + } + } + + /** + * Extract database provider from datasource block + */ + private extractProvider(schema: string): string { + const match = schema.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/) + return match ? match[1] : 'unknown' + } + + /** + * Extract all model definitions + */ + private extractModels(schema: string): IntrospectedModel[] { + const models: IntrospectedModel[] = [] + + // Match model blocks + const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g + let match + + while ((match = modelRegex.exec(schema)) !== null) { + const name = match[1] + const body = match[2] + + const fields = this.extractFields(body) + const primaryKey = fields.find(f => f.isId)?.name || 'id' + + models.push({ + name, + fields, + hasRelations: fields.some(f => f.relation !== undefined), + primaryKey, + }) + } + + return models + } + + /** + * Extract fields from a model body + */ + private extractFields(body: string): IntrospectedField[] { + const fields: IntrospectedField[] = [] + const lines = body.split('\n') + + for (const line of lines) { + const trimmed = line.trim() + + // Skip empty lines, comments, and model-level attributes + if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) { + continue + } + + const field = this.parseFieldLine(trimmed) + if (field) { + fields.push(field) + } + } + + return fields + } + + /** + * Parse a single field line + */ + private parseFieldLine(line: string): IntrospectedField | null { + // Basic field pattern: name Type modifiers attributes + // Examples: + // id String @id @default(cuid()) + // title String + // isActive Boolean? @default(true) + // posts Post[] + // author User @relation(fields: [authorId], references: [id]) + + // Remove comments + const withoutComment = line.split('//')[0].trim() + + // Match field name and type + const fieldMatch = withoutComment.match(/^(\w+)\s+(\w+)(\?)?(\[\])?(.*)$/) + if (!fieldMatch) return null + + const [, name, rawType, optional, isList, rest] = fieldMatch + + // Skip if this looks like an index or other non-field line + if (['@@', 'index', 'unique'].some(kw => name.startsWith(kw))) { + return null + } + + const field: IntrospectedField = { + name, + type: rawType, + isRequired: !optional, + isUnique: rest.includes('@unique'), + isId: rest.includes('@id'), + isList: !!isList, + } + + // Extract default value + const defaultMatch = rest.match(/@default\(([^)]+)\)/) + if (defaultMatch) { + field.defaultValue = defaultMatch[1] + } + + // Extract relation + const relationMatch = rest.match(/@relation\(([^)]+)\)/) + if (relationMatch) { + const relationBody = relationMatch[1] + + // Parse relation parts + const fieldsMatch = relationBody.match(/fields:\s*\[([^\]]+)\]/) + const referencesMatch = relationBody.match(/references:\s*\[([^\]]+)\]/) + const nameMatch = relationBody.match(/name:\s*"([^"]+)"/) || relationBody.match(/"([^"]+)"/) + + field.relation = { + name: nameMatch ? nameMatch[1] : '', + model: rawType, + fields: fieldsMatch ? fieldsMatch[1].split(',').map(f => f.trim()) : [], + references: referencesMatch ? referencesMatch[1].split(',').map(r => r.trim()) : [], + } + } + + return field + } + + /** + * Extract enum definitions + */ + private extractEnums(schema: string): Array<{ name: string; values: string[] }> { + const enums: Array<{ name: string; values: string[] }> = [] + + // Match enum blocks + const enumRegex = /enum\s+(\w+)\s*\{([^}]+)\}/g + let match + + while ((match = enumRegex.exec(schema)) !== null) { + const name = match[1] + const body = match[2] + + const values = body + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('//')) + + enums.push({ name, values }) + } + + return enums + } + + /** + * Map Prisma type to OpenSaaS field type + */ + mapPrismaTypeToOpenSaas(prismaType: string): { type: string; import: string } { + const mappings: Record = { + 'String': { type: 'text', import: 'text' }, + 'Int': { type: 'integer', import: 'integer' }, + 'Float': { type: 'float', import: 'float' }, + 'Boolean': { type: 'checkbox', import: 'checkbox' }, + 'DateTime': { type: 'timestamp', import: 'timestamp' }, + 'Json': { type: 'json', import: 'json' }, + 'BigInt': { type: 'text', import: 'text' }, // No native support + 'Decimal': { type: 'text', import: 'text' }, // No native support + 'Bytes': { type: 'text', import: 'text' }, // No native support + } + + return mappings[prismaType] || { type: 'text', import: 'text' } + } + + /** + * Get warnings for unsupported features + */ + getWarnings(schema: IntrospectedSchema): string[] { + const warnings: string[] = [] + + // Check for unsupported types + for (const model of schema.models) { + for (const field of model.fields) { + if (['BigInt', 'Decimal', 'Bytes'].includes(field.type)) { + warnings.push(`Field "${model.name}.${field.name}" uses unsupported type "${field.type}" - will be mapped to text()`) + } + } + } + + // Check for composite IDs + // This would require checking for @@id in the original schema + + return warnings + } +} +``` + +### `packages/cli/src/migration/introspectors/keystone-introspector.ts` + +```typescript +/** + * KeystoneJS Config Introspector + * + * Loads keystone.config.ts using jiti and extracts list definitions. + * KeystoneJS → OpenSaaS migration is mostly 1:1. + */ + +import path from 'path' +import fs from 'fs-extra' +import { createJiti } from 'jiti' + +export interface KeystoneList { + name: string + fields: KeystoneField[] + access?: Record + hooks?: Record +} + +export interface KeystoneField { + name: string + type: string + options?: Record +} + +export interface KeystoneSchema { + lists: KeystoneList[] + db?: { + provider: string + url?: string + } +} + +export class KeystoneIntrospector { + /** + * Introspect a KeystoneJS config file + */ + async introspect(cwd: string, configPath: string = 'keystone.config.ts'): Promise { + const fullPath = path.isAbsolute(configPath) ? configPath : path.join(cwd, configPath) + + // Try alternative paths + const paths = [ + fullPath, + path.join(cwd, 'keystone.ts'), + path.join(cwd, 'keystone.config.js'), + ] + + let foundPath: string | undefined + for (const p of paths) { + if (await fs.pathExists(p)) { + foundPath = p + break + } + } + + if (!foundPath) { + throw new Error(`KeystoneJS config not found. Tried: ${paths.join(', ')}`) + } + + try { + // Use jiti to load TypeScript config + const jiti = createJiti(import.meta.url, { + interopDefault: true, + moduleCache: false, + }) + + const configModule = await jiti.import(foundPath) + const config = (configModule as any).default || configModule + + return this.parseConfig(config) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to load KeystoneJS config: ${message}`) + } + } + + /** + * Parse the loaded KeystoneJS config object + */ + private parseConfig(config: any): KeystoneSchema { + const result: KeystoneSchema = { + lists: [], + } + + // Extract database config + if (config.db) { + result.db = { + provider: config.db.provider || 'unknown', + url: config.db.url, + } + } + + // Extract lists + if (config.lists) { + for (const [name, listDef] of Object.entries(config.lists)) { + const list = this.parseList(name, listDef) + result.lists.push(list) + } + } + + return result + } + + /** + * Parse a single list definition + */ + private parseList(name: string, listDef: any): KeystoneList { + const list: KeystoneList = { + name, + fields: [], + } + + // Extract fields + if (listDef.fields) { + for (const [fieldName, fieldDef] of Object.entries(listDef.fields)) { + const field = this.parseField(fieldName, fieldDef) + list.fields.push(field) + } + } + + // Store access and hooks for reference (not used in migration but useful) + if (listDef.access) { + list.access = listDef.access + } + if (listDef.hooks) { + list.hooks = listDef.hooks + } + + return list + } + + /** + * Parse a single field definition + */ + private parseField(name: string, fieldDef: any): KeystoneField { + // KeystoneJS fields are objects with a type property or function results + let type = 'unknown' + let options: Record = {} + + if (typeof fieldDef === 'object' && fieldDef !== null) { + // Check for common field type patterns + if (fieldDef.type) { + type = fieldDef.type + } else if (fieldDef._type) { + type = fieldDef._type + } else if (fieldDef.constructor?.name) { + // Some field builders return objects with constructor name + type = fieldDef.constructor.name + } + + // Extract common options + if (fieldDef.validation) options.validation = fieldDef.validation + if (fieldDef.defaultValue !== undefined) options.defaultValue = fieldDef.defaultValue + if (fieldDef.isRequired !== undefined) options.isRequired = fieldDef.isRequired + if (fieldDef.ref) options.ref = fieldDef.ref + if (fieldDef.many !== undefined) options.many = fieldDef.many + } + + return { name, type, options } + } + + /** + * Map KeystoneJS field type to OpenSaaS equivalent + */ + mapKeystoneTypeToOpenSaas(keystoneType: string): { type: string; import: string } { + // KeystoneJS → OpenSaaS is mostly 1:1 + const mappings: Record = { + 'text': { type: 'text', import: 'text' }, + 'integer': { type: 'integer', import: 'integer' }, + 'float': { type: 'float', import: 'float' }, + 'checkbox': { type: 'checkbox', import: 'checkbox' }, + 'timestamp': { type: 'timestamp', import: 'timestamp' }, + 'select': { type: 'select', import: 'select' }, + 'relationship': { type: 'relationship', import: 'relationship' }, + 'password': { type: 'password', import: 'password' }, + 'json': { type: 'json', import: 'json' }, + // KeystoneJS-specific types + 'image': { type: 'text', import: 'text' }, // Needs file storage plugin + 'file': { type: 'text', import: 'text' }, // Needs file storage plugin + 'virtual': { type: 'text', import: 'text' }, // Not supported + 'calendarDay': { type: 'timestamp', import: 'timestamp' }, + } + + const lower = keystoneType.toLowerCase() + return mappings[lower] || { type: 'text', import: 'text' } + } + + /** + * Get warnings for unsupported features + */ + getWarnings(schema: KeystoneSchema): string[] { + const warnings: string[] = [] + + for (const list of schema.lists) { + for (const field of list.fields) { + const lower = field.type.toLowerCase() + + if (['image', 'file'].includes(lower)) { + warnings.push(`Field "${list.name}.${field.name}" uses "${field.type}" - consider adding file storage plugin`) + } + + if (lower === 'virtual') { + warnings.push(`Field "${list.name}.${field.name}" uses "virtual" - this is not supported, will be skipped`) + } + } + } + + return warnings + } +} +``` + +### `packages/cli/src/migration/introspectors/nextjs-introspector.ts` + +```typescript +/** + * Next.js Project Introspector + * + * Detects Next.js version, auth libraries, database libraries, + * and other project characteristics. + */ + +import path from 'path' +import fs from 'fs-extra' +import { glob } from 'glob' + +export interface NextjsAnalysis { + version: string + routerType: 'app' | 'pages' | 'both' | 'unknown' + typescript: boolean + authLibrary?: string + databaseLibrary?: string + hasPrisma: boolean + hasEnvFile: boolean + existingDependencies: string[] +} + +export class NextjsIntrospector { + /** + * Analyze a Next.js project + */ + async introspect(cwd: string): Promise { + const packageJsonPath = path.join(cwd, 'package.json') + + if (!await fs.pathExists(packageJsonPath)) { + throw new Error('package.json not found') + } + + const pkg = await fs.readJSON(packageJsonPath) + + const analysis: NextjsAnalysis = { + version: this.getNextVersion(pkg), + routerType: await this.detectRouterType(cwd), + typescript: await this.hasTypeScript(cwd), + hasPrisma: this.hasDependency(pkg, '@prisma/client') || await fs.pathExists(path.join(cwd, 'prisma')), + hasEnvFile: await fs.pathExists(path.join(cwd, '.env')) || await fs.pathExists(path.join(cwd, '.env.local')), + existingDependencies: this.getAllDependencies(pkg), + } + + // Detect auth library + analysis.authLibrary = this.detectAuthLibrary(pkg) + + // Detect database library + analysis.databaseLibrary = this.detectDatabaseLibrary(pkg) + + return analysis + } + + /** + * Get Next.js version from package.json + */ + private getNextVersion(pkg: any): string { + const version = pkg.dependencies?.next || pkg.devDependencies?.next || 'unknown' + // Strip semver prefixes like ^ or ~ + return version.replace(/^[\^~]/, '') + } + + /** + * Detect if project uses app router, pages router, or both + */ + private async detectRouterType(cwd: string): Promise<'app' | 'pages' | 'both' | 'unknown'> { + const hasApp = await fs.pathExists(path.join(cwd, 'app')) || + await fs.pathExists(path.join(cwd, 'src', 'app')) + const hasPages = await fs.pathExists(path.join(cwd, 'pages')) || + await fs.pathExists(path.join(cwd, 'src', 'pages')) + + if (hasApp && hasPages) return 'both' + if (hasApp) return 'app' + if (hasPages) return 'pages' + return 'unknown' + } + + /** + * Check if project uses TypeScript + */ + private async hasTypeScript(cwd: string): Promise { + return await fs.pathExists(path.join(cwd, 'tsconfig.json')) + } + + /** + * Check if package.json has a dependency + */ + private hasDependency(pkg: any, name: string): boolean { + return !!(pkg.dependencies?.[name] || pkg.devDependencies?.[name]) + } + + /** + * Get all dependencies + */ + private getAllDependencies(pkg: any): string[] { + return [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.devDependencies || {}), + ] + } + + /** + * Detect auth library being used + */ + private detectAuthLibrary(pkg: any): string | undefined { + const authLibraries = [ + { name: 'next-auth', dep: 'next-auth' }, + { name: 'better-auth', dep: 'better-auth' }, + { name: 'clerk', dep: '@clerk/nextjs' }, + { name: 'auth0', dep: '@auth0/nextjs-auth0' }, + { name: 'supabase', dep: '@supabase/auth-helpers-nextjs' }, + { name: 'lucia', dep: 'lucia' }, + { name: 'kinde', dep: '@kinde-oss/kinde-auth-nextjs' }, + ] + + for (const lib of authLibraries) { + if (this.hasDependency(pkg, lib.dep)) { + return lib.name + } + } + + return undefined + } + + /** + * Detect database library being used + */ + private detectDatabaseLibrary(pkg: any): string | undefined { + const dbLibraries = [ + { name: 'prisma', dep: '@prisma/client' }, + { name: 'drizzle', dep: 'drizzle-orm' }, + { name: 'typeorm', dep: 'typeorm' }, + { name: 'mongoose', dep: 'mongoose' }, + { name: 'knex', dep: 'knex' }, + { name: 'sequelize', dep: 'sequelize' }, + { name: 'kysely', dep: 'kysely' }, + ] + + for (const lib of dbLibraries) { + if (this.hasDependency(pkg, lib.dep)) { + return lib.name + } + } + + return undefined + } + + /** + * Get migration recommendations based on analysis + */ + getRecommendations(analysis: NextjsAnalysis): string[] { + const recommendations: string[] = [] + + if (analysis.routerType === 'pages') { + recommendations.push('Consider migrating to App Router for best OpenSaaS Stack integration') + } + + if (analysis.authLibrary && analysis.authLibrary !== 'better-auth') { + recommendations.push(`Consider migrating from ${analysis.authLibrary} to Better-auth (used by OpenSaaS Stack auth plugin)`) + } + + if (!analysis.hasPrisma) { + recommendations.push('OpenSaaS Stack uses Prisma - you\'ll need to set up your data models') + } + + if (analysis.databaseLibrary && analysis.databaseLibrary !== 'prisma') { + recommendations.push(`You\'re using ${analysis.databaseLibrary} - you may need to migrate to Prisma or run both`) + } + + if (!analysis.hasEnvFile) { + recommendations.push('Create a .env file with DATABASE_URL for your database connection') + } + + return recommendations + } + + /** + * Get warnings for potential issues + */ + getWarnings(analysis: NextjsAnalysis): string[] { + const warnings: string[] = [] + + if (analysis.version.startsWith('12') || analysis.version.startsWith('11')) { + warnings.push(`Next.js ${analysis.version} is quite old - consider upgrading to 14+ for best results`) + } + + if (analysis.databaseLibrary === 'mongoose') { + warnings.push('MongoDB/Mongoose is not fully supported by Prisma - migration may require database change') + } + + return warnings + } +} +``` + +--- + +## Acceptance Criteria + +### Prisma Introspector + +1. **File Handling** + - [ ] Reads schema from default path (`prisma/schema.prisma`) + - [ ] Reads schema from custom path + - [ ] Throws meaningful error if file not found + +2. **Provider Detection** + - [ ] Extracts provider from datasource block + - [ ] Handles sqlite, postgresql, mysql, mongodb + +3. **Model Extraction** + - [ ] Finds all model definitions + - [ ] Extracts model names correctly + - [ ] Counts fields per model + +4. **Field Parsing** + - [ ] Parses field name and type + - [ ] Detects optional fields (`?`) + - [ ] Detects list fields (`[]`) + - [ ] Detects `@id` fields + - [ ] Detects `@unique` fields + - [ ] Extracts `@default` values + - [ ] Parses `@relation` with fields and references + +5. **Enum Extraction** + - [ ] Finds all enum definitions + - [ ] Extracts enum values + +6. **Type Mapping** + - [ ] Maps all Prisma types to OpenSaaS equivalents + - [ ] Generates warnings for unsupported types + +### KeystoneJS Introspector + +1. **Config Loading** + - [ ] Loads TypeScript config using jiti + - [ ] Handles keystone.config.ts + - [ ] Handles keystone.ts + - [ ] Throws meaningful error if not found + +2. **List Extraction** + - [ ] Extracts all list definitions + - [ ] Extracts list names + +3. **Field Extraction** + - [ ] Extracts field names and types + - [ ] Captures field options + +4. **Type Mapping** + - [ ] Maps KeystoneJS types to OpenSaaS equivalents + - [ ] Generates warnings for unsupported types + +### Next.js Introspector + +1. **Project Detection** + - [ ] Detects Next.js version + - [ ] Detects router type (app/pages/both) + - [ ] Detects TypeScript usage + +2. **Library Detection** + - [ ] Detects auth libraries + - [ ] Detects database libraries + - [ ] Lists all dependencies + +3. **Recommendations** + - [ ] Provides useful migration recommendations + - [ ] Generates appropriate warnings + +--- + +## Testing + +### Unit Tests + +Create `packages/cli/tests/introspectors/prisma-introspector.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { PrismaIntrospector } from '../../src/migration/introspectors/prisma-introspector' +import fs from 'fs-extra' +import path from 'path' +import os from 'os' + +describe('PrismaIntrospector', () => { + let introspector: PrismaIntrospector + let tempDir: string + + beforeEach(async () => { + introspector = new PrismaIntrospector() + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prisma-test-')) + await fs.ensureDir(path.join(tempDir, 'prisma')) + }) + + afterEach(async () => { + await fs.remove(tempDir) + }) + + it('should parse a simple schema', async () => { + const schema = ` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId String +} +` + await fs.writeFile(path.join(tempDir, 'prisma', 'schema.prisma'), schema) + + const result = await introspector.introspect(tempDir) + + expect(result.provider).toBe('sqlite') + expect(result.models).toHaveLength(2) + + const user = result.models.find(m => m.name === 'User') + expect(user).toBeDefined() + expect(user!.fields).toHaveLength(4) + + const post = result.models.find(m => m.name === 'Post') + expect(post).toBeDefined() + expect(post!.hasRelations).toBe(true) + }) + + it('should parse enums', async () => { + const schema = ` +datasource db { + provider = "postgresql" +} + +enum Role { + USER + ADMIN + MODERATOR +} + +model User { + id String @id + role Role @default(USER) +} +` + await fs.writeFile(path.join(tempDir, 'prisma', 'schema.prisma'), schema) + + const result = await introspector.introspect(tempDir) + + expect(result.enums).toHaveLength(1) + expect(result.enums[0].name).toBe('Role') + expect(result.enums[0].values).toEqual(['USER', 'ADMIN', 'MODERATOR']) + }) + + it('should handle optional and list fields', async () => { + const schema = ` +datasource db { + provider = "sqlite" +} + +model User { + id String @id + name String? + emails String[] + isActive Boolean @default(true) +} +` + await fs.writeFile(path.join(tempDir, 'prisma', 'schema.prisma'), schema) + + const result = await introspector.introspect(tempDir) + const user = result.models[0] + + const nameField = user.fields.find(f => f.name === 'name') + expect(nameField!.isRequired).toBe(false) + + const emailsField = user.fields.find(f => f.name === 'emails') + expect(emailsField!.isList).toBe(true) + }) + + it('should throw for missing schema', async () => { + await expect(introspector.introspect(tempDir, 'nonexistent.prisma')) + .rejects.toThrow('Schema file not found') + }) +}) +``` + +### Manual Testing + +```bash +# Build +cd packages/cli +pnpm build + +# Test Prisma introspector +node -e " +import { PrismaIntrospector } from './dist/migration/introspectors/prisma-introspector.js' +const i = new PrismaIntrospector() +i.introspect('/path/to/prisma/project').then(console.log).catch(console.error) +" + +# Test KeystoneJS introspector +node -e " +import { KeystoneIntrospector } from './dist/migration/introspectors/keystone-introspector.js' +const i = new KeystoneIntrospector() +i.introspect('/path/to/keystone/project').then(console.log).catch(console.error) +" +``` diff --git a/specs/migration-helper/phase-5-config-generator.md b/specs/migration-helper/phase-5-config-generator.md new file mode 100644 index 00000000..217d8fa1 --- /dev/null +++ b/specs/migration-helper/phase-5-config-generator.md @@ -0,0 +1,961 @@ +# Phase 5: Migration Config Generator + +## Task Overview + +Create the config generator that takes migration session data (introspected schema + wizard answers) and produces a complete, working `opensaas.config.ts` file along with any additional files, dependencies, and instructions. + +## Context + +### How the Generator Is Used + +The generator is called when the migration wizard completes: + +```typescript +// In migration-wizard.ts +private async generateMigrationConfig(session: MigrationSession) { + const output = await this.generator.generate(session) + // Returns formatted markdown with config, deps, files, warnings, steps +} +``` + +### Input Data + +The generator receives a `MigrationSession` with: + +```typescript +interface MigrationSession { + id: string + projectType: 'prisma' | 'nextjs' | 'keystone' + analysis: ProjectAnalysis + answers: Record + // ... +} + +interface ProjectAnalysis { + projectTypes: ProjectType[] + cwd: string + models?: Array<{ name: string; fieldCount: number }> + provider?: string + hasAuth?: boolean +} +``` + +### Output Format + +```typescript +interface MigrationOutput { + configContent: string // Complete opensaas.config.ts + dependencies: string[] // npm packages to install + files: Array<{ // Additional files + path: string + content: string + language: string + description: string + }> + steps: string[] // Next steps checklist + warnings: string[] // Unsupported features +} +``` + +### Reference: Existing Config Structure + +From `examples/starter-auth/opensaas.config.ts`: + +```typescript +import { config, list } from '@opensaas/stack-core' +import { text, relationship, timestamp, select, password } from '@opensaas/stack-core/fields' +import { withAuth, authConfig } from '@opensaas/stack-auth' +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3' +import Database from 'better-sqlite3' + +const isOwner = ({ session, item }: { session: any; item: any }) => + session?.userId === item?.authorId + +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./dev.db', + prismaClientConstructor: (PrismaClient) => { + const db = new Database(process.env.DATABASE_URL?.replace('file:', '') || './dev.db') + const adapter = new PrismaBetterSqlite3(db) + return new PrismaClient({ adapter }) + }, + }, + lists: { + Post: list({ + fields: { + title: text({ validation: { isRequired: true } }), + content: text({ ui: { displayMode: 'textarea' } }), + status: select({ + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + defaultValue: 'draft', + }), + author: relationship({ ref: 'User.posts' }), + publishedAt: timestamp(), + }, + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: isOwner, + delete: isOwner, + }, + }, + }), + }, + ui: { + basePath: '/admin', + }, + }), + authConfig({ + emailAndPassword: { enabled: true }, + }) +) +``` + +--- + +## Requirements + +### 1. Generate Complete Config + +The generator must produce: + +1. **Imports** - Based on used field types and features +2. **Access Control Helpers** - Based on wizard answers +3. **Database Configuration** - Provider-specific with Prisma 7 adapter +4. **List Definitions** - From introspected models with field mappings +5. **Auth Integration** - If enabled in wizard +6. **UI Configuration** - Admin base path + +### 2. Handle Different Project Types + +- **Prisma** - Map models and fields from introspected schema +- **KeystoneJS** - Nearly 1:1 mapping, update imports +- **Next.js** - Generate starter config with minimal models + +### 3. Generate Access Control + +Based on wizard answer `default_access`: +- `public-read-auth-write` - Anyone reads, authenticated writes +- `authenticated-only` - Only logged-in users +- `owner-only` - Users access only their own data +- `admin-only` - Only admins + +Based on wizard answer `models_with_owner`: +- Generate owner check for specified models + +--- + +## File Template + +### `packages/cli/src/migration/generators/migration-generator.ts` + +```typescript +/** + * Migration Config Generator + * + * Generates opensaas.config.ts from migration session data. + */ + +import type { + MigrationSession, + MigrationOutput, + IntrospectedSchema, + IntrospectedModel, + IntrospectedField, +} from '../types.js' +import { PrismaIntrospector } from '../introspectors/prisma-introspector.js' +import { KeystoneIntrospector } from '../introspectors/keystone-introspector.js' + +export class MigrationGenerator { + private prismaIntrospector: PrismaIntrospector + private keystoneIntrospector: KeystoneIntrospector + + constructor() { + this.prismaIntrospector = new PrismaIntrospector() + this.keystoneIntrospector = new KeystoneIntrospector() + } + + /** + * Generate migration output from session + */ + async generate(session: MigrationSession): Promise { + const { projectType, analysis, answers } = session + + // Get full schema if available + let schema: IntrospectedSchema | undefined + try { + if (projectType === 'prisma') { + schema = await this.prismaIntrospector.introspect(analysis.cwd) + } + } catch { + // Continue without schema + } + + // Collect used field types for imports + const usedFieldTypes = new Set(['text']) // Always need text + const warnings: string[] = [] + + // Generate lists + const lists = this.generateLists(schema, answers, usedFieldTypes, warnings) + + // Generate access control helpers + const accessHelpers = this.generateAccessHelpers(answers) + + // Generate database config + const dbConfig = this.generateDatabaseConfig( + answers.db_provider as string || analysis.provider || 'sqlite' + ) + + // Determine if using auth + const useAuth = answers.enable_auth === true + + // Generate imports + const imports = this.generateImports(usedFieldTypes, useAuth, dbConfig.provider) + + // Generate the full config + const configContent = this.assembleConfig({ + imports, + accessHelpers, + dbConfig, + lists, + useAuth, + authMethods: (answers.auth_methods as string[]) || ['email-password'], + adminBasePath: (answers.admin_base_path as string) || '/admin', + }) + + // Generate dependencies list + const dependencies = this.generateDependencies(dbConfig.provider, useAuth) + + // Generate additional files + const files = this.generateAdditionalFiles(answers) + + // Generate next steps + const steps = this.generateSteps(useAuth) + + return { + configContent, + dependencies, + files, + steps, + warnings, + } + } + + /** + * Generate list definitions from schema + */ + private generateLists( + schema: IntrospectedSchema | undefined, + answers: Record, + usedFieldTypes: Set, + warnings: string[] + ): string { + if (!schema || schema.models.length === 0) { + // No schema, generate example lists + usedFieldTypes.add('timestamp') + return ` // Add your lists here + // Example: + // Post: list({ + // fields: { + // title: text({ validation: { isRequired: true } }), + // content: text(), + // createdAt: timestamp({ defaultValue: { kind: 'now' } }), + // }, + // }),` + } + + // Filter out auth models if using auth plugin + const skipAuthModels = answers.skip_auth_models === true + const authModelNames = ['User', 'Account', 'Session', 'Verification'] + + const modelsToGenerate = schema.models.filter(model => { + if (skipAuthModels && authModelNames.includes(model.name)) { + return false + } + return true + }) + + // Get models that should have owner access + const ownerModels = new Set(answers.models_with_owner as string[] || []) + + // Generate each list + const listDefinitions = modelsToGenerate.map(model => { + return this.generateList(model, schema, ownerModels.has(model.name), answers, usedFieldTypes, warnings) + }) + + return listDefinitions.join('\n\n') + } + + /** + * Generate a single list definition + */ + private generateList( + model: IntrospectedModel, + schema: IntrospectedSchema, + hasOwnerAccess: boolean, + answers: Record, + usedFieldTypes: Set, + warnings: string[] + ): string { + const fields: string[] = [] + + // Skip system fields (id, createdAt, updatedAt) - OpenSaaS adds these automatically + const systemFields = ['id', 'createdAt', 'updatedAt'] + + for (const field of model.fields) { + if (systemFields.includes(field.name)) continue + if (field.isId) continue // Skip ID fields + + const fieldDef = this.generateField(field, schema, usedFieldTypes, warnings) + if (fieldDef) { + fields.push(` ${field.name}: ${fieldDef},`) + } + } + + // Generate access control + const access = this.generateListAccess(hasOwnerAccess, model, answers) + + return ` ${model.name}: list({ + fields: { +${fields.join('\n')} + },${access} + }),` + } + + /** + * Generate a field definition + */ + private generateField( + field: IntrospectedField, + schema: IntrospectedSchema, + usedFieldTypes: Set, + warnings: string[] + ): string | null { + // Handle relationships + if (field.relation) { + usedFieldTypes.add('relationship') + + // Find the related model and back-reference field + const relatedModel = schema.models.find(m => m.name === field.relation!.model) + const backRef = relatedModel?.fields.find(f => + f.relation && f.relation.model === field.name.replace(/Id$/, '') + ) + + const ref = backRef + ? `${field.relation.model}.${backRef.name}` + : field.relation.model + + const many = field.isList ? ', many: true' : '' + return `relationship({ ref: '${ref}'${many} })` + } + + // Map Prisma/Keystone types to OpenSaaS + const mapping = this.prismaIntrospector.mapPrismaTypeToOpenSaas(field.type) + usedFieldTypes.add(mapping.import) + + // Build options + const options: string[] = [] + + if (field.isRequired && !field.defaultValue) { + options.push('validation: { isRequired: true }') + } + + if (field.isUnique) { + options.push("isIndexed: 'unique'") + } + + // Handle enums as select fields + const enumDef = schema.enums.find(e => e.name === field.type) + if (enumDef) { + usedFieldTypes.add('select') + const enumOptions = enumDef.values + .map(v => `{ label: '${v}', value: '${v}' }`) + .join(', ') + + let selectOptions = `options: [${enumOptions}]` + if (field.defaultValue) { + selectOptions += `, defaultValue: '${field.defaultValue.replace(/'/g, '')}'` + } + return `select({ ${selectOptions} })` + } + + // Handle default values + if (field.defaultValue) { + if (field.type === 'DateTime' && field.defaultValue === 'now()') { + options.push("defaultValue: { kind: 'now' }") + } else if (field.type === 'Boolean') { + options.push(`defaultValue: ${field.defaultValue}`) + } + // Other defaults are harder to map, skip for now + } + + // Generate unsupported type warning + if (['BigInt', 'Decimal', 'Bytes'].includes(field.type)) { + warnings.push(`Field "${field.name}" uses unsupported type "${field.type}" - mapped to text()`) + } + + const optionsStr = options.length > 0 ? `{ ${options.join(', ')} }` : '' + return `${mapping.type}(${optionsStr})` + } + + /** + * Generate list access control + */ + private generateListAccess( + hasOwnerAccess: boolean, + model: IntrospectedModel, + answers: Record + ): string { + const defaultAccess = answers.default_access as string || 'public-read-auth-write' + + if (hasOwnerAccess) { + // Find the user relationship field + const userField = model.fields.find(f => + f.relation?.model === 'User' || + f.name.toLowerCase().includes('author') || + f.name.toLowerCase().includes('owner') || + f.name.toLowerCase().includes('user') + ) + + const ownerField = userField?.name || 'authorId' + const ownerIdField = ownerField.endsWith('Id') ? ownerField : `${ownerField}Id` + + return ` + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: ({ session, item }) => session?.userId === item?.${ownerIdField}, + delete: ({ session, item }) => session?.userId === item?.${ownerIdField}, + }, + filter: { + // Optionally filter queries to only show user's own items + // query: ({ session }) => ({ ${ownerIdField}: { equals: session?.userId } }), + }, + },` + } + + // Generate based on default access pattern + switch (defaultAccess) { + case 'authenticated-only': + return ` + access: { + operation: { + query: ({ session }) => !!session, + create: ({ session }) => !!session, + update: ({ session }) => !!session, + delete: ({ session }) => !!session, + }, + },` + + case 'owner-only': + return ` + access: { + operation: { + query: ({ session }) => !!session, + create: ({ session }) => !!session, + update: ({ session }) => !!session, + delete: ({ session }) => !!session, + }, + // Add filter to scope to user's own items: + // filter: { query: ({ session }) => ({ userId: { equals: session?.userId } }) }, + },` + + case 'admin-only': + return ` + access: { + operation: { + query: ({ session }) => session?.role === 'admin', + create: ({ session }) => session?.role === 'admin', + update: ({ session }) => session?.role === 'admin', + delete: ({ session }) => session?.role === 'admin', + }, + },` + + case 'public-read-auth-write': + default: + return ` + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: ({ session }) => !!session, + delete: ({ session }) => !!session, + }, + },` + } + } + + /** + * Generate access control helper functions + */ + private generateAccessHelpers(answers: Record): string { + const helpers: string[] = [] + const ownerModels = answers.models_with_owner as string[] || [] + + if (ownerModels.length > 0) { + helpers.push(`// Access control helpers +const isAuthenticated = ({ session }: { session: any }) => !!session + +const isOwner = ({ session, item }: { session: any; item: any }) => + session?.userId === item?.authorId + +const isAdmin = ({ session }: { session: any }) => + session?.role === 'admin' +`) + } + + return helpers.join('\n') + } + + /** + * Generate database configuration + */ + private generateDatabaseConfig(provider: string): { + provider: string + configCode: string + imports: string[] + } { + switch (provider) { + case 'postgresql': + return { + provider: 'postgresql', + imports: [ + "import { PrismaPg } from '@prisma/adapter-pg'", + "import pg from 'pg'", + ], + configCode: ` db: { + provider: 'postgresql', + url: process.env.DATABASE_URL!, + prismaClientConstructor: (PrismaClient) => { + const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }) + const adapter = new PrismaPg(pool) + return new PrismaClient({ adapter }) + }, + },`, + } + + case 'mysql': + return { + provider: 'mysql', + imports: [ + "import { PrismaMySql } from '@prisma/adapter-mysql2'", + "import mysql from 'mysql2/promise'", + ], + configCode: ` db: { + provider: 'mysql', + url: process.env.DATABASE_URL!, + prismaClientConstructor: (PrismaClient) => { + const pool = mysql.createPool(process.env.DATABASE_URL!) + const adapter = new PrismaMySql(pool) + return new PrismaClient({ adapter }) + }, + },`, + } + + case 'sqlite': + default: + return { + provider: 'sqlite', + imports: [ + "import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'", + "import Database from 'better-sqlite3'", + ], + configCode: ` db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./dev.db', + prismaClientConstructor: (PrismaClient) => { + const dbPath = (process.env.DATABASE_URL || 'file:./dev.db').replace('file:', '') + const db = new Database(dbPath) + const adapter = new PrismaBetterSqlite3(db) + return new PrismaClient({ adapter }) + }, + },`, + } + } + } + + /** + * Generate import statements + */ + private generateImports( + usedFieldTypes: Set, + useAuth: boolean, + dbProvider: string + ): string { + const imports: string[] = [] + + // Core imports + imports.push("import { config, list } from '@opensaas/stack-core'") + + // Field imports + const fieldTypes = Array.from(usedFieldTypes).sort() + imports.push(`import { ${fieldTypes.join(', ')} } from '@opensaas/stack-core/fields'`) + + // Auth imports + if (useAuth) { + imports.push("import { withAuth, authConfig } from '@opensaas/stack-auth'") + } + + // Database adapter imports + const dbConfig = this.generateDatabaseConfig(dbProvider) + imports.push(...dbConfig.imports) + + return imports.join('\n') + } + + /** + * Assemble the complete config file + */ + private assembleConfig(options: { + imports: string + accessHelpers: string + dbConfig: { configCode: string } + lists: string + useAuth: boolean + authMethods: string[] + adminBasePath: string + }): string { + const { imports, accessHelpers, dbConfig, lists, useAuth, authMethods, adminBasePath } = options + + // Generate auth config + let authConfigStr = '' + if (useAuth) { + const authOptions: string[] = [] + if (authMethods.includes('email-password')) { + authOptions.push('emailAndPassword: { enabled: true }') + } + if (authMethods.includes('magic-link')) { + authOptions.push('magicLink: { enabled: true }') + } + // OAuth providers would need additional setup + if (authMethods.includes('google')) { + authOptions.push('// google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }') + } + if (authMethods.includes('github')) { + authOptions.push('// github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! }') + } + + authConfigStr = ` + authConfig({ + ${authOptions.join(',\n ')} + })` + } + + // Build config body + const configBody = `{ +${dbConfig.configCode} + lists: { +${lists} + }, + ui: { + basePath: '${adminBasePath}', + }, + }` + + // Wrap with auth if needed + const exportStatement = useAuth + ? `export default withAuth( + config(${configBody}),${authConfigStr} +)` + : `export default config(${configBody})` + + return `${imports} + +${accessHelpers} +${exportStatement} +` + } + + /** + * Generate list of dependencies to install + */ + private generateDependencies(dbProvider: string, useAuth: boolean): string[] { + const deps: string[] = [ + '@opensaas/stack-core', + '@opensaas/stack-ui', + '@prisma/client', + 'prisma', + ] + + // Database adapter deps + switch (dbProvider) { + case 'postgresql': + deps.push('@prisma/adapter-pg', 'pg', '@types/pg') + break + case 'mysql': + deps.push('@prisma/adapter-mysql2', 'mysql2') + break + case 'sqlite': + default: + deps.push('@prisma/adapter-better-sqlite3', 'better-sqlite3', '@types/better-sqlite3') + break + } + + // Auth deps + if (useAuth) { + deps.push('@opensaas/stack-auth', 'better-auth') + } + + return deps + } + + /** + * Generate additional files if needed + */ + private generateAdditionalFiles(answers: Record): Array<{ + path: string + content: string + language: string + description: string + }> { + const files: Array<{ + path: string + content: string + language: string + description: string + }> = [] + + // Generate .env.example + const envVars: string[] = [ + '# Database', + 'DATABASE_URL="file:./dev.db"', + '', + ] + + if (answers.enable_auth) { + envVars.push('# Auth') + envVars.push('BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"') + envVars.push('BETTER_AUTH_URL="http://localhost:3000"') + envVars.push('') + + const authMethods = answers.auth_methods as string[] || [] + if (authMethods.includes('google')) { + envVars.push('# Google OAuth (optional)') + envVars.push('GOOGLE_CLIENT_ID=""') + envVars.push('GOOGLE_CLIENT_SECRET=""') + envVars.push('') + } + if (authMethods.includes('github')) { + envVars.push('# GitHub OAuth (optional)') + envVars.push('GITHUB_CLIENT_ID=""') + envVars.push('GITHUB_CLIENT_SECRET=""') + envVars.push('') + } + } + + files.push({ + path: '.env.example', + content: envVars.join('\n'), + language: 'bash', + description: 'Environment variables template', + }) + + return files + } + + /** + * Generate next steps + */ + private generateSteps(useAuth: boolean): string[] { + const steps = [ + 'Save the generated config to `opensaas.config.ts`', + 'Copy `.env.example` to `.env` and fill in values', + 'Install dependencies: `pnpm add `', + 'Generate Prisma schema: `pnpm opensaas generate`', + 'Generate Prisma client: `npx prisma generate`', + 'Push schema to database: `npx prisma db push`', + 'Start development server: `pnpm dev`', + `Visit admin UI at http://localhost:3000${useAuth ? '' : '/admin'}`, + ] + + if (useAuth) { + steps.splice(2, 0, 'Generate BETTER_AUTH_SECRET: `openssl rand -base64 32`') + } + + return steps + } +} +``` + +--- + +## Acceptance Criteria + +1. **Config Generation** + - [ ] Generates valid TypeScript config + - [ ] Includes all necessary imports + - [ ] Generates correct database adapter config for each provider + - [ ] Lists have proper field definitions + - [ ] Access control matches wizard answers + +2. **Field Mapping** + - [ ] Maps all Prisma types correctly + - [ ] Handles relationships with proper refs + - [ ] Handles enums as select fields + - [ ] Handles optional fields + - [ ] Handles unique fields + - [ ] Handles default values where possible + +3. **Auth Integration** + - [ ] Wraps config with `withAuth` when enabled + - [ ] Generates correct `authConfig` options + - [ ] Skips User/Account/Session models when auth enabled + +4. **Access Control** + - [ ] Generates correct access for `public-read-auth-write` + - [ ] Generates correct access for `authenticated-only` + - [ ] Generates correct access for `owner-only` + - [ ] Generates correct access for `admin-only` + - [ ] Generates owner checks for specified models + +5. **Dependencies** + - [ ] Lists all required npm packages + - [ ] Includes correct database adapter deps + - [ ] Includes auth deps when enabled + +6. **Additional Files** + - [ ] Generates `.env.example` with all needed vars + - [ ] Includes OAuth vars when those methods selected + +7. **Next Steps** + - [ ] Provides clear, ordered instructions + - [ ] Includes auth-specific steps when needed + +--- + +## Testing + +### Unit Tests + +Create `packages/cli/tests/migration-generator.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { MigrationGenerator } from '../src/migration/generators/migration-generator' +import type { MigrationSession } from '../src/migration/types' + +describe('MigrationGenerator', () => { + const generator = new MigrationGenerator() + + it('should generate basic config', async () => { + const session: MigrationSession = { + id: 'test', + projectType: 'prisma', + analysis: { + projectTypes: ['prisma'], + cwd: '/tmp', + provider: 'sqlite', + }, + currentQuestionIndex: 0, + answers: { + preserve_database: true, + db_provider: 'sqlite', + enable_auth: false, + default_access: 'public-read-auth-write', + admin_base_path: '/admin', + }, + isComplete: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + const output = await generator.generate(session) + + expect(output.configContent).toContain("import { config, list } from '@opensaas/stack-core'") + expect(output.configContent).toContain("provider: 'sqlite'") + expect(output.dependencies).toContain('@opensaas/stack-core') + expect(output.steps.length).toBeGreaterThan(0) + }) + + it('should include auth when enabled', async () => { + const session: MigrationSession = { + id: 'test', + projectType: 'prisma', + analysis: { + projectTypes: ['prisma'], + cwd: '/tmp', + }, + currentQuestionIndex: 0, + answers: { + enable_auth: true, + auth_methods: ['email-password'], + db_provider: 'postgresql', + }, + isComplete: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + const output = await generator.generate(session) + + expect(output.configContent).toContain('withAuth') + expect(output.configContent).toContain('authConfig') + expect(output.dependencies).toContain('@opensaas/stack-auth') + expect(output.dependencies).toContain('better-auth') + }) + + it('should generate owner access control', async () => { + const session: MigrationSession = { + id: 'test', + projectType: 'prisma', + analysis: { + projectTypes: ['prisma'], + cwd: '/tmp', + models: [{ name: 'Post', fieldCount: 5 }], + }, + currentQuestionIndex: 0, + answers: { + models_with_owner: ['Post'], + default_access: 'public-read-auth-write', + }, + isComplete: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + const output = await generator.generate(session) + + expect(output.configContent).toContain('session?.userId === item?.') + }) +}) +``` + +### Manual Testing + +```bash +# Build +cd packages/cli +pnpm build + +# Test generator directly +node -e " +import { MigrationGenerator } from './dist/migration/generators/migration-generator.js' +const g = new MigrationGenerator() +g.generate({ + id: 'test', + projectType: 'prisma', + analysis: { projectTypes: ['prisma'], cwd: process.cwd() }, + answers: { db_provider: 'sqlite', enable_auth: true, auth_methods: ['email-password'] }, + isComplete: true, + createdAt: new Date(), + updatedAt: new Date(), +}).then(r => console.log(r.configContent)) +" +``` + +### Integration Testing + +1. Generate a config from a real Prisma project +2. Save to `opensaas.config.ts` +3. Run `opensaas generate` +4. Verify it succeeds without errors +5. Run `npx prisma generate` +6. Run `npx prisma db push` +7. Start the app and verify admin UI works diff --git a/specs/migration-helper/phase-6-claude-agent.md b/specs/migration-helper/phase-6-claude-agent.md new file mode 100644 index 00000000..e557e3a2 --- /dev/null +++ b/specs/migration-helper/phase-6-claude-agent.md @@ -0,0 +1,591 @@ +# Phase 6: Claude Code Agent Templates + +## Task Overview + +Create the template files that get generated in the user's `.claude/` directory when they run `opensaas migrate --with-ai`. These templates configure Claude Code to act as a migration assistant with access to the MCP tools. + +## Context + +### How Templates Are Used + +When a user runs `opensaas migrate --with-ai`, the CLI: + +1. Analyzes their project (Phase 1) +2. Creates `.claude/` directory structure +3. Generates `settings.json` with MCP server +4. Creates agent templates with project-specific context +5. Creates command templates + +The templates include placeholders that get filled with project analysis data. + +### Existing Claude Code Patterns + +The monorepo has example Claude Code configuration at `.claude/`: + +``` +.claude/ +├── settings.json # MCP server registration +├── README.md # Project documentation for Claude +├── agents/ +│ └── github-issue-creator.md # Specialized agent +└── commands/ + └── fix-e2e.md # Custom command +``` + +**settings.json:** +```json +{ + "mcpServers": { + "opensaas-stack": { + "command": "opensaas", + "args": ["mcp", "start"], + "disabled": false + } + } +} +``` + +**Agent format:** +```markdown +You are [agent name]. + +## Context + +[project-specific information] + +## Your Role + +[what the agent should do] + +## Available Tools + +[list of MCP tools] + +## Guidelines + +[how to behave] +``` + +--- + +## Requirements + +### 1. Template Generator + +**Modify:** `packages/cli/src/commands/migrate.ts` + +The `setupClaudeCode` function should generate templates with: +- Project-specific analysis data +- Dynamic model lists +- Appropriate MCP tool references +- Clear instructions for Claude + +### 2. Template Files + +Generate these files: + +| File | Purpose | +|------|---------| +| `.claude/settings.json` | MCP server registration | +| `.claude/README.md` | Project context for Claude | +| `.claude/agents/migration-assistant.md` | Main migration agent | +| `.claude/commands/analyze-schema.md` | Command to analyze schema | +| `.claude/commands/generate-config.md` | Command to generate config | +| `.claude/commands/validate-migration.md` | Command to validate config | + +--- + +## Template Specifications + +### 1. `.claude/settings.json` + +```json +{ + "mcpServers": { + "opensaas-migration": { + "command": "npx", + "args": ["@opensaas/stack-cli", "mcp", "start"], + "disabled": false + } + } +} +``` + +**Notes:** +- Uses `npx` for portability (works without global install) +- Server name is `opensaas-migration` to distinguish from global server +- Can coexist with other MCP servers + +### 2. `.claude/README.md` + +Template with placeholders: + +```markdown +# OpenSaaS Stack Migration + +This project is being migrated to OpenSaaS Stack. + +## Project Summary + +- **Project Type:** {{PROJECT_TYPES}} +- **Database Provider:** {{PROVIDER}} +- **Models Detected:** {{MODEL_COUNT}} + +### Models + +{{MODEL_LIST}} + +## Quick Start + +Ask Claude: **"Help me migrate to OpenSaaS Stack"** + +Claude will guide you through: +1. Reviewing your current schema +2. Configuring access control +3. Setting up authentication (optional) +4. Generating `opensaas.config.ts` + +## Available Commands + +| Command | Description | +|---------|-------------| +| `/analyze-schema` | View detailed schema analysis | +| `/generate-config` | Generate the config file | +| `/validate-migration` | Validate generated config | + +## Resources + +- [OpenSaaS Stack Documentation](https://stack.opensaas.au/) +- [Migration Guide](https://stack.opensaas.au/guides/migration) +- [GitHub Repository](https://github.com/OpenSaasAU/stack) + +## Generated By + +This migration was set up using: +```bash +npx @opensaas/stack-cli migrate --with-ai +``` +``` + +### 3. `.claude/agents/migration-assistant.md` + +```markdown +You are the OpenSaaS Stack Migration Assistant, helping users migrate their existing projects to OpenSaaS Stack. + +## Project Context + +**Project Type:** {{PROJECT_TYPES}} +**Database Provider:** {{PROVIDER}} +**Total Models:** {{MODEL_COUNT}} + +### Detected Models + +{{MODEL_DETAILS}} + +## Your Role + +Guide the user through a complete migration to OpenSaaS Stack: + +1. **Analyze** their current project structure +2. **Explain** what OpenSaaS Stack offers (access control, admin UI, type safety) +3. **Guide** them through the migration wizard +4. **Generate** a working `opensaas.config.ts` +5. **Validate** the generated configuration +6. **Provide** clear next steps + +## Available MCP Tools + +### Schema Analysis +- `opensaas_introspect_prisma` - Analyze Prisma schema in detail +- `opensaas_introspect_keystone` - Analyze KeystoneJS config + +### Migration Wizard +- `opensaas_start_migration` - Start the interactive wizard +- `opensaas_answer_migration` - Answer wizard questions + +### Documentation +- `opensaas_search_migration_docs` - Search migration documentation +- `opensaas_get_example` - Get example code patterns + +### Validation +- `opensaas_validate_feature` - Validate implementation + +## Conversation Guidelines + +### When the user says "help me migrate" or similar: + +1. **Acknowledge** their project: + > "I can see you have a {{PROJECT_TYPE}} project with {{MODEL_COUNT}} models. Let me help you migrate to OpenSaaS Stack!" + +2. **Start the wizard** by calling: + ``` + opensaas_start_migration({ projectType: "{{PROJECT_TYPE_LOWER}}" }) + ``` + +3. **Present questions naturally** - don't mention session IDs or technical details to the user + +4. **Explain choices** - help them understand what each option means: + - Access control patterns + - Authentication options + - Database configuration + +5. **Show progress** - let them know how far along they are + +6. **Generate the config** when complete and explain what was created + +### When explaining OpenSaaS Stack: + +Highlight these benefits: +- **Built-in access control** - Secure by default +- **Admin UI** - Auto-generated from your schema +- **Type safety** - Full TypeScript support +- **Prisma integration** - Uses familiar ORM +- **Plugin system** - Easy to extend + +### When answering questions: + +- Use `opensaas_search_migration_docs` to find accurate information +- Use `opensaas_get_example` to show code patterns +- Be honest if something isn't supported + +### Tone + +- Be encouraging and helpful +- Explain technical concepts simply +- Celebrate progress ("Great choice!", "Almost there!") +- Don't overwhelm with information + +## Example Conversation + +**User:** Help me migrate to OpenSaaS Stack + +**You:** I can see you have a Prisma project with 5 models (User, Post, Comment, Tag, Category). OpenSaaS Stack will give you: + +- Automatic admin UI for managing your data +- Built-in access control to secure your API +- Type-safe database operations + +Let me start the migration wizard to configure your project... + +[Call opensaas_start_migration] + +**User:** [answers questions] + +**You:** [Continue through wizard, explain each choice, generate final config] + +## Error Handling + +If something goes wrong: +1. Explain what happened in simple terms +2. Suggest alternatives or manual steps +3. Link to documentation for more help + +## After Migration + +Once the config is generated, guide them through: +1. Installing dependencies +2. Running `opensaas generate` +3. Running `prisma db push` +4. Starting their dev server +5. Visiting the admin UI +``` + +### 4. `.claude/commands/analyze-schema.md` + +```markdown +Analyze the current project schema and provide a detailed breakdown. + +## Instructions + +1. Use `opensaas_introspect_prisma` or `opensaas_introspect_keystone` based on project type +2. Present the results in a clear, organized format +3. Highlight: + - All models and their fields + - Relationships between models + - Potential access control patterns + - Any issues or warnings + +## Output Format + +Present like this: + +### Models Summary + +| Model | Fields | Has Relations | Suggested Access | +|-------|--------|---------------|------------------| +| ... | ... | ... | ... | + +### Detailed Analysis + +[For each model, show fields and relationships] + +### Recommendations + +[Based on the schema, suggest access control patterns] +``` + +### 5. `.claude/commands/generate-config.md` + +```markdown +Generate the opensaas.config.ts file for this project. + +## Instructions + +1. If migration wizard hasn't been started, start it: + ``` + opensaas_start_migration({ projectType: "{{PROJECT_TYPE_LOWER}}" }) + ``` + +2. Guide the user through any remaining questions + +3. When complete, display: + - The generated config file + - Dependencies to install + - Next steps to run + +4. Offer to explain any part of the generated config + +## Quick Mode + +If the user wants defaults, use these answers: +- preserve_database: true +- db_provider: {{PROVIDER}} +- enable_auth: {{HAS_AUTH}} +- default_access: "public-read-auth-write" +- admin_base_path: "/admin" +``` + +### 6. `.claude/commands/validate-migration.md` + +```markdown +Validate the generated opensaas.config.ts file. + +## Instructions + +1. Check if opensaas.config.ts exists in the project root + +2. If it exists, verify: + - Syntax is valid TypeScript + - All imports are correct + - Database config is complete + - Lists match original schema + +3. Try running: + ```bash + npx @opensaas/stack-cli generate + ``` + +4. Report any errors and suggest fixes + +5. If validation passes, confirm next steps: + - `npx prisma generate` + - `npx prisma db push` + - `pnpm dev` + +## Common Issues + +- Missing dependencies → suggest `pnpm add ...` +- Database URL not set → remind about .env file +- Type errors → suggest specific fixes +``` + +--- + +## Implementation + +### Template Generator Function + +Update `packages/cli/src/commands/migrate.ts`: + +```typescript +interface TemplateData { + projectTypes: string[] + provider: string + modelCount: number + models: Array<{ name: string; fieldCount: number }> + hasAuth: boolean +} + +function generateTemplateContent(template: string, data: TemplateData): string { + return template + .replace(/\{\{PROJECT_TYPES\}\}/g, data.projectTypes.join(', ')) + .replace(/\{\{PROJECT_TYPE\}\}/g, data.projectTypes[0] || 'unknown') + .replace(/\{\{PROJECT_TYPE_LOWER\}\}/g, (data.projectTypes[0] || 'prisma').toLowerCase()) + .replace(/\{\{PROVIDER\}\}/g, data.provider || 'sqlite') + .replace(/\{\{MODEL_COUNT\}\}/g, String(data.modelCount)) + .replace(/\{\{HAS_AUTH\}\}/g, String(data.hasAuth)) + .replace(/\{\{MODEL_LIST\}\}/g, data.models.map(m => `- ${m.name} (${m.fieldCount} fields)`).join('\n')) + .replace(/\{\{MODEL_DETAILS\}\}/g, data.models.map(m => `- **${m.name}**: ${m.fieldCount} fields`).join('\n')) +} + +async function setupClaudeCode(cwd: string, analysis: ProjectAnalysis): Promise { + const claudeDir = path.join(cwd, '.claude') + const agentsDir = path.join(claudeDir, 'agents') + const commandsDir = path.join(claudeDir, 'commands') + + await fs.ensureDir(agentsDir) + await fs.ensureDir(commandsDir) + + const templateData: TemplateData = { + projectTypes: analysis.projectTypes, + provider: analysis.provider || 'sqlite', + modelCount: analysis.models?.length || 0, + models: analysis.models || [], + hasAuth: analysis.hasAuth || false, + } + + // Generate settings.json + const settings = { + mcpServers: { + 'opensaas-migration': { + command: 'npx', + args: ['@opensaas/stack-cli', 'mcp', 'start'], + disabled: false, + }, + }, + } + await fs.writeJSON(path.join(claudeDir, 'settings.json'), settings, { spaces: 2 }) + + // Generate README + const readmeTemplate = `... (from template above)` + await fs.writeFile( + path.join(claudeDir, 'README.md'), + generateTemplateContent(readmeTemplate, templateData) + ) + + // Generate migration assistant agent + const agentTemplate = `... (from template above)` + await fs.writeFile( + path.join(agentsDir, 'migration-assistant.md'), + generateTemplateContent(agentTemplate, templateData) + ) + + // Generate commands + const commands = { + 'analyze-schema.md': `... (from template above)`, + 'generate-config.md': `... (from template above)`, + 'validate-migration.md': `... (from template above)`, + } + + for (const [filename, template] of Object.entries(commands)) { + await fs.writeFile( + path.join(commandsDir, filename), + generateTemplateContent(template, templateData) + ) + } +} +``` + +--- + +## Acceptance Criteria + +1. **Directory Structure** + - [ ] Creates `.claude/` directory + - [ ] Creates `agents/` subdirectory + - [ ] Creates `commands/` subdirectory + +2. **settings.json** + - [ ] Valid JSON format + - [ ] Registers MCP server correctly + - [ ] Uses npx for portability + +3. **README.md** + - [ ] Contains project summary + - [ ] Lists detected models + - [ ] Provides quick start instructions + - [ ] Links to documentation + +4. **migration-assistant.md** + - [ ] Contains project context + - [ ] Lists all MCP tools + - [ ] Provides conversation guidelines + - [ ] Includes example conversation + - [ ] Has error handling guidance + +5. **Command Templates** + - [ ] analyze-schema.md provides clear instructions + - [ ] generate-config.md guides through wizard + - [ ] validate-migration.md checks generated config + +6. **Template Variables** + - [ ] All placeholders are replaced + - [ ] Data matches actual project analysis + - [ ] Handles missing data gracefully + +--- + +## Testing + +### Manual Testing + +```bash +# Build CLI +cd packages/cli +pnpm build + +# Test on a Prisma project +cd /path/to/prisma-project +npx @opensaas/stack-cli migrate --with-ai + +# Verify files created +ls -la .claude/ +cat .claude/settings.json +cat .claude/README.md +cat .claude/agents/migration-assistant.md +cat .claude/commands/analyze-schema.md + +# Open in Claude Code and test +# 1. Ask "help me migrate" +# 2. Try /analyze-schema command +# 3. Try /generate-config command +``` + +### Verification Checklist + +1. **Open project in Claude Code** + - [ ] MCP server connects successfully + - [ ] Tools appear in tool list + +2. **Test migration assistant** + - [ ] Responds to "help me migrate" + - [ ] Uses correct MCP tools + - [ ] Guides through wizard properly + - [ ] Generates valid config + +3. **Test commands** + - [ ] `/analyze-schema` works + - [ ] `/generate-config` works + - [ ] `/validate-migration` works + +4. **End-to-end** + - [ ] Generated config is valid + - [ ] `opensaas generate` succeeds + - [ ] `prisma db push` succeeds + - [ ] Admin UI works + +--- + +## Notes + +### Claude Code Behavior + +- Claude Code reads `.claude/README.md` for project context +- Agents in `.claude/agents/` can be invoked by name +- Commands in `.claude/commands/` become slash commands +- MCP servers in `settings.json` are auto-connected + +### Template Best Practices + +1. **Keep templates readable** - Users may want to customize +2. **Include comments** - Explain what each section does +3. **Use markdown** - Claude renders it nicely +4. **Be specific** - Give Claude clear instructions +5. **Handle edge cases** - What if analysis is incomplete? + +### Future Enhancements + +- **Per-model agents** - Specialized agents for complex models +- **Custom commands** - User-defined migration commands +- **Prompt library** - Reusable prompt snippets +- **Version tracking** - Track migration progress