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,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);
});
});