feat(KB-620): add multi-project migration and first-run experience

- Add FirstRunDetector for fresh-install and migration state detection
- Implement MigrationOrchestrator for auto-registering existing projects
- Create BackwardCompat layer to maintain single-project CLI workflows
- Add fn init command for manual project registration
- Add dashboard first-run wizard API endpoints (/api/setup-state, /api/complete-setup)
- Add runtimeLog, ipcLog, projectManagerLog, hybridExecutorLog to engine logger
- Include changeset documenting migration features and rollback procedure
This commit is contained in:
gsxdsm
2026-04-01 07:19:44 -07:00
parent 228648adfa
commit 0559962685
11 changed files with 2006 additions and 2 deletions

View File

@@ -0,0 +1,255 @@
/**
* Tests for backward compatibility layer
*
* Ensures single-project workflows continue working without --project flags.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
// Helper to create temp directories
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
// Helper to create a fake kb project
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".kb");
mkdirSync(kbDir, { recursive: true });
writeFileSync(join(kbDir, "kb.db"), "SQLite format 3\x00");
}
describe("Backward Compatibility Layer", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-backward-compat-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("single project auto-resolution", () => {
it("should auto-resolve single project without --project flag", async () => {
const tempProjectDir = tempDir("kb-single-compat-");
const project = await central.registerProject({
name: "Single Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
// No projectId provided - should auto-resolve to single project
const context = await compat.resolveProjectContext("/any/dir");
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should use explicit project ID when provided", async () => {
const tempProjectDir = tempDir("kb-explicit-compat-");
const project = await central.registerProject({
name: "Explicit Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/dir", project.id);
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should find project by name (case-insensitive)", async () => {
const tempProjectDir = tempDir("kb-name-compat-");
const project = await central.registerProject({
name: "My Awesome Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
// Use lowercase name - should still find it
const context = await compat.resolveProjectContext("/some/dir", "my awesome project");
expect(context.projectId).toBe(project.id);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("multiple projects requires explicit selection", () => {
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
const tempProjectDir1 = tempDir("kb-multi-compat1-");
const tempProjectDir2 = tempDir("kb-multi-compat2-");
const project1 = await central.registerProject({
name: "Project One",
path: tempProjectDir1,
});
const project2 = await central.registerProject({
name: "Project Two",
path: tempProjectDir2,
});
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
ProjectRequiredError
);
try {
await compat.resolveProjectContext("/some/dir");
} catch (err) {
expect(err).toBeInstanceOf(ProjectRequiredError);
// Should provide list of available projects
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
const ids = (err as ProjectRequiredError).availableProjects.map((p) => p.id);
expect(ids).toContain(project1.id);
expect(ids).toContain(project2.id);
}
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should resolve correctly when explicit project provided with multiple projects", async () => {
const tempProjectDir1 = tempDir("kb-multi-explicit1-");
const tempProjectDir2 = tempDir("kb-multi-explicit2-");
const project1 = await central.registerProject({
name: "Project One",
path: tempProjectDir1,
});
await central.registerProject({
name: "Project Two",
path: tempProjectDir2,
});
const compat = new BackwardCompat(central);
// Explicitly select project1
const context = await compat.resolveProjectContext("/some/dir", project1.id);
expect(context.projectId).toBe(project1.id);
expect(context.workingDirectory).toBe(tempProjectDir1);
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
});
describe("legacy mode without central database", () => {
it("should return legacy mode when no central DB", async () => {
// Close and remove central DB
await central.close();
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
// Re-create central but don't init
central = new CentralCore(tempGlobalDir);
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/legacy/dir");
expect(context.isLegacy).toBe(true);
expect(context.projectId).toBe("legacy");
expect(context.workingDirectory).toBe("/some/legacy/dir");
});
it("should report legacy mode correctly", async () => {
// Close and remove central DB
await central.close();
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
central = new CentralCore(tempGlobalDir);
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(true);
});
it("should report non-legacy mode when central DB exists", async () => {
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(false);
});
});
describe("auto-migration on resolve", () => {
it("should auto-register project found in cwd when no projects registered", async () => {
// Create a project directory with .kb/
const projectDir = tempDir("kb-auto-migrate-compat-");
createFakeKbProject(projectDir);
const compat = new BackwardCompat(central);
// Should auto-register the project found in cwd
const context = await compat.resolveProjectContext(projectDir);
expect(context.isLegacy).toBe(false);
expect(context.workingDirectory).toBe(projectDir);
// Verify project was registered
const isRegistered = await central.isProjectRegistered(projectDir);
expect(isRegistered).toBe(true);
rmSync(projectDir, { recursive: true, force: true });
});
});
describe("error messages", () => {
it("should provide helpful error when project not found", async () => {
const compat = new BackwardCompat(central);
await expect(
compat.resolveProjectContext("/some/dir", "nonexistent-project")
).rejects.toThrow(/not found/i);
});
it("should provide helpful error when no projects registered", async () => {
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
/no projects registered/i
);
});
});
});
describe("ProjectRequiredError backward compat", () => {
it("should include both id and name for each available project", () => {
const available = [
{ id: "proj_abc123", name: "Frontend" },
{ id: "proj_def456", name: "Backend" },
{ id: "proj_ghi789", name: "Docs" },
];
const error = new ProjectRequiredError(
"Multiple projects available",
available
);
expect(error.availableProjects).toHaveLength(3);
expect(error.availableProjects[0]).toHaveProperty("id");
expect(error.availableProjects[0]).toHaveProperty("name");
});
it("should be catchable as ProjectRequiredError", async () => {
const error = new ProjectRequiredError("test", []);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(ProjectRequiredError);
expect(error.name).toBe("ProjectRequiredError");
});
});

View File

@@ -31,7 +31,7 @@ import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { isAbsolute, join, basename } from "node:path";
import type {
RegisteredProject,
ProjectHealth,
@@ -945,4 +945,134 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
metadata: fromJson<Record<string, unknown>>(row.metadata),
};
}
// ── Migration Helpers ────────────────────────────────────────────────
/**
* Auto-register a project at the given path.
*
* This is used during migration from single-project to multi-project mode.
* Generates the project name from git remote or directory name.
*
* @param projectPath — Absolute path to project directory
* @returns Registered project
* @throws Error if path doesn't exist, isn't absolute, or registration fails
*/
async autoRegisterProject(projectPath: string): Promise<RegisteredProject> {
this.ensureInitialized();
// Check if already registered
const existing = await this.getProjectByPath(projectPath);
if (existing) {
return existing;
}
// Generate name from git remote or directory
const name = await this.generateProjectName(projectPath);
// Ensure unique name
const uniqueName = await this.ensureUniqueName(name);
// Register with in-process isolation
return this.registerProject({
name: uniqueName,
path: projectPath,
isolationMode: "in-process",
});
}
/**
* Get the current first-run state for this central instance.
*
* @returns First-run state
*/
async getFirstRunState(): Promise<import("./migration.js").FirstRunState> {
const { FirstRunDetector } = await import("./migration.js");
const detector = new FirstRunDetector(this.globalDir);
return detector.detectFirstRunState(this);
}
/**
* Check if a project path is already registered.
*
* @param projectPath — Absolute project path
* @returns true if already registered
*/
async isProjectRegistered(projectPath: string): Promise<boolean> {
const existing = await this.getProjectByPath(projectPath);
return !!existing;
}
/**
* Generate a project name from git remote or directory name.
*/
private 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.
*/
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;
}
/**
* Ensure a project name is unique by appending -N suffix if needed.
*/
private async ensureUniqueName(baseName: string): Promise<string> {
const existing = await this.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;
}
}

View File

@@ -532,3 +532,60 @@ async function createBackups(kbDir: string): Promise<void> {
}
}
}
// ── Central Migration ────────────────────────────────────────────────
/**
* Check if migration to central database is needed.
*
* Returns true if:
* - Central DB doesn't exist AND
* - cwd has `.kb/kb.db` (existing single-project)
*
* @param cwd — Current working directory to check
* @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
*/
export function needsCentralMigration(cwd: string, globalDir?: string): boolean {
const { FirstRunDetector } = require("./migration.js");
const detector = new FirstRunDetector(globalDir);
// Check if central DB exists
if (detector.hasCentralDb()) {
return false;
}
// Check if cwd has a kb project
return detector["hasKbProject"](cwd);
}
/**
* Detect existing projects by walking up from cwd.
*
* @param cwd — Starting directory (default: process.cwd())
* @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
* @returns Array of detected projects
*/
export async function detectExistingProjects(
cwd?: string,
globalDir?: string
): Promise<Array<{ path: string; name: string; hasDb: boolean }>> {
const { FirstRunDetector } = require("./migration.js");
const detector = new FirstRunDetector(globalDir);
return detector.detectExistingProjects(cwd);
}
/**
* Auto-migrate an existing single project to central database.
*
* @param existingProjectPath — Absolute path to existing project
* @param central — Initialized CentralCore instance
* @returns Migration result
*/
export async function autoMigrateToCentral(
existingProjectPath: string,
central: import("./central-core.js").CentralCore
): Promise<import("./migration.js").MigrationResult> {
const { MigrationCoordinator } = require("./migration.js");
const coordinator = new MigrationCoordinator(central);
return coordinator.registerSingleProject(existingProjectPath);
}

View File

@@ -123,3 +123,24 @@ export type {
CentralActivityLogEntry,
GlobalConcurrencyState
} from "./types.js";
// ── Migration and First-Run Experience ────────────────────────────────
export {
FirstRunDetector,
MigrationCoordinator,
BackwardCompat,
ProjectRequiredError,
} from "./migration.js";
export type {
FirstRunState,
DetectedProject,
MigrationResult,
ProjectSetupInput,
ResolvedContext,
} from "./migration.js";
export {
needsCentralMigration,
detectExistingProjects,
autoMigrateToCentral,
} from "./db-migrate.js";

View File

@@ -0,0 +1,643 @@
/**
* Tests for migration and first-run detection
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
FirstRunDetector,
MigrationCoordinator,
BackwardCompat,
ProjectRequiredError,
type ProjectSetupInput,
} from "./migration.js";
import { CentralCore } from "./central-core.js";
// Helper to create temp directories
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
// Helper to create a fake kb project
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".kb");
mkdirSync(kbDir, { recursive: true });
// Create empty kb.db file (SQLite needs actual format, but for detection an empty file works)
writeFileSync(join(kbDir, "kb.db"), "SQLite format 3\x00");
}
// Helper to create a fake git remote
async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("git", ["init"], { cwd: dir });
await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: dir });
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: dir });
if (remoteUrl) {
await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir });
}
}
describe("FirstRunDetector", () => {
let tempGlobalDir: string;
let originalCwd: string;
beforeEach(() => {
tempGlobalDir = tempDir("kb-migration-test-");
originalCwd = process.cwd();
});
afterEach(() => {
// Cleanup
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
process.chdir(originalCwd);
});
describe("detectFirstRunState", () => {
it("should detect fresh-install when no central DB and no local .kb/", async () => {
const tempProjectDir = tempDir("kb-fresh-");
process.chdir(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("fresh-install");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect needs-migration when local .kb/ exists but no central DB", async () => {
const tempProjectDir = tempDir("kb-needs-migration-");
createFakeKbProject(tempProjectDir);
process.chdir(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("needs-migration");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect setup-wizard when central DB exists but is empty", async () => {
// Initialize central DB with no projects
const central = new CentralCore(tempGlobalDir);
await central.init();
await central.close();
const tempProjectDir = tempDir("kb-setup-wizard-");
process.chdir(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const state = await detector.detectFirstRunState();
expect(state).toBe("setup-wizard");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should detect normal-operation when central DB has projects", async () => {
// Create a separate global dir for this test to avoid conflicts with beforeEach's tempGlobalDir
const testGlobalDir = tempDir("kb-normal-op-global-");
// Create and initialize central
const testCentral = new CentralCore(testGlobalDir);
await testCentral.init();
// Register a project
const projectDir = tempDir("kb-test-project-");
await testCentral.registerProject({
name: "Test Project",
path: projectDir,
});
// Create a temp dir for the cwd
const tempProjectDir = tempDir("kb-normal-op-");
process.chdir(tempProjectDir);
// Pass existing central to avoid concurrent connection issues
const detector = new FirstRunDetector(testGlobalDir);
const state = await detector.detectFirstRunState(testCentral);
expect(state).toBe("normal-operation");
// Cleanup
await testCentral.close();
rmSync(tempProjectDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
rmSync(testGlobalDir, { recursive: true, force: true });
});
});
describe("hasCentralDb", () => {
it("should return false when central DB does not exist", () => {
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.hasCentralDb()).toBe(false);
});
it("should return true when central DB exists", async () => {
const central = new CentralCore(tempGlobalDir);
await central.init();
await central.close();
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.hasCentralDb()).toBe(true);
});
});
describe("detectExistingProjects", () => {
it("should detect project in cwd", async () => {
const tempProjectDir = tempDir("kb-detect-");
createFakeKbProject(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(tempProjectDir);
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(tempProjectDir);
expect(projects[0].hasDb).toBe(true);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should walk up directory tree to find .kb/", async () => {
const tempProjectDir = tempDir("kb-parent-");
createFakeKbProject(tempProjectDir);
const nestedDir = join(tempProjectDir, "src", "components");
mkdirSync(nestedDir, { recursive: true });
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(nestedDir);
expect(projects).toHaveLength(1);
expect(projects[0].path).toBe(tempProjectDir);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should return empty array when no project found", async () => {
const emptyDir = tempDir("kb-empty-");
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(emptyDir);
expect(projects).toHaveLength(0);
rmSync(emptyDir, { recursive: true, force: true });
});
});
describe("generateProjectName", () => {
it("should use directory basename when no git remote", async () => {
const tempProjectDir = tempDir("my-awesome-project-");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toContain("my-awesome-project");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should extract repo name from HTTPS git remote", async () => {
const tempProjectDir = tempDir("kb-git-https-");
await initGitRepo(tempProjectDir, "https://github.com/owner/my-repo.git");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toBe("my-repo");
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should extract repo name from SSH git remote", async () => {
const tempProjectDir = tempDir("kb-git-ssh-");
await initGitRepo(tempProjectDir, "git@github.com:owner/my-ssh-repo");
const detector = new FirstRunDetector(tempGlobalDir);
const name = await detector.generateProjectName(tempProjectDir);
expect(name).toBe("my-ssh-repo");
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("getCentralDbPath", () => {
it("should return correct path", () => {
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "kb-central.db"));
});
});
});
describe("MigrationCoordinator", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-coordinator-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("registerSingleProject", () => {
it("should register a new project successfully", async () => {
const tempProjectDir = tempDir("kb-register-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject(tempProjectDir);
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(1);
expect(result.errors).toHaveLength(0);
// Verify project was registered
const project = await central.getProject(result.projectsRegistered[0]);
expect(project).toBeDefined();
expect(project!.path).toBe(tempProjectDir);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should be idempotent - return existing project if already registered", async () => {
const tempProjectDir = tempDir("kb-idempotent-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
// First registration
const result1 = await coordinator.registerSingleProject(tempProjectDir);
expect(result1.success).toBe(true);
// Second registration - should be idempotent
const result2 = await coordinator.registerSingleProject(tempProjectDir);
expect(result2.success).toBe(true);
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
expect(result2.errors).toHaveLength(0);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should reject relative paths", async () => {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject("./relative/path");
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain("must be absolute");
});
it("should handle duplicate names by appending suffix", async () => {
// Use same base name for both projects
const baseName = "kb-dup-" + Date.now();
const tempProjectDir1 = join(tmpdir(), `${baseName}-1`);
const tempProjectDir2 = join(tmpdir(), `${baseName}-2`);
mkdirSync(tempProjectDir1, { recursive: true });
mkdirSync(tempProjectDir2, { recursive: true });
createFakeKbProject(tempProjectDir1);
createFakeKbProject(tempProjectDir2);
const coordinator = new MigrationCoordinator(central);
// Register first project with explicit name
const project1 = await central.registerProject({
name: "my-project",
path: tempProjectDir1,
});
expect(project1.name).toBe("my-project");
// Register second project with same base name via coordinator
const result2 = await coordinator.registerSingleProject(tempProjectDir2);
expect(result2.success).toBe(true);
const project2 = await central.getProject(result2.projectsRegistered[0]);
expect(project2!.name).toMatch(/-\d+$/); // Should have -1, -2, etc. suffix
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
});
describe("completeSetup", () => {
it("should register multiple projects from wizard", async () => {
const tempProjectDir1 = tempDir("kb-setup1-");
const tempProjectDir2 = tempDir("kb-setup2-");
createFakeKbProject(tempProjectDir1);
createFakeKbProject(tempProjectDir2);
const coordinator = new MigrationCoordinator(central);
const inputs: ProjectSetupInput[] = [
{ path: tempProjectDir1, name: "Project One" },
{ path: tempProjectDir2, name: "Project Two" },
];
const result = await coordinator.completeSetup(inputs);
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(2);
expect(result.errors).toHaveLength(0);
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should skip already registered projects", async () => {
const tempProjectDir = tempDir("kb-setup-existing-");
createFakeKbProject(tempProjectDir);
const coordinator = new MigrationCoordinator(central);
// Register first
const result1 = await coordinator.registerSingleProject(tempProjectDir);
// Try to register again via completeSetup
const inputs: ProjectSetupInput[] = [{ path: tempProjectDir, name: "Some Name" }];
const result2 = await coordinator.completeSetup(inputs);
expect(result2.success).toBe(true);
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("coordinateMigration", () => {
it("should return success for fresh-install state", async () => {
// Close and remove central to simulate fresh state
await central.close();
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
// Create fresh temp dir with no .kb/
const tempFreshDir = tempDir("kb-fresh-coord-");
central = new CentralCore(tempGlobalDir);
await central.init();
// Change to fresh dir (no .kb/)
const originalCwd = process.cwd();
process.chdir(tempFreshDir);
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.coordinateMigration();
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(0);
process.chdir(originalCwd);
rmSync(tempFreshDir, { recursive: true, force: true });
});
});
});
describe("BackwardCompat", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-compat-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("resolveProjectContext", () => {
it("should use explicit project ID when provided", async () => {
const tempProjectDir = tempDir("kb-explicit-");
const project = await central.registerProject({
name: "Explicit Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/other/dir", project.id);
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should auto-use single project when no explicit ID provided", async () => {
const tempProjectDir = tempDir("kb-single-");
const project = await central.registerProject({
name: "Single Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/other/dir");
expect(context.projectId).toBe(project.id);
expect(context.workingDirectory).toBe(tempProjectDir);
expect(context.isLegacy).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
const tempProjectDir1 = tempDir("kb-multi1-");
const tempProjectDir2 = tempDir("kb-multi2-");
await central.registerProject({ name: "Project 1", path: tempProjectDir1 });
await central.registerProject({ name: "Project 2", path: tempProjectDir2 });
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
ProjectRequiredError
);
try {
await compat.resolveProjectContext("/some/dir");
} catch (err) {
expect(err).toBeInstanceOf(ProjectRequiredError);
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
}
rmSync(tempProjectDir1, { recursive: true, force: true });
rmSync(tempProjectDir2, { recursive: true, force: true });
});
it("should find project by name (case-insensitive)", async () => {
const tempProjectDir = tempDir("kb-byname-");
const project = await central.registerProject({
name: "My Project",
path: tempProjectDir,
});
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext("/some/dir", "my project");
expect(context.projectId).toBe(project.id);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should throw when project not found", async () => {
const compat = new BackwardCompat(central);
await expect(compat.resolveProjectContext("/some/dir", "nonexistent")).rejects.toThrow(
ProjectRequiredError
);
});
});
describe("isLegacyMode", () => {
it("should return false when central DB exists", async () => {
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(false);
});
it("should return true when no central DB", async () => {
// Close and remove central DB
await central.close();
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
// Need to re-init CentralCore for it to work
central = new CentralCore(tempGlobalDir);
const compat = new BackwardCompat(central);
expect(await compat.isLegacyMode()).toBe(true);
});
});
});
describe("CentralCore migration helpers", () => {
let tempGlobalDir: string;
let central: CentralCore;
beforeEach(async () => {
tempGlobalDir = tempDir("kb-central-migration-test-");
central = new CentralCore(tempGlobalDir);
await central.init();
});
afterEach(async () => {
await central.close();
try {
rmSync(tempGlobalDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("autoRegisterProject", () => {
it("should auto-register a project with generated name", async () => {
const tempProjectDir = tempDir("kb-autoreg-");
createFakeKbProject(tempProjectDir);
const project = await central.autoRegisterProject(tempProjectDir);
expect(project).toBeDefined();
expect(project.path).toBe(tempProjectDir);
expect(project.isolationMode).toBe("in-process");
expect(project.name).toContain("kb-autoreg"); // Based on directory name
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should be idempotent - return existing project if already registered", async () => {
const tempProjectDir = tempDir("kb-autoreg-dup-");
createFakeKbProject(tempProjectDir);
const project1 = await central.autoRegisterProject(tempProjectDir);
const project2 = await central.autoRegisterProject(tempProjectDir);
expect(project1.id).toBe(project2.id);
expect(project1.name).toBe(project2.name);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("isProjectRegistered", () => {
it("should return false for unregistered project", async () => {
const tempProjectDir = tempDir("kb-unreg-");
const isRegistered = await central.isProjectRegistered(tempProjectDir);
expect(isRegistered).toBe(false);
rmSync(tempProjectDir, { recursive: true, force: true });
});
it("should return true for registered project", async () => {
const tempProjectDir = tempDir("kb-registered-");
await central.registerProject({
name: "Registered",
path: tempProjectDir,
});
const isRegistered = await central.isProjectRegistered(tempProjectDir);
expect(isRegistered).toBe(true);
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
describe("getFirstRunState", () => {
it("should return setup-wizard when no projects", async () => {
const state = await central.getFirstRunState();
expect(state).toBe("setup-wizard");
});
it("should return normal-operation when projects exist", async () => {
const tempProjectDir = tempDir("kb-state-test-");
await central.registerProject({
name: "State Test",
path: tempProjectDir,
});
const state = await central.getFirstRunState();
expect(state).toBe("normal-operation");
rmSync(tempProjectDir, { recursive: true, force: true });
});
});
});
describe("ProjectRequiredError", () => {
it("should include available projects in error", () => {
const available = [
{ id: "proj_1", name: "Project One" },
{ id: "proj_2", name: "Project Two" },
];
const error = new ProjectRequiredError("Test message", available);
expect(error.message).toBe("Test message");
expect(error.name).toBe("ProjectRequiredError");
expect(error.availableProjects).toEqual(available);
});
});

View File

@@ -0,0 +1,631 @@
/**
* 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";
// ── 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 .kb/ in cwd
const cwd = process.cwd();
const localKbExists = this.hasKbProject(cwd);
return localKbExists ? "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 {
return "setup-wizard";
}
}
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, "kb-central.db");
return existsSync(centralDbPath);
}
/**
* Get the path to the central database.
*/
getCentralDbPath(): string {
return join(this.globalDir, "kb-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 {
const kbDir = join(dir, ".kb");
const dbPath = join(kbDir, "kb.db");
if (!existsSync(kbDir)) return false;
if (!existsSync(dbPath)) return false;
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
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":
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;
}
// 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;
}
// Generate unique name
const detector = new FirstRunDetector(this.central.getGlobalDir());
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",
});
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 {
// 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",
});
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;
}
}
// ── 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) {
// No projects registered - check if cwd has a .kb/ directory
if (this.hasKbProject(cwd)) {
// Auto-migrate this project
const coordinator = new MigrationCoordinator(this.central);
const result = await coordinator.registerSingleProject(cwd);
if (result.success && result.projectsRegistered.length > 0) {
const newProject = await this.central.getProject(result.projectsRegistered[0]);
if (newProject) {
return {
projectId: newProject.id,
workingDirectory: newProject.path,
isLegacy: false,
};
}
}
}
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 kb project.
*/
private hasKbProject(dir: string): boolean {
const kbDir = join(dir, ".kb");
const dbPath = join(kbDir, "kb.db");
if (!existsSync(kbDir)) return false;
if (!existsSync(dbPath)) return false;
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
}