spfn
Version:
Superfunction CLI - Add SPFN to your Next.js project
1,407 lines (1,385 loc) • 116 kB
JavaScript
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(",").