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:
300
packages/core/src/__tests__/first-run.test.ts
Normal file
300
packages/core/src/__tests__/first-run.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
394
packages/core/src/__tests__/migration-orchestrator.test.ts
Normal file
394
packages/core/src/__tests__/migration-orchestrator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
192
packages/core/src/__tests__/store-backward-compat.test.ts
Normal file
192
packages/core/src/__tests__/store-backward-compat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
273
packages/core/src/first-run.ts
Normal file
273
packages/core/src/first-run.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
472
packages/core/src/migration-orchestrator.ts
Normal file
472
packages/core/src/migration-orchestrator.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user