/** * Migration and First-Run Experience * * Handles the transition from single-project to multi-project mode: * - Detects first-run state (fresh install, needs migration, setup wizard, normal) * - Auto-discovers existing .kb/ directories for migration * - Coordinates migration to central database * - Provides backward compatibility for single-project workflows * * @module migration */ import { existsSync, statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, resolve, basename, dirname } from "node:path"; import type { CentralCore } from "./central-core.js"; import { CentralCore as CentralCoreClass } from "./central-core.js"; /** * Check whether `//` exists as a non-empty regular file. * Used to decide if a directory contains either a legacy (.kb/kb.db) or * current (.fusion/fusion.db) project database. */ function hasProjectDbFile(dir: string, folderName: string, dbName: string): boolean { const projectDir = join(dir, folderName); const dbPath = join(projectDir, dbName); if (!existsSync(projectDir)) return false; if (!existsSync(dbPath)) return false; try { const stat = statSync(dbPath); return stat.isFile() && stat.size > 0; } catch { return false; } } // ── Types ──────────────────────────────────────────────────────────── /** First-run state detection results */ export type FirstRunState = | "fresh-install" // No central DB, no .kb/ anywhere | "needs-migration" // No central DB, but .kb/kb.db exists in cwd | "setup-wizard" // Central DB exists but has zero projects | "normal-operation"; // Central DB exists with projects /** Detected project for migration consideration */ export interface DetectedProject { /** Absolute path to project directory */ path: string; /** Auto-generated or derived project name */ name: string; /** Whether the project has a valid kb.db */ hasDb: boolean; } /** Result of a migration operation */ export interface MigrationResult { /** Whether the migration succeeded */ success: boolean; /** IDs of projects that were registered */ projectsRegistered: string[]; /** Error messages for any failures */ errors: string[]; } /** Input for setting up a project via the wizard */ export interface ProjectSetupInput { /** Project path */ path: string; /** Display name */ name: string; /** Isolation mode preference */ isolationMode?: "in-process" | "child-process"; } /** Resolved project context for backward compatibility */ export interface ResolvedContext { /** Project ID in central registry */ projectId: string; /** Absolute path to project working directory */ workingDirectory: string; /** Whether running in legacy mode (no central DB) */ isLegacy: boolean; } /** Error thrown when project selection is required but not provided */ export class ProjectRequiredError extends Error { constructor( message: string, public readonly availableProjects: Array<{ id: string; name: string }> ) { super(message); this.name = "ProjectRequiredError"; } } // ── FirstRunDetector ───────────────────────────────────────────────── /** * Detects the first-run state and existing projects for migration. * * This class determines which startup path to take: * - Fresh install → Show setup wizard * - Existing single project → Auto-migrate * - Already migrated → Normal operation */ export class FirstRunDetector { private readonly globalDir: string; /** * Create a FirstRunDetector. * @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`. */ constructor(globalDir?: string) { this.globalDir = globalDir ?? this.getDefaultGlobalDir(); } /** * Detect the current first-run state. * * Returns one of four states: * - `"fresh-install"` — No central DB, no local `.kb/` found * - `"needs-migration"` — No central DB, but `.kb/kb.db` exists in cwd * - `"setup-wizard"` — Central DB exists but has zero projects * - `"normal-operation"` — Central DB exists with one or more projects * * @param existingCentral — Optional existing CentralCore instance to use instead of creating a new one */ async detectFirstRunState(existingCentral?: CentralCore): Promise { const hasCentral = this.hasCentralDb(); if (!hasCentral) { // No central DB - check for local project in cwd or parent directories const cwd = process.cwd(); const detected = await this.detectExistingProjects(cwd); return detected.length > 0 ? "needs-migration" : "fresh-install"; } // Central DB exists - check if it has projects let central: CentralCore | undefined = existingCentral; let shouldClose = false; if (!central) { try { central = new CentralCoreClass(this.globalDir); await central.init(); shouldClose = true; } catch { // Central DB exists but is unreadable — fall back to local detection const cwd = process.cwd(); const detected = await this.detectExistingProjects(cwd); return detected.length > 0 ? "needs-migration" : "fresh-install"; } } try { const projects = await central.listProjects(); return projects.length === 0 ? "setup-wizard" : "normal-operation"; } catch { // Central DB exists but is unreadable - treat as setup wizard return "setup-wizard"; } finally { if (shouldClose && central) { await central.close(); } } } /** * Check if the central database exists. */ hasCentralDb(): boolean { const centralDbPath = join(this.globalDir, "fusion-central.db"); return existsSync(centralDbPath); } /** * Get the path to the central database. */ getCentralDbPath(): string { return join(this.globalDir, "fusion-central.db"); } /** * Detect existing projects by walking up the directory tree. * * Starting from `cwd`, walks up looking for `.kb/kb.db` files. * Stops at home directory or root. * * @param cwd — Starting directory (default: process.cwd()) * @returns Array of detected projects */ async detectExistingProjects(cwd?: string): Promise { const startDir = cwd ?? process.cwd(); const projects: DetectedProject[] = []; const visited = new Set(); let current = resolve(startDir); const home = homedir(); const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root while (current !== home && current !== root) { if (visited.has(current)) break; visited.add(current); if (this.hasKbProject(current)) { const name = await this.generateProjectName(current); projects.push({ path: current, name, hasDb: true, }); // Only detect one project - stop at first match break; } const parent = dirname(current); if (parent === current) break; current = parent; } return projects; } /** * Generate a project name from git remote or directory name. * * Priority: * 1. Git remote origin URL (extract repo name) * 2. Directory basename * * @param projectPath — Absolute path to project * @returns Generated name */ async generateProjectName(projectPath: string): Promise { // Try git remote first try { const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const execFileAsync = promisify(execFile); const { stdout } = await execFileAsync( "git", ["remote", "get-url", "origin"], { cwd: projectPath, timeout: 5000 } ); const remoteUrl = stdout.trim(); if (remoteUrl) { const name = this.extractRepoName(remoteUrl); if (name) return name; } } catch { // Git not available or no remote - fall through to directory name } // Fallback to directory name return basename(projectPath); } /** * Extract repository name from git remote URL. * * Handles formats: * - https://github.com/owner/repo.git → repo * - https://github.com/owner/repo → repo * - git@github.com:owner/repo.git → repo * - git@github.com:owner/repo → repo */ private extractRepoName(remoteUrl: string): string | null { // Remove .git suffix const withoutGit = remoteUrl.replace(/\.git$/, ""); // Handle SSH format: git@host:owner/repo const sshMatch = withoutGit.match(/:([^/:]+\/([^/]+))$/); if (sshMatch) { return sshMatch[2]; } // Handle HTTPS format: https://host/owner/repo const httpsMatch = withoutGit.match(/\/([^/]+)$/); if (httpsMatch) { return httpsMatch[1]; } return null; } /** * Check if a directory contains a valid kb project. */ private hasKbProject(dir: string): boolean { // Check for current .fusion/fusion.db or legacy .kb/kb.db return hasProjectDbFile(dir, ".fusion", "fusion.db") || hasProjectDbFile(dir, ".kb", "kb.db"); } private getDefaultGlobalDir(): string { return join(homedir(), ".pi", "kb"); } } // ── MigrationCoordinator ───────────────────────────────────────────── /** * Coordinates migration and setup flows. * * Orchestrates: * - Auto-migration of existing single projects * - Setup wizard project registration * - Idempotent re-runs */ export class MigrationCoordinator { private readonly central: CentralCore; /** * Create a MigrationCoordinator. * @param central — Initialized CentralCore instance */ constructor(central: CentralCore) { this.central = central; } /** * Coordinate the full migration flow based on current state. * * Detects state and executes appropriate migration path: * - needs-migration → Auto-register existing project * - setup-wizard → No-op (call completeSetup separately) * - others → No-op */ async coordinateMigration(): Promise { const detector = new FirstRunDetector(this.central.getGlobalDir()); const state = await detector.detectFirstRunState(); switch (state) { case "needs-migration": { // Find the project in cwd const projects = await detector.detectExistingProjects(process.cwd()); if (projects.length === 0) { return { success: false, projectsRegistered: [], errors: ["No existing kb project found for migration"], }; } return this.registerSingleProject(projects[0].path); } case "fresh-install": return { success: true, projectsRegistered: [], errors: [], }; case "setup-wizard": { // Central DB exists but no projects — check for local project to auto-register const localProjects = await detector.detectExistingProjects(process.cwd()); if (localProjects.length > 0) { return this.registerSingleProject(localProjects[0].path); } return { success: true, projectsRegistered: [], errors: [], }; } case "normal-operation": // No migration needed return { success: true, projectsRegistered: [], errors: [], }; } } /** * Register a single existing project (for auto-migration). * * @param projectPath — Absolute path to project * @returns Migration result */ async registerSingleProject(projectPath: string): Promise { const result: MigrationResult = { success: false, projectsRegistered: [], errors: [], }; // Validate path if (!isAbsolute(projectPath)) { result.errors.push(`Project path must be absolute: ${projectPath}`); return result; } // Validate it's an actual kb project const detector = new FirstRunDetector(this.central.getGlobalDir()); if (!this.isValidKbProject(projectPath)) { result.errors.push(`Path is not a valid kb project: ${projectPath}`); return result; } // Check if already registered try { const existing = await this.central.getProjectByPath(projectPath); if (existing) { // Already registered - idempotent success result.success = true; result.projectsRegistered.push(existing.id); return result; } } catch (err) { result.errors.push(`Failed to check existing registration: ${(err as Error).message}`); return result; } // Check for overlapping registered projects (nested inside or parent of existing) try { const allProjects = await this.central.listProjects(); const normalizedPath = resolve(projectPath); for (const p of allProjects) { const normalizedExisting = resolve(p.path); if (normalizedPath.startsWith(normalizedExisting + "/") || normalizedExisting.startsWith(normalizedPath + "/")) { result.errors.push(`Path "${projectPath}" overlaps an existing registered project at "${p.path}"`); return result; } } } catch { // Non-fatal — continue with registration } // Generate unique name const baseName = await detector.generateProjectName(projectPath); const uniqueName = await this.ensureUniqueName(baseName); // Register the project try { const project = await this.central.registerProject({ name: uniqueName, path: projectPath, isolationMode: "in-process", }); // Activate the project after successful registration await this.central.updateProject(project.id, { status: "active" }); result.success = true; result.projectsRegistered.push(project.id); } catch (err) { result.errors.push(`Failed to register project: ${(err as Error).message}`); } return result; } /** * Complete setup by registering multiple projects (from wizard). * * @param projects — Array of project setup inputs * @returns Migration result */ async completeSetup(projects: ProjectSetupInput[]): Promise { const result: MigrationResult = { success: true, projectsRegistered: [], errors: [], }; for (const input of projects) { try { // Validate it's a valid kb project if (!this.isValidKbProject(input.path)) { result.success = false; result.errors.push(`Path is not a valid kb project: ${input.path}`); continue; } // Check if already registered const existing = await this.central.getProjectByPath(input.path); if (existing) { result.projectsRegistered.push(existing.id); continue; } // Ensure unique name const uniqueName = await this.ensureUniqueName(input.name); // Register const project = await this.central.registerProject({ name: uniqueName, path: input.path, isolationMode: input.isolationMode ?? "in-process", }); // Activate after registration await this.central.updateProject(project.id, { status: "active" }); result.projectsRegistered.push(project.id); } catch (err) { result.success = false; result.errors.push(`Failed to register ${input.name}: ${(err as Error).message}`); } } return result; } /** * Ensure a project name is unique by appending -N suffix if needed. */ private async ensureUniqueName(baseName: string): Promise { const existing = await this.central.listProjects(); const existingNames = new Set(existing.map((p) => p.name.toLowerCase())); if (!existingNames.has(baseName.toLowerCase())) { return baseName; } // Find unique suffix let counter = 1; let candidate = `${baseName}-${counter}`; while (existingNames.has(candidate.toLowerCase())) { counter++; candidate = `${baseName}-${counter}`; } return candidate; } /** * Check if a directory is a valid kb project (has .fusion/fusion.db or .kb/kb.db). */ private isValidKbProject(dir: string): boolean { return hasProjectDbFile(dir, ".fusion", "fusion.db") || hasProjectDbFile(dir, ".kb", "kb.db"); } } // ── BackwardCompat ─────────────────────────────────────────────────── /** * Backward compatibility layer for single-project workflows. * * Ensures existing users with single projects continue working * without needing to specify `--project` flags. */ export class BackwardCompat { private readonly central: CentralCore; /** * Create a BackwardCompat helper. * @param central — Initialized CentralCore instance */ constructor(central: CentralCore) { this.central = central; } /** * Resolve project context for a command. * * Resolution order: * 1. If `projectId` provided → look up that project * 2. If no `projectId` and single project registered → auto-use it * 3. If no `projectId` and multiple projects → throw ProjectRequiredError * 4. If no central DB → return legacy mode (use cwd directly) * * @param cwd — Current working directory * @param projectId — Optional explicit project ID/name * @returns Resolved context * @throws ProjectRequiredError when multiple projects and no selection */ async resolveProjectContext( cwd: string, projectId?: string ): Promise { // Check for legacy mode (no central DB) const detector = new FirstRunDetector(this.central.getGlobalDir()); if (!detector.hasCentralDb()) { return { projectId: "legacy", workingDirectory: cwd, isLegacy: true, }; } // Explicit project ID provided if (projectId) { const project = await this.findProjectByIdOrName(projectId); if (!project) { throw new ProjectRequiredError( `Project not found: ${projectId}`, await this.getAvailableProjects() ); } return { projectId: project.id, workingDirectory: project.path, isLegacy: false, }; } // No explicit project - check how many are registered const projects = await this.central.listProjects(); if (projects.length === 0) { throw new ProjectRequiredError( "No projects registered. Run 'fn init' or 'fn project add' to set up a project.", [] ); } if (projects.length === 1) { // Single project - auto-use it for backward compatibility const project = projects[0]; return { projectId: project.id, workingDirectory: project.path, isLegacy: false, }; } // Multiple projects - require explicit selection throw new ProjectRequiredError( "Multiple projects registered. Use --project to specify which project to use.", projects.map((p) => ({ id: p.id, name: p.name })) ); } /** * Check if running in legacy mode (no central database). */ async isLegacyMode(): Promise { const detector = new FirstRunDetector(this.central.getGlobalDir()); return !detector.hasCentralDb(); } /** * Find a project by ID or name (case-insensitive name match). */ private async findProjectByIdOrName(idOrName: string): Promise { // Try exact ID match first const byId = await this.central.getProject(idOrName); if (byId) return byId; // Try name match (case-insensitive) const all = await this.central.listProjects(); const lower = idOrName.toLowerCase(); return all.find((p) => p.name.toLowerCase() === lower); } /** * Get list of available projects for error messages. */ private async getAvailableProjects(): Promise> { const all = await this.central.listProjects(); return all.map((p) => ({ id: p.id, name: p.name })); } /** * Check if a directory contains a current .fusion project or legacy .kb project. */ private hasProjectData(dir: string): boolean { return hasProjectDbFile(dir, ".fusion", "fusion.db") || hasProjectDbFile(dir, ".kb", "kb.db"); } }