#!/usr/bin/env bun import { render } from "ink"; import { App } from "./app.tsx"; import { loadConfig, createDefaultConfig, findConfigPath } from "./config/loader.ts"; import { initDb, closeDb } from "./db/index.ts"; import { COMMANDS, findCommand, validateFlags, renderHelp, allLongFlagNames, allShortFlagNames, allFlagsTakingValue, } from "./cli/registry.ts"; import type { GitforestConfig } from "./types/index.ts"; let isShuttingDown = false; async function gracefulShutdown(signal: string, exitCode: number): Promise { if (isShuttingDown) return; isShuttingDown = true; try { closeDb(); } catch (error) { console.error('Error during shutdown:', error); } process.exit(exitCode); } // Register signal handlers — clean exits on signals, non-zero on crashes so // callers/CI can distinguish "user quit" from "we crashed". process.on('SIGINT', () => gracefulShutdown('SIGINT', 0)); process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 0)); process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error); gracefulShutdown('uncaughtException', 1); }); process.on('unhandledRejection', (reason) => { console.error('Unhandled Rejection:', reason); gracefulShutdown('unhandledRejection', 1); }); /** * Parse argv into (command, flags, positional). Syntactic checks only: * unknown flags are rejected against the registry's union of declared * flags. Per-command flag validation runs after command lookup via * validateFlags() — that's where "--target on `list`" becomes an error. * * The special pre-command flag `--init` (create default config) is * accepted here without per-command validation since it bypasses the * registry entirely. */ function parseArgs(args: string[]): { command: string | null; flags: Record; positional: string[]; errors: string[]; } { const flags: Record = {}; const positional: string[] = []; const errors: string[] = []; let command: string | null = null; const knownLong = new Set([...allLongFlagNames(), "init"]); const shortMap = allShortFlagNames(); const valueFlags = allFlagsTakingValue(); for (let i = 0; i < args.length; i++) { const arg = args[i]!; if (arg.startsWith("--")) { const key = arg.slice(2); if (!knownLong.has(key)) { errors.push(`Unknown flag: --${key}`); continue; } if (valueFlags.has(key)) { const nextArg = args[i + 1]; if (nextArg && !nextArg.startsWith("-")) { flags[key] = nextArg; i++; } else { errors.push(`Flag --${key} requires a value`); } } else { flags[key] = true; } } else if (arg.startsWith("-") && arg.length === 2) { const shortKey = arg.slice(1); const longKey = shortMap.get(shortKey); if (!longKey) { errors.push(`Unknown flag: -${shortKey}`); continue; } if (valueFlags.has(longKey)) { const nextArg = args[i + 1]; if (nextArg && !nextArg.startsWith("-")) { flags[longKey] = nextArg; i++; } else { errors.push(`Flag -${shortKey} requires a value`); } } else { flags[longKey] = true; } } else if (!command) { command = arg; } else { positional.push(arg); } } return { command, flags, positional, errors }; } /** * Run the onboarding wizard for first-time users */ async function runOnboardingWizard(): Promise { const { OnboardingWizard } = await import("./components/onboarding/OnboardingWizard.tsx"); let completedConfig: GitforestConfig | null = null; let cancelled = false; const handleComplete = (config: GitforestConfig) => { completedConfig = config; }; const handleCancel = () => { cancelled = true; }; // Render onboarding wizard const instance = render( instance.unmount()} /> ); // Wait for completion or cancellation await instance.waitUntilExit(); if (cancelled) { console.log("\nOnboarding cancelled. Run 'gitforest --init' to create a default config."); process.exit(0); } if (completedConfig) { // Start main TUI app await initDb(); const { waitUntilExit: waitAppExit } = render(); await waitAppExit(); closeDb(); } } async function main() { const args = process.argv.slice(2); const { command, flags, positional, errors } = parseArgs(args); if (errors.length > 0) { for (const error of errors) { console.error(`Error: ${error}`); } console.log("\nRun 'gitforest --help' for usage information."); process.exit(1); } // --init creates a default config and exits — bypasses the registry. if (flags["init"]) { const existingConfig = findConfigPath(); if (existingConfig) { console.log(`Config already exists at: ${existingConfig}`); process.exit(1); } const configPath = await createDefaultConfig(); console.log(`Created default config at: ${configPath}`); console.log("\nEdit the config to add your project directories, then run 'gitforest' again."); process.exit(0); } // --help prints help and exits, with or without a command. if (flags["help"]) { console.log(renderHelp()); process.exit(0); } // First-time-run path: no config and no command → onboarding wizard. const configPath = findConfigPath(); if (!configPath && !command) { await runOnboardingWizard(); return; } try { const config = await loadConfig(); if (command) { const spec = findCommand(command); if (!spec) { console.error(`Unknown command: ${command}`); console.error("\nRun 'gitforest --help' for usage information."); process.exit(1); } const flagErrors = validateFlags(spec, flags); if (flagErrors.length > 0) { for (const error of flagErrors) { console.error(`Error: ${error}`); } process.exit(1); } await spec.run({ config, flags, positional }); process.exit(0); } // TUI mode — no command specified. await initDb(); const { waitUntilExit } = render(); await waitUntilExit(); closeDb(); } catch (error) { console.error("Error:", error instanceof Error ? error.message : error); if (error instanceof Error && error.message.includes("No config file found")) { console.log("\nRun 'gitforest' to start the onboarding wizard,"); console.log("or 'gitforest --init' to create a default config file."); } process.exit(1); } } // Tree-shaking guard: keep a reference to COMMANDS so the registry module is // not dropped by an aggressive bundler. The registry's run() callbacks are // what actually exercise the CLI handlers. void COMMANDS; main();