spfn
Version:
Superfunction CLI - Add SPFN to your Next.js project
383 lines (373 loc) • 13.9 kB
JavaScript
import {
detectPackageManager,
logger
} from "./chunk-QH74KQEW.js";
// src/commands/init.ts
import { Command } from "commander";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import prompts from "prompts";
import ora from "ora";
import { execa } from "execa";
import fse from "fs-extra";
import { dirname } from "path";
import { fileURLToPath } from "url";
import chalk from "chalk";
var { copySync, ensureDirSync, writeFileSync } = fse;
var __dirname = dirname(fileURLToPath(import.meta.url));
function findTemplatesPath() {
const npmPath = join(__dirname, "templates");
if (existsSync(npmPath)) {
return npmPath;
}
const devPath = join(__dirname, "..", "templates");
if (existsSync(devPath)) {
return devPath;
}
throw new Error("Templates directory not found. Please rebuild the package.");
}
async function initializeSpfn(options = {}) {
const cwd = process.cwd();
const packageJsonPath = join(cwd, "package.json");
if (!existsSync(packageJsonPath)) {
logger.error("No package.json found. Please run this in a Next.js project.");
process.exit(1);
}
const packageJson = JSON.parse(await import("fs").then(
(fs) => fs.promises.readFile(packageJsonPath, "utf-8")
));
const hasNext = packageJson.dependencies?.next || packageJson.devDependencies?.next;
if (!hasNext) {
logger.warn("Next.js not detected in dependencies.");
if (!options.yes) {
const { proceed } = await prompts(
{
type: "confirm",
name: "proceed",
message: "Continue anyway?",
initial: false
}
);
if (!proceed) {
process.exit(0);
}
}
}
logger.info("Initializing SPFN in your Next.js project...\n");
if (existsSync(join(cwd, "src", "server"))) {
logger.warn("src/server directory already exists.");
if (!options.yes) {
const { overwrite } = await prompts(
{
type: "confirm",
name: "overwrite",
message: "Overwrite existing files?",
initial: false
}
);
if (!overwrite) {
logger.info("Cancelled.");
process.exit(0);
}
}
}
const pm = detectPackageManager(cwd);
logger.step(`Detected package manager: ${pm}`);
const spinner = ora("Setting up server structure...").start();
try {
const templatesDir = findTemplatesPath();
const serverTemplateDir = join(templatesDir, "server");
const targetDir = join(cwd, "src", "server");
if (!existsSync(serverTemplateDir)) {
spinner.fail("Failed to create server structure");
logger.error(`Server templates not found at: ${serverTemplateDir}`);
process.exit(1);
}
ensureDirSync(targetDir);
copySync(serverTemplateDir, targetDir);
const libTemplateDir = join(templatesDir, "lib");
const libTargetDir = join(cwd, "src", "lib");
if (existsSync(libTemplateDir)) {
ensureDirSync(libTargetDir);
copySync(libTemplateDir, libTargetDir);
}
spinner.succeed("Server structure created");
} catch (error) {
spinner.fail("Failed to create server structure");
logger.error(String(error));
process.exit(1);
}
const dockerComposePath = join(cwd, "docker-compose.yml");
if (!existsSync(dockerComposePath)) {
try {
const templatesDir = findTemplatesPath();
const dockerComposeTemplate = join(templatesDir, "docker-compose.yml");
if (existsSync(dockerComposeTemplate)) {
copySync(dockerComposeTemplate, dockerComposePath);
logger.success("Created docker-compose.yml (PostgreSQL + Redis)");
}
} catch (error) {
logger.warn("Could not copy docker-compose.yml");
}
}
try {
const templatesDir = findTemplatesPath();
const dockerfilePath = join(cwd, "Dockerfile");
if (!existsSync(dockerfilePath)) {
const dockerfileTemplate = join(templatesDir, "Dockerfile");
if (existsSync(dockerfileTemplate)) {
copySync(dockerfileTemplate, dockerfilePath);
logger.success("Created Dockerfile");
}
}
const dockerignorePath = join(cwd, ".dockerignore");
if (!existsSync(dockerignorePath)) {
const dockerignoreTemplate = join(templatesDir, ".dockerignore");
if (existsSync(dockerignoreTemplate)) {
copySync(dockerignoreTemplate, dockerignorePath);
logger.success("Created .dockerignore");
}
}
const dockerComposeProdPath = join(cwd, "docker-compose.production.yml");
if (!existsSync(dockerComposeProdPath)) {
const dockerComposeProdTemplate = join(templatesDir, "docker-compose.production.yml");
if (existsSync(dockerComposeProdTemplate)) {
copySync(dockerComposeProdTemplate, dockerComposeProdPath);
logger.success("Created docker-compose.production.yml");
}
}
} catch (error) {
logger.warn("Could not copy Docker files (you can create them manually)");
}
try {
const templatesDir = findTemplatesPath();
const guideTemplateDir = join(templatesDir, ".guide");
const guideTargetDir = join(cwd, ".guide");
if (existsSync(guideTemplateDir) && !existsSync(guideTargetDir)) {
copySync(guideTemplateDir, guideTargetDir);
logger.success("Created .guide directory (quick start & deployment guides)");
}
} catch (error) {
logger.warn("Could not copy .guide directory");
}
const deploymentConfigPath = join(cwd, "spfn.config.js");
if (!existsSync(deploymentConfigPath)) {
try {
const projectName = packageJson.name?.replace(/[@\/]/g, "-").toLowerCase() || cwd.split("/").pop()?.toLowerCase() || "my-app";
const configContent = `/**
* SPFN Configuration
*
* This file configures your SPFN application deployment settings.
*
* @type {import('spfn').SpfnConfig}
*/
export default {
/**
* Package manager to use for dependency installation
* Options: 'npm' | 'yarn' | 'pnpm' | 'bun'
*/
packageManager: '${pm}',
/**
* Deployment configuration for SPFN cloud platform
*/
deployment: {
/**
* Your app's subdomain on spfn.app
*
* This will automatically create region-specific domains:
* - {subdomain}.{region}.spfn.app \u2192 Next.js frontend (port 3790)
* - api-{subdomain}.{region}.spfn.app \u2192 SPFN backend (port 8790)
*
* Example: subdomain: '${projectName}', region: 'us' creates:
* - ${projectName}.us.spfn.app
* - api-${projectName}.us.spfn.app
*/
subdomain: '${projectName}',
/**
* Deployment region (optional, defaults to 'us')
*
* Available regions:
* - 'us': Virginia, USA (default)
* - 'kr': Seoul, South Korea
* - 'jp': Tokyo, Japan [Coming soon]
* - 'sg': Singapore [Coming soon]
* - 'eu': Frankfurt, Germany [Coming soon]
*/
region: 'us',
/**
* Custom domains (optional)
*
* Add your own custom domains here. Make sure to configure DNS:
* - CNAME record pointing to spfn.app
*
* Example:
* customDomains: {
* nextjs: ['www.example.com', 'example.com'],
* spfn: ['api.example.com']
* }
*/
customDomains: {
/**
* Custom domains for Next.js frontend
*/
nextjs: [],
/**
* Custom domains for SPFN backend API
*/
spfn: []
},
/**
* Environment variables (optional)
*
* Most environment variables are auto-generated by the CI/CD pipeline.
* Only add custom values if you need to override defaults.
*
* \u{1F527} Auto-generated variables (leave env empty for defaults):
* - NEXT_PUBLIC_API_URL: https://api-{subdomain}.{region}.spfn.app
* (Used by browser/client-side code)
* - API_URL: http://localhost:8790
* (Used by Next.js SSR/API Routes - same container, internal)
*
* \u{1F4CB} When to add custom env:
* - Using custom API domain (not *.spfn.app)
* - Additional environment variables for your app
*
* \u26A0\uFE0F SECURITY WARNING:
* - These values are committed to Git
* - Do NOT put sensitive credentials here (DB passwords, API keys, etc.)
* - For production secrets, use your CI/CD secrets management
*
* Example (custom API domain):
* env: {
* NEXT_PUBLIC_API_URL: 'https://api.custom.com',
* API_URL: 'https://api.custom.com',
* NEXT_PUBLIC_FEATURE_FLAG: 'true'
* }
*/
env: {}
}
}
`;
writeFileSync(deploymentConfigPath, configContent);
logger.success(`Created spfn.config.js (subdomain: ${projectName}.spfn.app)`);
} catch (error) {
logger.warn("Could not create spfn.config.js");
}
}
spinner.start("Updating package.json...");
packageJson.dependencies = packageJson.dependencies || {};
packageJson.devDependencies = packageJson.devDependencies || {};
packageJson.scripts = packageJson.scripts || {};
packageJson.dependencies["@spfn/core"] = "alpha";
packageJson.dependencies["@sinclair/typebox"] = "^0.34.0";
packageJson.dependencies["drizzle-typebox"] = "^0.1.0";
packageJson.dependencies["spfn"] = "alpha";
packageJson.dependencies["concurrently"] = "^9.2.1";
packageJson.devDependencies["@types/node"] = "^20.11.0";
packageJson.devDependencies["tsx"] = "^4.20.6";
packageJson.devDependencies["tsup"] = "^8.5.0";
packageJson.devDependencies["drizzle-kit"] = "^0.31.5";
packageJson.devDependencies["dotenv"] = "^17.2.3";
if (!packageJson.scripts["build"]) {
packageJson.scripts["build"] = "next build --turbopack";
}
if (!packageJson.scripts["start"]) {
packageJson.scripts["start"] = "next start";
}
packageJson.scripts["spfn:dev"] = "spfn dev";
packageJson.scripts["spfn:server"] = "spfn dev --server-only";
packageJson.scripts["spfn:next"] = "next dev --turbo --port 3790";
packageJson.scripts["spfn:start"] = "spfn start";
packageJson.scripts["spfn:build"] = "spfn build";
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
spinner.succeed("package.json updated");
spinner.start("Installing dependencies...");
try {
const installArgs = pm === "npm" ? ["install", "--legacy-peer-deps"] : ["install"];
await execa(pm, installArgs, { cwd });
spinner.succeed("Dependencies installed");
} catch (error) {
spinner.fail("Failed to install dependencies");
logger.error(String(error));
process.exit(1);
}
const envExamplePath = join(cwd, ".env.local.example");
if (!existsSync(envExamplePath)) {
writeFileSync(envExamplePath, `# Database (matches docker-compose.yml)
DATABASE_URL=postgresql://spfn:spfn@localhost:5432/spfn_dev
# Redis (optional)
REDIS_URL=redis://localhost:6379
# SPFN API URLs
SERVER_API_URL=http://localhost:8790 # Internal (Server-side only)
NEXT_PUBLIC_API_URL=http://localhost:8790 # Public (Client-side & fallback)
`);
logger.success("Created .env.local.example");
}
const spfnrcPath = join(cwd, ".spfnrc.json");
if (!existsSync(spfnrcPath)) {
const spfnrcConfig = {
codegen: {
generators: [
{
name: "@spfn/core:contract",
enabled: true
}
]
}
};
writeFileSync(spfnrcPath, JSON.stringify(spfnrcConfig, null, 2) + "\n");
logger.success("Created .spfnrc.json (codegen configuration)");
}
const gitignorePath = join(cwd, ".gitignore");
if (existsSync(gitignorePath)) {
try {
const gitignoreContent = readFileSync(gitignorePath, "utf-8");
if (!gitignoreContent.includes(".spfn")) {
const updatedContent = gitignoreContent.replace(
/# production\n\/build/,
"# production\n/build\n\n# spfn\n/.spfn/"
);
writeFileSync(gitignorePath, updatedContent);
logger.success("Updated .gitignore with .spfn directory");
}
} catch (error) {
logger.warn("Could not update .gitignore (you can add .spfn manually)");
}
}
const tsconfigPath = join(cwd, "tsconfig.json");
if (existsSync(tsconfigPath)) {
try {
const tsconfigContent = readFileSync(tsconfigPath, "utf-8");
const tsconfig = JSON.parse(tsconfigContent);
if (!tsconfig.exclude) {
tsconfig.exclude = [];
}
if (!tsconfig.exclude.includes("src/server")) {
tsconfig.exclude.push("src/server");
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
logger.success("Updated tsconfig.json (excluded src/server for Vercel compatibility)");
}
} catch (error) {
logger.warn('Could not update tsconfig.json (you can add "src/server" to exclude manually)');
}
}
console.log("\n" + chalk.green.bold("\u2713 SPFN initialized successfully!\n"));
console.log("Next steps:");
console.log(" 1. Start PostgreSQL & Redis (if not installed locally):");
console.log(" " + chalk.cyan("docker compose up -d"));
console.log(" 2. Copy .env.local.example to .env.local");
console.log(" " + chalk.cyan("cp .env.local.example .env.local"));
console.log(" 3. Run: " + chalk.cyan(pm === "npm" ? "npm run spfn:dev" : `${pm} run spfn:dev`));
console.log(" 4. Visit:");
console.log(" - Next.js: " + chalk.cyan("http://localhost:3790"));
console.log(" - API: " + chalk.cyan("http://localhost:8790/health"));
console.log("\nAvailable scripts:");
console.log(" \u2022 " + chalk.cyan("spfn:dev") + " - Start SPFN server (8790) + Next.js (3790)");
console.log(" \u2022 " + chalk.cyan("spfn:server") + " - Start SPFN server only (8790)");
console.log(" \u2022 " + chalk.cyan("spfn:next") + " - Start Next.js only (3790)");
}
var initCommand = new Command("init").description("Initialize SPFN in your Next.js project").option("-y, --yes", "Skip prompts and use defaults").action(initializeSpfn);
export {
initializeSpfn,
initCommand
};