import { spawn } from 'node:child_process'; import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { biomeTemplate, eslintTemplate, prChecklistTemplate, preCommitHookTemplate, pyprojectSection, rustfmtTemplate, } from './templates.js'; import type { RailsConfig, ScaffoldResult } from './types.js'; const PYPROJECT_START_MARKER = '# >>> mosaic-quality-rails >>>'; const PYPROJECT_END_MARKER = '# <<< mosaic-quality-rails <<<'; async function ensureDirectory(filePath: string): Promise { await mkdir(dirname(filePath), { recursive: true }); } async function writeRelativeFile( projectPath: string, relativePath: string, contents: string, result: ScaffoldResult, ): Promise { const absolutePath = join(projectPath, relativePath); await ensureDirectory(absolutePath); await writeFile(absolutePath, contents, { encoding: 'utf8', mode: 0o644 }); result.filesWritten.push(relativePath); } async function upsertPyproject( projectPath: string, profile: RailsConfig['profile'], result: ScaffoldResult, ): Promise { const pyprojectPath = join(projectPath, 'pyproject.toml'); const nextSection = pyprojectSection(profile); let previous = ''; try { previous = await readFile(pyprojectPath, 'utf8'); } catch { previous = ''; } const existingStart = previous.indexOf(PYPROJECT_START_MARKER); const existingEnd = previous.indexOf(PYPROJECT_END_MARKER); if (existingStart >= 0 && existingEnd > existingStart) { const before = previous.slice(0, existingStart).trimEnd(); const after = previous.slice(existingEnd + PYPROJECT_END_MARKER.length).trimStart(); const rebuilt = [before, nextSection.trim(), after] .filter((segment) => segment.length > 0) .join('\n\n'); await writeRelativeFile(projectPath, 'pyproject.toml', `${rebuilt}\n`, result); return; } const separator = previous.trim().length > 0 ? '\n\n' : ''; await writeRelativeFile(projectPath, 'pyproject.toml', `${previous.trimEnd()}${separator}${nextSection}`, result); } function runCommand(command: string, args: string[], cwd: string): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, stdio: 'ignore', env: process.env, }); child.on('error', reject); child.on('exit', (code) => { if (code === 0) { resolve(); return; } reject(new Error(`Command failed (${code ?? 'unknown'}): ${command} ${args.join(' ')}`)); }); }); } function buildNodeDevDependencies(config: RailsConfig): string[] { const dependencies = new Set(); if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) { dependencies.add('eslint'); dependencies.add('@typescript-eslint/parser'); dependencies.add('@typescript-eslint/eslint-plugin'); } if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) { dependencies.add('@biomejs/biome'); } if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) { dependencies.add('prettier'); } if (config.hooks) { dependencies.add('husky'); } return [...dependencies]; } async function installNodeDependencies(config: RailsConfig, result: ScaffoldResult): Promise { const dependencies = buildNodeDevDependencies(config); if (dependencies.length === 0) { return; } const commandLine = `pnpm add -D ${dependencies.join(' ')}`; if (process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL === '1') { result.commandsToRun.push(commandLine); return; } try { await runCommand('pnpm', ['add', '-D', ...dependencies], config.projectPath); } catch (error) { result.warnings.push( `Failed to auto-install Node dependencies: ${error instanceof Error ? error.message : String(error)}`, ); result.commandsToRun.push(commandLine); } } export async function scaffoldQualityRails(config: RailsConfig): Promise { const result: ScaffoldResult = { filesWritten: [], commandsToRun: [], warnings: [], }; const normalizedLinters = new Set(config.linters.map((linter) => linter.toLowerCase())); if (config.kind === 'node') { if (normalizedLinters.has('eslint')) { await writeRelativeFile( config.projectPath, '.eslintrc', eslintTemplate(config.profile), result, ); } if (normalizedLinters.has('biome')) { await writeRelativeFile( config.projectPath, 'biome.json', biomeTemplate(config.profile), result, ); } await installNodeDependencies(config, result); } if (config.kind === 'python') { await upsertPyproject(config.projectPath, config.profile, result); } if (config.kind === 'rust') { await writeRelativeFile( config.projectPath, 'rustfmt.toml', rustfmtTemplate(config.profile), result, ); } if (config.hooks) { await writeRelativeFile( config.projectPath, '.githooks/pre-commit', preCommitHookTemplate(config), result, ); await chmod(join(config.projectPath, '.githooks/pre-commit'), 0o755); result.commandsToRun.push('git config core.hooksPath .githooks'); } await writeRelativeFile( config.projectPath, 'PR-CHECKLIST.md', prChecklistTemplate(config.profile), result, ); if (config.kind === 'unknown') { result.warnings.push( 'Unable to detect project kind. Generated generic rails only (hooks + PR checklist).', ); } return result; }