645 lines
21 KiB
TypeScript
645 lines
21 KiB
TypeScript
/**
|
|
* 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<CliContext['packageManager'], 'unknown'> = 'npm';
|
|
|
|
function normalizePackageManager(packageManager: CliContext['packageManager'] | undefined): Exclude<CliContext['packageManager'], 'unknown'> {
|
|
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<string | null> {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
const scripts = (pkg.scripts as Record<string, string>) || {};
|
|
|
|
const apophisScripts: Record<string, string> = {
|
|
'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<string, unknown>);
|
|
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<string>;
|
|
confirm: (opts: { message: string }) => Promise<boolean>;
|
|
text: (opts: { message: string; placeholder?: string }) => Promise<string>;
|
|
}
|
|
|
|
async function loadPrompts(): Promise<PromptsModule> {
|
|
// 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<InitResult> {
|
|
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 <name> 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<number> {
|
|
const result = await initHandler(args, ctx);
|
|
|
|
if (result.message) {
|
|
console.log(result.message);
|
|
}
|
|
|
|
return result.exitCode;
|
|
}
|