/** * Init command for fn CLI. * * Initializes a new fn project in the current directory by: * 1. Creating the .fusion/ directory with fusion.db * 2. Registering the project in the central database * * Idempotent: if already initialized, reports success without recreating. */ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { join, resolve, basename } from "node:path"; import { exec } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); import { CentralCore, GitRepositoryInitializationError, QMD_INSTALL_COMMAND, isQmdAvailable, isValidSqliteDatabaseFile, readProjectIdentity, writeProjectIdentity, } from "@fusion/core"; import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js"; import { isGitRepo } from "./git.js"; import { installBundledFusionSkill, type SkillInstallResult, } from "./skill-installation.js"; /** Options for the init command */ export interface InitOptions { /** Override the auto-detected project name */ name?: string; /** Path to initialize (defaults to cwd) */ path?: string; /** Initialize a git repository if one does not exist */ git?: boolean; } /** * Run the init command. * * @param options - Optional configuration for init * @returns Promise that resolves when initialization is complete */ export async function runInit(options: InitOptions = {}): Promise { const cwd = options.path ? resolve(options.path) : process.cwd(); const fusionDir = join(cwd, ".fusion"); const dbPath = join(fusionDir, "fusion.db"); const hasDbPath = existsSync(dbPath); const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath); // Check if already initialized if (existsSync(fusionDir) && hasDbPath && hasValidDb) { // Check if registered in central DB const central = new CentralCore(); await central.init(); const existing = await central.getProjectByPath(cwd); if (existing) { try { writeProjectIdentity(join(cwd, ".fusion"), { id: existing.id, createdAt: existing.createdAt, }); } catch { // Best-effort backfill only. } console.log(`✓ fn project already initialized: "${existing.name}"`); console.log(` Path: ${cwd}`); console.log(`\n Project is registered in the central registry.`); console.log(` To re-initialize with a different name, run:`); console.log(` fn project remove ${existing.name}`); console.log(` fn init --name `); await central.close(); return; } // Has .fusion/ but not registered - offer to register const projectName = options.name ?? await detectProjectName(cwd); console.log(`⚠ Project directory exists but not registered.`); console.log(` Run: fn project add ${projectName} ${cwd}`); console.log(` Or: rm -rf ${fusionDir} && fn init`); await central.close(); return; } if (existsSync(fusionDir) && hasDbPath && !hasValidDb) { throw new Error( `Existing database at ${dbPath} is not a valid SQLite database. ` + "Restore it from .fusion/backups or move it aside before re-running fn init.", ); } // Get or generate project name const projectName = options.name ?? await detectProjectName(cwd); console.log(`Initializing fn project: "${projectName}"`); console.log(` Path: ${cwd}`); // Create .fusion/ directory if (!existsSync(fusionDir)) { mkdirSync(fusionDir, { recursive: true }); console.log(` ✓ Created .fusion/ directory`); } if (options.git && !(await isGitRepo(cwd))) { await initializeGitRepo(cwd); console.log(` ✓ Initialized git repository`); } // Add local Fusion/Pi storage directories to .gitignore await addLocalStorageToGitignore(cwd); await warnIfQmdMissing(); // Create fusion.db (empty SQLite file) if (!existsSync(dbPath)) { // A zero-byte bootstrap file is a valid SQLite starting point. writeFileSync(dbPath, ""); console.log(` ✓ Created fusion.db`); } const bundledSkillInstall = installBundledFusionSkill(); logBundledSkillInstallResults(bundledSkillInstall.results); // Register in central database const central = new CentralCore(); await central.init(); try { // Check if already registered const existing = await central.getProjectByPath(cwd); if (existing) { console.log(` ✓ Already registered in central database`); maybeInstallClaudeSkillForNewProject(cwd); console.log(`\n✓ Project "${projectName}" is ready!`); console.log(`\n Next steps:`); console.log(` fn task list # View tasks`); console.log(` fn task create # Create a task`); console.log(` fn dashboard # Open the web UI`); await central.close(); return; } const identity = existsSync(dbPath) ? readProjectIdentity(fusionDir) : null; const ensured = await central.ensureProjectForPath({ path: cwd, identity: identity ?? undefined, name: projectName, }); const project = ensured.project; // Activate the project (registration sets it to 'initializing') await central.updateProject(project.id, { status: "active" }); try { writeProjectIdentity(join(cwd, ".fusion"), { id: project.id, createdAt: project.createdAt, }); } catch (identityError) { console.warn(` ⚠ Could not persist project identity: ${identityError instanceof Error ? identityError.message : String(identityError)}`); } maybeInstallClaudeSkillForNewProject(cwd); if (ensured.gitRepository === "initialized") { console.log(` ✓ Initialized git repository`); } console.log(` ✓ Registered in central database`); console.log(`\n✓ Project "${project.name}" initialized successfully!`); console.log(`\n Next steps:`); console.log(` fn task list # View tasks`); console.log(` fn task create # Create a task`); console.log(` fn dashboard # Open the web UI`); await central.close(); } catch (err) { if (err instanceof GitRepositoryInitializationError) { await central.close(); throw err; } // If central DB registration fails, still report success since local files are created console.log(` ⚠ Could not register in central database: ${(err as Error).message}`); console.log(`\n✓ Project initialized locally (central registration can be done later)`); console.log(`\n To register later, run:`); console.log(` fn project add ${projectName} ${cwd}`); await central.close(); } } /** * Detect a project name from git remote or directory name. */ async function detectProjectName(dir: string): Promise { // Fast-path for non-git directories to avoid spawning git unnecessarily. // (This also prevents occasional command stalls in constrained CI envs.) if (!existsSync(join(dir, ".git"))) { return basename(dir) || "my-project"; } // Try git remote first try { const { stdout: remoteUrl } = await execAsync("git remote get-url origin", { cwd: dir, timeout: 10_000, }); const trimmed = remoteUrl.trim(); if (trimmed) { // Extract repo name from URL // Handles: https://github.com/user/repo.git, git@github.com:user/repo.git const match = trimmed.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/); if (match) { return match[2]; } } } catch { // Not a git repo or no origin remote } // Fallback to directory name return basename(dir) || "my-project"; } /** * Add local Fusion/Pi storage directories to .gitignore if not already present. * Idempotent: only adds missing entries. */ async function addLocalStorageToGitignore(cwd: string): Promise { const gitignorePath = join(cwd, ".gitignore"); let content = ""; if (existsSync(gitignorePath)) { try { content = readFileSync(gitignorePath, "utf-8"); } catch { // Best-effort: if we can't read, treat as empty } } const lines = content.split(/\r?\n/); const existingEntries = new Set(lines.map((line) => line.trim())); const missingEntries = [".fusion", ".pi", "fusion.db", "fusion.db-wal", "fusion.db-shm"] .filter((entry) => !existingEntries.has(entry)); if (missingEntries.length === 0) { return; } const prefix = content.length === 0 || content.endsWith("\n") ? "" : "\n"; const newContent = `${content}${prefix}${missingEntries.join("\n")}\n`; try { writeFileSync(gitignorePath, newContent); console.log(` ✓ Updated .gitignore (added: ${missingEntries.join(", ")})`); } catch { // Best-effort: don't fail init if we can't write to .gitignore console.log(` ⚠ Could not update .gitignore (best-effort)`); } } async function initializeGitRepo(cwd: string): Promise { await execAsync("git init", { cwd, timeout: 10_000 }); try { const { stdout } = await execAsync("git symbolic-ref --quiet --short HEAD", { cwd, timeout: 10_000, }); if (stdout.trim() !== "main") { await execAsync("git checkout -b main", { cwd, timeout: 10_000 }); } } catch { // Older git versions or detached/unborn states may fail symbolic-ref. // Best-effort: create/switch to main. try { await execAsync("git checkout -b main", { cwd, timeout: 10_000 }); } catch { await execAsync("git checkout main", { cwd, timeout: 10_000 }); } } await ensureGitConfig(cwd, "user.name", "Fusion"); await ensureGitConfig(cwd, "user.email", "noreply@runfusion.ai"); const gitkeepPath = join(cwd, ".gitkeep"); if (!existsSync(gitkeepPath)) { writeFileSync(gitkeepPath, "\n"); } await execAsync("git add .gitkeep", { cwd, timeout: 10_000 }); await execAsync('git commit --allow-empty -m "chore: initial commit"', { cwd, timeout: 10_000, }); } async function ensureGitConfig(cwd: string, key: string, value: string): Promise { try { const { stdout } = await execAsync(`git config --get ${key}`, { cwd, timeout: 10_000 }); if (stdout.trim().length > 0) { return; } } catch { // Missing config; set a local default. } await execAsync(`git config ${key} "${value}"`, { cwd, timeout: 10_000 }); } async function warnIfQmdMissing(): Promise { if (await isQmdAvailable()) { console.log(` ✓ qmd available for memory search`); return; } console.log(` ⚠ qmd not found; memory search will use local file fallback`); console.log(` Install qmd for indexed retrieval: ${QMD_INSTALL_COMMAND}`); } function logBundledSkillInstallResults(results: SkillInstallResult[]): void { for (const result of results) { const clientLabel = result.client[0].toUpperCase() + result.client.slice(1); if (result.outcome === "installed") { console.log(` ✓ Installed bundled Fusion skill for ${clientLabel}: ${result.targetDir}`); continue; } if (result.outcome === "skipped") { console.log(` ✓ Existing ${clientLabel} Fusion skill preserved: ${result.targetDir}`); continue; } console.warn( ` ⚠ Could not install bundled Fusion skill for ${clientLabel}: ${result.reason ?? "unknown error"}`, ); } }