Files
fusion/packages/core/src/migration-orchestrator.ts

458 lines
14 KiB
TypeScript

/**
* Migration Orchestrator — Coordinates auto-migration from single-project to multi-project mode.
*
* Detects existing kb projects on the filesystem and automatically registers them
* in the central project registry. Provides safety checks, progress callbacks,
* and dry-run capabilities.
*
* @example
* ```typescript
* const central = new CentralCore();
* await central.init();
*
* const orchestrator = new MigrationOrchestrator(central);
*
* // Check if migration is needed
* if (await orchestrator.needsMigration()) {
* // Run migration with auto-registration
* const result = await orchestrator.runMigration({ autoRegister: true });
* console.log(`Registered ${result.projectsRegistered.length} projects`);
* }
* ```
*/
import { existsSync, statSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { isAbsolute, join, basename, normalize, resolve, sep } from "node:path";
import type {
DetectedProject,
MigrationOptions,
MigrationResult,
RegisteredProject,
} from "./types.js";
import type { CentralCore } from "./central-core.js";
import { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
// ── Constants ──────────────────────────────────────────────────────────────
/** Maximum number of projects to auto-register (safety limit) */
export const MAX_AUTO_REGISTER_PROJECTS = 100;
/** Default maximum scan depth */
export const DEFAULT_MAX_DEPTH = 5;
/** Directories to exclude from scanning */
export const EXCLUDED_DIRS = [
"node_modules",
".git",
".cache",
"dist",
"build",
"out",
".worktrees",
".next",
".turbo",
".npm",
".pnpm-store",
"coverage",
".nyc_output",
"tmp",
"temp",
];
/** Check if a directory name should be excluded from scanning */
function isExcludedDir(name: string): boolean {
// Exclude hidden directories (starting with .) and known build/cache directories
if (name.startsWith(".")) return true;
return EXCLUDED_DIRS.includes(name.toLowerCase());
}
/** Check if a path is within another path (circular detection) */
function isPathWithin(child: string, parent: string): boolean {
const normalizedChild = normalize(child);
const normalizedParent = normalize(parent);
// Ensure both paths end with platform-specific separator for proper prefix matching
const childWithSep = normalizedChild.endsWith(sep) ? normalizedChild : normalizedChild + sep;
const parentWithSep = normalizedParent.endsWith(sep) ? normalizedParent : normalizedParent + sep;
return childWithSep.startsWith(parentWithSep);
}
// ── MigrationOrchestrator Class ───────────────────────────────────────────
export class MigrationOrchestrator {
private centralCore: CentralCore;
/**
* Create a MigrationOrchestrator instance.
* @param centralCore — Initialized CentralCore instance
*/
constructor(centralCore: CentralCore) {
this.centralCore = centralCore;
}
/**
* Detect existing kb projects by walking the filesystem.
*
* Scans from the starting path up to maxDepth levels deep, looking for
* directories containing `.fusion/fusion.db` (or legacy `.fusion/fusion.db`)
*
* Security notes:
* - Only scans from the specified startPath
* - Respects maxDepth to prevent deep recursion
* - Skips hidden directories and common build/cache directories
* - Does not follow symbolic links
*
* @param startPath — Directory to start scanning from
* @param maxDepth — Maximum recursion depth (default: 5)
* @returns Array of detected projects
*/
async detectExistingProjects(
startPath: string = process.cwd(),
maxDepth: number = DEFAULT_MAX_DEPTH
): Promise<DetectedProject[]> {
// Check if path is relative BEFORE resolving
if (!isAbsolute(startPath)) {
throw new Error(`Scan path must be absolute: ${startPath}`);
}
const scanPath = resolve(startPath);
if (!existsSync(scanPath)) {
throw new Error(`Scan path does not exist: ${scanPath}`);
}
const detected: DetectedProject[] = [];
const visited = new Set<string>();
await this.scanDirectory(scanPath, 0, maxDepth, detected, visited);
// Sort by path for consistent ordering
detected.sort((a, b) => a.path.localeCompare(b.path));
return detected;
}
/**
* Recursively scan a directory for kb projects.
*/
private async scanDirectory(
dir: string,
depth: number,
maxDepth: number,
detected: DetectedProject[],
visited: Set<string>
): Promise<void> {
// Prevent infinite loops from symlinks or circular references
const normalizedDir = normalize(dir);
if (visited.has(normalizedDir)) {
return;
}
visited.add(normalizedDir);
// Respect depth limit
if (depth > maxDepth) {
return;
}
// Check if this directory is a kb project (has .fusion/fusion.db or .fusion/fusion.db)
const hasKbDb = this.isKbProject(dir);
if (hasKbDb) {
const name = this.generateProjectName(dir);
detected.push({
path: dir,
name,
hasDb: true,
});
// Don't recurse into kb projects - they're project roots
return;
}
// Try to read directory entries
let entries: string[];
try {
entries = await readdir(dir);
} catch {
// Permission denied or other error - skip this directory
return;
}
// Recurse into subdirectories
for (const entry of entries) {
if (isExcludedDir(entry)) {
continue;
}
const fullPath = join(dir, entry);
// Skip symlinks to avoid cycles
try {
const stats = statSync(fullPath);
if (!stats.isDirectory() || stats.isSymbolicLink()) {
continue;
}
} catch {
// Can't stat - skip
continue;
}
await this.scanDirectory(fullPath, depth + 1, maxDepth, detected, visited);
}
}
/**
* Check if a directory contains a valid kb project.
* Validates that .fusion/fusion.db is an openable SQLite database.
*/
private isKbProject(dir: string): boolean {
return isValidSqliteDatabaseFile(join(dir, ".fusion", "fusion.db"));
}
/**
* Generate a project name from a directory path.
* Uses the basename of the directory.
*/
private generateProjectName(dir: string): string {
return basename(dir);
}
/**
* Auto-register detected projects in the central registry.
*
* - Filters to projects with valid fusion.db
* - Skips already-registered projects
* - Generates unique names (appends number if conflict: name, name-2, name-3)
* - Sets isolationMode to 'in-process' for migrated projects
* - Enforces MAX_AUTO_REGISTER_PROJECTS limit
*
* @param detected — Projects detected during scan
* @returns Array of newly registered projects
*/
async autoRegisterProjects(detected: DetectedProject[]): Promise<RegisteredProject[]> {
const registered: RegisteredProject[] = [];
const existingProjects = await this.centralCore.listProjects();
// Safety limit check
if (detected.length > MAX_AUTO_REGISTER_PROJECTS) {
throw new Error(
`Too many projects detected (${detected.length}). ` +
`Maximum allowed for auto-registration is ${MAX_AUTO_REGISTER_PROJECTS}. ` +
`Register projects manually using 'fn project add <path>'.`
);
}
for (const project of detected) {
// Skip if no valid database
if (!project.hasDb) {
continue;
}
// Skip if already registered by path
const existingByPath = existingProjects.find(
(p: import("./types.js").RegisteredProject) => normalize(p.path) === normalize(project.path)
);
if (existingByPath) {
continue;
}
// Check for circular registration (project inside another registered project)
const circularParent = existingProjects.find(
(p: import("./types.js").RegisteredProject) => isPathWithin(project.path, p.path) || isPathWithin(p.path, project.path)
);
if (circularParent) {
continue;
}
// Generate unique name
const uniqueName = await this.generateUniqueName(project.name, [
...existingProjects.map((p: import("./types.js").RegisteredProject) => p.name),
...registered.map((p: import("./types.js").RegisteredProject) => p.name),
]);
try {
const newProject = await this.centralCore.registerProject({
name: uniqueName,
path: project.path,
isolationMode: "in-process",
});
// Update status to active (registration sets it to 'initializing')
const activeProject = await this.centralCore.updateProject(newProject.id, { status: "active" });
registered.push(activeProject);
} catch (err) {
// Log but continue with other projects
console.warn(`[migration] Failed to register ${project.path}:`, (err as Error).message);
}
}
return registered;
}
/**
* Generate a unique project name, appending a number suffix if needed.
* Format: name, name-2, name-3, etc.
*/
private async generateUniqueName(baseName: string, existingNames: string[]): Promise<string> {
const lowerExisting = new Set(existingNames.map((n) => n.toLowerCase()));
if (!lowerExisting.has(baseName.toLowerCase())) {
return baseName;
}
let counter = 2;
let candidate = `${baseName}-${counter}`;
while (lowerExisting.has(candidate.toLowerCase())) {
counter++;
candidate = `${baseName}-${counter}`;
}
return candidate;
}
/**
* Check if migration is needed.
*
* Returns true if:
* - Central database exists but has no projects registered
* - AND there are existing kb projects on the filesystem
*
* This indicates a first-run scenario where we should auto-migrate.
*
* @param startPath — Optional path to scan for projects (default: process.cwd())
*/
async needsMigration(startPath?: string): Promise<boolean> {
// Check if central core is initialized
if (!this.centralCore.isInitialized()) {
return true;
}
// Check if any projects are already registered
const projects = await this.centralCore.listProjects();
if (projects.length > 0) {
return false;
}
// Check if there are any legacy projects to migrate
const scanPath = startPath ?? process.cwd();
const detected = await this.detectExistingProjects(scanPath, 3); // Shallow scan
return detected.length > 0;
}
/**
* Run the full migration process.
*
* Orchestrates detection → registration → validation with progress
* callbacks and dry-run support.
*
* @param options — Migration options
* @returns Migration result with details
*/
async runMigration(options?: MigrationOptions): Promise<MigrationResult> {
const result: MigrationResult = {
projectsDetected: [],
projectsRegistered: [],
projectsSkipped: [],
errors: [],
};
const startPath = options?.startPath ?? process.cwd();
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
const dryRun = options?.dryRun ?? false;
// Phase 1: Detection
try {
result.projectsDetected = await this.detectExistingProjects(startPath, maxDepth);
} catch (err) {
result.errors.push({
path: startPath,
error: `Detection failed: ${(err as Error).message}`,
});
return result;
}
// Report progress after detection
if (options?.onProgress) {
options.onProgress(0, result.projectsDetected.length, "Detection complete");
}
// Phase 2: Registration (or dry-run simulation)
if (dryRun) {
// In dry-run mode, simulate what would be registered
const existingProjects = await this.centralCore.listProjects();
for (const project of result.projectsDetected) {
if (!project.hasDb) {
result.projectsSkipped.push({ path: project.path, reason: "No valid kb database" });
continue;
}
const existingByPath = existingProjects.find(
(p: import("./types.js").RegisteredProject) => normalize(p.path) === normalize(project.path)
);
if (existingByPath) {
result.projectsSkipped.push({ path: project.path, reason: "Already registered" });
continue;
}
// Would be registered in non-dry-run mode
result.projectsSkipped.push({ path: project.path, reason: "[DRY RUN] Would register" });
}
} else if (options?.autoRegister) {
// Auto-register detected projects
try {
const registered = await this.autoRegisterProjects(result.projectsDetected);
result.projectsRegistered = registered;
// Track skipped projects
const registeredPaths = new Set(registered.map((p) => normalize(p.path)));
for (const project of result.projectsDetected) {
if (!registeredPaths.has(normalize(project.path))) {
// Determine why it was skipped
if (!project.hasDb) {
result.projectsSkipped.push({ path: project.path, reason: "No valid kb database" });
} else {
result.projectsSkipped.push({ path: project.path, reason: "Already registered or error" });
}
}
if (options?.onProgress) {
const current = result.projectsDetected.indexOf(project) + 1;
options.onProgress(current, result.projectsDetected.length, project.path);
}
}
} catch (err) {
result.errors.push({
path: "registration",
error: (err as Error).message,
});
}
} else {
// Detection only mode - mark all as "would register"
for (const project of result.projectsDetected) {
if (!project.hasDb) {
result.projectsSkipped.push({ path: project.path, reason: "No valid kb database" });
} else {
result.projectsSkipped.push({
path: project.path,
reason: "Detection only (autoRegister not enabled)",
});
}
}
}
return result;
}
}
// ── Factory Function ───────────────────────────────────────────────────────
/**
* Create a MigrationOrchestrator instance.
* @param centralCore — Initialized CentralCore instance
* @returns MigrationOrchestrator
*/
export function createMigrationOrchestrator(centralCore: CentralCore): MigrationOrchestrator {
return new MigrationOrchestrator(centralCore);
}