chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* S3: Init command scaffold templates
|
||||
* Each preset returns a config object and file contents for the init command.
|
||||
*/
|
||||
|
||||
import type { ApophisConfig, PresetDefinition, ProfileDefinition, EnvironmentPolicy } from '../../../core/types.js';
|
||||
|
||||
export interface ScaffoldResult {
|
||||
config: ApophisConfig;
|
||||
readmeContent: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// safe-ci: Minimal CI-safe preset (default)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function safeCiScaffold(): ScaffoldResult {
|
||||
const preset: PresetDefinition = {
|
||||
name: 'safe-ci',
|
||||
depth: 'quick',
|
||||
timeout: 5000,
|
||||
parallel: false,
|
||||
chaos: false,
|
||||
observe: false,
|
||||
};
|
||||
|
||||
const profile: ProfileDefinition = {
|
||||
name: 'quick',
|
||||
mode: 'verify',
|
||||
preset: 'safe-ci',
|
||||
routes: ['POST /users'],
|
||||
};
|
||||
|
||||
const envLocal: EnvironmentPolicy = {
|
||||
name: 'local',
|
||||
allowVerify: true,
|
||||
allowObserve: true,
|
||||
allowQualify: false,
|
||||
allowChaos: false,
|
||||
allowBlocking: true,
|
||||
requireSink: false,
|
||||
};
|
||||
|
||||
const config: ApophisConfig = {
|
||||
mode: 'verify',
|
||||
profiles: { quick: profile },
|
||||
presets: { 'safe-ci': preset },
|
||||
environments: { local: envLocal },
|
||||
};
|
||||
|
||||
const readmeContent = `
|
||||
# APOPHIS Setup — safe-ci preset
|
||||
|
||||
This project was scaffolded with \`apophis init --preset safe-ci\`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Ensure you have a Fastify app with @fastify/swagger registered.
|
||||
2. Add behavioral contracts to your route schemas using \`x-ensures\`.
|
||||
3. Run: apophis verify --profile quick
|
||||
|
||||
## What This Preset Does
|
||||
|
||||
- Runs only behavioral contracts (not schema-only routes).
|
||||
- No chaos, no observe, no stateful testing.
|
||||
- Safe for CI pipelines.
|
||||
- Timeout: 5s per route.
|
||||
|
||||
## Example Behavioral Contract
|
||||
|
||||
Add this inside your route schema to check that a created resource is retrievable:
|
||||
|
||||
\`\`\`javascript
|
||||
"x-ensures": [
|
||||
"response_code(GET /users/{response_body(this).id}) == 200"
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
If \`apophis verify\` says "No behavioral contracts found", it means your routes have schemas but no \`x-ensures\` or \`x-requires\` clauses. Add at least one clause per route you want to verify.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Add more routes to the \`routes\` array in your profile.
|
||||
- Try \`apophis init --preset platform-observe\` for production readiness.
|
||||
- Try \`apophis init --preset protocol-lab\` for multi-step flows.
|
||||
`;
|
||||
|
||||
return { config, readmeContent };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// platform-observe: Production-ready with observe mode
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function platformObserveScaffold(): ScaffoldResult {
|
||||
const preset: PresetDefinition = {
|
||||
name: 'platform-observe',
|
||||
depth: 'standard',
|
||||
timeout: 10000,
|
||||
parallel: true,
|
||||
chaos: false,
|
||||
observe: true,
|
||||
};
|
||||
|
||||
const profile: ProfileDefinition = {
|
||||
name: 'staging-observe',
|
||||
mode: 'observe',
|
||||
preset: 'platform-observe',
|
||||
routes: [],
|
||||
};
|
||||
|
||||
const envStaging: EnvironmentPolicy = {
|
||||
name: 'staging',
|
||||
allowVerify: true,
|
||||
allowObserve: true,
|
||||
allowQualify: true,
|
||||
allowChaos: false,
|
||||
allowBlocking: false,
|
||||
requireSink: true,
|
||||
};
|
||||
|
||||
const envProduction: EnvironmentPolicy = {
|
||||
name: 'production',
|
||||
allowVerify: true,
|
||||
allowObserve: true,
|
||||
allowQualify: false,
|
||||
allowChaos: false,
|
||||
allowBlocking: false,
|
||||
requireSink: true,
|
||||
};
|
||||
|
||||
const config: ApophisConfig = {
|
||||
mode: 'observe',
|
||||
profile: 'staging-observe',
|
||||
profiles: { 'staging-observe': profile },
|
||||
presets: { 'platform-observe': preset },
|
||||
environments: {
|
||||
staging: envStaging,
|
||||
production: envProduction,
|
||||
},
|
||||
};
|
||||
|
||||
const readmeContent = `
|
||||
# APOPHIS Setup — platform-observe preset
|
||||
|
||||
This project was scaffolded with \`apophis init --preset platform-observe\`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Ensure you have a Fastify app with @fastify/swagger registered.
|
||||
2. Configure your reporting sink (see environments.staging.requireSink).
|
||||
3. Run: apophis observe --profile staging-observe
|
||||
|
||||
## What This Preset Does
|
||||
|
||||
- Enables observe mode for production readiness checks.
|
||||
- Validates non-blocking semantics and sink configuration.
|
||||
- Parallel execution for faster feedback.
|
||||
- Requires sink config in staging/production.
|
||||
|
||||
## Safety
|
||||
|
||||
- Observe mode is non-blocking by default.
|
||||
- Production requires explicit policy to enable blocking.
|
||||
- Chaos is disabled in this preset.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Add a sink configuration to your environment policy.
|
||||
- Run \`apophis doctor\` to validate the full setup.
|
||||
`;
|
||||
|
||||
return { config, readmeContent };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// llm-safe: Minimal preset for LLM-generated codebases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function llmSafeScaffold(): ScaffoldResult {
|
||||
const preset: PresetDefinition = {
|
||||
name: 'llm-safe',
|
||||
depth: 'quick',
|
||||
timeout: 3000,
|
||||
parallel: false,
|
||||
chaos: false,
|
||||
observe: false,
|
||||
};
|
||||
|
||||
const profile: ProfileDefinition = {
|
||||
name: 'llm-check',
|
||||
mode: 'verify',
|
||||
preset: 'llm-safe',
|
||||
routes: [],
|
||||
};
|
||||
|
||||
const envLocal: EnvironmentPolicy = {
|
||||
name: 'local',
|
||||
allowVerify: true,
|
||||
allowObserve: false,
|
||||
allowQualify: false,
|
||||
allowChaos: false,
|
||||
allowBlocking: false,
|
||||
requireSink: false,
|
||||
};
|
||||
|
||||
const config: ApophisConfig = {
|
||||
mode: 'verify',
|
||||
profile: 'llm-check',
|
||||
profiles: { 'llm-check': profile },
|
||||
presets: { 'llm-safe': preset },
|
||||
environments: { local: envLocal },
|
||||
};
|
||||
|
||||
const readmeContent = `
|
||||
# APOPHIS Setup — llm-safe preset
|
||||
|
||||
This project was scaffolded with \`apophis init --preset llm-safe\`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Ensure you have a Fastify app with @fastify/swagger registered.
|
||||
2. Add behavioral contracts to your route schemas using \`x-ensures\`.
|
||||
3. Run: apophis verify --profile llm-check
|
||||
|
||||
## What This Preset Does
|
||||
|
||||
- Ultra-minimal preset for LLM-generated codebases.
|
||||
- 3s timeout per route (fast feedback).
|
||||
- No observe, no qualify, no chaos — verify only.
|
||||
- Conservative defaults to avoid surprising failures.
|
||||
|
||||
## Example Behavioral Contract
|
||||
|
||||
Add this inside your route schema to check that a created resource is retrievable:
|
||||
|
||||
\`\`\`javascript
|
||||
"x-ensures": [
|
||||
"response_code(GET /users/{response_body(this).id}) == 200"
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
If \`apophis verify\` says "No behavioral contracts found", it means your routes have schemas but no \`x-ensures\` or \`x-requires\` clauses. Add at least one clause per route you want to verify.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Add routes to the \`routes\` array once you have behavioral contracts.
|
||||
- Run \`apophis doctor\` to check for missing dependencies.
|
||||
`;
|
||||
|
||||
return { config, readmeContent };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// protocol-lab: Multi-step flow and stateful testing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function protocolLabScaffold(): ScaffoldResult {
|
||||
const preset: PresetDefinition = {
|
||||
name: 'protocol-lab',
|
||||
depth: 'deep',
|
||||
timeout: 15000,
|
||||
parallel: false,
|
||||
chaos: true,
|
||||
observe: false,
|
||||
};
|
||||
|
||||
const profile: ProfileDefinition = {
|
||||
name: 'oauth-nightly',
|
||||
mode: 'qualify',
|
||||
preset: 'protocol-lab',
|
||||
routes: [],
|
||||
seed: 42,
|
||||
};
|
||||
|
||||
const envLocal: EnvironmentPolicy = {
|
||||
name: 'local',
|
||||
allowVerify: true,
|
||||
allowObserve: true,
|
||||
allowQualify: true,
|
||||
allowChaos: true,
|
||||
allowBlocking: true,
|
||||
requireSink: false,
|
||||
};
|
||||
|
||||
const envTest: EnvironmentPolicy = {
|
||||
name: 'test',
|
||||
allowVerify: true,
|
||||
allowObserve: true,
|
||||
allowQualify: true,
|
||||
allowChaos: true,
|
||||
allowBlocking: true,
|
||||
requireSink: false,
|
||||
};
|
||||
|
||||
const config: ApophisConfig = {
|
||||
mode: 'qualify',
|
||||
profile: 'oauth-nightly',
|
||||
profiles: { 'oauth-nightly': profile },
|
||||
presets: { 'protocol-lab': preset },
|
||||
environments: {
|
||||
local: envLocal,
|
||||
test: envTest,
|
||||
},
|
||||
};
|
||||
|
||||
const readmeContent = `
|
||||
# APOPHIS Setup — protocol-lab preset
|
||||
|
||||
This project was scaffolded with \`apophis init --preset protocol-lab\`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Ensure you have a Fastify app with @fastify/swagger registered.
|
||||
2. Define multi-step flows in your route schemas.
|
||||
3. Run: apophis qualify --profile oauth-nightly --seed 42
|
||||
|
||||
## What This Preset Does
|
||||
|
||||
- Enables qualify mode for stateful and scenario testing.
|
||||
- Chaos engineering enabled (local/test only).
|
||||
- Deep depth for thorough exploration.
|
||||
- 15s timeout per route.
|
||||
|
||||
## Safety
|
||||
|
||||
- Chaos is blocked in production by default.
|
||||
- Use \`apophis doctor\` to validate environment safety before qualifying.
|
||||
|
||||
## Machine Output in CI
|
||||
|
||||
Qualify can produce large output. In CI, use machine-readable formats and filter events:
|
||||
|
||||
- \`--format json\` emits a single stable JSON artifact (good for small-to-medium runs).
|
||||
- \`--format ndjson\` emits one event per line (good for streaming parsers).
|
||||
- Use \`--quiet\` to suppress human progress text.
|
||||
- Pipe ndjson to \`jq\` or a custom filter to extract only failures:
|
||||
\`\`\`bash
|
||||
apophis qualify --profile oauth-nightly --format ndjson | jq 'select(.type == "route.failed")'
|
||||
\`\`\`
|
||||
- For very large runs, consider writing artifacts to a directory and parsing the JSON file instead of stdout:
|
||||
\`\`\`bash
|
||||
apophis qualify --profile oauth-nightly --format json --artifact-dir reports/apophis
|
||||
\`\`\`
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Define scenario sequences in your config.
|
||||
- Add route allowlists for chaos if needed.
|
||||
- Run \`apophis replay --artifact <path>\` to debug failures.
|
||||
`;
|
||||
|
||||
return { config, readmeContent };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Preset registry
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const PRESETS: Record<string, () => ScaffoldResult> = {
|
||||
'safe-ci': safeCiScaffold,
|
||||
'platform-observe': platformObserveScaffold,
|
||||
'llm-safe': llmSafeScaffold,
|
||||
'protocol-lab': protocolLabScaffold,
|
||||
};
|
||||
|
||||
export function getPresetNames(): string[] {
|
||||
return Object.keys(PRESETS);
|
||||
}
|
||||
|
||||
export function getScaffoldForPreset(preset: string): ScaffoldResult | null {
|
||||
const fn = PRESETS[preset];
|
||||
return fn ? fn() : null;
|
||||
}
|
||||
Reference in New Issue
Block a user