-
Notifications
You must be signed in to change notification settings - Fork 2
refactor(cli): enhance IaC workflow and automation capabilities #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { existsSync } from "node:fs"; | ||
| import { readFile } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { z } from "zod"; | ||
|
|
||
| export interface ProjectEnvironment { | ||
| database: { | ||
| provider: 'postgresql' | 'turso' | 'planetscale' | 'supabase' | 'neon'; | ||
| connectionString?: string; | ||
| url?: string; | ||
| authToken?: string; | ||
| }; | ||
| auth: { | ||
| secret?: string; | ||
| url?: string; | ||
| }; | ||
| storage: { | ||
| provider?: string; | ||
| bucket?: string; | ||
| accessKey?: string; | ||
| secretKey?: string; | ||
| endpoint?: string; | ||
| }; | ||
| ai: { | ||
| openaiKey?: string; | ||
| embeddingProvider?: string; | ||
| }; | ||
| monitoring: { | ||
| sentryDsn?: string; | ||
| logLevel?: string; | ||
| }; | ||
| custom: Record<string, string>; | ||
| } | ||
|
|
||
| export async function detectEnvironmentConfig(projectRoot: string): Promise<ProjectEnvironment> { | ||
| const envConfig: ProjectEnvironment = { | ||
| database: { provider: 'postgresql' }, | ||
| auth: {}, | ||
| storage: {}, | ||
| ai: {}, | ||
| monitoring: {}, | ||
| custom: {}, | ||
| }; | ||
|
|
||
| const envFiles = ['.env', '.env.local', '.env.development', '.env.staging', '.env.production']; | ||
|
|
||
| for (const envFile of envFiles) { | ||
| const filePath = path.join(projectRoot, envFile); | ||
| if (!existsSync(filePath)) continue; | ||
|
|
||
| const content = await readFile(filePath, 'utf-8'); | ||
| const lines = content.split('\n'); | ||
|
|
||
| for (const line of lines) { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed || trimmed.startsWith('#')) continue; | ||
|
|
||
| const [key, ...valueParts] = trimmed.split('='); | ||
| const value = valueParts.join('=').trim(); | ||
|
|
||
| // Database detection | ||
| if (key === 'DATABASE_URL') envConfig.database.connectionString = value; | ||
| if (key === 'TURSO_URL') { | ||
| envConfig.database.provider = 'turso'; | ||
| envConfig.database.url = value; | ||
| } | ||
| if (key === 'TURSO_AUTH_TOKEN') envConfig.database.authToken = value; | ||
| if (key === 'DATABASE_URL' && value.includes('neon')) envConfig.database.provider = 'neon'; | ||
| if (key === 'DATABASE_URL' && value.includes('planetscale')) envConfig.database.provider = 'planetscale'; | ||
|
|
||
| // Auth | ||
| if (key === 'AUTH_SECRET') envConfig.auth.secret = value; | ||
| if (key === 'AUTH_URL') envConfig.auth.url = value; | ||
|
|
||
| // Storage | ||
| if (key === 'STORAGE_PROVIDER') envConfig.storage.provider = value; | ||
| if (key === 'STORAGE_BUCKET') envConfig.storage.bucket = value; | ||
| if (key === 'STORAGE_ACCESS_KEY') envConfig.storage.accessKey = value; | ||
| if (key === 'STORAGE_SECRET_KEY') envConfig.storage.secretKey = value; | ||
| if (key === 'STORAGE_ENDPOINT') envConfig.storage.endpoint = value; | ||
|
|
||
| // AI | ||
| if (key === 'OPENAI_API_KEY') envConfig.ai.openaiKey = value; | ||
|
|
||
| // Monitoring | ||
| if (key === 'SENTRY_DSN') envConfig.monitoring.sentryDsn = value; | ||
| if (key === 'LOG_LEVEL') envConfig.monitoring.logLevel = value; | ||
| } | ||
| } | ||
|
|
||
| // Read betterbase.config.ts | ||
| const configPath = path.join(projectRoot, 'betterbase.config.ts'); | ||
| if (existsSync(configPath)) { | ||
| const configContent = await readFile(configPath, 'utf-8'); | ||
| // Parse provider type from config | ||
| const providerMatch = configContent.match(/type:\s*["']([^"']+)["']/); | ||
| if (providerMatch) { | ||
| const captured = providerMatch[1].trim().toLowerCase(); | ||
| const allowedProviders = ['postgresql', 'turso', 'planetscale', 'supabase', 'neon'] as const; | ||
| type AllowedProvider = typeof allowedProviders[number]; | ||
| if (allowedProviders.includes(captured as AllowedProvider)) { | ||
| envConfig.database.provider = captured as AllowedProvider; | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| return envConfig; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import { existsSync } from "node:fs"; | ||
| import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import * as logger from "../../utils/logger"; | ||
|
|
||
| export interface LegacyRoute { | ||
| path: string; | ||
| method: string; | ||
| content: string; | ||
| } | ||
|
|
||
| export interface LegacySchema { | ||
| content: string; | ||
| filePath: string; | ||
| } | ||
|
|
||
| export async function runMigrateLegacyToIaC(projectRoot: string): Promise<void> { | ||
| logger.blank(); | ||
| logger.info("Migrating legacy BetterBase project to IaC-only mode..."); | ||
|
|
||
| const betterbaseDir = path.join(projectRoot, "betterbase"); | ||
| const routesDir = path.join(projectRoot, "src/routes"); | ||
| const schemaPath = path.join(projectRoot, "src/db/schema.ts"); | ||
|
|
||
| // 1. Detect legacy patterns | ||
| const legacyRoutes = await scanLegacyRoutes(projectRoot); | ||
| const legacySchema = await scanLegacySchema(projectRoot); | ||
|
|
||
| if (legacyRoutes.length === 0 && !legacySchema) { | ||
| logger.warn("No legacy patterns detected. This may already be an IaC project."); | ||
| return; | ||
| } | ||
|
|
||
| logger.info(`Found ${legacyRoutes.length} legacy route(s)`); | ||
| if (legacySchema) { | ||
| logger.info("Found legacy schema file"); | ||
| } | ||
|
|
||
| // 2. Create betterbase/ directory structure | ||
| await mkdir(betterbaseDir, { recursive: true }); | ||
| await mkdir(path.join(betterbaseDir, "queries"), { recursive: true }); | ||
| await mkdir(path.join(betterbaseDir, "mutations"), { recursive: true }); | ||
| await mkdir(path.join(betterbaseDir, "actions"), { recursive: true }); | ||
| await mkdir(path.join(betterbaseDir, "_generated"), { recursive: true }); | ||
|
|
||
| // 3. Convert legacy schema to IaC schema | ||
| if (legacySchema) { | ||
| const schemaCode = generateSchemaFromDrizzle(legacySchema.content); | ||
| await writeFile(path.join(betterbaseDir, "schema.ts"), schemaCode); | ||
| logger.success("Generated betterbase/schema.ts from legacy schema"); | ||
| } | ||
|
|
||
| // 4. Convert routes to IaC functions | ||
| for (const route of legacyRoutes) { | ||
| const functionCode = convertToIaCFunction(route); | ||
| const targetPath = route.method === "GET" | ||
| ? path.join(betterbaseDir, "queries", `${route.path}.ts`) | ||
| : path.join(betterbaseDir, "mutations", `${route.path}.ts`); | ||
| await writeFile(targetPath, functionCode); | ||
|
Comment on lines
+56
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Converted route files can overwrite each other.
Also applies to: 100-101 🤖 Prompt for AI Agents |
||
| logger.success(`Converted ${route.method} ${route.path} to IaC function`); | ||
| } | ||
|
|
||
| // 5. Generate AGENTS.md | ||
| await generateAgentsConstraintFile(projectRoot); | ||
| logger.success("Created AGENTS.md with IaC constraints"); | ||
|
|
||
| // 6. Remove legacy routes (optional - ask user) | ||
| logger.blank(); | ||
| logger.info("Legacy migration complete. You may want to:"); | ||
| logger.info(" - Review the generated betterbase/schema.ts"); | ||
| logger.info(" - Check converted functions in betterbase/queries and betterbase/mutations"); | ||
| logger.info(" - Run 'bb iac sync' to apply schema changes"); | ||
| logger.info(" - Remove src/routes/ directory (it's no longer needed)"); | ||
| } | ||
|
|
||
| async function scanLegacyRoutes(projectRoot: string): Promise<LegacyRoute[]> { | ||
| const routes: LegacyRoute[] = []; | ||
| const routesDir = path.join(projectRoot, "src/routes"); | ||
|
|
||
| if (!existsSync(routesDir)) return routes; | ||
|
|
||
| const entries = await readdir(routesDir, { withFileTypes: true, recursive: true }); | ||
|
|
||
| for (const entry of entries) { | ||
| if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; | ||
|
|
||
| const fullPath = path.join(entry.parentPath || routesDir, entry.name); | ||
| const content = await readFile(fullPath, "utf-8"); | ||
|
|
||
| // Check if this is a Hono route file | ||
| if (content.includes("Hono") && (content.includes(".get(") || content.includes(".post(") || content.includes(".put(") || content.includes(".delete("))) { | ||
| // Extract HTTP methods | ||
| const methods = []; | ||
| if (content.includes(".get(")) methods.push("GET"); | ||
| if (content.includes(".post(")) methods.push("POST"); | ||
| if (content.includes(".put(")) methods.push("PUT"); | ||
| if (content.includes(".delete(")) methods.push("DELETE"); | ||
|
|
||
| routes.push({ | ||
| path: entry.name.replace(".ts", ""), | ||
| method: methods[0] || "GET", | ||
| content, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return routes; | ||
| } | ||
|
|
||
| async function scanLegacySchema(projectRoot: string): Promise<LegacySchema | null> { | ||
| const schemaPath = path.join(projectRoot, "src/db/schema.ts"); | ||
|
|
||
| if (!existsSync(schemaPath)) return null; | ||
|
|
||
| return { | ||
| content: await readFile(schemaPath, "utf-8"), | ||
| filePath: schemaPath, | ||
| }; | ||
| } | ||
|
|
||
| function generateSchemaFromDrizzle(drizzleSchema: string): string { | ||
| // This is a simplified conversion - in reality, this would parse the Drizzle schema | ||
| // and convert it to the IaC format | ||
| return `import { defineSchema, v } from "@betterbase/core/iac"; | ||
|
|
||
| export default defineSchema({ | ||
| // TODO: Convert your legacy Drizzle tables to IaC format | ||
| // Example: | ||
| // users: defineTable({ | ||
| // email: v.string().unique(), | ||
| // name: v.string().optional(), | ||
| // }), | ||
| }); | ||
| `; | ||
| } | ||
|
|
||
| function convertToIaCFunction(route: LegacyRoute): string { | ||
| // This is a simplified conversion - in reality, this would parse the Hono route | ||
| // and convert it to an IaC function | ||
| const template = route.method === "GET" | ||
| ? `import { ctx } from "@betterbase/core/iac"; | ||
|
|
||
| export default async function() { | ||
| // TODO: Implement query logic | ||
| return ctx.db.query("${route.path}").collect(); | ||
| } | ||
| ` | ||
| : `import { ctx } from "@betterbase/core/iac"; | ||
|
|
||
| export default async function(input: any) { | ||
| // TODO: Implement mutation logic | ||
| await ctx.db.insert("${route.path}", input); | ||
| return { success: true }; | ||
| } | ||
| `; | ||
|
|
||
| return template; | ||
| } | ||
|
|
||
| async function generateAgentsConstraintFile(projectRoot: string): Promise<void> { | ||
| const templatePath = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "templates", "iac", "AGENTS.md"); | ||
| let agentsContent: string; | ||
| try { | ||
| agentsContent = await readFile(templatePath, "utf-8"); | ||
| } catch { | ||
| throw new Error(`Failed to read AGENTS.md template from ${templatePath}. Ensure templates/iac/AGENTS.md exists.`); | ||
| } | ||
|
|
||
| await writeFile(path.join(projectRoot, "AGENTS.md"), agentsContent); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { ProjectEnvironment } from "./env-detector"; | ||
| import { createApiClient } from "../utils/api-client"; | ||
| import { isAuthenticated } from "../utils/credentials"; | ||
| import { SerializedSchema } from "@betterbase/core/iac"; | ||
|
|
||
| export interface SyncWithServerOptions { | ||
| schema: SerializedSchema; | ||
| envConfig: ProjectEnvironment; | ||
| environment: string; | ||
| force?: boolean; | ||
| } | ||
|
|
||
| export async function syncWithServer( | ||
| projectRoot: string, | ||
| config: SyncWithServerOptions | ||
| ): Promise<{ success: boolean }> { | ||
| // Check authentication | ||
| if (!await isAuthenticated()) { | ||
| throw new Error( | ||
| 'Not authenticated. Run: bb login --headless --api-key $BETTERBASE_API_KEY' | ||
| ); | ||
| } | ||
|
|
||
| const apiClient = createApiClient(); | ||
|
|
||
| // 1. Register project if not exists | ||
| const project = await apiClient.registerProject({ | ||
| name: config.envConfig.database.connectionString?.split('/').pop() ?? 'unknown', | ||
| environment: config.environment, | ||
| config: config.envConfig, | ||
| }); | ||
|
|
||
| // 2. Sync schema | ||
| const syncResult = await apiClient.syncSchema({ | ||
| projectId: project.id, | ||
| schema: config.schema, | ||
| force: config.force, | ||
| }); | ||
|
|
||
| // 3. Sync environment variables | ||
| await apiClient.syncEnvironment({ | ||
| projectId: project.id, | ||
| envConfig: config.envConfig, | ||
| }); | ||
|
|
||
| return syncResult; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Environment detector drops non-whitelisted variables.
customis never populated, so unknown env keys are discarded. That breaks full environment sync in headless mode.Proposed fix
if (key === 'SENTRY_DSN') envConfig.monitoring.sentryDsn = value; if (key === 'LOG_LEVEL') envConfig.monitoring.logLevel = value; + + // Preserve non-whitelisted variables for server sync + const knownKeys = new Set([ + 'DATABASE_URL','TURSO_URL','TURSO_AUTH_TOKEN', + 'AUTH_SECRET','AUTH_URL', + 'STORAGE_PROVIDER','STORAGE_BUCKET','STORAGE_ACCESS_KEY','STORAGE_SECRET_KEY','STORAGE_ENDPOINT', + 'OPENAI_API_KEY','SENTRY_DSN','LOG_LEVEL' + ]); + if (!knownKeys.has(key)) envConfig.custom[key] = value;Based on learnings: "Run
bb validate-projectto detect and reject non-IaC patterns..." and charter requirement that headless sync must auto-parse env config and sync environment duringbb iac sync.📝 Committable suggestion
🤖 Prompt for AI Agents