/** * S3: Init command for APOPHIS CLI * Scaffold config, scripts, and example usage in one pass. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { CliContext } from '../../core/types.js'; import { USAGE_ERROR, SUCCESS } from '../../core/exit-codes.js'; import { getScaffoldForPreset, getPresetNames, type ScaffoldResult } from './scaffolds/index.js'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface InitOptions { preset?: string; force?: boolean; noninteractive?: boolean; cwd?: string; } export interface InitResult { exitCode: number; message: string; filesWritten: string[]; nextCommand: string; } const DEFAULT_INSTALL_PM: Exclude = 'npm'; function normalizePackageManager(packageManager: CliContext['packageManager'] | undefined): Exclude { if (!packageManager || packageManager === 'unknown') { return DEFAULT_INSTALL_PM; } return packageManager; } function renderInstallCommand( packageManager: CliContext['packageManager'] | undefined, packages: string[], ): string { const normalized = normalizePackageManager(packageManager); if (normalized === 'yarn') { return `yarn add ${packages.join(' ')}`; } if (normalized === 'pnpm') { return `pnpm add ${packages.join(' ')}`; } if (normalized === 'bun') { return `bun add ${packages.join(' ')}`; } return `npm install ${packages.join(' ')}`; } // ───────────────────────────────────────────────────────────────────────────── // Fastify detection // ───────────────────────────────────────────────────────────────────────────── /** * Detect if the project is a Fastify app by looking for: * - fastify imports in JS/TS files * - Common server file names (server.js, app.js, index.js, etc.) */ export async function detectFastifyEntrypoint(cwd: string): Promise { const candidates = [ 'app.js', 'app.ts', 'server.js', 'server.ts', 'index.js', 'index.ts', 'src/app.js', 'src/app.ts', 'src/server.js', 'src/server.ts', 'src/index.js', 'src/index.ts', ]; for (const candidate of candidates) { const fullPath = resolve(cwd, candidate); if (!existsSync(fullPath)) continue; const content = readFileSync(fullPath, 'utf-8'); // Look for fastify import patterns if ( content.includes('fastify') || content.includes('Fastify') || content.includes('@fastify') || content.includes('fastify-plugin') ) { return candidate; } } return null; } /** * Check if @fastify/swagger is registered in the project. * We check package.json dependencies and the entrypoint file. */ export async function checkSwaggerRegistration(cwd: string, entrypoint: string | null): Promise<{ hasSwaggerDep: boolean; hasSwaggerImport: boolean; }> { const pkgPath = resolve(cwd, 'package.json'); let hasSwaggerDep = false; if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); const deps = { ...pkg.dependencies, ...pkg.devDependencies, }; hasSwaggerDep = '@fastify/swagger' in deps; } catch { // Ignore parse errors } } let hasSwaggerImport = false; if (entrypoint) { const entryPath = resolve(cwd, entrypoint); if (existsSync(entryPath)) { const content = readFileSync(entryPath, 'utf-8'); hasSwaggerImport = content.includes('@fastify/swagger') || content.includes('fastify-swagger'); } } return { hasSwaggerDep, hasSwaggerImport }; } /** * Detect if the project uses TypeScript. */ export function detectTypeScript(cwd: string): boolean { return ( existsSync(resolve(cwd, 'tsconfig.json')) || existsSync(resolve(cwd, 'src/app.ts')) || existsSync(resolve(cwd, 'src/server.ts')) || existsSync(resolve(cwd, 'src/index.ts')) ); } // ───────────────────────────────────────────────────────────────────────────── // Package.json script merging // ───────────────────────────────────────────────────────────────────────────── /** * Merge apophis scripts into package.json without clobbering existing scripts. */ export function mergePackageScripts(pkg: Record): Record { const scripts = (pkg.scripts as Record) || {}; const apophisScripts: Record = { 'apophis:verify': 'apophis verify --profile quick', 'apophis:doctor': 'apophis doctor', }; const mergedScripts = { ...scripts }; for (const [key, value] of Object.entries(apophisScripts)) { // Only add if not already present if (!(key in mergedScripts)) { mergedScripts[key] = value; } } return { ...pkg, scripts: mergedScripts, }; } // ───────────────────────────────────────────────────────────────────────────── // File writing // ───────────────────────────────────────────────────────────────────────────── /** * Write the config file (apophis.config.js or .ts). */ export function writeConfigFile( cwd: string, scaffold: ScaffoldResult, isTypeScript: boolean, force: boolean, ): { path: string; existed: boolean } { const ext = isTypeScript ? 'ts' : 'js'; const configPath = resolve(cwd, `apophis.config.${ext}`); const existed = existsSync(configPath); if (existed && !force) { return { path: configPath, existed: true }; } const configContent = generateConfigContent(scaffold.config, isTypeScript); writeFileSync(configPath, configContent, 'utf-8'); return { path: configPath, existed: false }; } /** * Generate config file content as a formatted string. */ function generateConfigContent(config: ScaffoldResult['config'], isTypeScript: boolean): string { const lines: string[] = []; lines.push('/**'); lines.push(' * APOPHIS configuration'); lines.push(' * Generated by `apophis init`'); lines.push(' */'); lines.push(''); if (isTypeScript) { lines.push('import type { ApophisConfig } from "apophis-fastify/cli";'); lines.push(''); lines.push('const config: ApophisConfig = ' + stringifyConfig(config) + ';'); lines.push(''); lines.push('export default config;'); } else { lines.push('export default ' + stringifyConfig(config) + ';'); } lines.push(''); return lines.join('\n'); } /** * Stringify a config object with proper indentation. */ function stringifyConfig(obj: unknown, indent = 2): string { if (obj === null) return 'null'; if (typeof obj === 'string') return JSON.stringify(obj); if (typeof obj === 'number') return String(obj); if (typeof obj === 'boolean') return String(obj); if (Array.isArray(obj)) { if (obj.length === 0) return '[]'; const items = obj.map(item => stringifyConfig(item, indent + 2)).join(',\n' + ' '.repeat(indent)); return '[\n' + ' '.repeat(indent) + items + '\n' + ' '.repeat(indent - 2) + ']'; } if (typeof obj === 'object') { const entries = Object.entries(obj as Record); if (entries.length === 0) return '{}'; const items = entries .map(([key, value]) => { const keyStr = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); return `${keyStr}: ${stringifyConfig(value, indent + 2)}`; }) .join(',\n' + ' '.repeat(indent)); return '{\n' + ' '.repeat(indent) + items + '\n' + ' '.repeat(indent - 2) + '}'; } return String(obj); } /** * Write the README guidance file. */ export function writeReadmeFile( cwd: string, scaffold: ScaffoldResult, force: boolean, ): { path: string; existed: boolean } { const readmePath = resolve(cwd, 'APOPHIS.md'); const existed = existsSync(readmePath); if (existed && !force) { return { path: readmePath, existed: true }; } writeFileSync(readmePath, scaffold.readmeContent.trim() + '\n', 'utf-8'); return { path: readmePath, existed: false }; } /** * Update package.json with merged scripts. */ export function updatePackageJson(cwd: string): { path: string; modified: boolean; error?: string } { const pkgPath = resolve(cwd, 'package.json'); if (!existsSync(pkgPath)) { const bootstrapPackage = { name: 'apophis-app', version: '0.1.0', private: true, type: 'module', scripts: { 'apophis:doctor': 'apophis doctor', 'apophis:verify': 'apophis verify --profile quick', }, dependencies: { fastify: '^5.0.0', '@fastify/swagger': '^9.0.0', }, }; try { writeFileSync(pkgPath, JSON.stringify(bootstrapPackage, null, 2) + '\n', 'utf-8'); return { path: pkgPath, modified: true }; } catch (err) { return { path: pkgPath, modified: false, error: String(err) }; } } try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); const merged = mergePackageScripts(pkg); // Check if anything changed const originalScripts = JSON.stringify(pkg.scripts || {}); const mergedScripts = JSON.stringify(merged.scripts || {}); if (originalScripts === mergedScripts) { return { path: pkgPath, modified: false }; } writeFileSync(pkgPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8'); return { path: pkgPath, modified: true }; } catch (err) { return { path: pkgPath, modified: false, error: String(err) }; } } export function writeBootstrapAppFile( cwd: string, existingEntrypoint: string | null, ): { path: string; created: boolean } { const appPath = resolve(cwd, 'app.js'); if (existingEntrypoint || existsSync(appPath)) { return { path: appPath, created: false }; } const appContent = `/** * Generated by \`apophis init\`. * This is a minimal Fastify-like app that is runnable with \`apophis verify\`. */ const routes = [ { method: 'POST', url: '/users', schema: { body: { type: 'object', required: ['name'], properties: { name: { type: 'string' }, }, }, response: { 201: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, }, }, }, 'x-ensures': [ 'response_code(this) == 201', ], }, }, ]; const app = { routes, async ready() {}, hasRoute({ method, url }) { const normalizedMethod = String(method || '').toUpperCase(); return routes.some(route => route.method === normalizedMethod && route.url === url); }, async inject({ method, url, payload }) { const normalizedMethod = String(method || '').toUpperCase(); if (normalizedMethod === 'POST' && url === '/users') { const body = { id: 'usr-1', name: payload && typeof payload === 'object' && 'name' in payload ? String(payload.name) : 'test', }; return { statusCode: 201, headers: { 'content-type': 'application/json' }, body, json() { return body; }, }; } const body = { message: 'not found' }; return { statusCode: 404, headers: { 'content-type': 'application/json' }, body, json() { return body; }, }; }, }; export default app; `; writeFileSync(appPath, appContent, 'utf-8'); return { path: appPath, created: true }; } // ───────────────────────────────────────────────────────────────────────────── // Interactive prompts (lazy-loaded) // ───────────────────────────────────────────────────────────────────────────── interface PromptsModule { select: (opts: { message: string; options: { value: string; label: string }[] }) => Promise; confirm: (opts: { message: string }) => Promise; text: (opts: { message: string; placeholder?: string }) => Promise; } async function loadPrompts(): Promise { // Lazy-load @clack/prompts only when interactive const mod = await import('@clack/prompts'); return mod as unknown as PromptsModule; } // ───────────────────────────────────────────────────────────────────────────── // Main init handler // ───────────────────────────────────────────────────────────────────────────── export async function initHandler(args: string[], ctx: CliContext): Promise { const options = parseInitOptions(args, ctx); const cwd = options.cwd || ctx.cwd; // Detect project structure const isTypeScript = detectTypeScript(cwd); const fastifyEntry = await detectFastifyEntrypoint(cwd); const swaggerCheck = await checkSwaggerRegistration(cwd, fastifyEntry); // Determine preset let preset = options.preset; if (!preset) { if (options.noninteractive) { return { exitCode: USAGE_ERROR, message: 'Missing required --preset flag in non-interactive mode. Use one of: ' + getPresetNames().join(', '), filesWritten: [], nextCommand: '', }; } // Interactive mode: prompt for preset if (ctx.isTTY && !ctx.isCI) { try { const prompts = await loadPrompts(); const presetNames = getPresetNames(); const choice = await prompts.select({ message: 'Choose a preset:', options: presetNames.map(name => ({ value: name, label: name, })), }); preset = choice; } catch { // Fallback if prompts fail return { exitCode: USAGE_ERROR, message: 'Failed to prompt for preset. Use --preset in non-interactive mode.', filesWritten: [], nextCommand: '', }; } } else { // Non-TTY, non-CI: default to safe-ci preset = 'safe-ci'; } } // Validate preset const scaffold = getScaffoldForPreset(preset); if (!scaffold) { return { exitCode: USAGE_ERROR, message: `Unknown preset "${preset}". Available presets: ${getPresetNames().join(', ')}`, filesWritten: [], nextCommand: '', }; } // Check for existing config const configExt = isTypeScript ? 'ts' : 'js'; const configPath = resolve(cwd, `apophis.config.${configExt}`); const configExisted = existsSync(configPath); if (configExisted && !options.force) { return { exitCode: USAGE_ERROR, message: `Config file already exists: apophis.config.${configExt}. Use --force to overwrite.`, filesWritten: [], nextCommand: '', }; } // Write files const filesWritten: string[] = []; const forceWrite = options.force ?? false; const configResult = writeConfigFile(cwd, scaffold, isTypeScript, forceWrite); if (configResult.existed && !forceWrite) { return { exitCode: USAGE_ERROR, message: `Config file already exists: ${configResult.path}. Use --force to overwrite.`, filesWritten: [], nextCommand: '', }; } filesWritten.push(configResult.path); const readmeResult = writeReadmeFile(cwd, scaffold, forceWrite); if (!readmeResult.existed || forceWrite) { filesWritten.push(readmeResult.path); } const pkgResult = updatePackageJson(cwd); if (pkgResult.modified) { filesWritten.push(pkgResult.path); } const bootstrapAppResult = writeBootstrapAppFile(cwd, fastifyEntry); if (bootstrapAppResult.created) { filesWritten.push(bootstrapAppResult.path); } // Build next command const profileName = scaffold.config.profile || 'quick'; const routeHint = scaffold.config.routes?.[0] || ''; const nextCommand = routeHint ? `apophis verify --profile ${profileName} --routes "${routeHint}"` : `apophis verify --profile ${profileName}`; // Build message const lines: string[] = []; lines.push(`Initialized APOPHIS with preset "${preset}"`); lines.push(''); lines.push('Files written:'); for (const file of filesWritten) { lines.push(` ${file}`); } const installPeerDepsCommand = renderInstallCommand(ctx.packageManager, ['fastify', '@fastify/swagger']); const installSwaggerCommand = renderInstallCommand(ctx.packageManager, ['@fastify/swagger']); lines.push(''); lines.push('First success path:'); lines.push(` 1. ${installPeerDepsCommand}`); lines.push(' 2. apophis doctor'); lines.push(` 3. ${nextCommand}`); lines.push(''); lines.push('If verify says "No behavioral contracts found", add x-ensures to your route schema:'); lines.push(' "x-ensures": ['); lines.push(' "response_code(GET /users/{response_body(this).id}) == 200"'); lines.push(' ]'); lines.push(''); lines.push('See APOPHIS.md and docs/getting-started.md for full examples.'); if (!swaggerCheck.hasSwaggerDep && !bootstrapAppResult.created) { lines.push(''); lines.push('Warning: @fastify/swagger not found in dependencies.'); lines.push('APOPHIS requires @fastify/swagger to discover routes.'); lines.push('Install it with:'); lines.push(` ${installSwaggerCommand}`); } else if (!bootstrapAppResult.created && !swaggerCheck.hasSwaggerImport) { lines.push(''); lines.push('Warning: @fastify/swagger is installed but not imported in your entrypoint.'); lines.push('Register it in your Fastify app:'); lines.push(` await app.register(import("@fastify/swagger"), { openapi: { info: { title: "API", version: "1.0.0" } } });`); } if (fastifyEntry) { lines.push(''); lines.push(`Detected Fastify entrypoint: ${fastifyEntry}`); } lines.push(''); lines.push('Next command:'); lines.push(` ${nextCommand}`); return { exitCode: SUCCESS, message: lines.join('\n'), filesWritten, nextCommand, }; } // ───────────────────────────────────────────────────────────────────────────── // Option parsing // ───────────────────────────────────────────────────────────────────────────── function parseInitOptions(args: string[], ctx: CliContext): InitOptions { const options: InitOptions = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--preset' || arg === '-p') { options.preset = args[++i]; } else if (arg === '--force' || arg === '-f') { options.force = true; } else if (arg === '--noninteractive') { options.noninteractive = true; } else if (arg === '--cwd') { options.cwd = args[++i]; } } // Non-interactive if CI or not TTY if (ctx.isCI || !ctx.isTTY) { options.noninteractive = true; } return options; } // ───────────────────────────────────────────────────────────────────────────── // CLI adapter // ───────────────────────────────────────────────────────────────────────────── export async function handleInit(args: string[], ctx: CliContext): Promise { const result = await initHandler(args, ctx); if (result.message) { console.log(result.message); } return result.exitCode; }