refactor: CI optimization, logging improvements, and codebase formatting (#456)

* fix(db): remove unique constraint on folder to support multi-channel agents

* ci: implement automated skill drift detection and self-healing PRs

* fix: align registration logic with Gavriel's feedback and fix build/test issues from Daniel Mi

* style: conform to prettier standards for CI validation

* test: fix branch naming inconsistency in CI (master vs main)

* fix(ci): robust module resolution by removing file extensions in scripts

* refactor(ci): simplify skill validation by removing redundant combination tests

* style: conform skills-engine to prettier, unify logging in index.ts and cleanup unused imports

* refactor: extract multi-channel DB changes to separate branch

Move channel column, folder suffix logic, and related migrations
to feat/multi-channel-db-v2 for independent review. This PR now
contains only CI/CD optimizations, Prettier formatting, and
logging improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-02-25 23:13:36 +02:00
committed by GitHub
parent bd2e236f73
commit 11c201088b
76 changed files with 2333 additions and 1308 deletions

View File

@@ -1,8 +1,18 @@
import { applySkill } from '../skills-engine/apply.js';
import { initNanoclawDir } from '../skills-engine/init.js';
const skillDir = process.argv[2];
const args = process.argv.slice(2);
// Handle --init flag: initialize .nanoclaw/ directory and exit
if (args.includes('--init')) {
initNanoclawDir();
console.log(JSON.stringify({ success: true, action: 'init' }));
process.exit(0);
}
const skillDir = args[0];
if (!skillDir) {
console.error('Usage: tsx scripts/apply-skill.ts <skill-dir>');
console.error('Usage: tsx scripts/apply-skill.ts [--init] <skill-dir>');
process.exit(1);
}

266
scripts/fix-skill-drift.ts Normal file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env npx tsx
/**
* Auto-fix drifted skills by three-way merging their modify/ files.
*
* For each drifted skill's `modifies` entry:
* 1. Find the commit where the skill's modify/ copy was last updated
* 2. Retrieve the source file at that commit (old base)
* 3. git merge-file <modify/file> <old_base> <current_main>
* - Clean merge → modify/ file is auto-updated
* - Conflicts → conflict markers left in place for human/Claude review
*
* The calling workflow should commit the resulting changes and create a PR.
*
* Sets GitHub Actions outputs:
* has_conflicts — "true" | "false"
* fixed_count — number of auto-fixed files
* conflict_count — number of files with unresolved conflict markers
* summary — human-readable summary for PR body
*
* Usage: npx tsx scripts/fix-skill-drift.ts add-telegram add-discord
*/
import { execFileSync, execSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { parse } from 'yaml';
import type { SkillManifest } from '../skills-engine/types.js';
interface FixResult {
skill: string;
file: string;
status: 'auto-fixed' | 'conflict' | 'skipped' | 'error';
conflicts?: number;
reason?: string;
}
function readManifest(skillDir: string): SkillManifest {
const manifestPath = path.join(skillDir, 'manifest.yaml');
return parse(fs.readFileSync(manifestPath, 'utf-8')) as SkillManifest;
}
function fixSkill(skillName: string, projectRoot: string): FixResult[] {
const skillDir = path.join(projectRoot, '.claude', 'skills', skillName);
const manifest = readManifest(skillDir);
const results: FixResult[] = [];
for (const relPath of manifest.modifies) {
const modifyPath = path.join(skillDir, 'modify', relPath);
const currentPath = path.join(projectRoot, relPath);
if (!fs.existsSync(modifyPath)) {
results.push({
skill: skillName,
file: relPath,
status: 'skipped',
reason: 'modify/ file not found',
});
continue;
}
if (!fs.existsSync(currentPath)) {
results.push({
skill: skillName,
file: relPath,
status: 'skipped',
reason: 'source file not found on main',
});
continue;
}
// Find when the skill's modify file was last changed
let lastCommit: string;
try {
lastCommit = execSync(`git log -1 --format=%H -- "${modifyPath}"`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
results.push({
skill: skillName,
file: relPath,
status: 'skipped',
reason: 'no git history for modify file',
});
continue;
}
if (!lastCommit) {
results.push({
skill: skillName,
file: relPath,
status: 'skipped',
reason: 'no commits found for modify file',
});
continue;
}
// Get the source file at that commit (the old base the skill was written against)
const tmpOldBase = path.join(
os.tmpdir(),
`nanoclaw-drift-base-${crypto.randomUUID()}`,
);
try {
const oldBase = execSync(`git show "${lastCommit}:${relPath}"`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
fs.writeFileSync(tmpOldBase, oldBase);
} catch {
results.push({
skill: skillName,
file: relPath,
status: 'skipped',
reason: `source file not found at commit ${lastCommit.slice(0, 7)}`,
});
continue;
}
// If old base == current main, the source hasn't changed since the skill was updated.
// The skill is already in sync for this file.
const currentContent = fs.readFileSync(currentPath, 'utf-8');
const oldBaseContent = fs.readFileSync(tmpOldBase, 'utf-8');
if (oldBaseContent === currentContent) {
fs.unlinkSync(tmpOldBase);
results.push({
skill: skillName,
file: relPath,
status: 'skipped',
reason: 'source unchanged since skill update',
});
continue;
}
// Three-way merge: modify/file ← old_base → current_main
// git merge-file modifies first argument in-place
try {
execFileSync('git', ['merge-file', modifyPath, tmpOldBase, currentPath], {
stdio: 'pipe',
});
results.push({ skill: skillName, file: relPath, status: 'auto-fixed' });
} catch (err: any) {
const exitCode = err.status ?? -1;
if (exitCode > 0) {
// Positive exit code = number of conflicts, file has markers
results.push({
skill: skillName,
file: relPath,
status: 'conflict',
conflicts: exitCode,
});
} else {
results.push({
skill: skillName,
file: relPath,
status: 'error',
reason: err.message,
});
}
} finally {
try {
fs.unlinkSync(tmpOldBase);
} catch {
/* ignore */
}
}
}
return results;
}
function setOutput(key: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) return;
if (value.includes('\n')) {
const delimiter = `ghadelim_${Date.now()}`;
fs.appendFileSync(
outputFile,
`${key}<<${delimiter}\n${value}\n${delimiter}\n`,
);
} else {
fs.appendFileSync(outputFile, `${key}=${value}\n`);
}
}
async function main(): Promise<void> {
const projectRoot = process.cwd();
const skillNames = process.argv.slice(2);
if (skillNames.length === 0) {
console.error(
'Usage: npx tsx scripts/fix-skill-drift.ts <skill1> [skill2] ...',
);
process.exit(1);
}
console.log(`Attempting auto-fix for: ${skillNames.join(', ')}\n`);
const allResults: FixResult[] = [];
for (const skillName of skillNames) {
console.log(`--- ${skillName} ---`);
const results = fixSkill(skillName, projectRoot);
allResults.push(...results);
for (const r of results) {
const icon =
r.status === 'auto-fixed'
? 'FIXED'
: r.status === 'conflict'
? `CONFLICT (${r.conflicts})`
: r.status === 'skipped'
? 'SKIP'
: 'ERROR';
const detail = r.reason ? ` -- ${r.reason}` : '';
console.log(` ${icon} ${r.file}${detail}`);
}
}
// Summary
const fixed = allResults.filter((r) => r.status === 'auto-fixed');
const conflicts = allResults.filter((r) => r.status === 'conflict');
const skipped = allResults.filter((r) => r.status === 'skipped');
console.log('\n=== Summary ===');
console.log(` Auto-fixed: ${fixed.length}`);
console.log(` Conflicts: ${conflicts.length}`);
console.log(` Skipped: ${skipped.length}`);
// Build markdown summary for PR body
const summaryLines: string[] = [];
for (const skillName of skillNames) {
const skillResults = allResults.filter((r) => r.skill === skillName);
const fixedFiles = skillResults.filter((r) => r.status === 'auto-fixed');
const conflictFiles = skillResults.filter((r) => r.status === 'conflict');
summaryLines.push(`### ${skillName}`);
if (fixedFiles.length > 0) {
summaryLines.push(
`Auto-fixed: ${fixedFiles.map((r) => `\`${r.file}\``).join(', ')}`,
);
}
if (conflictFiles.length > 0) {
summaryLines.push(
`Needs manual resolution: ${conflictFiles.map((r) => `\`${r.file}\``).join(', ')}`,
);
}
if (fixedFiles.length === 0 && conflictFiles.length === 0) {
summaryLines.push('No modify/ files needed updating.');
}
summaryLines.push('');
}
// GitHub outputs
setOutput('has_conflicts', conflicts.length > 0 ? 'true' : 'false');
setOutput('fixed_count', String(fixed.length));
setOutput('conflict_count', String(conflicts.length));
setOutput('summary', summaryLines.join('\n'));
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -1,118 +0,0 @@
#!/usr/bin/env npx tsx
import fs from 'fs';
import path from 'path';
import { parse } from 'yaml';
import { SkillManifest } from '../skills-engine/types.js';
export interface MatrixEntry {
skills: string[];
reason: string;
}
export interface SkillOverlapInfo {
name: string;
modifies: string[];
npmDependencies: string[];
}
/**
* Extract overlap-relevant info from a parsed manifest.
* @param dirName - The skill's directory name (e.g. 'add-discord'), used in matrix
* entries so CI/scripts can locate the skill package on disk.
*/
export function extractOverlapInfo(manifest: SkillManifest, dirName: string): SkillOverlapInfo {
const npmDeps = manifest.structured?.npm_dependencies
? Object.keys(manifest.structured.npm_dependencies)
: [];
return {
name: dirName,
modifies: manifest.modifies ?? [],
npmDependencies: npmDeps,
};
}
/**
* Compute overlap matrix from a list of skill overlap infos.
* Two skills overlap if they share any `modifies` entry or both declare
* `structured.npm_dependencies` for the same package.
*/
export function computeOverlapMatrix(skills: SkillOverlapInfo[]): MatrixEntry[] {
const entries: MatrixEntry[] = [];
for (let i = 0; i < skills.length; i++) {
for (let j = i + 1; j < skills.length; j++) {
const a = skills[i];
const b = skills[j];
const reasons: string[] = [];
// Check shared modifies entries
const sharedModifies = a.modifies.filter((m) => b.modifies.includes(m));
if (sharedModifies.length > 0) {
reasons.push(`shared modifies: ${sharedModifies.join(', ')}`);
}
// Check shared npm_dependencies packages
const sharedNpm = a.npmDependencies.filter((pkg) =>
b.npmDependencies.includes(pkg),
);
if (sharedNpm.length > 0) {
reasons.push(`shared npm packages: ${sharedNpm.join(', ')}`);
}
if (reasons.length > 0) {
entries.push({
skills: [a.name, b.name],
reason: reasons.join('; '),
});
}
}
}
return entries;
}
/**
* Read all skill manifests from a skills directory (e.g. .claude/skills/).
* Each subdirectory should contain a manifest.yaml.
* Returns both the parsed manifest and the directory name.
*/
export function readAllManifests(skillsDir: string): { manifest: SkillManifest; dirName: string }[] {
if (!fs.existsSync(skillsDir)) {
return [];
}
const results: { manifest: SkillManifest; dirName: string }[] = [];
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
if (!fs.existsSync(manifestPath)) continue;
const content = fs.readFileSync(manifestPath, 'utf-8');
const manifest = parse(content) as SkillManifest;
results.push({ manifest, dirName: entry.name });
}
return results;
}
/**
* Generate the full CI matrix from a skills directory.
*/
export function generateMatrix(skillsDir: string): MatrixEntry[] {
const entries = readAllManifests(skillsDir);
const overlapInfos = entries.map((e) => extractOverlapInfo(e.manifest, e.dirName));
return computeOverlapMatrix(overlapInfos);
}
// --- Main ---
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.url.replace('file://', ''))) {
const projectRoot = process.cwd();
const skillsDir = path.join(projectRoot, '.claude', 'skills');
const matrix = generateMatrix(skillsDir);
console.log(JSON.stringify(matrix, null, 2));
}

View File

@@ -1,144 +0,0 @@
#!/usr/bin/env npx tsx
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { generateMatrix, MatrixEntry } from './generate-ci-matrix.js';
interface TestResult {
entry: MatrixEntry;
passed: boolean;
error?: string;
}
function copyDirRecursive(src: string, dest: string, exclude: string[] = []): void {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (exclude.includes(entry.name)) continue;
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath, exclude);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
async function runMatrixEntry(
projectRoot: string,
entry: MatrixEntry,
): Promise<TestResult> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-ci-'));
try {
// Copy project to temp dir (exclude heavy/irrelevant dirs)
copyDirRecursive(projectRoot, tmpDir, [
'node_modules',
'.git',
'dist',
'data',
'store',
'logs',
'.nanoclaw',
]);
// Install dependencies
execSync('npm install --ignore-scripts', {
cwd: tmpDir,
stdio: 'pipe',
timeout: 120_000,
});
// Initialize nanoclaw dir
execSync('npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index.js\'; initNanoclawDir();"', {
cwd: tmpDir,
stdio: 'pipe',
timeout: 30_000,
});
// Apply each skill in sequence
for (const skillName of entry.skills) {
const skillDir = path.join(tmpDir, '.claude', 'skills', skillName);
if (!fs.existsSync(skillDir)) {
return {
entry,
passed: false,
error: `Skill directory not found: ${skillName}`,
};
}
const result = execSync(
`npx tsx scripts/apply-skill.ts "${skillDir}"`,
{ cwd: tmpDir, stdio: 'pipe', timeout: 120_000 },
);
const parsed = JSON.parse(result.toString());
if (!parsed.success) {
return {
entry,
passed: false,
error: `Failed to apply skill ${skillName}: ${parsed.error}`,
};
}
}
// Run all skill tests
execSync('npx vitest run --config vitest.skills.config.ts', {
cwd: tmpDir,
stdio: 'pipe',
timeout: 300_000,
});
return { entry, passed: true };
} catch (err: any) {
return {
entry,
passed: false,
error: err.message || String(err),
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
// --- Main ---
async function main(): Promise<void> {
const projectRoot = process.cwd();
const skillsDir = path.join(projectRoot, '.claude', 'skills');
const matrix = generateMatrix(skillsDir);
if (matrix.length === 0) {
console.log('No overlapping skills found. Nothing to test.');
process.exit(0);
}
console.log(`Found ${matrix.length} overlapping skill combination(s):\n`);
for (const entry of matrix) {
console.log(` [${entry.skills.join(', ')}] — ${entry.reason}`);
}
console.log('');
const results: TestResult[] = [];
for (const entry of matrix) {
console.log(`Testing: [${entry.skills.join(', ')}]...`);
const result = await runMatrixEntry(projectRoot, entry);
results.push(result);
console.log(` ${result.passed ? 'PASS' : 'FAIL'}${result.error ? `${result.error}` : ''}`);
}
console.log('\n--- Summary ---');
const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length;
console.log(`${passed} passed, ${failed} failed out of ${results.length} combination(s)`);
if (failed > 0) {
process.exit(1);
}
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -43,9 +43,7 @@ const results: MigrationResult[] = [];
const migrationsDir = path.join(newCorePath, 'migrations');
if (!fs.existsSync(migrationsDir)) {
console.log(
JSON.stringify({ migrationsRun: 0, results: [] }, null, 2),
);
console.log(JSON.stringify({ migrationsRun: 0, results: [] }, null, 2));
process.exit(0);
}
@@ -84,18 +82,13 @@ for (const version of migrationVersions) {
});
results.push({ version, success: true });
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
const message = err instanceof Error ? err.message : String(err);
results.push({ version, success: false, error: message });
}
}
console.log(
JSON.stringify(
{ migrationsRun: results.length, results },
null,
2,
),
JSON.stringify({ migrationsRun: results.length, results }, null, 2),
);
// Exit with error if any migration failed

View File

@@ -13,7 +13,9 @@ async function main() {
if (result.customPatchWarning) {
console.warn(`\nWarning: ${result.customPatchWarning}`);
console.warn('To proceed, remove the custom_patch from state.yaml and re-run.');
console.warn(
'To proceed, remove the custom_patch from state.yaml and re-run.',
);
process.exit(1);
}

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env npx tsx
/**
* Validate all skills by applying each in isolation against current main.
*
* For each skill:
* 1. Reset working tree to clean state
* 2. Initialize .nanoclaw/ (snapshot current source as base)
* 3. Apply skill via apply-skill.ts
* 4. Run tsc --noEmit (typecheck)
* 5. Run the skill's test command (from manifest.yaml)
*
* Sets GitHub Actions outputs:
* drifted — "true" | "false"
* drifted_skills — JSON array of drifted skill names, e.g. ["add-telegram"]
* results — JSON array of per-skill results
*
* Exit code 1 if any skill drifted, 0 otherwise.
*
* Usage:
* npx tsx scripts/validate-all-skills.ts # validate all
* npx tsx scripts/validate-all-skills.ts add-telegram # validate one
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { parse } from 'yaml';
import type { SkillManifest } from '../skills-engine/types.js';
interface SkillValidationResult {
name: string;
success: boolean;
failedStep?: 'apply' | 'typecheck' | 'test';
error?: string;
}
function discoverSkills(
skillsDir: string,
): { name: string; dir: string; manifest: SkillManifest }[] {
if (!fs.existsSync(skillsDir)) return [];
const results: { name: string; dir: string; manifest: SkillManifest }[] = [];
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
if (!fs.existsSync(manifestPath)) continue;
const manifest = parse(
fs.readFileSync(manifestPath, 'utf-8'),
) as SkillManifest;
results.push({
name: entry.name,
dir: path.join(skillsDir, entry.name),
manifest,
});
}
return results;
}
/** Restore tracked files and remove untracked skill artifacts. */
function resetWorkingTree(): void {
execSync('git checkout -- .', { stdio: 'pipe' });
// Remove untracked files added by skill application (e.g. src/channels/telegram.ts)
// but preserve node_modules to avoid costly reinstalls.
execSync('git clean -fd --exclude=node_modules', { stdio: 'pipe' });
// Clean skills-system state directory
if (fs.existsSync('.nanoclaw')) {
fs.rmSync('.nanoclaw', { recursive: true, force: true });
}
}
function initNanoclaw(): void {
execSync(
'npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index\'; initNanoclawDir();"',
{ stdio: 'pipe', timeout: 30_000 },
);
}
/** Append a key=value to $GITHUB_OUTPUT (no-op locally). */
function setOutput(key: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) return;
if (value.includes('\n')) {
const delimiter = `ghadelim_${Date.now()}`;
fs.appendFileSync(
outputFile,
`${key}<<${delimiter}\n${value}\n${delimiter}\n`,
);
} else {
fs.appendFileSync(outputFile, `${key}=${value}\n`);
}
}
function truncate(s: string, max = 300): string {
return s.length > max ? s.slice(0, max) + '...' : s;
}
async function main(): Promise<void> {
const projectRoot = process.cwd();
const skillsDir = path.join(projectRoot, '.claude', 'skills');
// Allow filtering to specific skills via CLI args
const filterSkills = process.argv.slice(2);
let skills = discoverSkills(skillsDir);
if (filterSkills.length > 0) {
skills = skills.filter((s) => filterSkills.includes(s.name));
}
if (skills.length === 0) {
console.log('No skills found to validate.');
setOutput('drifted', 'false');
setOutput('drifted_skills', '[]');
setOutput('results', '[]');
process.exit(0);
}
console.log(
`Validating ${skills.length} skill(s): ${skills.map((s) => s.name).join(', ')}\n`,
);
const results: SkillValidationResult[] = [];
for (const skill of skills) {
console.log(`--- ${skill.name} ---`);
// Clean slate
resetWorkingTree();
initNanoclaw();
// Step 1: Apply skill
try {
const applyOutput = execSync(
`npx tsx scripts/apply-skill.ts "${skill.dir}"`,
{
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 120_000,
},
);
// parse stdout to verify success
try {
const parsed = JSON.parse(applyOutput);
if (!parsed.success) {
console.log(` FAIL (apply): ${truncate(parsed.error || 'unknown')}`);
results.push({
name: skill.name,
success: false,
failedStep: 'apply',
error: parsed.error,
});
continue;
}
} catch {
// Non-JSON stdout with exit 0 is treated as success
}
} catch (err: any) {
const stderr = err.stderr?.toString() || '';
const stdout = err.stdout?.toString() || '';
let error = 'Apply failed';
try {
const parsed = JSON.parse(stdout);
error = parsed.error || error;
} catch {
error = stderr || stdout || err.message;
}
console.log(` FAIL (apply): ${truncate(error)}`);
results.push({
name: skill.name,
success: false,
failedStep: 'apply',
error,
});
continue;
}
console.log(' apply: OK');
// Step 2: Typecheck
try {
execSync('npx tsc --noEmit', {
stdio: 'pipe',
timeout: 120_000,
});
} catch (err: any) {
const error = err.stdout?.toString() || err.message;
console.log(` FAIL (typecheck): ${truncate(error)}`);
results.push({
name: skill.name,
success: false,
failedStep: 'typecheck',
error,
});
continue;
}
console.log(' typecheck: OK');
// Step 3: Skill's own test command
if (skill.manifest.test) {
try {
execSync(skill.manifest.test, {
stdio: 'pipe',
timeout: 300_000,
});
} catch (err: any) {
const error =
err.stdout?.toString() || err.stderr?.toString() || err.message;
console.log(` FAIL (test): ${truncate(error)}`);
results.push({
name: skill.name,
success: false,
failedStep: 'test',
error,
});
continue;
}
console.log(' test: OK');
}
console.log(' PASS');
results.push({ name: skill.name, success: true });
}
// Restore clean state
resetWorkingTree();
// Summary
const drifted = results.filter((r) => !r.success);
const passed = results.filter((r) => r.success);
console.log('\n=== Summary ===');
for (const r of results) {
const status = r.success ? 'PASS' : 'FAIL';
const detail = r.failedStep ? ` (${r.failedStep})` : '';
console.log(` ${status} ${r.name}${detail}`);
}
console.log(`\n${passed.length} passed, ${drifted.length} failed`);
// GitHub Actions outputs
setOutput('drifted', drifted.length > 0 ? 'true' : 'false');
setOutput('drifted_skills', JSON.stringify(drifted.map((d) => d.name)));
setOutput('results', JSON.stringify(results));
if (drifted.length > 0) {
process.exit(1);
}
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});