- core: extract ai-engine-loader.ts to share @fusion/engine dynamic-import boilerplate between ai-summarize and memory-compaction (incl. AgentMessage type); collapse getInbox/getOutbox, listInsights/countInsights, listRuns/countRuns, and three hasProjectDb* variants behind shared helpers. - core: drop unused pluginLoaderLog export; tighten two `any` casts (db.walCheckpoint row, plugin-loader error.code). - engine: extract resolveRoleFallback helper from buildSessionSkillContext/Sync; remove 22 stale `eslint-disable no-explicit-any` directives across project-engine, self-healing, triage, worktree-pool. - dashboard: apply ESLint autofix (let→const, empty `interface extends`→type). All three packages: typecheck clean, full test suites pass (14,414 tests), builds clean. Net lint: -31 warnings. No public behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
673 lines
20 KiB
TypeScript
673 lines
20 KiB
TypeScript
/**
|
|
* 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 `<dir>/<folderName>/<dbName>` 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<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 ? "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<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
|
|
|
|
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<string> {
|
|
// 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<MigrationResult> {
|
|
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<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 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<MigrationResult> {
|
|
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<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 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<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 }));
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
}
|
|
}
|