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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed .coderabbit-review-trigger
Empty file.
679 changes: 195 additions & 484 deletions .coderabbit.yaml

Large diffs are not rendered by default.

5,177 changes: 0 additions & 5,177 deletions logs.txt

This file was deleted.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/iac/analyze.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import path, { extname, join } from "node:path";
import * as logger from "../../utils/logger";

Expand Down
108 changes: 108 additions & 0 deletions packages/cli/src/commands/iac/env-detector.ts
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;
}
Comment on lines +58 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Environment detector drops non-whitelisted variables.

custom is 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-project to detect and reject non-IaC patterns..." and charter requirement that headless sync must auto-parse env config and sync environment during bb iac sync.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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;
// 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;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/iac/env-detector.ts` around lines 58 - 88, The
detector currently only assigns known keys to envConfig (database, auth,
storage, ai, monitoring) and never fills envConfig.custom, which loses unknown
env vars; after the existing if-blocks that handle DATABASE_URL, TURSO_*,
AUTH_*, STORAGE_*, OPENAI_API_KEY, SENTRY_DSN, LOG_LEVEL, add logic that if key
is non-empty and not matched by any whitelist fields then assign
envConfig.custom[key] = value (ensure envConfig.custom is initialized as an
object), and skip adding duplicate empty keys; this preserves all unrecognized
env variables for headless sync while leaving the existing
envConfig.database/auth/storage/ai/monitoring assignments intact.

}

// 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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return envConfig;
}
170 changes: 170 additions & 0 deletions packages/cli/src/commands/iac/migrate-legacy.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Converted route files can overwrite each other.

route.path is based on entry.name only, so src/routes/admin/index.ts and src/routes/user/index.ts both emit index.ts in the same target directory.

Also applies to: 100-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/iac/migrate-legacy.ts` around lines 56 - 59, The
current write of converted routes uses route.path as a filename which causes
collisions (e.g., multiple index.ts files) — update the target path generation
so it preserves the original route directory structure (or otherwise makes
filenames unique) instead of flattening to a single filename; specifically,
change the code that builds targetPath (which uses route.method, betterbaseDir,
route.path and writeFile with functionCode) to join the equivalent source
subpath (or a sanitized route.entry path/name) into the target (e.g.,
path.join(betterbaseDir, "queries"|"mutations", ...routeSourceSubdirs,
`${routeFileName}.ts`)), ensure any intermediate directories are created before
calling writeFile, and sanitize/normalize names to avoid collisions.

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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
47 changes: 47 additions & 0 deletions packages/cli/src/commands/iac/server-sync.ts
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
34 changes: 32 additions & 2 deletions packages/cli/src/commands/iac/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import chalk from "chalk";
import { mkdir, readdir, writeFile } from "fs/promises";
import { done, error, info, section, success, sym, warn } from "../../utils/logger";
import { withSpinner } from "../../utils/spinner";
import { detectEnvironmentConfig } from "./env-detector";
import { syncWithServer } from "./server-sync";

export async function runIacSync(
projectRoot: string,
opts: { force?: boolean; silent?: boolean } = {},
opts: {
force?: boolean;
silent?: boolean;
headless?: boolean; // NEW: Skip interactive prompts
autoRegister?: boolean; // NEW: Auto-register with server
environment?: string; // NEW: Target environment
} = {},
) {
const startTime = Date.now();
const betterbaseDir = join(projectRoot, "betterbase");
Expand Down Expand Up @@ -39,7 +47,7 @@ export async function runIacSync(

const diff = diffSchemas(previous, current);

if (diff.isEmpty) {
if (diff.isEmpty && !opts.headless && !opts.autoRegister) {
if (!opts.silent) success("Schema is up to date. No changes detected.");
return;
}
Expand Down Expand Up @@ -90,6 +98,28 @@ export async function runIacSync(
await writeFile(join(migrDir, migration.filename), migration.sql);
if (!opts.silent) info(`Migration written: ${migration.filename}`);

// 4. HEADLESS SYNC: Auto-sync with server
if (opts.headless || opts.autoRegister) {
if (!opts.silent) {
section("Headless Sync");
info("Synchronizing with @betterbase/server...");
}

// Detect environment configuration
const envConfig = await detectEnvironmentConfig(projectRoot);

// Sync with server (use current serialized schema)
await syncWithServer(projectRoot, {
schema: current,
envConfig,
environment: opts.environment ?? 'local',
force: opts.force,
});

if (!opts.silent) success("Headless sync complete.");
}

// 5. Apply migration locally (existing logic)
if (opts.silent) {
const drizzleCode = generateDrizzleSchema(current, "postgres");
await writeFile(drizzleOut, drizzleCode);
Expand Down
Loading
Loading