UNPKG

spfn

Version:

Superfunction CLI - Add SPFN to your Next.js project

1,407 lines (1,385 loc) 116 kB
import { setupCommand } from "./chunk-K5K5BTB7.js"; import { initCommand } from "./chunk-526QKBO7.js"; import { detectPackageManager, logger } from "./chunk-QH74KQEW.js"; // src/index.ts import { Command as Command10 } from "commander"; // src/commands/create.ts import { Command } from "commander"; import { existsSync } from "fs"; import { join } from "path"; import prompts from "prompts"; import ora from "ora"; import { execa } from "execa"; import chalk from "chalk"; async function createProject(projectName, options) { const cwd = process.cwd(); const projectPath = join(cwd, projectName); if (existsSync(projectPath)) { logger.error(`Directory ${projectName} already exists.`); process.exit(1); } console.log(chalk.blue.bold("\n\u{1F680} Creating Next.js project with SPFN...\n")); let pm = options.pm || detectPackageManager(cwd); if (!options.yes && !options.pm) { const { selectedPm } = await prompts({ type: "select", name: "selectedPm", message: "Which package manager do you want to use?", choices: [ { title: "pnpm (recommended)", value: "pnpm" }, { title: "npm", value: "npm" }, { title: "yarn", value: "yarn" }, { title: "bun", value: "bun" } ], initial: 0 }); if (!selectedPm) { process.exit(0); } pm = selectedPm; } logger.step(`Using package manager: ${pm}`); const spinner = ora("Creating Next.js project...").start(); try { const createNextAppArgs = [ "create-next-app@latest", projectName, "--typescript", "--app", "--src-dir", "--import-alias", "@/*", "--tailwind", "--no-eslint", "--yes" // Skip prompts ]; if (options.skipInstall) { createNextAppArgs.push("--skip-install"); } if (options.skipGit) { createNextAppArgs.push("--skip-git"); } const createCommand2 = pm === "npm" ? "npx" : pm === "yarn" ? "yarn" : pm === "pnpm" ? "pnpm" : "bunx"; const createArgs = createCommand2 === "npx" ? createNextAppArgs : ["dlx", ...createNextAppArgs]; await execa(createCommand2, createArgs, { cwd, stdio: "inherit" }); spinner.succeed("Next.js project created"); } catch (error) { spinner.fail("Failed to create Next.js project"); logger.error(String(error)); process.exit(1); } process.chdir(projectPath); logger.info(` \u{1F4C2} Changed directory to ${projectName} `); const iconsSpinner = ora("Setting up SVGR for icon management...").start(); try { const installArgs = pm === "npm" ? ["install", "--save-dev", "@svgr/webpack"] : pm === "yarn" ? ["add", "-D", "@svgr/webpack"] : pm === "pnpm" ? ["add", "-D", "@svgr/webpack"] : ["add", "-d", "@svgr/webpack"]; await execa(pm, installArgs, { cwd: projectPath }); const { setupIcons } = await import("./setup-JA2ADEQ6.js"); await setupIcons(); iconsSpinner.succeed("SVGR setup completed"); } catch (error) { iconsSpinner.warn("Failed to setup SVGR (you can run `spfn setup icons` later)"); } if (options.shadcn) { const shadcnSpinner = ora("Setting up shadcn/ui...").start(); try { const shadcnCommand = pm === "npm" ? "npx" : pm === "pnpm" ? "pnpx" : pm === "yarn" ? "yarn dlx" : "bunx"; const shadcnArgs = pm === "yarn" ? ["shadcn@latest", "init", "--yes", "--defaults"] : ["shadcn@latest", "init", "--yes", "--defaults"]; await execa(shadcnCommand, shadcnArgs, { cwd: projectPath, stdio: "inherit" }); shadcnSpinner.succeed("shadcn/ui initialized"); } catch (error) { shadcnSpinner.warn("Failed to initialize shadcn/ui (you can run `npx shadcn@latest init` later)"); } } const initSpinner = ora("Initializing SPFN...").start(); try { const { initializeSpfn } = await import("./init-EKWKKNUL.js"); await initializeSpfn({ yes: true }); initSpinner.succeed("SPFN initialized"); } catch (error) { initSpinner.fail("Failed to initialize SPFN"); logger.error(String(error)); process.exit(1); } console.log("\n" + chalk.green.bold("\u2713 Project created successfully!\n")); console.log(chalk.bold("Next steps:\n")); console.log(` ${chalk.cyan("cd")} ${projectName}`); console.log(` ${chalk.cyan("docker compose up -d")} ${chalk.gray("# Start PostgreSQL & Redis")}`); console.log(` ${chalk.cyan("cp .env.local.example .env.local")} ${chalk.gray("# Configure environment")}`); console.log(` ${chalk.cyan(`${pm === "npm" ? "npm run" : pm + " run"} spfn:dev`)} ${chalk.gray("# Start dev server")} `); console.log(chalk.bold("Your app will be available at:\n")); console.log(` ${chalk.cyan("http://localhost:3790")} ${chalk.gray("(Next.js)")}`); console.log(` ${chalk.cyan("http://localhost:8790")} ${chalk.gray("(SPFN API)")} `); console.log(chalk.bold("\u{1F680} Ready for production?\n")); console.log(" " + chalk.cyan("Build for production:")); console.log(` ${chalk.cyan(pm === "npm" ? "npm run" : pm + " run")} spfn:build`); console.log(` ${chalk.cyan(pm === "npm" ? "npm run" : pm + " run")} spfn:start `); console.log(" " + chalk.cyan("Or deploy with Docker:")); console.log(` ${chalk.cyan("docker compose -f docker-compose.production.yml up --build -d")} `); console.log(chalk.dim(" \u{1F4D6} See .guide/deployment.md for complete deployment guide")); console.log(chalk.dim(" \u{1F310} Documentation: https://github.com/spfn/spfn\n")); } var createCommand = new Command("create").description("Create a new Next.js project with SPFN").argument("<project-name>", "Name of the project directory").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing a git repository").option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)").option("--shadcn", "Setup shadcn/ui (component library)").option("-y, --yes", "Skip prompts and use defaults").action(async (projectName, options) => { await createProject(projectName, options); }); // src/commands/dev.ts import { Command as Command2 } from "commander"; import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync } from "fs"; import { join as join2 } from "path"; import { execa as execa2 } from "execa"; import chokidar from "chokidar"; var devCommand = new Command2("dev").description("Start SPFN development server (detects and runs Next.js + Hono)").option("-p, --port <port>", "Server port", "8790").option("-h, --host <host>", "Server host", "localhost").option("--routes <path>", "Routes directory path").option("--server-only", "Run only Hono server (skip Next.js)").option("--no-watch", "Disable hot reload (watch mode)").action(async (options) => { process.setMaxListeners(20); if (!process.env.NODE_ENV) { process.env.NODE_ENV = "development"; } const cwd = process.cwd(); const serverDir = join2(cwd, "src", "server"); if (!existsSync2(serverDir)) { logger.error("src/server directory not found."); logger.info('Run "spfn init" first to initialize SPFN in your project.'); process.exit(1); } const packageJsonPath = join2(cwd, "package.json"); let hasNext = false; if (existsSync2(packageJsonPath)) { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); hasNext = !!(packageJson.dependencies?.next || packageJson.devDependencies?.next); } const tempDir = join2(cwd, "node_modules", ".spfn"); const serverEntry = join2(tempDir, "server.mjs"); const watcherEntry = join2(tempDir, "watcher.mjs"); mkdirSync(tempDir, { recursive: true }); writeFileSync(serverEntry, ` // Load environment variables FIRST (before any imports that depend on them) // Use centralized environment loader for standard dotenv priority const { loadEnvironment } = await import('@spfn/core/env'); loadEnvironment({ debug: true }); // Import and start server const { startServer } = await import('@spfn/core/server'); await startServer({ port: ${options.port}, host: '${options.host}', ${options.routes ? `routesPath: '${options.routes}',` : ""}debug: true }); `); writeFileSync(watcherEntry, ` // Load environment variables const { loadEnvironment } = await import('@spfn/core/env'); loadEnvironment({ debug: false }); // Initialize database for generators that need it const { initDatabase, closeDatabase } = await import('@spfn/core/db'); await initDatabase({ pool: { max: 3 } // Watcher needs fewer connections than server }); import { CodegenOrchestrator, loadCodegenConfig, createGeneratorsFromConfig } from '@spfn/core/codegen'; const cwd = process.cwd(); const config = loadCodegenConfig(cwd); const generators = await createGeneratorsFromConfig(config, cwd); const orchestrator = new CodegenOrchestrator({ generators, cwd, debug: true }); // Setup graceful shutdown const cleanup = async () => { await orchestrator.close(); await closeDatabase(); }; process.on('SIGTERM', async () => { await cleanup(); process.exit(0); }); process.on('SIGINT', async () => { await cleanup(); process.exit(0); }); await orchestrator.watch(); // Keep process alive await new Promise(() => {}); `); const pm = detectPackageManager(cwd); if (options.serverOnly || !hasNext) { const watchMode2 = options.watch !== false; logger.info(`Starting SPFN Server on http://${options.host}:${options.port}${watchMode2 ? " (watch mode)" : ""} `); let serverProcess2 = null; let watcherProcess2 = null; let isRestarting2 = false; const startWatcher2 = () => { const watcherCmd = pm === "npm" ? "npx" : pm; const watcherArgs = pm === "npm" ? ["tsx", watcherEntry] : ["exec", "tsx", watcherEntry]; watcherProcess2 = execa2(watcherCmd, watcherArgs, { cwd, stdio: "inherit", reject: false }); watcherProcess2.then((result) => { if (result.exitCode !== 0 && result.exitCode !== 130) { logger.error(`Codegen watcher exited with code ${result.exitCode}`); process.exit(1); } }); }; const startServer2 = () => { const serverCmd = pm === "npm" ? "npx" : pm; const serverArgs = pm === "npm" ? ["tsx", serverEntry] : ["exec", "tsx", serverEntry]; serverProcess2 = execa2(serverCmd, serverArgs, { cwd, stdio: "inherit", reject: false }); }; const restartServer2 = async () => { if (isRestarting2) return; isRestarting2 = true; logger.info("[SPFN] File changed, restarting server..."); if (serverProcess2) { try { serverProcess2.kill("SIGTERM"); await serverProcess2.catch(() => { }); await new Promise((resolve) => setTimeout(resolve, 500)); } catch (error) { } } startServer2(); isRestarting2 = false; }; if (watchMode2) { const watcher = chokidar.watch(serverDir, { ignored: /(^|[\/\\])\../, // ignore dotfiles persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 } }); watcher.on("change", (path2) => { logger.info(`[SPFN] Changed: ${path2.replace(cwd + "/", "")}`); restartServer2(); }); watcher.on("add", (path2) => { logger.info(`[SPFN] Added: ${path2.replace(cwd + "/", "")}`); restartServer2(); }); watcher.on("unlink", (path2) => { logger.info(`[SPFN] Removed: ${path2.replace(cwd + "/", "")}`); restartServer2(); }); } const cleanup2 = async () => { if (serverProcess2) { serverProcess2.kill("SIGTERM"); } if (watcherProcess2) { watcherProcess2.kill("SIGTERM"); } process.exit(0); }; process.on("SIGINT", cleanup2); process.on("SIGTERM", cleanup2); startWatcher2(); startServer2(); await new Promise(() => { }); return; } const watchMode = options.watch !== false; logger.info(`Starting SPFN server + Next.js (Turbopack)${watchMode ? " (watch mode)" : ""}... `); let serverProcess = null; let watcherProcess = null; let nextProcess = null; let isRestarting = false; const startWatcher = () => { const watcherCmd = pm === "npm" ? "npx" : pm; const watcherArgs = pm === "npm" ? ["tsx", watcherEntry] : ["exec", "tsx", watcherEntry]; watcherProcess = execa2(watcherCmd, watcherArgs, { cwd, stdio: "inherit", reject: false }); watcherProcess.then((result) => { if (result.exitCode !== 0 && result.exitCode !== 130) { logger.error(`Codegen watcher exited with code ${result.exitCode}`); process.exit(1); } }); }; const startNext = () => { const nextCmd = pm === "npm" ? "npm" : pm; const nextArgs = pm === "npm" ? ["run", "spfn:next"] : ["run", "spfn:next"]; nextProcess = execa2(nextCmd, nextArgs, { cwd, stdio: "inherit", reject: false }); nextProcess.then((result) => { if (result.exitCode !== 0 && result.exitCode !== 130) { logger.error(`Next.js exited with code ${result.exitCode}`); process.exit(1); } }); }; const startServer = () => { const serverCmd = pm === "npm" ? "npx" : pm; const serverArgs = pm === "npm" ? ["tsx", serverEntry] : ["exec", "tsx", serverEntry]; serverProcess = execa2(serverCmd, serverArgs, { cwd, stdio: "inherit", reject: false }); }; const restartServer = async () => { if (isRestarting) return; isRestarting = true; logger.info("[SPFN] File changed, restarting server..."); if (serverProcess) { try { serverProcess.kill("SIGTERM"); await serverProcess.catch(() => { }); await new Promise((resolve) => setTimeout(resolve, 500)); } catch (error) { } } startServer(); isRestarting = false; }; if (watchMode) { const watcher = chokidar.watch(serverDir, { ignored: /(^|[\/\\])\../, // ignore dotfiles persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 } }); watcher.on("change", (path2) => { logger.info(`[SPFN] Changed: ${path2.replace(cwd + "/", "")}`); restartServer(); }); watcher.on("add", (path2) => { logger.info(`[SPFN] Added: ${path2.replace(cwd + "/", "")}`); restartServer(); }); watcher.on("unlink", (path2) => { logger.info(`[SPFN] Removed: ${path2.replace(cwd + "/", "")}`); restartServer(); }); } const cleanup = async () => { if (serverProcess) { serverProcess.kill("SIGTERM"); } if (watcherProcess) { watcherProcess.kill("SIGTERM"); } if (nextProcess) { nextProcess.kill("SIGTERM"); } process.exit(0); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); startWatcher(); startServer(); await new Promise((resolve) => setTimeout(resolve, 2e3)); startNext(); await new Promise(() => { }); }); // src/commands/build.ts import { Command as Command3 } from "commander"; import { existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs"; import { join as join3 } from "path"; import { execa as execa3 } from "execa"; import ora2 from "ora"; import chalk2 from "chalk"; import { build } from "tsup"; async function buildProject(options) { if (!process.env.NODE_ENV) { process.env.NODE_ENV = "production"; } const cwd = process.cwd(); const pm = detectPackageManager(cwd); const packageJsonPath = join3(cwd, "package.json"); let hasNext = false; if (existsSync3(packageJsonPath)) { const packageJson = JSON.parse(await import("fs").then( (fs2) => fs2.promises.readFile(packageJsonPath, "utf-8") )); hasNext = !!(packageJson.dependencies?.next || packageJson.devDependencies?.next); } const serverDir = join3(cwd, "src", "server"); const hasServer = existsSync3(serverDir); console.log(chalk2.blue.bold("\n\u{1F3D7}\uFE0F Building SPFN project for production...\n")); if (hasServer) { const spinner = ora2("Generating API client...").start(); try { const { CodegenOrchestrator, loadCodegenConfig, createGeneratorsFromConfig } = await import("@spfn/core/codegen"); const config = loadCodegenConfig(cwd); const generators = await createGeneratorsFromConfig(config, cwd); const orchestrator = new CodegenOrchestrator({ generators, cwd, debug: false }); await orchestrator.generateAll(); spinner.succeed("API client generated"); } catch (error) { spinner.warn("API client generation failed (non-critical)"); logger.warn(String(error)); } } if (hasNext && !options.serverOnly) { const spinner = ora2("Building Next.js...").start(); try { await execa3(pm, ["run", "build"], { cwd, stdio: "inherit" }); spinner.succeed("Next.js build completed"); } catch (error) { spinner.fail("Next.js build failed"); logger.error(String(error)); process.exit(1); } } if (hasServer && !options.nextOnly) { const spinner = ora2("Building SPFN server...").start(); try { const outputDir = join3(cwd, ".spfn", "server"); mkdirSync2(outputDir, { recursive: true }); const serverDir2 = join3(cwd, "src", "server"); if (!existsSync3(serverDir2)) { spinner.fail("SPFN server build failed"); logger.error("src/server/ directory not found"); logger.error('Please run "spfn init" to initialize the project.'); process.exit(1); } await build({ entry: ["src/server/**/*.ts"], format: ["esm"], outDir: ".spfn/server", clean: true, splitting: false, tsconfig: "src/server/tsconfig.json", external: [ "drizzle-orm", "hono", "@hono/node-server", "postgres", "ioredis", "pino", "chalk", "@sinclair/typebox", "@spfn/core" ], silent: false, onSuccess: async () => { } }); const prodServerPath = join3(cwd, ".spfn", "prod-server.mjs"); const prodServerContent = `// Load environment variables FIRST (before any imports that depend on them) // Use centralized environment loader for standard dotenv priority const { loadEnvironment } = await import('@spfn/core/env'); loadEnvironment({ debug: false }); // Now import server (logger singleton will be created with correct NODE_ENV) const { startServer } = await import('@spfn/core/server'); import { join } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Environment variables: from .env files OR injected by container/kubernetes const port = process.env.SPFN_PORT || process.env.PORT || '8790'; const host = process.env.SPFN_HOST || process.env.HOST || '0.0.0.0'; await startServer({ port: Number(port), host, routesPath: join(__dirname, 'server', 'routes'), debug: false }); `; writeFileSync2(prodServerPath, prodServerContent); spinner.succeed(`SPFN server build completed \u2192 .spfn/server`); } catch (error) { spinner.fail("SPFN server build failed"); logger.error(String(error)); process.exit(1); } } if (!hasNext && !hasServer) { logger.error("No Next.js or SPFN server found in this project."); process.exit(1); } console.log("\n" + chalk2.green.bold("\u2713 Build completed successfully!\n")); console.log(chalk2.bold("Next steps:\n")); console.log(" " + chalk2.cyan("Start production server:")); console.log(` ${chalk2.cyan(pm === "npm" ? "npm run" : pm + " run")} spfn:start ${chalk2.gray("# Start SPFN + Next.js")} `); console.log(" " + chalk2.cyan("Or deploy with Docker:")); console.log(` ${chalk2.cyan("docker compose -f docker-compose.production.yml up --build -d")} `); console.log(chalk2.dim(" \u{1F4D6} See .guide/deployment.md for complete deployment guide\n")); } var buildCommand = new Command3("build").description("Build SPFN project for production (Next.js + Server)").option("--server-only", "Build only SPFN server (skip Next.js)").option("--next-only", "Build only Next.js (skip SPFN server)").option("--turbo", "Use Turbopack for Next.js build").action(async (options) => { await buildProject(options); }); // src/commands/start.ts import { Command as Command4 } from "commander"; import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs"; import { join as join4 } from "path"; import { execa as execa4 } from "execa"; import chalk3 from "chalk"; var startCommand = new Command4("start").description("Start SPFN production server (Next.js + Hono)").option("--server-only", "Run only SPFN server (skip Next.js)").option("--next-only", "Run only Next.js (skip SPFN server)").option("-p, --port <port>", "Server port", "8790").option("-h, --host <host>", "Server host", "0.0.0.0").action(async (options) => { if (!process.env.NODE_ENV) { process.env.NODE_ENV = "production"; } const cwd = process.cwd(); const packageJsonPath = join4(cwd, "package.json"); let hasNext = false; if (existsSync4(packageJsonPath)) { const packageJson = JSON.parse(readFileSync2(packageJsonPath, "utf-8")); hasNext = !!(packageJson.dependencies?.next || packageJson.devDependencies?.next); } const builtServerDir = join4(cwd, ".spfn", "server"); const hasBuiltServer = existsSync4(builtServerDir); const nextBuildDir = join4(cwd, ".next"); const hasNextBuild = existsSync4(nextBuildDir); if (!options.nextOnly && !hasBuiltServer) { logger.error('.spfn/server directory not found. Please run "spfn build" first.'); process.exit(1); } if (!options.serverOnly && hasNext && !hasNextBuild) { logger.error('.next directory not found. Please run "spfn build" first.'); process.exit(1); } const pm = detectPackageManager(cwd); const serverEntry = join4(cwd, ".spfn", "prod-server.mjs"); if (!existsSync4(serverEntry)) { logger.error('.spfn/prod-server.mjs not found. Please run "spfn build" first.'); process.exit(1); } process.env.SPFN_PORT = options.port; process.env.SPFN_HOST = options.host; if (options.serverOnly || !hasNext) { logger.info(`Starting SPFN Server (production) on http://${options.host}:${options.port} `); try { await execa4("node", [serverEntry], { stdio: "inherit", cwd, env: { ...process.env } }); } catch (error) { logger.error(`Failed to start server: ${error}`); process.exit(1); } return; } if (options.nextOnly) { logger.info("Starting Next.js (production) on http://0.0.0.0:3790\n"); try { await execa4("npx", ["next", "start", "-H", "0.0.0.0", "-p", "3790"], { stdio: "inherit", cwd }); } catch (error) { logger.error(`Failed to start Next.js: ${error}`); process.exit(1); } return; } const nextCmd = "next start -H 0.0.0.0 -p 3790"; const serverCmd = `node ${serverEntry}`; console.log(chalk3.blue.bold("\n\u{1F680} Starting SPFN production server...\n")); logger.info("Next.js: http://0.0.0.0:3790"); logger.info(`SPFN API: http://${options.host}:${options.port} `); try { await execa4( pm === "npm" ? "npx" : pm, pm === "npm" ? ["concurrently", "--raw", "--kill-others", `"${nextCmd}"`, `"${serverCmd}"`] : ["exec", "concurrently", "--raw", "--kill-others", `"${nextCmd}"`, `"${serverCmd}"`], { stdio: "inherit", cwd, shell: true, env: { ...process.env } } ); } catch (error) { const execError = error; if (execError.exitCode === 130) { process.exit(0); } logger.error(`Failed to start production servers: ${error}`); process.exit(1); } }); // src/commands/codegen.ts import { Command as Command5 } from "commander"; import { existsSync as existsSync5, writeFileSync as writeFileSync3 } from "fs"; import { join as join5 } from "path"; import chalk4 from "chalk"; async function initCodegen(options) { const cwd = process.cwd(); const rcPath = join5(cwd, ".spfnrc.json"); if (existsSync5(rcPath)) { logger.warn(".spfnrc.json already exists"); logger.info("Edit manually to add custom generators"); process.exit(0); } const config = { codegen: { generators: [ { name: "@spfn/core:contract", enabled: true } ] } }; writeFileSync3(rcPath, JSON.stringify(config, null, 2) + "\n"); console.log("\n" + chalk4.green.bold("\u2713 Created .spfnrc.json\n")); console.log("Configuration:"); console.log(chalk4.gray(JSON.stringify(config, null, 2))); if (options.withExample) { console.log("\n" + chalk4.yellow("\u{1F4DD} To add custom generators:")); console.log(" 1. Create your generator file (e.g., src/generators/my-generator.ts)"); console.log(" 2. Add to .spfnrc.json:"); console.log(chalk4.gray(` { "codegen": { "generators": [ { "name": "@spfn/core:contract", "enabled": true }, { "path": "./src/generators/my-generator.ts" } ] } } `)); console.log(" 3. Run: " + chalk4.cyan("spfn dev") + " (generators run automatically)"); } else { console.log("\n" + chalk4.yellow("\u{1F4DD} Next steps:")); console.log(" \u2022 Add custom generators to .spfnrc.json"); console.log(" \u2022 Run: " + chalk4.cyan("spfn dev") + " to start development with code generation"); } } async function listGenerators() { const cwd = process.cwd(); const { loadCodegenConfig, createGeneratorsFromConfig } = await import("@spfn/core/codegen"); const config = loadCodegenConfig(cwd); const generators = await createGeneratorsFromConfig(config, cwd); if (generators.length === 0) { logger.info("No generators configured"); logger.info('Run "spfn codegen init" to initialize configuration'); return; } console.log("\n" + chalk4.bold("Registered Generators:")); generators.forEach((gen, index) => { console.log(` ${index + 1}. ${chalk4.cyan(gen.name)}`); console.log(` Patterns: ${chalk4.gray(gen.watchPatterns.join(", "))}`); }); console.log(""); } async function runGenerators() { const cwd = process.cwd(); logger.info("Running code generators...\n"); const { loadCodegenConfig, createGeneratorsFromConfig, CodegenOrchestrator } = await import("@spfn/core/codegen"); const config = loadCodegenConfig(cwd); const generators = await createGeneratorsFromConfig(config, cwd); if (generators.length === 0) { logger.warn("No generators configured"); logger.info('Run "spfn codegen init" to initialize configuration'); return; } const orchestrator = new CodegenOrchestrator({ generators, cwd, debug: false }); await orchestrator.generateAll(); console.log("\n" + chalk4.green.bold("\u2713 Code generation completed")); } var codegenCommand = new Command5("codegen").description("Code generation management"); codegenCommand.command("init").description("Initialize .spfnrc.json with codegen configuration").option("--with-example", "Show example custom generator usage").action(initCodegen); codegenCommand.command("list").alias("ls").description("List registered code generators").action(listGenerators); codegenCommand.command("run").description("Run code generators once (no watch mode)").action(runGenerators); // src/commands/key.ts import { Command as Command6 } from "commander"; import { randomBytes } from "crypto"; import { execSync } from "child_process"; import chalk5 from "chalk"; var PRESETS = { "auth-encryption": { bytes: 32, description: "AES-256 encryption key for @spfn/auth", envVar: "SPFN_ENCRYPTION_KEY", usage: "setupAuth({ encryptionKey: ... })" }, "nextauth-secret": { bytes: 32, description: "NextAuth.js secret key", envVar: "NEXTAUTH_SECRET", usage: "Used by NextAuth.js for session encryption" }, "jwt-secret": { bytes: 64, description: "JWT signing secret (512 bits)", envVar: "JWT_SECRET", usage: "For signing/verifying JWT tokens" }, "session-secret": { bytes: 32, description: "Session encryption secret", envVar: "SESSION_SECRET", usage: "For encrypting session data" }, "api-key": { bytes: 32, description: "Generic API key", envVar: "API_KEY", usage: "For API authentication" } }; function copyToClipboard(text) { try { if (process.platform === "darwin") { execSync("pbcopy", { input: text }); return true; } else if (process.platform === "linux") { execSync("xclip -selection clipboard", { input: text }); return true; } else if (process.platform === "win32") { execSync("clip", { input: text }); return true; } return false; } catch (error) { return false; } } function generateSecret(bytes, preset, envVarName, copy) { const key = randomBytes(bytes).toString("hex"); const config = preset ? PRESETS[preset] : null; console.log("\n" + chalk5.green.bold("\u2713 Generated secret key:")); if (config) { console.log(chalk5.dim(` ${config.description} (${bytes * 8} bits)`)); } else { console.log(chalk5.dim(` ${bytes * 8}-bit secret`)); } console.log("\n" + chalk5.cyan(key) + "\n"); const varName = envVarName || config?.envVar || "SECRET_KEY"; console.log(chalk5.dim("Add to your .env file:")); console.log(chalk5.yellow(`${varName}=${key} `)); if (config?.usage) { console.log(chalk5.dim("Usage:")); console.log(chalk5.gray(` ${config.usage} `)); } if (copy) { if (copyToClipboard(key)) { console.log(chalk5.green("\u2713 Copied to clipboard!\n")); } else { logger.warn("Could not copy to clipboard"); } } } function listPresets() { console.log("\n" + chalk5.bold("Available presets:")); console.log(); Object.entries(PRESETS).forEach(([name, config]) => { console.log(` ${chalk5.cyan(name.padEnd(20))} ${chalk5.dim(config.description)}`); console.log(` ${" ".repeat(20)} ${chalk5.gray(`\u2192 ${config.envVar} (${config.bytes * 8} bits)`)}`); console.log(); }); console.log(chalk5.dim("Usage:")); console.log(chalk5.gray(" spfn key <preset>")); console.log(chalk5.gray(" spfn key auth-encryption --copy")); console.log(); } var generateValueCommand = new Command6("generate").alias("gen").description("Generate random value (simple output, no metadata)").option("-b, --bytes <number>", "Number of random bytes", "32").option("-c, --copy", "Copy to clipboard").action((options) => { const bytes = parseInt(options.bytes, 10); if (isNaN(bytes) || bytes < 1 || bytes > 128) { logger.error("Invalid bytes value. Must be between 1 and 128."); process.exit(1); } const value = randomBytes(bytes).toString("hex"); console.log(value); if (options.copy) { if (copyToClipboard(value)) { console.error(chalk5.green("\u2713 Copied to clipboard")); } else { console.error(chalk5.yellow("\u26A0 Could not copy to clipboard")); } } }); var keyCommand = new Command6("key").alias("k").description("Generate secure random keys and secrets").argument("[preset]", `Preset type (use --list to see all)`).option("-l, --list", "List all available presets").option("-b, --bytes <number>", "Number of random bytes to generate", "32").option("-e, --env <name>", "Environment variable name").option("-c, --copy", "Copy to clipboard").action((preset, options) => { if (options.list) { listPresets(); return; } const bytes = parseInt(options.bytes, 10); if (isNaN(bytes) || bytes < 1 || bytes > 128) { logger.error("Invalid bytes value. Must be between 1 and 128."); process.exit(1); } if (preset && !(preset in PRESETS)) { logger.error(`Unknown preset: ${preset}`); console.log("\nAvailable presets:"); Object.entries(PRESETS).forEach(([name, config]) => { console.log(` ${chalk5.cyan(name)}: ${config.description}`); }); console.log("\nUse " + chalk5.cyan("--list") + " to see detailed information"); process.exit(1); } generateSecret( bytes, preset, options.env, options.copy ); }); keyCommand.addCommand(generateValueCommand); // src/commands/db.ts import { Command as Command7 } from "commander"; import { existsSync as existsSync6, writeFileSync as writeFileSync4, unlinkSync } from "fs"; import { promises as fs } from "fs"; import path from "path"; import { exec, spawn } from "child_process"; import { promisify } from "util"; import chalk6 from "chalk"; import ora3 from "ora"; import prompts2 from "prompts"; import net from "net"; var execAsync = promisify(exec); async function isPortAvailable(port) { return new Promise((resolve) => { const server = net.createServer(); server.once("error", () => { server.close(); resolve(false); }); server.once("listening", () => { server.close(); resolve(true); }); server.listen(port, "127.0.0.1"); }); } async function findAvailablePort(startPort, maxAttempts = 10) { for (let i = 0; i < maxAttempts; i++) { const port = startPort + i; if (await isPortAvailable(port)) { return port; } } throw new Error(`No available ports found between ${startPort} and ${startPort + maxAttempts - 1}`); } function parseDatabaseUrl(dbUrl) { try { const url = new URL(dbUrl); return { host: url.hostname, port: url.port || "5432", user: url.username, password: url.password, database: url.pathname.slice(1) // Remove leading / }; } catch (error) { throw new Error(`Invalid DATABASE_URL format: ${error instanceof Error ? error.message : "Unknown error"}`); } } function formatBytes(bytes) { if (bytes === 0) { return "0 B"; } const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; } function formatTimestamp() { const now = /* @__PURE__ */ new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, "0"); const day = String(now.getDate()).padStart(2, "0"); const hours = String(now.getHours()).padStart(2, "0"); const minutes = String(now.getMinutes()).padStart(2, "0"); const seconds = String(now.getSeconds()).padStart(2, "0"); return `${year}-${month}-${day}_${hours}${minutes}${seconds}`; } async function ensureBackupInGitignore() { const gitignorePath = path.join(process.cwd(), ".gitignore"); try { let content = ""; let exists = existsSync6(gitignorePath); if (exists) { content = await fs.readFile(gitignorePath, "utf-8"); } const lines = content.split("\n"); const hasBackupsIgnore = lines.some( (line) => line.trim() === "backups/" || line.trim() === "/backups/" || line.trim() === "backups" ); if (!hasBackupsIgnore) { const entry = exists && content && !content.endsWith("\n") ? "\n\n# Database backups\nbackups/\n" : "# Database backups\nbackups/\n"; await fs.appendFile(gitignorePath, entry); console.log(chalk6.dim("\u2713 Added backups/ to .gitignore")); } } catch (error) { console.log(chalk6.dim("\u26A0\uFE0F Could not update .gitignore")); } } async function ensureBackupDir() { const backupDir = path.join(process.cwd(), "backups"); try { await fs.mkdir(backupDir, { recursive: true }); const gitignorePath = path.join(backupDir, ".gitignore"); const gitignoreExists = existsSync6(gitignorePath); if (!gitignoreExists) { await fs.writeFile(gitignorePath, "# Ignore all backup files\n*.sql\n*.dump\n*.meta.json\n"); } await ensureBackupInGitignore(); return backupDir; } catch (error) { throw new Error(`Failed to create backup directory: ${error instanceof Error ? error.message : "Unknown error"}`); } } async function collectGitInfo() { try { const { stdout: isRepo } = await execAsync('git rev-parse --is-inside-work-tree 2>/dev/null || echo "false"'); if (isRepo.trim() !== "true") { return void 0; } const { stdout: commit } = await execAsync("git rev-parse HEAD"); const { stdout: branch } = await execAsync("git rev-parse --abbrev-ref HEAD"); let tag; try { const { stdout: tagOutput } = await execAsync("git describe --tags --exact-match 2>/dev/null"); tag = tagOutput.trim() || void 0; } catch { } const { stdout: status } = await execAsync("git status --porcelain"); const dirty = status.trim().length > 0; return { commit: commit.trim(), branch: branch.trim(), tag, dirty }; } catch (error) { return void 0; } } async function collectMigrationInfo(dbUrl) { try { const { Pool } = await import("pg"); const pool = new Pool({ connectionString: dbUrl }); try { const tableCheck = await pool.query(` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_name = '__drizzle_migrations' ); `); if (!tableCheck.rows[0].exists) { return void 0; } const result = await pool.query(` SELECT * FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1; `); if (result.rows.length === 0) { return void 0; } const lastMigration = result.rows[0]; const countResult = await pool.query(` SELECT COUNT(*) as count FROM __drizzle_migrations; `); return { lastApplied: lastMigration.hash || "unknown", count: parseInt(countResult.rows[0].count), hash: lastMigration.hash }; } finally { await pool.end(); } } catch (error) { console.log(chalk6.dim("\u26A0\uFE0F Could not fetch migration info")); return void 0; } } async function saveBackupMetadata(metadata, backupFilename) { const metadataPath = backupFilename.replace(/\.(sql|dump)$/, ".meta.json"); try { await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); console.log(chalk6.dim(`\u2713 Metadata saved: ${path.basename(metadataPath)}`)); } catch (error) { console.log(chalk6.dim("\u26A0\uFE0F Could not save metadata")); } } async function loadBackupMetadata(backupFilename) { const metadataPath = backupFilename.replace(/\.(sql|dump)$/, ".meta.json"); try { const content = await fs.readFile(metadataPath, "utf-8"); return JSON.parse(content); } catch (error) { return void 0; } } async function listBackupFiles() { const backupDir = path.join(process.cwd(), "backups"); try { const files = await fs.readdir(backupDir); const backups = await Promise.all( files.filter((f) => f.endsWith(".sql") || f.endsWith(".dump")).map(async (f) => { const filepath = path.join(backupDir, f); const stats = await fs.stat(filepath); const metadata = await loadBackupMetadata(filepath); return { name: f, path: filepath, size: formatBytes(stats.size), sizeBytes: stats.size, date: stats.mtime, metadata }; }) ); return backups.sort((a, b) => b.date.getTime() - a.date.getTime()); } catch (error) { if (error.code === "ENOENT") { return []; } throw error; } } async function runDrizzleCommand(command) { const { loadEnvironment } = await import("@spfn/core/env"); loadEnvironment({ debug: false }); const hasUserConfig = existsSync6("./drizzle.config.ts"); const tempConfigPath = `./drizzle.config.${process.pid}.${Date.now()}.temp.ts`; try { const configPath = hasUserConfig ? "./drizzle.config.ts" : tempConfigPath; if (!hasUserConfig) { if (!process.env.DATABASE_URL) { console.error(chalk6.red("\u274C DATABASE_URL not found in environment")); console.log(chalk6.yellow("\n\u{1F4A1} Tip: Add DATABASE_URL to your .env file")); process.exit(1); } const { generateDrizzleConfigFile } = await import("@spfn/core/db"); const configContent = generateDrizzleConfigFile({ cwd: process.cwd(), // Exclude package schemas to avoid .ts/.js mixing (packages use migrations instead) disablePackageDiscovery: true }); writeFileSync4(tempConfigPath, configContent); console.log(chalk6.dim("Using auto-generated Drizzle config\n")); } const fullCommand = `drizzle-kit ${command} --config=${configPath}`; const { stdout, stderr } = await execAsync(fullCommand); if (stdout) { console.log(stdout); } if (stderr) { console.error(stderr); } } finally { if (!hasUserConfig && existsSync6(tempConfigPath)) { unlinkSync(tempConfigPath); } } } async function runWithSpinner(spinnerText, command, successMessage, failMessage) { const spinner = ora3(spinnerText).start(); try { spinner.stop(); await runDrizzleCommand(command); console.log(chalk6.green(`\u2705 ${successMessage}`)); } catch (error) { spinner.fail(failMessage); console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error")); process.exit(1); } } async function dbGenerate() { await runWithSpinner( "Generating database migrations...", "generate", "Migrations generated successfully", "Failed to generate migrations" ); } async function dbPush() { const { loadEnvironment } = await import("@spfn/core/env"); loadEnvironment({ debug: false }); await runWithSpinner( "Pushing schema changes to database...", "push", "Schema pushed successfully", "Failed to push schema" ); const { discoverFunctionMigrations, executeFunctionMigrations } = await import("./function-migrations-AXX6HWXL.js"); const functions = discoverFunctionMigrations(process.cwd()); if (functions.length > 0) { console.log(chalk6.blue("\n\u{1F4E6} Applying function package migrations:")); functions.forEach((func) => { console.log(chalk6.dim(` - ${func.packageName}`)); }); try { await executeFunctionMigrations(functions); console.log(chalk6.green("\n\u2705 All function migrations applied\n")); } catch (error) { console.error(chalk6.red("\n\u274C Failed to apply function migrations")); console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error")); process.exit(1); } } } async function dbMigrate(options = {}) { const { loadEnvironment } = await import("@spfn/core/env"); loadEnvironment({ debug: false }); if (options.withBackup) { console.log(chalk6.blue("\u{1F4E6} Creating pre-migration backup...\n")); await dbBackup({ format: "custom", tag: "pre-migration", env: process.env.NODE_ENV }); console.log(""); } const { discoverFunctionMigrations, executeFunctionMigrations } = await import("./function-migrations-AXX6HWXL.js"); const functions = discoverFunctionMigrations(process.cwd()); if (functions.length > 0) { console.log(chalk6.blue("\u{1F4E6} Applying function package migrations:")); functions.forEach((func) => { console.log(chalk6.dim(` - ${func.packageName}`)); }); try { await executeFunctionMigrations(functions); console.log(chalk6.green("\u2705 Function migrations applied\n")); } catch (error) { console.error(chalk6.red("\n\u274C Failed to apply function migrations")); console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error")); process.exit(1); } } await runWithSpinner( "Running project migrations...", "migrate", "Project migrations applied successfully", "Failed to run project migrations" ); } async function dbStudio(requestedPort) { console.log(chalk6.blue("\u{1F3A8} Opening Drizzle Studio...\n")); const { loadEnvironment } = await import("@spfn/core/env"); loadEnvironment({ debug: false }); const defaultPort = 4983; const startPort = requestedPort || defaultPort; let port; try { port = await findAvailablePort(startPort); if (port !== startPort) { console.log(chalk6.yellow(`\u26A0\uFE0F Port ${startPort} is in use, using port ${port} instead `)); } } catch (error) { console.error(chalk6.red(error instanceof Error ? error.message : "Failed to find available port")); process.exit(1); } const hasUserConfig = existsSync6("./drizzle.config.ts"); const tempConfigPath = `./drizzle.config.${process.pid}.${Date.now()}.temp.ts`; try { const configPath = hasUserConfig ? "./drizzle.config.ts" : tempConfigPath; if (!hasUserConfig) { if (!process.env.DATABASE_URL) { console.error(chalk6.red("\u274C DATABASE_URL not found in environment")); console.log(chalk6.yellow("\n\u{1F4A1} Tip: Add DATABASE_URL to your .env file")); process.exit(1); } const { generateDrizzleConfigFile } = await import("@spfn/core/db"); const configContent = generateDrizzleConfigFile({ cwd: process.cwd(), disablePackageDiscovery: true }); writeFileSync4(tempConfigPath, configContent); console.log(chalk6.dim("Using auto-generated Drizzle config\n")); } const studioProcess = spawn("drizzle-kit", ["studio", `--port=${port}`, `--config=${configPath}`], { stdio: "inherit", shell: true }); const cleanup = () => { if (!hasUserConfig && existsSync6(tempConfigPath)) { unlinkSync(tempConfigPath); } }; studioProcess.on("exit", (code) => { cleanup(); if (code !== 0 && code !== null) { console.error(chalk6.red(` \u274C Drizzle Studio exited with code ${code}`)); process.exit(code); } }); studioProcess.on("error", (error) => { cleanup(); console.error(chalk6.red("\u274C Failed to start Drizzle Studio")); console.error(chalk6.red(error.message)); process.exit(1); }); process.on("SIGINT", () => { console.log(chalk6.yellow("\n\n\u{1F44B} Shutting down Drizzle Studio...")); studioProcess.kill("SIGTERM"); cleanup(); process.exit(0); }); process.on("SIGTERM", () => { studioProcess.kill("SIGTERM"); cleanup(); process.exit(0); }); } catch (error) { if (!hasUserConfig && existsSync6(tempConfigPath)) { unlinkSync(tempConfigPath); } console.error(chalk6.red("\u274C Failed to start Drizzle Studio")); console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error")); process.exit(1); } } async function dbDrop() { console.log(chalk6.yellow("\u26A0\uFE0F WARNING: This will drop all tables in your database!")); const { confirm } = await prompts2({ type: "confirm", name: "confirm", message: "Are you sure you want to drop all tables?", initial: false }); if (!confirm) { console.log(chalk6.gray("Cancelled.")); process.exit(0); } await runWithSpinner( "Dropping all tables...", "drop", "All tables dropped", "Failed to drop tables" ); } async function dbCheck() { const spinner = ora3("Checking database connection...").start(); try { await runDrizzleCommand("check"); spinner.succeed("Database connection OK"); } catch (error) { spinner.fail("Database connection failed"); console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error")); process.exit(1); } } async function dbBackup(options) { console.log(chalk6.blue("\u{1F4BE} Creating database backup...\n")); const { loadEnvironment } = await import("@spfn/core/env"); loadEnvironment({ debug: false }); const dbUrl = process.env.DATABASE_URL; if (!dbUrl) { console.error(chalk6.red("\u274C DATABASE_URL not found in environment")); console.log(chalk6.yellow("\n\u{1F4A1} Tip: Add DATABASE_URL to your .env file")); process.exit(1); } const dbInfo = parseDatabaseUrl(dbUrl); const backupDir = await ensureBackupDir(); const timestamp = formatTimestamp(); const format = options.format || "sql"; const ext = format === "sql" ? "sql" : "dump"; const filename = options.output || path.join(backupDir, `${dbInfo.database}_${timestamp}.${ext}`); if (options.dataOnly && options.schemaOnly) { console.error(chalk6.red("\u274C Cannot use --data-only and --schema-only together")); process.exit(1); } const args = [ "-h", dbInfo.host, "-p", dbInfo.port, "-U", dbInfo.user, "-d", dbInfo.database, "-f", filename ]; if (format === "custom") { args.push("-Fc"); } if (options.schema) { args.push("-n", options.schema); } if (options.dataOnly) { args.push("--data-only"); } if (options.schemaOnly) { args.push("--schema-only"); } const spinner = ora3("Creating backup...").start(); const pgDump = spawn("pg_dump", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, PGPASSWORD: dbInfo.password } }); let errorOutput = ""; pgDump.stderr?.on("data", (data) => { errorOutput += data.toString(); }); pgDump.on("close", async (code) => { if (code === 0) { const stats = await fs.stat(filename); const size = formatBytes(stats.size); spinner.succeed("Backup created"); console.log(chalk6.green(` \u2705 Backup created successfully`)); console.log(chalk6.gray(` File: ${filename}`)); console.log(chalk6.gray(` Size: ${size}`)); console.log(chalk6.dim("\n\u{1F4CB} Collecting metadata...")); const [gitInfo, migrationInfo] = await Promise.all([ collectGitInfo(), collectMigrationInfo(dbUrl) ]); const tags = []; if (options.tag) { tags.push(...options.tag.split(",").