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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user