diff --git a/packages/cli/src/commands/function.ts b/packages/cli/src/commands/function.ts new file mode 100644 index 0000000..4c48dd0 --- /dev/null +++ b/packages/cli/src/commands/function.ts @@ -0,0 +1,432 @@ +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { + bundleFunction, + readFunctionConfig, + listFunctions, + isFunctionBuilt, + type FunctionConfig, + type FunctionInfo, +} from '@betterbase/core/functions'; +import { + deployToCloudflare, + deployToVercel, + syncEnvToCloudflare, + getCloudflareLogs, + getVercelLogs, +} from '@betterbase/core/functions'; +import * as logger from '../utils/logger'; + +// Store running function processes for cleanup +const runningFunctions: Map = new Map(); +const FUNCTION_PORT_START = 3001; + +/** + * Run the function command + */ +export async function runFunctionCommand( + args: string[], + projectRoot: string = process.cwd() +): Promise { + const [action, nameOrOption, extra] = args; + + switch (action) { + case 'create': + await runFunctionCreate(nameOrOption, projectRoot); + break; + case 'dev': + await runFunctionDev(nameOrOption, projectRoot); + break; + case 'build': + await runFunctionBuild(nameOrOption, projectRoot); + break; + case 'list': + await runFunctionList(projectRoot); + break; + case 'logs': + await runFunctionLogs(nameOrOption, projectRoot); + break; + case 'deploy': + await runFunctionDeploy(nameOrOption, projectRoot, extra === '--sync-env'); + break; + default: + logger.error(`Unknown function action: ${action}`); + console.log('\nAvailable commands:'); + console.log(' bb function create - Create a new edge function'); + console.log(' bb function dev - Run function locally with hot reload'); + console.log(' bb function build - Bundle function for deployment'); + console.log(' bb function list - List all functions'); + console.log(' bb function logs - Show function logs'); + console.log(' bb function deploy - Deploy function to cloud'); + console.log(' bb function deploy --sync-env - Deploy and sync env vars'); + } +} + +/** + * Create a new function + */ +async function runFunctionCreate( + name: string | undefined, + projectRoot: string +): Promise { + if (!name) { + logger.error('Function name is required'); + console.log('Usage: bb function create '); + return; + } + + // Validate name + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + logger.error('Function name can only contain letters, numbers, underscores, and hyphens'); + return; + } + + const functionsDir = join(projectRoot, 'src', 'functions', name); + + if (existsSync(functionsDir)) { + logger.error(`Function "${name}" already exists`); + return; + } + + // Create function directory + mkdirSync(functionsDir, { recursive: true }); + + // Create index.ts template + const indexContent = `import { Hono } from 'hono' + +const app = new Hono() + +app.get('/', (c) => c.json({ message: 'Hello from BetterBase edge function!' })) + +app.post('/', async (c) => { + const body = await c.req.json() + return c.json({ received: body }) +}) + +export default app +`; + writeFileSync(join(functionsDir, 'index.ts'), indexContent); + + // Create config.ts template + const configContent = `export default { + name: '${name}', + runtime: 'cloudflare-workers' as const, // 'cloudflare-workers' | 'vercel-edge' + env: [] as string[], // env var names this function needs +} +`; + writeFileSync(join(functionsDir, 'config.ts'), configContent); + + console.log(`Function created: src/functions/${name}/`); + console.log(`Run with: bb function dev ${name}`); +} + +/** + * Run function in development mode with hot reload + */ +async function runFunctionDev( + name: string | undefined, + projectRoot: string +): Promise { + if (!name) { + logger.error('Function name is required'); + console.log('Usage: bb function dev '); + return; + } + + const functionsDir = join(projectRoot, 'src', 'functions', name); + const indexPath = join(functionsDir, 'index.ts'); + + if (!existsSync(functionsDir)) { + logger.error(`Function "${name}" not found`); + return; + } + + if (!existsSync(indexPath)) { + logger.error(`Function entry point not found: ${indexPath}`); + return; + } + + // Determine port - find the index of this function + const functions = await listFunctions(projectRoot); + const functionIndex = functions.findIndex((f: FunctionInfo) => f.name === name); + const port = FUNCTION_PORT_START + (functionIndex >= 0 ? functionIndex : 0); + + console.log(`Starting function "${name}" on port ${port}...`); + console.log(`Watching for changes in src/functions/${name}/`); + + // Start the function with bun --watch + const proc = spawn( + 'bun', + ['run', '--watch', indexPath], + { + cwd: projectRoot, + stdio: 'inherit', + env: { + ...process.env, + PORT: String(port), + BUN_ENV: 'development', + }, + } + ); + + runningFunctions.set(name, proc); + + // Handle cleanup on exit + const cleanup = (): void => { + const p = runningFunctions.get(name); + if (p) { + p.kill(); + runningFunctions.delete(name); + } + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + console.log(`Function ${name} running at http://localhost:${port}`); +} + +/** + * Build a function for deployment + */ +async function runFunctionBuild( + name: string | undefined, + projectRoot: string +): Promise { + if (!name) { + logger.error('Function name is required'); + console.log('Usage: bb function build '); + return; + } + + console.log(`Building function "${name}"...`); + + const result = await bundleFunction(name, projectRoot); + + if (!result.success) { + logger.error('Build failed:'); + for (const error of result.errors) { + console.log(` - ${error}`); + } + return; + } + + const sizeKB = (result.size / 1024).toFixed(2); + console.log(`Build successful!`); + console.log(` Output: ${result.outputPath}`); + console.log(` Size: ${sizeKB} KB`); +} + +/** + * List all functions + */ +async function runFunctionList(projectRoot: string): Promise { + const functions = await listFunctions(projectRoot); + + if (functions.length === 0) { + console.log('No functions found. Create one with: bb function create '); + return; + } + + console.log('\nFunctions:\n'); + console.log(' Name | Runtime | Status'); + console.log(' --------------|----------------------|-------'); + + for (const fn of functions) { + const built = await isFunctionBuilt(fn.name, projectRoot); + const status = built ? 'built' : 'not built'; + const runtime = fn.runtime; + console.log(` ${fn.name.padEnd(14)} | ${runtime.padEnd(20)} | ${status}`); + } + + console.log(''); +} + +/** + * Show function logs + */ +async function runFunctionLogs( + name: string | undefined, + projectRoot: string +): Promise { + if (!name) { + logger.error('Function name is required'); + console.log('Usage: bb function logs '); + return; + } + + const config = await readFunctionConfig(name, projectRoot); + const runtime = config?.runtime ?? 'cloudflare-workers'; + + console.log(`Fetching logs for "${name}" (${runtime})...`); + + if (runtime === 'cloudflare-workers') { + const result = await getCloudflareLogs(name, projectRoot); + + if (!result.success) { + logger.error(result.message || 'Failed to get logs'); + console.log('\nTo view Cloudflare Worker logs:'); + console.log(' 1. Install wrangler: bun add -g wrangler'); + console.log(' 2. Run: wrangler tail '); + return; + } + + console.log('\nLogs:'); + for (const log of result.logs) { + console.log(log); + } + } else { + const result = await getVercelLogs(name); + + if (!result.success) { + logger.error(result.message || 'Failed to get logs'); + console.log('\nTo view Vercel logs:'); + console.log(' 1. Install vercel: bun add -g vercel'); + console.log(' 2. Run: vercel logs '); + return; + } + + console.log('\nLogs:'); + for (const log of result.logs) { + console.log(log); + } + } +} + +/** + * Deploy a function + */ +async function runFunctionDeploy( + name: string | undefined, + projectRoot: string, + syncEnv: boolean +): Promise { + if (!name) { + logger.error('Function name is required'); + console.log('Usage: bb function deploy [--sync-env]'); + return; + } + + const functionsDir = join(projectRoot, 'src', 'functions', name); + + if (!existsSync(functionsDir)) { + logger.error(`Function "${name}" not found`); + return; + } + + // First, build the function + console.log(`Building function "${name}" before deployment...`); + const buildResult = await bundleFunction(name, projectRoot); + + if (!buildResult.success) { + logger.error('Build failed:'); + for (const error of buildResult.errors) { + console.log(` - ${error}`); + } + return; + } + + console.log(`Build successful (${(buildResult.size / 1024).toFixed(2)} KB)\n`); + + // Get function config + const config = await readFunctionConfig(name, projectRoot); + const runtime = config?.runtime ?? 'cloudflare-workers'; + + console.log(`Deploying to ${runtime}...`); + + let deployResult; + + if (runtime === 'cloudflare-workers') { + deployResult = await deployToCloudflare( + name, + buildResult.outputPath, + config ?? { name, runtime: 'cloudflare-workers', env: [] }, + projectRoot + ); + } else { + deployResult = await deployToVercel( + name, + buildResult.outputPath, + config ?? { name, runtime: 'vercel-edge', env: [] }, + projectRoot + ); + } + + if (!deployResult.success) { + logger.error('Deployment failed:'); + for (const log of deployResult.logs) { + console.log(` ${log}`); + } + return; + } + + console.log(`\nDeployment successful!`); + console.log(` URL: ${deployResult.url}`); + + // Handle env sync + if (syncEnv && config && config.env.length > 0) { + console.log(`\nSyncing ${config.env.length} environment variables...`); + + // Read .env file + const envPath = join(projectRoot, '.env'); + const envValues: Record = {}; + + if (existsSync(envPath)) { + const envContent = readFileSync(envPath, 'utf-8'); + const envLines = envContent.split('\n'); + + for (const line of envLines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key && config.env.includes(key)) { + envValues[key] = valueParts.join('=').trim(); + } + } + } + } + + const missing: string[] = []; + for (const envVar of config.env) { + if (!envValues[envVar]) { + missing.push(envVar); + } + } + + if (missing.length > 0) { + console.log(`\nWarning: Missing env vars in .env: ${missing.join(', ')}`); + } + + console.log(`\nThe following env vars will be synced:`); + for (const envVar of config.env) { + const isSet = envValues[envVar] !== undefined; + console.log(` ${envVar} = ${isSet ? '(set)' : '(not set)'}`); + } + + if (runtime === 'cloudflare-workers') { + const syncResult = await syncEnvToCloudflare( + name, + config, + projectRoot, + envValues + ); + if (syncResult.success) { + console.log(`\n${syncResult.message}`); + } else { + logger.error(syncResult.message); + } + } + } +} + +/** + * Stop all running functions + */ +export function stopAllFunctions(): void { + for (const [name, proc] of runningFunctions) { + console.log(`Stopping function "${name}"...`); + proc.kill(); + } + runningFunctions.clear(); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 52031a2..8863e2b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,7 @@ import { runStorageInitCommand, runStorageBucketsListCommand, runStorageUploadCo import { runGenerateGraphqlCommand, runGraphqlPlaygroundCommand } from './commands/graphql'; import { runRlsCommand } from './commands/rls'; import { runWebhookCommand } from './commands/webhook'; +import { runFunctionCommand } from './commands/function'; import * as logger from './utils/logger'; import packageJson from '../package.json'; @@ -247,6 +248,63 @@ export function createProgram(): Command { await runWebhookCommand([], process.cwd()); }); + const fn = program.command('function').description('Edge function management'); + + fn + .command('create') + .description('Create a new edge function') + .argument('', 'function name') + .argument('[project-root]', 'project root directory', process.cwd()) + .action(async (name: string, projectRoot: string) => { + await runFunctionCommand(['create', name], projectRoot); + }); + + fn + .command('dev') + .description('Run function locally with hot reload') + .argument('', 'function name') + .argument('[project-root]', 'project root directory', process.cwd()) + .action(async (name: string, projectRoot: string) => { + await runFunctionCommand(['dev', name], projectRoot); + }); + + fn + .command('build') + .description('Bundle function for deployment') + .argument('', 'function name') + .argument('[project-root]', 'project root directory', process.cwd()) + .action(async (name: string, projectRoot: string) => { + await runFunctionCommand(['build', name], projectRoot); + }); + + fn + .command('list') + .description('List all functions') + .argument('[project-root]', 'project root directory', process.cwd()) + .action(async (projectRoot: string) => { + await runFunctionCommand(['list'], projectRoot); + }); + + fn + .command('logs') + .description('Show function logs') + .argument('', 'function name') + .argument('[project-root]', 'project root directory', process.cwd()) + .action(async (name: string, projectRoot: string) => { + await runFunctionCommand(['logs', name], projectRoot); + }); + + fn + .command('deploy') + .description('Deploy function to cloud') + .argument('', 'function name') + .option('--sync-env', 'Sync environment variables from .env') + .argument('[project-root]', 'project root directory', process.cwd()) + .action(async (name: string, options: { syncEnv?: boolean; projectRoot?: string }) => { + const projectRoot = options.projectRoot ?? process.cwd(); + await runFunctionCommand(['deploy', name, options.syncEnv ? '--sync-env' : ''], projectRoot); + }); + return program; } diff --git a/packages/core/src/functions/bundler.ts b/packages/core/src/functions/bundler.ts new file mode 100644 index 0000000..04d4cec --- /dev/null +++ b/packages/core/src/functions/bundler.ts @@ -0,0 +1,222 @@ +import { existsSync, mkdirSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface BundleResult { + outputPath: string; + size: number; + success: boolean; + errors: string[]; +} + +export interface FunctionConfig { + name: string; + runtime: 'cloudflare-workers' | 'vercel-edge'; + env: string[]; +} + +/** + * Bundle an edge function using Bun's build API. + * Outputs a single JavaScript file compatible with Cloudflare Workers. + */ +export async function bundleFunction( + name: string, + projectRoot: string +): Promise { + const functionsDir = join(projectRoot, 'src', 'functions', name); + const indexPath = join(functionsDir, 'index.ts'); + const outputDir = join(projectRoot, '.betterbase', 'functions'); + const outputPath = join(outputDir, `${name}.js`); + + const errors: string[] = []; + + // Check if function directory exists + if (!existsSync(functionsDir)) { + return { + outputPath: '', + size: 0, + success: false, + errors: [`Function directory not found: ${functionsDir}`], + }; + } + + // Check if index.ts exists + if (!existsSync(indexPath)) { + return { + outputPath: '', + size: 0, + success: false, + errors: [`Function entry point not found: ${indexPath}`], + }; + } + + // Ensure output directory exists + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + + try { + // Use Bun.build to bundle the function + // We use 'browser' target which produces ES modules compatible with Cloudflare Workers + const result = await Bun.build({ + entrypoints: [indexPath], + outdir: outputDir, + target: 'browser', + format: 'esm', + splitting: false, + minify: false, + sourcemap: 'none', + }); + + // Check for build errors + if (!result.success) { + for (const log of result.logs) { + const logMessage = 'message' in log ? log.message : String(log); + errors.push(`${logMessage}`); + } + return { + outputPath, + size: 0, + success: false, + errors, + }; + } + + // Bun outputs index.js by default, rename to function name + const defaultOutputPath = join(outputDir, 'index.js'); + if (existsSync(defaultOutputPath) && defaultOutputPath !== outputPath) { + const { renameSync } = await import('node:fs'); + renameSync(defaultOutputPath, outputPath); + } + + // Check if output file was created + if (!existsSync(outputPath)) { + return { + outputPath, + size: 0, + success: false, + errors: ['Build completed but output file was not created'], + }; + } + + // Read the output file to get its size + const outputContent = await readFile(outputPath, 'utf-8'); + const size = Buffer.byteLength(outputContent, 'utf-8'); + + return { + outputPath, + size, + success: true, + errors: [], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + outputPath, + size: 0, + success: false, + errors: [message], + }; + } +} + +/** + * Read function configuration from config.ts + */ +export async function readFunctionConfig( + name: string, + projectRoot: string +): Promise { + const configPath = join(projectRoot, 'src', 'functions', name, 'config.ts'); + + if (!existsSync(configPath)) { + return null; + } + + try { + const configContent = await readFile(configPath, 'utf-8'); + + // Parse the config using simple regex extraction + const nameMatch = configContent.match(/name:\s*['"]([^'"]+)['"]/); + const runtimeMatch = configContent.match(/runtime:\s*(['"])([^'"]+)\1/); + const envMatch = configContent.match(/env:\s*\[([^\]]*)\]/); + + const runtime = runtimeMatch?.[2] as FunctionConfig['runtime'] | undefined; + const env: string[] = []; + + if (envMatch) { + const envStr = envMatch[1]; + const envItems = envStr.match(/['"]([^'"]+)['"]/g); + if (envItems) { + for (const item of envItems) { + env.push(item.replace(/['"]/g, '')); + } + } + } + + return { + name: nameMatch?.[1] ?? name, + runtime: runtime ?? 'cloudflare-workers', + env, + }; + } catch { + return null; + } +} + +/** + * List all functions in the project + */ +export interface FunctionInfo { + name: string; + path: string; + runtime: 'cloudflare-workers' | 'vercel-edge'; + hasConfig: boolean; +} + +export async function listFunctions( + projectRoot: string +): Promise { + const functionsDir = join(projectRoot, 'src', 'functions'); + + if (!existsSync(functionsDir)) { + return []; + } + + const { readdirSync, statSync } = await import('node:fs'); + const functions: FunctionInfo[] = []; + + try { + const entries = readdirSync(functionsDir); + + for (const entry of entries) { + const entryPath = join(functionsDir, entry); + const stat = statSync(entryPath); + + if (stat.isDirectory()) { + const config = await readFunctionConfig(entry, projectRoot); + functions.push({ + name: entry, + path: entryPath, + runtime: config?.runtime ?? 'cloudflare-workers', + hasConfig: config !== null, + }); + } + } + } catch { + // Directory might not exist, return empty + } + + return functions; +} + +/** + * Check if a function has been built + */ +export async function isFunctionBuilt( + name: string, + projectRoot: string +): Promise { + const outputPath = join(projectRoot, '.betterbase', 'functions', `${name}.js`); + return existsSync(outputPath); +} diff --git a/packages/core/src/functions/deployer.ts b/packages/core/src/functions/deployer.ts new file mode 100644 index 0000000..21c976d --- /dev/null +++ b/packages/core/src/functions/deployer.ts @@ -0,0 +1,335 @@ +import { execSync, exec } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { FunctionConfig } from './bundler'; + +export interface DeployResult { + url: string; + success: boolean; + logs: string[]; +} + +/** + * Check if a CLI tool is installed + */ +function isCliInstalled(cliName: string): boolean { + try { + execSync(`which ${cliName}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Install a CLI tool globally using bun + */ +function installCli(cliName: string): Promise { + return new Promise((resolve, reject) => { + console.log(`Installing ${cliName}...`); + const child = exec(`bun add -g ${cliName}`, (error, stdout, stderr) => { + if (error) { + console.error(`Failed to install ${cliName}: ${stderr}`); + reject(error); + return; + } + console.log(`${cliName} installed successfully`); + resolve(); + }); + child.stdout?.on('data', (data) => console.log(data.toString())); + child.stderr?.on('data', (data) => console.error(data.toString())); + }); +} + +/** + * Deploy a function to Cloudflare Workers using wrangler + */ +export async function deployToCloudflare( + name: string, + bundlePath: string, + config: FunctionConfig, + projectRoot: string +): Promise { + const logs: string[] = []; + + // Check if wrangler is installed + if (!isCliInstalled('wrangler')) { + console.log('wrangler not found.'); + console.log('Install wrangler: bun add -g wrangler'); + return { + url: '', + success: false, + logs: ['wrangler CLI not installed. Run: bun add -g wrangler'], + }; + } + + // Generate wrangler.toml + const wranglerTomlPath = join(projectRoot, '.betterbase', `${name}.wrangler.toml`); + const wranglerTomlContent = generateWranglerToml(name, bundlePath, config); + const wranglerDir = join(projectRoot, '.betterbase'); + + if (!existsSync(wranglerDir)) { + mkdirSync(wranglerDir, { recursive: true }); + } + + writeFileSync(wranglerTomlPath, wranglerTomlContent); + logs.push(`Generated wrangler.toml at ${wranglerTomlPath}`); + + try { + console.log(`Deploying ${name} to Cloudflare Workers...`); + const output = execSync( + `wrangler deploy --config "${wranglerTomlPath}"`, + { encoding: 'utf-8', stdio: 'pipe' } + ); + logs.push(output); + + // Extract URL from output + const urlMatch = output.match(/https:\/\/[^\s]+\.workers\.dev/); + const url = urlMatch ? urlMatch[0] : ''; + + return { + url, + success: true, + logs, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logs.push(`Deployment failed: ${message}`); + return { + url: '', + success: false, + logs, + }; + } +} + +/** + * Generate wrangler.toml content for a function + */ +function generateWranglerToml( + name: string, + bundlePath: string, + config: FunctionConfig +): string { + const lines = [ + `name = "${name}"`, + `main = "${bundlePath}"`, + `compatibility_date = "2024-01-01"`, + '', + '[vars]', + ]; + + for (const envVar of config.env) { + lines.push(`${envVar} = ""`); + } + + return lines.join('\n') + '\n'; +} + +/** + * Deploy a function to Vercel Edge + */ +export async function deployToVercel( + name: string, + bundlePath: string, + config: FunctionConfig, + projectRoot: string +): Promise { + const logs: string[] = []; + + // Check if vercel CLI is installed + if (!isCliInstalled('vercel')) { + console.log('vercel not found.'); + console.log('Install vercel: bun add -g vercel'); + return { + url: '', + success: false, + logs: ['vercel CLI not installed. Run: bun add -g vercel'], + }; + } + + try { + console.log(`Deploying ${name} to Vercel Edge...`); + const output = execSync( + `vercel deploy --yes --name ${name} ${bundlePath}`, + { encoding: 'utf-8', stdio: 'pipe' } + ); + logs.push(output); + + // Extract URL from output + const urlMatch = output.match(/https:\/\/[^\s]+\.vercel\.app/); + const url = urlMatch ? urlMatch[0] : ''; + + return { + url, + success: true, + logs, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logs.push(`Deployment failed: ${message}`); + return { + url: '', + success: false, + logs, + }; + } +} + +/** + * Sync environment variables to Cloudflare Workers + */ +export async function syncEnvToCloudflare( + name: string, + config: FunctionConfig, + projectRoot: string, + envValues: Record +): Promise<{ success: boolean; message: string }> { + if (!isCliInstalled('wrangler')) { + return { + success: false, + message: 'wrangler CLI not installed', + }; + } + + const wranglerTomlPath = join(projectRoot, '.betterbase', `${name}.wrangler.toml`); + + if (!existsSync(wranglerTomlPath)) { + return { + success: false, + message: `wrangler.toml not found for function ${name}`, + }; + } + + try { + for (const envVar of config.env) { + const value = envValues[envVar]; + if (value) { + console.log(`Setting ${envVar} for ${name}...`); + execSync( + `wrangler secret put ${envVar} --config "${wranglerTomlPath}"`, + { input: value + '\n', stdio: 'pipe' } + ); + } + } + + return { + success: true, + message: `Synced ${config.env.length} environment variables`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + message: `Failed to sync env vars: ${message}`, + }; + } +} + +/** + * Sync environment variables to Vercel + */ +export async function syncEnvToVercel( + name: string, + config: FunctionConfig, + envValues: Record +): Promise<{ success: boolean; message: string }> { + if (!isCliInstalled('vercel')) { + return { + success: false, + message: 'vercel CLI not installed', + }; + } + + try { + // For Vercel, we use env add command + for (const envVar of config.env) { + const value = envValues[envVar]; + if (value) { + console.log(`Setting ${envVar} for ${name}...`); + execSync( + `vercel env add ${envVar} production`, + { input: value + '\n', stdio: 'pipe' } + ); + } + } + + return { + success: true, + message: `Synced ${config.env.length} environment variables`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + message: `Failed to sync env vars: ${message}`, + }; + } +} + +/** + * Get logs from Cloudflare Workers + */ +export async function getCloudflareLogs( + name: string, + projectRoot: string +): Promise<{ success: boolean; logs: string[]; message?: string }> { + if (!isCliInstalled('wrangler')) { + return { + success: false, + logs: [], + message: 'wrangler not found. Install it: bun add -g wrangler', + }; + } + + try { + const output = execSync(`wrangler tail ${name}`, { + encoding: 'utf-8', + stdio: 'pipe', + }); + return { + success: true, + logs: output.split('\n'), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + logs: [], + message: `Failed to get logs: ${message}`, + }; + } +} + +/** + * Get logs from Vercel + */ +export async function getVercelLogs( + name: string +): Promise<{ success: boolean; logs: string[]; message?: string }> { + if (!isCliInstalled('vercel')) { + return { + success: false, + logs: [], + message: 'vercel not found. Install it: bun add -g vercel', + }; + } + + try { + const output = execSync(`vercel logs ${name}`, { + encoding: 'utf-8', + stdio: 'pipe', + }); + return { + success: true, + logs: output.split('\n'), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + logs: [], + message: `Failed to get logs: ${message}`, + }; + } +} diff --git a/packages/core/src/functions/index.ts b/packages/core/src/functions/index.ts index 56eb3a3..2e5eb35 100644 --- a/packages/core/src/functions/index.ts +++ b/packages/core/src/functions/index.ts @@ -1,3 +1,2 @@ -// [stub] This module is implemented in Phase 15.1. -// Do not implement here — wait for the Phase prompt. -export {} +export * from './bundler'; +export * from './deployer'; diff --git a/templates/base/src/functions/.gitkeep b/templates/base/src/functions/.gitkeep new file mode 100644 index 0000000..2c4fd22 --- /dev/null +++ b/templates/base/src/functions/.gitkeep @@ -0,0 +1,21 @@ +# Edge Functions + +This directory contains your edge functions. Each subdirectory represents a single function. + +## Creating a Function + +```bash +bb function create my-function +``` + +This creates: +- `src/functions/my-function/index.ts` - The function code +- `src/functions/my-function/config.ts` - Function configuration + +## Available Commands + +- `bb function create ` - Create a new edge function +- `bb function dev ` - Run function locally with hot reload +- `bb function build ` - Bundle function for deployment +- `bb function list` - List all functions +- `bb function deploy ` - Deploy to Cloudflare Workers or Vercel Edge