feat(KB-504): add multi-project migration system

- Add MigrationOrchestrator with automatic project detection and registration
- Add FirstRunExperience with setup wizard for new users
- Add backward compatibility layer for single-project workflows
- Integrate migration hook into CLI entry point
- Add comprehensive tests for migration and first-run functionality
- Update AGENTS.md with multi-project migration and dashboard documentation
This commit is contained in:
gsxdsm
2026-04-01 01:40:42 -07:00
parent c6be1323d1
commit d2354560a0
11 changed files with 1949 additions and 1 deletions

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { FirstRunExperience, createFirstRunExperience } from "../first-run.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-first-run-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true });
writeFileSync(join(dir, ".kb", "kb.db"), "");
}
describe("FirstRunExperience", () => {
let tempDir: string;
let centralCore: CentralCore;
let firstRun: FirstRunExperience;
let originalCwd: string;
beforeEach(async () => {
tempDir = createTempDir();
centralCore = new CentralCore(tempDir);
await centralCore.init();
// Create GlobalSettingsStore with temp directory for isolation
const { GlobalSettingsStore } = await import("../global-settings.js");
const globalSettingsStore = new GlobalSettingsStore(tempDir);
await globalSettingsStore.init();
firstRun = new FirstRunExperience(centralCore, globalSettingsStore);
originalCwd = process.cwd();
});
afterEach(() => {
try {
process.chdir(originalCwd);
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("isFirstRun", () => {
it("should return true when no projects registered", async () => {
const result = await firstRun.isFirstRun();
expect(result).toBe(true);
});
it("should return false when projects are registered", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
const result = await firstRun.isFirstRun();
expect(result).toBe(false);
});
it("should return true when central core is not initialized", async () => {
const uninitializedCore = new CentralCore(tempDir);
const uninitializedFirstRun = new FirstRunExperience(uninitializedCore);
const result = await uninitializedFirstRun.isFirstRun();
expect(result).toBe(true);
});
});
describe("detectOrCreateInitialProject", () => {
it("should detect and register project from cwd", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
// Change to the project directory
process.chdir(projectDir);
const result = await firstRun.detectOrCreateInitialProject();
expect(result.type).toBe("detected");
if (result.type === "detected") {
expect(result.project.name).toBe("my-project");
// Use realpath comparison to handle macOS /private prefix
const realProjectDir = await realpath(projectDir);
expect(result.project.path).toBe(realProjectDir);
}
});
it("should return manual-setup when no project in cwd", async () => {
// Stay in tempDir which has no kb project
process.chdir(tempDir);
const result = await firstRun.detectOrCreateInitialProject();
expect(result.type).toBe("manual-setup");
});
it("should return manual-setup with detected projects", async () => {
// Create a project in a subdirectory
const projectDir = join(tempDir, "sub-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
process.chdir(tempDir);
const result = await firstRun.detectOrCreateInitialProject();
expect(result.type).toBe("manual-setup");
if (result.type === "manual-setup") {
expect(result.detectedFromCwd).toBeDefined();
expect(result.detectedFromCwd!.length).toBeGreaterThan(0);
}
});
});
describe("getSetupState", () => {
it("should return complete setup state for first run", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
process.chdir(tempDir);
const state = await firstRun.getSetupState();
expect(state.isFirstRun).toBe(true);
expect(state.hasDetectedProjects).toBe(true);
expect(state.detectedProjects.length).toBeGreaterThan(0);
expect(state.registeredProjects).toHaveLength(0);
expect(state.recommendedAction).toBe("auto-detect");
});
it("should return create-new when no projects detected", async () => {
process.chdir(tempDir);
const state = await firstRun.getSetupState();
expect(state.isFirstRun).toBe(true);
expect(state.hasDetectedProjects).toBe(false);
expect(state.recommendedAction).toBe("create-new");
});
it("should return manual-setup when not first run and no projects", async () => {
// Register a project first
const projectDir = join(tempDir, "existing-project");
mkdirSync(projectDir, { recursive: true });
await centralCore.registerProject({
name: "existing",
path: projectDir,
isolationMode: "in-process",
});
process.chdir(tempDir);
const state = await firstRun.getSetupState();
expect(state.isFirstRun).toBe(false);
expect(state.registeredProjects).toHaveLength(1);
expect(state.recommendedAction).toBe("manual-setup");
});
});
describe("completeSetup", () => {
it("should register projects and return success", async () => {
const projectDir = join(tempDir, "new-project");
mkdirSync(projectDir, { recursive: true });
const result = await firstRun.completeSetup([
{ path: projectDir, name: "new-project" },
]);
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe("new-project");
expect(result.nextSteps.length).toBeGreaterThan(0);
});
it("should handle multiple projects", async () => {
const project1 = join(tempDir, "project-1");
const project2 = join(tempDir, "project-2");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
const result = await firstRun.completeSetup([
{ path: project1, name: "project-1" },
{ path: project2, name: "project-2" },
]);
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(2);
});
it("should skip already registered projects", async () => {
const projectDir = join(tempDir, "existing-project");
mkdirSync(projectDir, { recursive: true });
// Register first
await centralCore.registerProject({
name: "existing-project",
path: projectDir,
isolationMode: "in-process",
});
const result = await firstRun.completeSetup([
{ path: projectDir, name: "existing-project" },
]);
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(1);
// Should use the existing registration
expect(result.projects[0].path).toBe(projectDir);
});
it("should handle invalid paths gracefully", async () => {
const result = await firstRun.completeSetup([
{ path: "/non/existent/path", name: "invalid" },
]);
expect(result.success).toBe(false);
expect(result.projects).toHaveLength(0);
});
it("should set projects to active status", async () => {
const projectDir = join(tempDir, "new-project");
mkdirSync(projectDir, { recursive: true });
const result = await firstRun.completeSetup([
{ path: projectDir, name: "new-project" },
]);
expect(result.projects[0].status).toBe("active");
});
it("should be idempotent (running twice doesn't duplicate)", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// First call
const result1 = await firstRun.completeSetup([
{ path: projectDir, name: "my-project" },
]);
expect(result1.success).toBe(true);
// Second call - should not error or duplicate
const result2 = await firstRun.completeSetup([
{ path: projectDir, name: "my-project" },
]);
expect(result2.success).toBe(true);
// Should still only have 1 project
const projects = await centralCore.listProjects();
expect(projects).toHaveLength(1);
expect(projects[0].name).toBe("my-project");
});
it("should persist setupComplete in global settings", async () => {
const projectDir = join(tempDir, "new-project");
mkdirSync(projectDir, { recursive: true });
// Before setup, setupComplete should not be set
const settingsBefore = await firstRun["globalSettingsStore"].getSettings();
expect(settingsBefore.setupComplete).toBeUndefined();
// Complete setup
await firstRun.completeSetup([{ path: projectDir, name: "new-project" }]);
// After successful setup, setupComplete should be true
const settingsAfter = await firstRun["globalSettingsStore"].getSettings();
expect(settingsAfter.setupComplete).toBe(true);
});
it("should check setupComplete flag in isFirstRun", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Initially should be first run
expect(await firstRun.isFirstRun()).toBe(true);
// Complete setup
await firstRun.completeSetup([{ path: projectDir, name: "my-project" }]);
// After setup, should NOT be first run (due to setupComplete flag)
expect(await firstRun.isFirstRun()).toBe(false);
});
});
describe("createFirstRunExperience", () => {
it("should create a FirstRunExperience instance", () => {
const instance = createFirstRunExperience(centralCore);
expect(instance).toBeInstanceOf(FirstRunExperience);
});
});
});

View File

@@ -0,0 +1,394 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, basename } from "node:path";
import { MigrationOrchestrator, createMigrationOrchestrator, MAX_AUTO_REGISTER_PROJECTS } from "../migration-orchestrator.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-migration-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true });
// Create an empty file as the database (enough for detection)
writeFileSync(join(dir, ".kb", "kb.db"), "");
}
describe("MigrationOrchestrator", () => {
let tempDir: string;
let centralCore: CentralCore;
let orchestrator: MigrationOrchestrator;
beforeEach(async () => {
tempDir = createTempDir();
centralCore = new CentralCore(tempDir);
await centralCore.init();
orchestrator = new MigrationOrchestrator(centralCore);
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("detectExistingProjects", () => {
it("should detect a single kb project", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const detected = await orchestrator.detectExistingProjects(projectDir);
expect(detected).toHaveLength(1);
expect(detected[0].path).toBe(projectDir);
expect(detected[0].name).toBe("my-project");
expect(detected[0].hasDb).toBe(true);
});
it("should detect multiple kb projects in subdirectories", async () => {
const project1 = join(tempDir, "project-a");
const project2 = join(tempDir, "project-b");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
createFakeKbProject(project1);
createFakeKbProject(project2);
const detected = await orchestrator.detectExistingProjects(tempDir);
expect(detected).toHaveLength(2);
expect(detected.map((p) => p.name).sort()).toEqual(["project-a", "project-b"]);
});
it("should skip node_modules directories", async () => {
const projectDir = join(tempDir, "my-project");
const nodeModules = join(projectDir, "node_modules", "some-package");
mkdirSync(nodeModules, { recursive: true });
createFakeKbProject(nodeModules);
createFakeKbProject(projectDir);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should only find the main project, not the one in node_modules
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should skip hidden directories", async () => {
const projectDir = join(tempDir, "my-project");
const hiddenDir = join(tempDir, ".hidden-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(hiddenDir, { recursive: true });
createFakeKbProject(projectDir);
createFakeKbProject(hiddenDir);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should not detect the hidden directory
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should skip build and cache directories", async () => {
const projectDir = join(tempDir, "my-project");
const distDir = join(tempDir, "dist", "some-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(distDir, { recursive: true });
createFakeKbProject(projectDir);
createFakeKbProject(distDir);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should not detect the one in dist
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should respect maxDepth parameter", async () => {
// Create nested structure: temp/a/b/c/project
const nested = join(tempDir, "a", "b", "c", "project");
mkdirSync(nested, { recursive: true });
createFakeKbProject(nested);
// With maxDepth=2, should not find the project at depth 4
const detected = await orchestrator.detectExistingProjects(tempDir, 2);
expect(detected).toHaveLength(0);
// With maxDepth=5, should find it
const detectedDeep = await orchestrator.detectExistingProjects(tempDir, 5);
expect(detectedDeep).toHaveLength(1);
});
it("should stop recursion at a project root (don't look inside projects)", async () => {
const projectDir = join(tempDir, "my-project");
const nestedProject = join(projectDir, "packages", "sub-project");
mkdirSync(nestedProject, { recursive: true });
createFakeKbProject(projectDir);
createFakeKbProject(nestedProject);
const detected = await orchestrator.detectExistingProjects(tempDir);
// Should only detect the top-level project, not the nested one
// (because we stop recursing when we find a project)
expect(detected).toHaveLength(1);
expect(detected[0].name).toBe("my-project");
});
it("should return empty array when no projects found", async () => {
const detected = await orchestrator.detectExistingProjects(tempDir);
expect(detected).toHaveLength(0);
});
it("should throw on non-existent path", async () => {
await expect(
orchestrator.detectExistingProjects("/non/existent/path")
).rejects.toThrow("Scan path does not exist");
});
it("should throw on relative path", async () => {
// A path starting with './' is relative - after resolve() it becomes absolute
// but we need to check before resolving
await expect(
orchestrator.detectExistingProjects("./relative/path")
).rejects.toThrow("Scan path must be absolute");
});
it("should detect projects without valid database as hasDb=false", async () => {
const projectDir = join(tempDir, "incomplete-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(join(projectDir, ".fusion"), { recursive: true });
// Create directory but no kb.db file
const detected = await orchestrator.detectExistingProjects(projectDir);
expect(detected).toHaveLength(0);
});
});
describe("autoRegisterProjects", () => {
it("should register detected projects", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const detected = [{ path: projectDir, name: "my-project", hasDb: true }];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(1);
expect(registered[0].name).toBe("my-project");
expect(registered[0].path).toBe(projectDir);
expect(registered[0].isolationMode).toBe("in-process");
expect(registered[0].status).toBe("active");
});
it("should skip projects without valid database", async () => {
const validProject = join(tempDir, "valid");
const invalidProject = join(tempDir, "invalid");
mkdirSync(validProject, { recursive: true });
mkdirSync(join(invalidProject, ".fusion"), { recursive: true });
createFakeKbProject(validProject);
const detected = [
{ path: validProject, name: "valid", hasDb: true },
{ path: invalidProject, name: "invalid", hasDb: false },
];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(1);
expect(registered[0].name).toBe("valid");
});
it("should skip already registered projects", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
// Register first
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
// Try to register again
const detected = [{ path: projectDir, name: "my-project", hasDb: true }];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(0);
});
it("should generate unique names for duplicate basenames", async () => {
const project1 = join(tempDir, "repos", "my-project");
const project2 = join(tempDir, "other", "my-project");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
createFakeKbProject(project1);
createFakeKbProject(project2);
// Register first project directly
await centralCore.registerProject({
name: "my-project",
path: project1,
isolationMode: "in-process",
});
// Auto-register should use name-2 for second project
const detected = [
{ path: project1, name: "my-project", hasDb: true },
{ path: project2, name: "my-project", hasDb: true },
];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(1);
expect(registered[0].name).toBe("my-project-2");
});
it("should skip projects that would create circular references", async () => {
const parent = join(tempDir, "parent-project");
const child = join(parent, "child-project");
mkdirSync(parent, { recursive: true });
mkdirSync(child, { recursive: true });
createFakeKbProject(parent);
createFakeKbProject(child);
// Register parent first
await centralCore.registerProject({
name: "parent-project",
path: parent,
isolationMode: "in-process",
});
// Try to register child (should be skipped as circular)
const detected = [
{ path: parent, name: "parent-project", hasDb: true },
{ path: child, name: "child-project", hasDb: true },
];
const registered = await orchestrator.autoRegisterProjects(detected);
expect(registered).toHaveLength(0); // Both skipped (parent already registered, child circular)
});
it("should throw when exceeding MAX_AUTO_REGISTER_PROJECTS", async () => {
// Create too many projects
const detected: Array<{ path: string; name: string; hasDb: boolean }> = [];
for (let i = 0; i < MAX_AUTO_REGISTER_PROJECTS + 1; i++) {
detected.push({ path: `/project/${i}`, name: `project-${i}`, hasDb: true });
}
await expect(orchestrator.autoRegisterProjects(detected)).rejects.toThrow(
"Too many projects detected"
);
});
});
describe("needsMigration", () => {
it("should return true when no projects registered and projects exist", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const needsMigration = await orchestrator.needsMigration(tempDir);
expect(needsMigration).toBe(true);
});
it("should return false when projects already registered", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
const needsMigration = await orchestrator.needsMigration(tempDir);
expect(needsMigration).toBe(false);
});
it("should return false when no projects exist on filesystem", async () => {
// No projects created
const needsMigration = await orchestrator.needsMigration(tempDir);
expect(needsMigration).toBe(false);
});
});
describe("runMigration", () => {
it("should run full migration with autoRegister", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const result = await orchestrator.runMigration({ startPath: tempDir, autoRegister: true });
expect(result.projectsDetected).toHaveLength(1);
expect(result.projectsRegistered).toHaveLength(1);
expect(result.projectsRegistered[0].name).toBe("my-project");
expect(result.errors).toHaveLength(0);
});
it("should run detection only without autoRegister", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const result = await orchestrator.runMigration({ startPath: tempDir, autoRegister: false });
expect(result.projectsDetected).toHaveLength(1);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.projectsSkipped).toHaveLength(1);
});
it("should support dry-run mode", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const result = await orchestrator.runMigration({ startPath: tempDir, dryRun: true });
expect(result.projectsDetected).toHaveLength(1);
expect(result.projectsRegistered).toHaveLength(0);
expect(result.projectsSkipped[0].reason).toContain("DRY RUN");
});
it("should call progress callback", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
createFakeKbProject(projectDir);
const onProgress = vi.fn();
await orchestrator.runMigration({ autoRegister: true, onProgress });
expect(onProgress).toHaveBeenCalled();
});
it("should handle detection errors gracefully", async () => {
// Pass a non-existent directory to cause an error
const nonExistentPath = join(tempDir, "does-not-exist");
const result = await orchestrator.runMigration({ startPath: nonExistentPath });
expect(result.errors).toHaveLength(1);
expect(result.errors[0].error).toContain("Detection failed");
});
});
describe("createMigrationOrchestrator", () => {
it("should create an orchestrator instance", () => {
const instance = createMigrationOrchestrator(centralCore);
expect(instance).toBeInstanceOf(MigrationOrchestrator);
});
});
});

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "../store.js";
import { CentralCore } from "../central-core.js";
// Helper to create a temp directory
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-compat-test-"));
}
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true });
writeFileSync(join(dir, ".kb", "kb.db"), "");
}
describe("TaskStore Backward Compatibility", () => {
let tempDir: string;
let centralCore: CentralCore;
let originalCwd: string;
beforeEach(async () => {
tempDir = createTempDir();
centralCore = new CentralCore(tempDir);
await centralCore.init();
originalCwd = process.cwd();
});
afterEach(async () => {
try {
process.chdir(originalCwd);
await centralCore.close();
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe("getOrCreateForProject", () => {
it("should create store for specified project ID", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Register the project first
const project = await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
const store = await TaskStore.getOrCreateForProject(project.id, centralCore);
expect(store).toBeInstanceOf(TaskStore);
// Verify it's using the correct path
const settings = await store.getSettings();
expect(settings).toBeDefined();
});
it("should find project by name when ID not found", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Register the project
await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
// Look up by name instead of ID
const store = await TaskStore.getOrCreateForProject("my-project", centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should use single registered project when no ID provided", async () => {
const projectDir = join(tempDir, "single-project");
mkdirSync(projectDir, { recursive: true });
// Register exactly one project
await centralCore.registerProject({
name: "single-project",
path: projectDir,
isolationMode: "in-process",
});
const store = await TaskStore.getOrCreateForProject(undefined, centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should throw when multiple projects and no ID specified", async () => {
const project1 = join(tempDir, "project-1");
const project2 = join(tempDir, "project-2");
mkdirSync(project1, { recursive: true });
mkdirSync(project2, { recursive: true });
// Register two projects
await centralCore.registerProject({
name: "project-1",
path: project1,
isolationMode: "in-process",
});
await centralCore.registerProject({
name: "project-2",
path: project2,
isolationMode: "in-process",
});
await expect(
TaskStore.getOrCreateForProject(undefined, centralCore)
).rejects.toThrow("Multiple projects registered");
});
it("should fall back to legacy mode when no projects registered", async () => {
// No projects registered in central core
process.chdir(tempDir);
const store = await TaskStore.getOrCreateForProject(undefined, centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should throw when project ID not found", async () => {
await expect(
TaskStore.getOrCreateForProject("non-existent-project", centralCore)
).rejects.toThrow('Project "non-existent-project" not found');
});
it("should auto-initialize central core if not provided", async () => {
const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true });
// Register a project
const { id: projectId } = await centralCore.registerProject({
name: "my-project",
path: projectDir,
isolationMode: "in-process",
});
// Pass the central core explicitly to ensure it uses the right database
const store = await TaskStore.getOrCreateForProject(projectId, centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
});
describe("existing constructor", () => {
it("should still support direct TaskStore construction", async () => {
const projectDir = join(tempDir, "direct-project");
mkdirSync(projectDir, { recursive: true });
// Direct construction should still work
const store = new TaskStore(projectDir);
await store.init();
expect(store).toBeInstanceOf(TaskStore);
// Should be able to create tasks
const task = await store.createTask({
description: "Test task",
column: "triage",
});
expect(task.id).toBeDefined();
expect(task.description).toBe("Test task");
});
});
describe("events without central core", () => {
it("should emit events in single-project mode", async () => {
const projectDir = join(tempDir, "event-test");
mkdirSync(projectDir, { recursive: true });
const store = new TaskStore(projectDir);
await store.init();
const taskCreatedListener = vi.fn();
store.on("task:created", taskCreatedListener);
await store.createTask({
description: "Event test task",
column: "triage",
});
expect(taskCreatedListener).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,273 @@
/**
* First-Run Experience — Setup wizard logic for new users and auto-migration.
*
* Handles the transition from single-project to multi-project mode:
* - Detects if this is a fresh installation (no projects registered)
* - Auto-detects existing kb projects from current working directory
* - Guides users through initial project registration
* - Provides setup state for dashboard wizard UI
*
* @example
* ```typescript
* const central = new CentralCore();
* await central.init();
*
* const firstRun = new FirstRunExperience(central);
*
* if (await firstRun.isFirstRun()) {
* const state = await firstRun.getSetupState();
* // Show wizard UI with state.detectedProjects
* }
* ```
*/
import type {
SetupState,
ProjectSetupInput,
SetupCompletionResult,
DetectedProject,
RegisteredProject,
GlobalSettings,
} from "./types.js";
import type { CentralCore } from "./central-core.js";
import { MigrationOrchestrator } from "./migration-orchestrator.js";
import { realpath } from "node:fs/promises";
import { GlobalSettingsStore } from "./global-settings.js";
// ── Constants ──────────────────────────────────────────────────────────────
/** Key in global settings to track if setup is complete */
export const SETUP_COMPLETE_KEY = "setupComplete";
// ── FirstRunExperience Class ─────────────────────────────────────────────
export class FirstRunExperience {
private centralCore: CentralCore;
private globalSettingsStore: GlobalSettingsStore;
private migrationOrchestrator: MigrationOrchestrator;
/**
* Create a FirstRunExperience instance.
* @param centralCore — Initialized CentralCore instance
* @param globalSettingsStore — GlobalSettingsStore instance
*/
constructor(centralCore: CentralCore, globalSettingsStore?: GlobalSettingsStore) {
this.centralCore = centralCore;
this.globalSettingsStore = globalSettingsStore ?? new GlobalSettingsStore();
this.migrationOrchestrator = new MigrationOrchestrator(centralCore);
}
/**
* Check if this is a first-run scenario.
*
* Returns true if:
* - No projects are registered in the central database
* - AND setup has not been marked as complete in global settings
*
* This indicates either:
* - Fresh installation (new user)
* - Reset central database (existing user starting fresh)
*/
async isFirstRun(): Promise<boolean> {
if (!this.centralCore.isInitialized()) {
return true;
}
// Check global settings for setup completion flag
const globalSettings = await this.globalSettingsStore.getSettings();
if (globalSettings.setupComplete) {
return false;
}
const projects = await this.centralCore.listProjects();
return projects.length === 0;
}
/**
* Detect or create the initial project.
*
* Tries the following in order:
* 1. Detect existing kb project from `process.cwd()`
* 2. If found, auto-register it and return
* 3. If not found, return guidance for manual setup
*
* @returns Detection result with type and optional project
*/
async detectOrCreateInitialProject(): Promise<
| { type: "detected"; project: RegisteredProject }
| { type: "manual-setup"; detectedFromCwd?: DetectedProject[] }
> {
const cwd = process.cwd();
// Try to detect from current directory
const detected = await this.migrationOrchestrator.detectExistingProjects(
cwd,
2 // Shallow scan - just cwd and immediate subdirectories
);
// Filter to projects with valid databases
const validProjects = detected.filter((p) => p.hasDb);
// Check if there's a project directly in cwd (exact match, not subdirectory)
// Use realpath to handle macOS /private symlink differences
const realCwd = await realpath(cwd);
const projectsWithRealPath = await Promise.all(
validProjects.map(async (p) => ({
...p,
realPath: await realpath(p.path),
}))
);
const projectInCwd = projectsWithRealPath.find((p) => p.realPath === realCwd);
if (projectInCwd) {
// Found a project directly in cwd - auto-register it
const toRegister = [{ ...projectInCwd, path: projectInCwd.realPath }];
const registered = await this.migrationOrchestrator.autoRegisterProjects(toRegister);
if (registered.length > 0) {
return { type: "detected", project: registered[0] };
}
}
// No project directly in cwd - return manual setup guidance
// Include any detected projects for user to choose from
return {
type: "manual-setup",
detectedFromCwd: validProjects.length > 0 ? validProjects : undefined,
};
}
/**
* Get the complete setup state for the wizard UI.
*
* Returns all information needed to render the first-run wizard:
* - Whether this is a first-run scenario
* - Projects detected on filesystem
* - Projects already registered
* - Recommended action based on state
*/
async getSetupState(): Promise<SetupState> {
const [isFirstRun, detectedProjects, registeredProjects] = await Promise.all([
this.isFirstRun(),
this.migrationOrchestrator.detectExistingProjects(process.cwd(), 3),
this.centralCore.listProjects(),
]);
const validDetected = detectedProjects.filter((p: DetectedProject) => p.hasDb);
const hasDetectedProjects = validDetected.length > 0;
// Determine recommended action
let recommendedAction: SetupState["recommendedAction"];
if (hasDetectedProjects) {
recommendedAction = "auto-detect";
} else if (isFirstRun) {
recommendedAction = "create-new";
} else {
recommendedAction = "manual-setup";
}
return {
isFirstRun,
hasDetectedProjects,
detectedProjects: validDetected,
registeredProjects,
recommendedAction,
};
}
/**
* Complete the setup by registering selected projects.
*
* This is the final step of the first-run wizard. It registers the
* projects selected by the user and marks setup as complete.
*
* @param projects — Projects to register
* @returns Setup completion result
*/
async completeSetup(
projects: ProjectSetupInput[]
): Promise<SetupCompletionResult> {
const registered: RegisteredProject[] = [];
const errors: Array<{ path: string; error: string }> = [];
for (const project of projects) {
try {
// Check if already registered
const existing = await this.centralCore.getProjectByPath(project.path);
if (existing) {
registered.push(existing);
continue;
}
// Register the project
const newProject = await this.centralCore.registerProject({
name: project.name,
path: project.path,
isolationMode: project.isolationMode ?? "in-process",
});
// Activate the project
const activeProject = await this.centralCore.updateProject(
newProject.id,
{ status: "active" }
);
registered.push(activeProject);
} catch (err) {
errors.push({
path: project.path,
error: (err as Error).message,
});
}
}
const success = registered.length > 0 && errors.length === 0;
const nextSteps: string[] = [];
if (success) {
if (registered.length === 1) {
nextSteps.push(
`Project "${registered[0].name}" is ready. Run "fn dashboard" to start the web UI.`
);
} else {
nextSteps.push(
`${registered.length} projects registered. Run "fn project list" to see them all.`
);
}
nextSteps.push('Use "fn project add <path>" to register additional projects.');
} else {
if (registered.length > 0) {
nextSteps.push(
`${registered.length} project(s) registered successfully, but ${errors.length} failed.`
);
}
nextSteps.push("Check error details above and try again.");
}
// Mark setup as complete in global settings if successful
if (success) {
await this.globalSettingsStore.updateSettings({ setupComplete: true });
}
return {
success,
projects: registered,
nextSteps,
};
}
}
// ── Factory Function ───────────────────────────────────────────────────────
/**
* Create a FirstRunExperience instance.
* @param centralCore — Initialized CentralCore instance
* @param globalSettingsStore — Optional GlobalSettingsStore instance
* @returns FirstRunExperience
*/
export function createFirstRunExperience(
centralCore: CentralCore,
globalSettingsStore?: GlobalSettingsStore
): FirstRunExperience {
return new FirstRunExperience(centralCore, globalSettingsStore);
}

View File

@@ -121,5 +121,26 @@ export type {
ProjectStatus,
ProjectHealth,
CentralActivityLogEntry,
GlobalConcurrencyState
GlobalConcurrencyState,
DetectedProject,
MigrationOptions,
MigrationResult,
ProjectSetupInput,
SetupState,
SetupCompletionResult,
} from "./types.js";
// ── Migration & First-Run (Multi-Project Support) ───────────────────────────
export {
MigrationOrchestrator,
createMigrationOrchestrator,
MAX_AUTO_REGISTER_PROJECTS,
DEFAULT_MAX_DEPTH,
EXCLUDED_DIRS,
} from "./migration-orchestrator.js";
export {
FirstRunExperience,
createFirstRunExperience,
} from "./first-run.js";

View File

@@ -0,0 +1,472 @@
/**
* 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,
IsolationMode,
} from "./types.js";
import type { CentralCore } from "./central-core.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 `.kb/kb.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 .kb/kb.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 .kb/kb.db exists and is a file.
*/
private isKbProject(dir: string): boolean {
const kbPath = join(dir, ".kb");
if (!existsSync(kbPath)) {
return false;
}
const dbPath = join(kbPath, "kb.db");
if (!existsSync(dbPath)) {
return false;
}
try {
const stats = statSync(dbPath);
return stats.isFile();
} catch {
return false;
}
}
/**
* 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 kb.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);
}

View File

@@ -2714,4 +2714,97 @@ ${notificationsSection}`;
}
return this.missionStore;
}
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
/**
* Get or create a TaskStore for a project, supporting backward-compatible
* single-project mode and multi-project resolution.
*
* Resolution logic:
* - If `projectId` provided: look up in central registry, create store for that path
* - If no `projectId` and single project registered: use that project
* - If no `projectId` and multiple projects: throw requiring explicit selection
* - If no central DB available: fall back to legacy behavior (current directory)
*
* @param projectId — Optional project ID to resolve
* @param centralCore — Optional CentralCore instance (creates new if not provided)
* @returns TaskStore initialized for the resolved project
* @throws Error if project resolution fails or multiple projects require explicit selection
*/
static async getOrCreateForProject(
projectId?: string,
centralCore?: import("./central-core.js").CentralCore
): Promise<TaskStore> {
// If no centralCore provided, try to create one
let core = centralCore;
let shouldCleanupCore = false;
if (!core) {
try {
const { CentralCore } = await import("./central-core.js");
core = new CentralCore();
await core.init();
shouldCleanupCore = true;
} catch {
// Central core not available - fall back to legacy mode
}
}
// Legacy mode: no central core available
if (!core) {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
try {
// If projectId provided, look it up directly
if (projectId) {
const project = await core.getProject(projectId);
if (!project) {
// Try to find by name
const allProjects = await core.listProjects();
const byName = allProjects.find(p => p.name === projectId);
if (!byName) {
throw new Error(`Project "${projectId}" not found`);
}
const store = new TaskStore(byName.path);
await store.init();
return store;
}
const store = new TaskStore(project.path);
await store.init();
return store;
}
// No projectId provided - check registered projects
const projects = await core.listProjects();
if (projects.length === 0) {
// No projects registered - fall back to legacy mode (current directory)
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
if (projects.length === 1) {
// Exactly one project - use it
const store = new TaskStore(projects[0].path);
await store.init();
return store;
}
// Multiple projects - require explicit selection
const projectList = projects.map(p => ` - ${p.name}: ${p.path}`).join("\n");
throw new Error(
`Multiple projects registered. Use --project <name> to specify one.\n\nAvailable projects:\n${projectList}`
);
} finally {
// Clean up the central core if we created it
if (shouldCleanupCore && core) {
await core.close();
}
}
}
}

View File

@@ -527,6 +527,9 @@ export interface GlobalSettings {
* no project can be auto-detected from the current directory.
* Used for multi-project CLI workflows. */
defaultProjectId?: string;
/** When true, indicates the first-run setup wizard has been completed.
* Set by FirstRunExperience.completeSetup() after successful migration. */
setupComplete?: boolean;
}
/**
@@ -1086,3 +1089,75 @@ export interface AgentUpdateInput {
role?: AgentCapability;
metadata?: Record<string, unknown>;
}
// ── Migration & First-Run Types (Multi-Project Support) ───────────────────
/** A project detected during filesystem scanning for auto-migration */
export interface DetectedProject {
/** Absolute path to the project directory */
path: string;
/** Project name (derived from directory basename) */
name: string;
/** Whether the project has a valid kb database */
hasDb: boolean;
}
/** Options for migration orchestration */
export interface MigrationOptions {
/** Starting path for project detection (default: process.cwd()) */
startPath?: string;
/** Whether to auto-register detected projects (default: false) */
autoRegister?: boolean;
/** Whether to perform a dry run (detect only, don't register) */
dryRun?: boolean;
/** Maximum depth to scan (default: 5) */
maxDepth?: number;
/** Progress callback for UI feedback */
onProgress?: (current: number, total: number, projectPath: string) => void;
}
/** Result of migration execution */
export interface MigrationResult {
/** Projects detected during scan */
projectsDetected: DetectedProject[];
/** Projects successfully registered */
projectsRegistered: RegisteredProject[];
/** Projects skipped (already registered or invalid) */
projectsSkipped: Array<{ path: string; reason: string }>;
/** Errors encountered during migration */
errors: Array<{ path: string; error: string }>;
}
/** Input for setting up a project during first-run wizard */
export interface ProjectSetupInput {
/** Absolute path to project directory */
path: string;
/** Display name for the project */
name: string;
/** Execution isolation mode (default: 'in-process') */
isolationMode?: IsolationMode;
}
/** Complete setup state for first-run experience */
export interface SetupState {
/** Whether this is a fresh installation (no projects registered) */
isFirstRun: boolean;
/** Whether any projects were detected during scan */
hasDetectedProjects: boolean;
/** Projects detected but not yet registered */
detectedProjects: DetectedProject[];
/** Projects already registered in the system */
registeredProjects: RegisteredProject[];
/** Recommended action based on current state */
recommendedAction: 'auto-detect' | 'manual-setup' | 'create-new';
}
/** Result of completing the setup wizard */
export interface SetupCompletionResult {
/** Whether setup completed successfully */
success: boolean;
/** Projects that were registered */
projects: RegisteredProject[];
/** Suggested next steps for the user */
nextSteps: string[];
}