Files
fusion/packages/core/src/migration.ts
Vhailors 3796ba087c fix(core): prevent FirstRunDetector from walking into OS temp directory
`FirstRunDetector.detectExistingProjects()` walks ancestor directories
looking for `.fusion/` projects, stopping only at `homedir()` and the
filesystem root. On systems where a prior Fusion session has left a
`/tmp/.fusion/fusion.db` (e.g. running tests, ephemeral dashboards,
crashed processes), the walk crosses into `/tmp` and incorrectly
"discovers" that stale state as a project.

This was caught by tests in packages/core/src/__tests__/store.test.ts
that intermittently failed when a real Fusion session had been run
on the same box. The walk would find the test's tmp dir AND the host's
real `/tmp/.fusion/fusion.db`, polluting the project-detection invariants.

Fix: import `tmpdir` from `node:os` and add it as a third walk-stop
boundary alongside `homedir()` and `/`. The OS temp directory is a
shared system surface and should never itself host a project.

The bug affected any user with a stale `/tmp/.fusion/` from a previous
session — invisible most of the time, but caused FirstRunDetector to
misclassify project state.

Tested: 4 previously-flaky tests in packages/core now pass deterministically.
2026-04-26 23:13:31 -07:00

650 lines
20 KiB
TypeScript

/**
* Migration and First-Run Experience
*
* Handles the multi-project setup flow:
* - Detects first-run state (fresh install, setup wizard, normal)
* - Auto-discovers existing .fusion/ directories for registration
* - Coordinates project registration to central database
* - Provides backward compatibility for single-project workflows
*
* @module migration
*/
import { existsSync, statSync } from "node:fs";
import { homedir, tmpdir } 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 `<dir>/<folderName>/<dbName>` exists as a non-empty regular file.
* Used to decide if a directory contains a 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 .fusion/ anywhere
| "setup-wizard" // Central DB exists but has zero projects
| "normal-operation"; // Central DB exists with projects
/** Detected project for registration 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 fusion.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 registration.
*
* This class determines which startup path to take:
* - Fresh install → Show setup wizard
* - Already set up → 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 three states:
* - `"fresh-install"` — No central DB, no local `.fusion/` found
* - `"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<FirstRunState> {
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 ? "setup-wizard" : "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 — treat as fresh install
return "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 `.fusion/fusion.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<DetectedProject[]> {
const startDir = cwd ?? process.cwd();
const projects: DetectedProject[] = [];
const visited = new Set<string>();
let current = resolve(startDir);
const home = homedir();
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
// Also stop at the OS temp directory — it is a shared system boundary and
// should never itself host a project; stopping here prevents the walk from
// picking up stale .fusion/ directories left by other processes in /tmp.
const systemTmp = resolve(tmpdir());
while (current !== home && current !== root && current !== systemTmp) {
if (visited.has(current)) break;
visited.add(current);
if (this.hasFusionProject(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<string> {
// Fast path: avoid invoking git for non-repositories (prevents unnecessary delays in tests/startup).
if (!existsSync(join(projectPath, ".git"))) {
return basename(projectPath);
}
// 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: 1000 }
);
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 fusion project (.fusion/fusion.db).
*/
private hasFusionProject(dir: string): boolean {
return hasProjectDbFile(dir, ".fusion", "fusion.db");
}
private getDefaultGlobalDir(): string {
return join(homedir(), ".pi", "kb");
}
}
// ── MigrationCoordinator ─────────────────────────────────────────────
/**
* Coordinates project setup flows.
*
* Orchestrates:
* - Auto-registration 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 setup flow based on current state.
*
* Detects state and executes appropriate path:
* - setup-wizard with local project → Auto-register existing project
* - others → No-op
*/
async coordinateMigration(): Promise<MigrationResult> {
const detector = new FirstRunDetector(this.central.getGlobalDir());
const state = await detector.detectFirstRunState();
switch (state) {
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-registration).
*
* @param projectPath — Absolute path to project
* @returns Migration result
*/
async registerSingleProject(projectPath: string): Promise<MigrationResult> {
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 fusion project
const detector = new FirstRunDetector(this.central.getGlobalDir());
if (!this.isValidFusionProject(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<MigrationResult> {
const result: MigrationResult = {
success: true,
projectsRegistered: [],
errors: [],
};
for (const input of projects) {
try {
// Validate it's a valid fusion project
if (!this.isValidFusionProject(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<string> {
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 fusion project (has .fusion/fusion.db).
*/
private isValidFusionProject(dir: string): boolean {
return hasProjectDbFile(dir, ".fusion", "fusion.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<ResolvedContext> {
// 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 <name> 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<boolean> {
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<import("./types.js").RegisteredProject | undefined> {
// 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<Array<{ id: string; name: string }>> {
const all = await this.central.listProjects();
return all.map((p) => ({ id: p.id, name: p.name }));
}
}