Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
207 lines
5.5 KiB
TypeScript
207 lines
5.5 KiB
TypeScript
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<void> {
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
async function writeRelativeFile(
|
|
projectPath: string,
|
|
relativePath: string,
|
|
contents: string,
|
|
result: ScaffoldResult,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string>();
|
|
|
|
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<void> {
|
|
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<ScaffoldResult> {
|
|
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;
|
|
}
|