feat(KB-620): add multi-project migration and first-run experience
- Add FirstRunDetector for fresh-install and migration state detection - Implement MigrationOrchestrator for auto-registering existing projects - Create BackwardCompat layer to maintain single-project CLI workflows - Add fn init command for manual project registration - Add dashboard first-run wizard API endpoints (/api/setup-state, /api/complete-setup) - Add runtimeLog, ipcLog, projectManagerLog, hybridExecutorLog to engine logger - Include changeset documenting migration features and rollback procedure
This commit is contained in:
@@ -51,6 +51,7 @@ const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
|
||||
Usage:
|
||||
fn init Initialize a new kb project in current directory
|
||||
fn dashboard Start the board web UI
|
||||
fn dashboard --paused Start with automation paused
|
||||
fn dashboard --dev Start web UI only (no AI engine)
|
||||
@@ -160,8 +161,92 @@ async function main() {
|
||||
|
||||
const command = args[0];
|
||||
|
||||
// Migration check for first-run experience
|
||||
// Skip for init command and help flags
|
||||
if (command !== "init" && command !== "--help" && command !== "-h") {
|
||||
try {
|
||||
const { FirstRunDetector, MigrationCoordinator } = await import("@fusion/core");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
const detector = new FirstRunDetector();
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
if (state === "needs-migration") {
|
||||
const cwd = process.cwd();
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.registerSingleProject(cwd);
|
||||
|
||||
if (result.success && result.projectsRegistered.length > 0) {
|
||||
const project = await central.getProject(result.projectsRegistered[0]);
|
||||
if (project) {
|
||||
console.log(`✓ Auto-registered project: ${project.name}`);
|
||||
}
|
||||
} else if (result.errors.length > 0) {
|
||||
console.warn(`Migration warning: ${result.errors[0]}`);
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore migration errors - user can manually run fn init
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "init": {
|
||||
// Initialize a new kb project
|
||||
const { existsSync, mkdirSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const { Database } = await import("@fusion/core");
|
||||
|
||||
const cwd = process.cwd();
|
||||
const kbDir = join(cwd, ".kb");
|
||||
|
||||
// Check if already initialized
|
||||
if (existsSync(kbDir)) {
|
||||
// Check if already registered in central
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const existing = await central.getProjectByPath(cwd);
|
||||
|
||||
if (existing) {
|
||||
console.log(`Project "${existing.name}" already initialized at ${cwd}`);
|
||||
await central.close();
|
||||
break;
|
||||
}
|
||||
|
||||
// Directory exists but not registered - just register it
|
||||
const project = await central.autoRegisterProject(cwd);
|
||||
console.log(`✓ Registered project "${project.name}" at ${cwd}`);
|
||||
await central.close();
|
||||
break;
|
||||
}
|
||||
|
||||
// Create .kb directory
|
||||
mkdirSync(kbDir, { recursive: true });
|
||||
|
||||
// Initialize SQLite database
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
db.close();
|
||||
|
||||
// Register in central
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const project = await central.autoRegisterProject(cwd);
|
||||
console.log(`✓ Initialized kb project "${project.name}" at ${cwd}`);
|
||||
await central.close();
|
||||
break;
|
||||
}
|
||||
|
||||
case "dashboard": {
|
||||
// Initialize native module resolution for Bun binary before starting dashboard
|
||||
// This sets up the paths so node-pty can find its native assets
|
||||
|
||||
255
packages/core/src/backward-compat.test.ts
Normal file
255
packages/core/src/backward-compat.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Tests for backward compatibility layer
|
||||
*
|
||||
* Ensures single-project workflows continue working without --project flags.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
|
||||
// Helper to create temp directories
|
||||
function tempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
// Helper to create a fake kb project
|
||||
function createFakeKbProject(dir: string): void {
|
||||
const kbDir = join(dir, ".kb");
|
||||
mkdirSync(kbDir, { recursive: true });
|
||||
writeFileSync(join(kbDir, "kb.db"), "SQLite format 3\x00");
|
||||
}
|
||||
|
||||
describe("Backward Compatibility Layer", () => {
|
||||
let tempGlobalDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempGlobalDir = tempDir("kb-backward-compat-test-");
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
try {
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("single project auto-resolution", () => {
|
||||
it("should auto-resolve single project without --project flag", async () => {
|
||||
const tempProjectDir = tempDir("kb-single-compat-");
|
||||
const project = await central.registerProject({
|
||||
name: "Single Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
// No projectId provided - should auto-resolve to single project
|
||||
const context = await compat.resolveProjectContext("/any/dir");
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
expect(context.workingDirectory).toBe(tempProjectDir);
|
||||
expect(context.isLegacy).toBe(false);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should use explicit project ID when provided", async () => {
|
||||
const tempProjectDir = tempDir("kb-explicit-compat-");
|
||||
const project = await central.registerProject({
|
||||
name: "Explicit Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
const context = await compat.resolveProjectContext("/some/dir", project.id);
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
expect(context.workingDirectory).toBe(tempProjectDir);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should find project by name (case-insensitive)", async () => {
|
||||
const tempProjectDir = tempDir("kb-name-compat-");
|
||||
const project = await central.registerProject({
|
||||
name: "My Awesome Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
// Use lowercase name - should still find it
|
||||
const context = await compat.resolveProjectContext("/some/dir", "my awesome project");
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple projects requires explicit selection", () => {
|
||||
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
|
||||
const tempProjectDir1 = tempDir("kb-multi-compat1-");
|
||||
const tempProjectDir2 = tempDir("kb-multi-compat2-");
|
||||
const project1 = await central.registerProject({
|
||||
name: "Project One",
|
||||
path: tempProjectDir1,
|
||||
});
|
||||
const project2 = await central.registerProject({
|
||||
name: "Project Two",
|
||||
path: tempProjectDir2,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
|
||||
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
|
||||
ProjectRequiredError
|
||||
);
|
||||
|
||||
try {
|
||||
await compat.resolveProjectContext("/some/dir");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ProjectRequiredError);
|
||||
// Should provide list of available projects
|
||||
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
|
||||
const ids = (err as ProjectRequiredError).availableProjects.map((p) => p.id);
|
||||
expect(ids).toContain(project1.id);
|
||||
expect(ids).toContain(project2.id);
|
||||
}
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should resolve correctly when explicit project provided with multiple projects", async () => {
|
||||
const tempProjectDir1 = tempDir("kb-multi-explicit1-");
|
||||
const tempProjectDir2 = tempDir("kb-multi-explicit2-");
|
||||
const project1 = await central.registerProject({
|
||||
name: "Project One",
|
||||
path: tempProjectDir1,
|
||||
});
|
||||
await central.registerProject({
|
||||
name: "Project Two",
|
||||
path: tempProjectDir2,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
// Explicitly select project1
|
||||
const context = await compat.resolveProjectContext("/some/dir", project1.id);
|
||||
|
||||
expect(context.projectId).toBe(project1.id);
|
||||
expect(context.workingDirectory).toBe(tempProjectDir1);
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy mode without central database", () => {
|
||||
it("should return legacy mode when no central DB", async () => {
|
||||
// Close and remove central DB
|
||||
await central.close();
|
||||
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
|
||||
|
||||
// Re-create central but don't init
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
const context = await compat.resolveProjectContext("/some/legacy/dir");
|
||||
|
||||
expect(context.isLegacy).toBe(true);
|
||||
expect(context.projectId).toBe("legacy");
|
||||
expect(context.workingDirectory).toBe("/some/legacy/dir");
|
||||
});
|
||||
|
||||
it("should report legacy mode correctly", async () => {
|
||||
// Close and remove central DB
|
||||
await central.close();
|
||||
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
|
||||
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
expect(await compat.isLegacyMode()).toBe(true);
|
||||
});
|
||||
|
||||
it("should report non-legacy mode when central DB exists", async () => {
|
||||
const compat = new BackwardCompat(central);
|
||||
expect(await compat.isLegacyMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto-migration on resolve", () => {
|
||||
it("should auto-register project found in cwd when no projects registered", async () => {
|
||||
// Create a project directory with .kb/
|
||||
const projectDir = tempDir("kb-auto-migrate-compat-");
|
||||
createFakeKbProject(projectDir);
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
|
||||
// Should auto-register the project found in cwd
|
||||
const context = await compat.resolveProjectContext(projectDir);
|
||||
|
||||
expect(context.isLegacy).toBe(false);
|
||||
expect(context.workingDirectory).toBe(projectDir);
|
||||
|
||||
// Verify project was registered
|
||||
const isRegistered = await central.isProjectRegistered(projectDir);
|
||||
expect(isRegistered).toBe(true);
|
||||
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("error messages", () => {
|
||||
it("should provide helpful error when project not found", async () => {
|
||||
const compat = new BackwardCompat(central);
|
||||
|
||||
await expect(
|
||||
compat.resolveProjectContext("/some/dir", "nonexistent-project")
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("should provide helpful error when no projects registered", async () => {
|
||||
const compat = new BackwardCompat(central);
|
||||
|
||||
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
|
||||
/no projects registered/i
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectRequiredError backward compat", () => {
|
||||
it("should include both id and name for each available project", () => {
|
||||
const available = [
|
||||
{ id: "proj_abc123", name: "Frontend" },
|
||||
{ id: "proj_def456", name: "Backend" },
|
||||
{ id: "proj_ghi789", name: "Docs" },
|
||||
];
|
||||
|
||||
const error = new ProjectRequiredError(
|
||||
"Multiple projects available",
|
||||
available
|
||||
);
|
||||
|
||||
expect(error.availableProjects).toHaveLength(3);
|
||||
expect(error.availableProjects[0]).toHaveProperty("id");
|
||||
expect(error.availableProjects[0]).toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("should be catchable as ProjectRequiredError", async () => {
|
||||
const error = new ProjectRequiredError("test", []);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(ProjectRequiredError);
|
||||
expect(error.name).toBe("ProjectRequiredError");
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { isAbsolute, join, basename } from "node:path";
|
||||
import type {
|
||||
RegisteredProject,
|
||||
ProjectHealth,
|
||||
@@ -945,4 +945,134 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Migration Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Auto-register a project at the given path.
|
||||
*
|
||||
* This is used during migration from single-project to multi-project mode.
|
||||
* Generates the project name from git remote or directory name.
|
||||
*
|
||||
* @param projectPath — Absolute path to project directory
|
||||
* @returns Registered project
|
||||
* @throws Error if path doesn't exist, isn't absolute, or registration fails
|
||||
*/
|
||||
async autoRegisterProject(projectPath: string): Promise<RegisteredProject> {
|
||||
this.ensureInitialized();
|
||||
|
||||
// Check if already registered
|
||||
const existing = await this.getProjectByPath(projectPath);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Generate name from git remote or directory
|
||||
const name = await this.generateProjectName(projectPath);
|
||||
|
||||
// Ensure unique name
|
||||
const uniqueName = await this.ensureUniqueName(name);
|
||||
|
||||
// Register with in-process isolation
|
||||
return this.registerProject({
|
||||
name: uniqueName,
|
||||
path: projectPath,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current first-run state for this central instance.
|
||||
*
|
||||
* @returns First-run state
|
||||
*/
|
||||
async getFirstRunState(): Promise<import("./migration.js").FirstRunState> {
|
||||
const { FirstRunDetector } = await import("./migration.js");
|
||||
const detector = new FirstRunDetector(this.globalDir);
|
||||
return detector.detectFirstRunState(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project path is already registered.
|
||||
*
|
||||
* @param projectPath — Absolute project path
|
||||
* @returns true if already registered
|
||||
*/
|
||||
async isProjectRegistered(projectPath: string): Promise<boolean> {
|
||||
const existing = await this.getProjectByPath(projectPath);
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a project name from git remote or directory name.
|
||||
*/
|
||||
private async generateProjectName(projectPath: string): Promise<string> {
|
||||
// Try git remote first
|
||||
try {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["remote", "get-url", "origin"],
|
||||
{ cwd: projectPath, timeout: 5000 }
|
||||
);
|
||||
|
||||
const remoteUrl = stdout.trim();
|
||||
if (remoteUrl) {
|
||||
const name = this.extractRepoName(remoteUrl);
|
||||
if (name) return name;
|
||||
}
|
||||
} catch {
|
||||
// Git not available or no remote - fall through to directory name
|
||||
}
|
||||
|
||||
// Fallback to directory name
|
||||
return basename(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract repository name from git remote URL.
|
||||
*/
|
||||
private extractRepoName(remoteUrl: string): string | null {
|
||||
// Remove .git suffix
|
||||
const withoutGit = remoteUrl.replace(/\.git$/, "");
|
||||
|
||||
// Handle SSH format: git@host:owner/repo
|
||||
const sshMatch = withoutGit.match(/:([^/:]+\/([^/]+))$/);
|
||||
if (sshMatch) {
|
||||
return sshMatch[2];
|
||||
}
|
||||
|
||||
// Handle HTTPS format: https://host/owner/repo
|
||||
const httpsMatch = withoutGit.match(/\/([^/]+)$/);
|
||||
if (httpsMatch) {
|
||||
return httpsMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a project name is unique by appending -N suffix if needed.
|
||||
*/
|
||||
private async ensureUniqueName(baseName: string): Promise<string> {
|
||||
const existing = await this.listProjects();
|
||||
const existingNames = new Set(existing.map((p) => p.name.toLowerCase()));
|
||||
|
||||
if (!existingNames.has(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// Find unique suffix
|
||||
let counter = 1;
|
||||
let candidate = `${baseName}-${counter}`;
|
||||
while (existingNames.has(candidate.toLowerCase())) {
|
||||
counter++;
|
||||
candidate = `${baseName}-${counter}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,3 +532,60 @@ async function createBackups(kbDir: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Central Migration ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if migration to central database is needed.
|
||||
*
|
||||
* Returns true if:
|
||||
* - Central DB doesn't exist AND
|
||||
* - cwd has `.kb/kb.db` (existing single-project)
|
||||
*
|
||||
* @param cwd — Current working directory to check
|
||||
* @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
|
||||
*/
|
||||
export function needsCentralMigration(cwd: string, globalDir?: string): boolean {
|
||||
const { FirstRunDetector } = require("./migration.js");
|
||||
const detector = new FirstRunDetector(globalDir);
|
||||
|
||||
// Check if central DB exists
|
||||
if (detector.hasCentralDb()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if cwd has a kb project
|
||||
return detector["hasKbProject"](cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect existing projects by walking up from cwd.
|
||||
*
|
||||
* @param cwd — Starting directory (default: process.cwd())
|
||||
* @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
|
||||
* @returns Array of detected projects
|
||||
*/
|
||||
export async function detectExistingProjects(
|
||||
cwd?: string,
|
||||
globalDir?: string
|
||||
): Promise<Array<{ path: string; name: string; hasDb: boolean }>> {
|
||||
const { FirstRunDetector } = require("./migration.js");
|
||||
const detector = new FirstRunDetector(globalDir);
|
||||
return detector.detectExistingProjects(cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-migrate an existing single project to central database.
|
||||
*
|
||||
* @param existingProjectPath — Absolute path to existing project
|
||||
* @param central — Initialized CentralCore instance
|
||||
* @returns Migration result
|
||||
*/
|
||||
export async function autoMigrateToCentral(
|
||||
existingProjectPath: string,
|
||||
central: import("./central-core.js").CentralCore
|
||||
): Promise<import("./migration.js").MigrationResult> {
|
||||
const { MigrationCoordinator } = require("./migration.js");
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
return coordinator.registerSingleProject(existingProjectPath);
|
||||
}
|
||||
|
||||
@@ -123,3 +123,24 @@ export type {
|
||||
CentralActivityLogEntry,
|
||||
GlobalConcurrencyState
|
||||
} from "./types.js";
|
||||
|
||||
// ── Migration and First-Run Experience ────────────────────────────────
|
||||
|
||||
export {
|
||||
FirstRunDetector,
|
||||
MigrationCoordinator,
|
||||
BackwardCompat,
|
||||
ProjectRequiredError,
|
||||
} from "./migration.js";
|
||||
export type {
|
||||
FirstRunState,
|
||||
DetectedProject,
|
||||
MigrationResult,
|
||||
ProjectSetupInput,
|
||||
ResolvedContext,
|
||||
} from "./migration.js";
|
||||
export {
|
||||
needsCentralMigration,
|
||||
detectExistingProjects,
|
||||
autoMigrateToCentral,
|
||||
} from "./db-migrate.js";
|
||||
|
||||
643
packages/core/src/migration.test.ts
Normal file
643
packages/core/src/migration.test.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* Tests for migration and first-run detection
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
FirstRunDetector,
|
||||
MigrationCoordinator,
|
||||
BackwardCompat,
|
||||
ProjectRequiredError,
|
||||
type ProjectSetupInput,
|
||||
} from "./migration.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
|
||||
// Helper to create temp directories
|
||||
function tempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
// Helper to create a fake kb project
|
||||
function createFakeKbProject(dir: string): void {
|
||||
const kbDir = join(dir, ".kb");
|
||||
mkdirSync(kbDir, { recursive: true });
|
||||
// Create empty kb.db file (SQLite needs actual format, but for detection an empty file works)
|
||||
writeFileSync(join(kbDir, "kb.db"), "SQLite format 3\x00");
|
||||
}
|
||||
|
||||
// Helper to create a fake git remote
|
||||
async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
await execFileAsync("git", ["init"], { cwd: dir });
|
||||
await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: dir });
|
||||
await execFileAsync("git", ["config", "user.name", "Test"], { cwd: dir });
|
||||
|
||||
if (remoteUrl) {
|
||||
await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir });
|
||||
}
|
||||
}
|
||||
|
||||
describe("FirstRunDetector", () => {
|
||||
let tempGlobalDir: string;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempGlobalDir = tempDir("kb-migration-test-");
|
||||
originalCwd = process.cwd();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
try {
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
describe("detectFirstRunState", () => {
|
||||
it("should detect fresh-install when no central DB and no local .kb/", async () => {
|
||||
const tempProjectDir = tempDir("kb-fresh-");
|
||||
process.chdir(tempProjectDir);
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
expect(state).toBe("fresh-install");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should detect needs-migration when local .kb/ exists but no central DB", async () => {
|
||||
const tempProjectDir = tempDir("kb-needs-migration-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
process.chdir(tempProjectDir);
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
expect(state).toBe("needs-migration");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should detect setup-wizard when central DB exists but is empty", async () => {
|
||||
// Initialize central DB with no projects
|
||||
const central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
await central.close();
|
||||
|
||||
const tempProjectDir = tempDir("kb-setup-wizard-");
|
||||
process.chdir(tempProjectDir);
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
expect(state).toBe("setup-wizard");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should detect normal-operation when central DB has projects", async () => {
|
||||
// Create a separate global dir for this test to avoid conflicts with beforeEach's tempGlobalDir
|
||||
const testGlobalDir = tempDir("kb-normal-op-global-");
|
||||
|
||||
// Create and initialize central
|
||||
const testCentral = new CentralCore(testGlobalDir);
|
||||
await testCentral.init();
|
||||
|
||||
// Register a project
|
||||
const projectDir = tempDir("kb-test-project-");
|
||||
await testCentral.registerProject({
|
||||
name: "Test Project",
|
||||
path: projectDir,
|
||||
});
|
||||
|
||||
// Create a temp dir for the cwd
|
||||
const tempProjectDir = tempDir("kb-normal-op-");
|
||||
process.chdir(tempProjectDir);
|
||||
|
||||
// Pass existing central to avoid concurrent connection issues
|
||||
const detector = new FirstRunDetector(testGlobalDir);
|
||||
const state = await detector.detectFirstRunState(testCentral);
|
||||
|
||||
expect(state).toBe("normal-operation");
|
||||
|
||||
// Cleanup
|
||||
await testCentral.close();
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(testGlobalDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCentralDb", () => {
|
||||
it("should return false when central DB does not exist", () => {
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
expect(detector.hasCentralDb()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when central DB exists", async () => {
|
||||
const central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
await central.close();
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
expect(detector.hasCentralDb()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectExistingProjects", () => {
|
||||
it("should detect project in cwd", async () => {
|
||||
const tempProjectDir = tempDir("kb-detect-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const projects = await detector.detectExistingProjects(tempProjectDir);
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0].path).toBe(tempProjectDir);
|
||||
expect(projects[0].hasDb).toBe(true);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should walk up directory tree to find .kb/", async () => {
|
||||
const tempProjectDir = tempDir("kb-parent-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
const nestedDir = join(tempProjectDir, "src", "components");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const projects = await detector.detectExistingProjects(nestedDir);
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0].path).toBe(tempProjectDir);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should return empty array when no project found", async () => {
|
||||
const emptyDir = tempDir("kb-empty-");
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const projects = await detector.detectExistingProjects(emptyDir);
|
||||
|
||||
expect(projects).toHaveLength(0);
|
||||
|
||||
rmSync(emptyDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateProjectName", () => {
|
||||
it("should use directory basename when no git remote", async () => {
|
||||
const tempProjectDir = tempDir("my-awesome-project-");
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const name = await detector.generateProjectName(tempProjectDir);
|
||||
|
||||
expect(name).toContain("my-awesome-project");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should extract repo name from HTTPS git remote", async () => {
|
||||
const tempProjectDir = tempDir("kb-git-https-");
|
||||
await initGitRepo(tempProjectDir, "https://github.com/owner/my-repo.git");
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const name = await detector.generateProjectName(tempProjectDir);
|
||||
|
||||
expect(name).toBe("my-repo");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should extract repo name from SSH git remote", async () => {
|
||||
const tempProjectDir = tempDir("kb-git-ssh-");
|
||||
await initGitRepo(tempProjectDir, "git@github.com:owner/my-ssh-repo");
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const name = await detector.generateProjectName(tempProjectDir);
|
||||
|
||||
expect(name).toBe("my-ssh-repo");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCentralDbPath", () => {
|
||||
it("should return correct path", () => {
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "kb-central.db"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MigrationCoordinator", () => {
|
||||
let tempGlobalDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempGlobalDir = tempDir("kb-coordinator-test-");
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
try {
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("registerSingleProject", () => {
|
||||
it("should register a new project successfully", async () => {
|
||||
const tempProjectDir = tempDir("kb-register-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.registerSingleProject(tempProjectDir);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
// Verify project was registered
|
||||
const project = await central.getProject(result.projectsRegistered[0]);
|
||||
expect(project).toBeDefined();
|
||||
expect(project!.path).toBe(tempProjectDir);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should be idempotent - return existing project if already registered", async () => {
|
||||
const tempProjectDir = tempDir("kb-idempotent-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
|
||||
// First registration
|
||||
const result1 = await coordinator.registerSingleProject(tempProjectDir);
|
||||
expect(result1.success).toBe(true);
|
||||
|
||||
// Second registration - should be idempotent
|
||||
const result2 = await coordinator.registerSingleProject(tempProjectDir);
|
||||
expect(result2.success).toBe(true);
|
||||
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
|
||||
expect(result2.errors).toHaveLength(0);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should reject relative paths", async () => {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.registerSingleProject("./relative/path");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain("must be absolute");
|
||||
});
|
||||
|
||||
it("should handle duplicate names by appending suffix", async () => {
|
||||
// Use same base name for both projects
|
||||
const baseName = "kb-dup-" + Date.now();
|
||||
const tempProjectDir1 = join(tmpdir(), `${baseName}-1`);
|
||||
const tempProjectDir2 = join(tmpdir(), `${baseName}-2`);
|
||||
mkdirSync(tempProjectDir1, { recursive: true });
|
||||
mkdirSync(tempProjectDir2, { recursive: true });
|
||||
createFakeKbProject(tempProjectDir1);
|
||||
createFakeKbProject(tempProjectDir2);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
|
||||
// Register first project with explicit name
|
||||
const project1 = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: tempProjectDir1,
|
||||
});
|
||||
expect(project1.name).toBe("my-project");
|
||||
|
||||
// Register second project with same base name via coordinator
|
||||
const result2 = await coordinator.registerSingleProject(tempProjectDir2);
|
||||
expect(result2.success).toBe(true);
|
||||
|
||||
const project2 = await central.getProject(result2.projectsRegistered[0]);
|
||||
expect(project2!.name).toMatch(/-\d+$/); // Should have -1, -2, etc. suffix
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeSetup", () => {
|
||||
it("should register multiple projects from wizard", async () => {
|
||||
const tempProjectDir1 = tempDir("kb-setup1-");
|
||||
const tempProjectDir2 = tempDir("kb-setup2-");
|
||||
createFakeKbProject(tempProjectDir1);
|
||||
createFakeKbProject(tempProjectDir2);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const inputs: ProjectSetupInput[] = [
|
||||
{ path: tempProjectDir1, name: "Project One" },
|
||||
{ path: tempProjectDir2, name: "Project Two" },
|
||||
];
|
||||
|
||||
const result = await coordinator.completeSetup(inputs);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(2);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should skip already registered projects", async () => {
|
||||
const tempProjectDir = tempDir("kb-setup-existing-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
|
||||
// Register first
|
||||
const result1 = await coordinator.registerSingleProject(tempProjectDir);
|
||||
|
||||
// Try to register again via completeSetup
|
||||
const inputs: ProjectSetupInput[] = [{ path: tempProjectDir, name: "Some Name" }];
|
||||
const result2 = await coordinator.completeSetup(inputs);
|
||||
|
||||
expect(result2.success).toBe(true);
|
||||
expect(result2.projectsRegistered).toEqual(result1.projectsRegistered);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("coordinateMigration", () => {
|
||||
it("should return success for fresh-install state", async () => {
|
||||
// Close and remove central to simulate fresh state
|
||||
await central.close();
|
||||
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
|
||||
|
||||
// Create fresh temp dir with no .kb/
|
||||
const tempFreshDir = tempDir("kb-fresh-coord-");
|
||||
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
|
||||
// Change to fresh dir (no .kb/)
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempFreshDir);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.coordinateMigration();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(0);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempFreshDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackwardCompat", () => {
|
||||
let tempGlobalDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempGlobalDir = tempDir("kb-compat-test-");
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
try {
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveProjectContext", () => {
|
||||
it("should use explicit project ID when provided", async () => {
|
||||
const tempProjectDir = tempDir("kb-explicit-");
|
||||
const project = await central.registerProject({
|
||||
name: "Explicit Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
const context = await compat.resolveProjectContext("/some/other/dir", project.id);
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
expect(context.workingDirectory).toBe(tempProjectDir);
|
||||
expect(context.isLegacy).toBe(false);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should auto-use single project when no explicit ID provided", async () => {
|
||||
const tempProjectDir = tempDir("kb-single-");
|
||||
const project = await central.registerProject({
|
||||
name: "Single Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
const context = await compat.resolveProjectContext("/some/other/dir");
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
expect(context.workingDirectory).toBe(tempProjectDir);
|
||||
expect(context.isLegacy).toBe(false);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
|
||||
const tempProjectDir1 = tempDir("kb-multi1-");
|
||||
const tempProjectDir2 = tempDir("kb-multi2-");
|
||||
await central.registerProject({ name: "Project 1", path: tempProjectDir1 });
|
||||
await central.registerProject({ name: "Project 2", path: tempProjectDir2 });
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
|
||||
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
|
||||
ProjectRequiredError
|
||||
);
|
||||
|
||||
try {
|
||||
await compat.resolveProjectContext("/some/dir");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ProjectRequiredError);
|
||||
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
|
||||
}
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should find project by name (case-insensitive)", async () => {
|
||||
const tempProjectDir = tempDir("kb-byname-");
|
||||
const project = await central.registerProject({
|
||||
name: "My Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
const context = await compat.resolveProjectContext("/some/dir", "my project");
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should throw when project not found", async () => {
|
||||
const compat = new BackwardCompat(central);
|
||||
|
||||
await expect(compat.resolveProjectContext("/some/dir", "nonexistent")).rejects.toThrow(
|
||||
ProjectRequiredError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLegacyMode", () => {
|
||||
it("should return false when central DB exists", async () => {
|
||||
const compat = new BackwardCompat(central);
|
||||
expect(await compat.isLegacyMode()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when no central DB", async () => {
|
||||
// Close and remove central DB
|
||||
await central.close();
|
||||
rmSync(join(tempGlobalDir, "kb-central.db"), { force: true });
|
||||
|
||||
// Need to re-init CentralCore for it to work
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
expect(await compat.isLegacyMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CentralCore migration helpers", () => {
|
||||
let tempGlobalDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempGlobalDir = tempDir("kb-central-migration-test-");
|
||||
central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
try {
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("autoRegisterProject", () => {
|
||||
it("should auto-register a project with generated name", async () => {
|
||||
const tempProjectDir = tempDir("kb-autoreg-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const project = await central.autoRegisterProject(tempProjectDir);
|
||||
|
||||
expect(project).toBeDefined();
|
||||
expect(project.path).toBe(tempProjectDir);
|
||||
expect(project.isolationMode).toBe("in-process");
|
||||
expect(project.name).toContain("kb-autoreg"); // Based on directory name
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should be idempotent - return existing project if already registered", async () => {
|
||||
const tempProjectDir = tempDir("kb-autoreg-dup-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const project1 = await central.autoRegisterProject(tempProjectDir);
|
||||
const project2 = await central.autoRegisterProject(tempProjectDir);
|
||||
|
||||
expect(project1.id).toBe(project2.id);
|
||||
expect(project1.name).toBe(project2.name);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProjectRegistered", () => {
|
||||
it("should return false for unregistered project", async () => {
|
||||
const tempProjectDir = tempDir("kb-unreg-");
|
||||
|
||||
const isRegistered = await central.isProjectRegistered(tempProjectDir);
|
||||
|
||||
expect(isRegistered).toBe(false);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should return true for registered project", async () => {
|
||||
const tempProjectDir = tempDir("kb-registered-");
|
||||
await central.registerProject({
|
||||
name: "Registered",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const isRegistered = await central.isProjectRegistered(tempProjectDir);
|
||||
|
||||
expect(isRegistered).toBe(true);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstRunState", () => {
|
||||
it("should return setup-wizard when no projects", async () => {
|
||||
const state = await central.getFirstRunState();
|
||||
expect(state).toBe("setup-wizard");
|
||||
});
|
||||
|
||||
it("should return normal-operation when projects exist", async () => {
|
||||
const tempProjectDir = tempDir("kb-state-test-");
|
||||
await central.registerProject({
|
||||
name: "State Test",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const state = await central.getFirstRunState();
|
||||
|
||||
expect(state).toBe("normal-operation");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectRequiredError", () => {
|
||||
it("should include available projects in error", () => {
|
||||
const available = [
|
||||
{ id: "proj_1", name: "Project One" },
|
||||
{ id: "proj_2", name: "Project Two" },
|
||||
];
|
||||
|
||||
const error = new ProjectRequiredError("Test message", available);
|
||||
|
||||
expect(error.message).toBe("Test message");
|
||||
expect(error.name).toBe("ProjectRequiredError");
|
||||
expect(error.availableProjects).toEqual(available);
|
||||
});
|
||||
});
|
||||
631
packages/core/src/migration.ts
Normal file
631
packages/core/src/migration.ts
Normal file
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* Migration and First-Run Experience
|
||||
*
|
||||
* Handles the transition from single-project to multi-project mode:
|
||||
* - Detects first-run state (fresh install, needs migration, setup wizard, normal)
|
||||
* - Auto-discovers existing .kb/ directories for migration
|
||||
* - Coordinates migration to central database
|
||||
* - Provides backward compatibility for single-project workflows
|
||||
*
|
||||
* @module migration
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
|
||||
import type { CentralCore } from "./central-core.js";
|
||||
import { CentralCore as CentralCoreClass } from "./central-core.js";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** First-run state detection results */
|
||||
export type FirstRunState =
|
||||
| "fresh-install" // No central DB, no .kb/ anywhere
|
||||
| "needs-migration" // No central DB, but .kb/kb.db exists in cwd
|
||||
| "setup-wizard" // Central DB exists but has zero projects
|
||||
| "normal-operation"; // Central DB exists with projects
|
||||
|
||||
/** Detected project for migration consideration */
|
||||
export interface DetectedProject {
|
||||
/** Absolute path to project directory */
|
||||
path: string;
|
||||
/** Auto-generated or derived project name */
|
||||
name: string;
|
||||
/** Whether the project has a valid kb.db */
|
||||
hasDb: boolean;
|
||||
}
|
||||
|
||||
/** Result of a migration operation */
|
||||
export interface MigrationResult {
|
||||
/** Whether the migration succeeded */
|
||||
success: boolean;
|
||||
/** IDs of projects that were registered */
|
||||
projectsRegistered: string[];
|
||||
/** Error messages for any failures */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Input for setting up a project via the wizard */
|
||||
export interface ProjectSetupInput {
|
||||
/** Project path */
|
||||
path: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Isolation mode preference */
|
||||
isolationMode?: "in-process" | "child-process";
|
||||
}
|
||||
|
||||
/** Resolved project context for backward compatibility */
|
||||
export interface ResolvedContext {
|
||||
/** Project ID in central registry */
|
||||
projectId: string;
|
||||
/** Absolute path to project working directory */
|
||||
workingDirectory: string;
|
||||
/** Whether running in legacy mode (no central DB) */
|
||||
isLegacy: boolean;
|
||||
}
|
||||
|
||||
/** Error thrown when project selection is required but not provided */
|
||||
export class ProjectRequiredError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly availableProjects: Array<{ id: string; name: string }>
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProjectRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── FirstRunDetector ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects the first-run state and existing projects for migration.
|
||||
*
|
||||
* This class determines which startup path to take:
|
||||
* - Fresh install → Show setup wizard
|
||||
* - Existing single project → Auto-migrate
|
||||
* - Already migrated → Normal operation
|
||||
*/
|
||||
export class FirstRunDetector {
|
||||
private readonly globalDir: string;
|
||||
|
||||
/**
|
||||
* Create a FirstRunDetector.
|
||||
* @param globalDir — Directory for central database. Defaults to `~/.pi/kb/`.
|
||||
*/
|
||||
constructor(globalDir?: string) {
|
||||
this.globalDir = globalDir ?? this.getDefaultGlobalDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the current first-run state.
|
||||
*
|
||||
* Returns one of four states:
|
||||
* - `"fresh-install"` — No central DB, no local `.kb/` found
|
||||
* - `"needs-migration"` — No central DB, but `.kb/kb.db` exists in cwd
|
||||
* - `"setup-wizard"` — Central DB exists but has zero projects
|
||||
* - `"normal-operation"` — Central DB exists with one or more projects
|
||||
*
|
||||
* @param existingCentral — Optional existing CentralCore instance to use instead of creating a new one
|
||||
*/
|
||||
async detectFirstRunState(existingCentral?: CentralCore): Promise<FirstRunState> {
|
||||
const hasCentral = this.hasCentralDb();
|
||||
|
||||
if (!hasCentral) {
|
||||
// No central DB - check for local .kb/ in cwd
|
||||
const cwd = process.cwd();
|
||||
const localKbExists = this.hasKbProject(cwd);
|
||||
return localKbExists ? "needs-migration" : "fresh-install";
|
||||
}
|
||||
|
||||
// Central DB exists - check if it has projects
|
||||
let central: CentralCore | undefined = existingCentral;
|
||||
let shouldClose = false;
|
||||
|
||||
if (!central) {
|
||||
try {
|
||||
central = new CentralCoreClass(this.globalDir);
|
||||
await central.init();
|
||||
shouldClose = true;
|
||||
} catch {
|
||||
return "setup-wizard";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await central.listProjects();
|
||||
return projects.length === 0 ? "setup-wizard" : "normal-operation";
|
||||
} catch {
|
||||
// Central DB exists but is unreadable - treat as setup wizard
|
||||
return "setup-wizard";
|
||||
} finally {
|
||||
if (shouldClose && central) {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the central database exists.
|
||||
*/
|
||||
hasCentralDb(): boolean {
|
||||
const centralDbPath = join(this.globalDir, "kb-central.db");
|
||||
return existsSync(centralDbPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the central database.
|
||||
*/
|
||||
getCentralDbPath(): string {
|
||||
return join(this.globalDir, "kb-central.db");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect existing projects by walking up the directory tree.
|
||||
*
|
||||
* Starting from `cwd`, walks up looking for `.kb/kb.db` files.
|
||||
* Stops at home directory or root.
|
||||
*
|
||||
* @param cwd — Starting directory (default: process.cwd())
|
||||
* @returns Array of detected projects
|
||||
*/
|
||||
async detectExistingProjects(cwd?: string): Promise<DetectedProject[]> {
|
||||
const startDir = cwd ?? process.cwd();
|
||||
const projects: DetectedProject[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
let current = resolve(startDir);
|
||||
const home = homedir();
|
||||
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
|
||||
|
||||
while (current !== home && current !== root) {
|
||||
if (visited.has(current)) break;
|
||||
visited.add(current);
|
||||
|
||||
if (this.hasKbProject(current)) {
|
||||
const name = await this.generateProjectName(current);
|
||||
projects.push({
|
||||
path: current,
|
||||
name,
|
||||
hasDb: true,
|
||||
});
|
||||
// Only detect one project - stop at first match
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a project name from git remote or directory name.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Git remote origin URL (extract repo name)
|
||||
* 2. Directory basename
|
||||
*
|
||||
* @param projectPath — Absolute path to project
|
||||
* @returns Generated name
|
||||
*/
|
||||
async generateProjectName(projectPath: string): Promise<string> {
|
||||
// Try git remote first
|
||||
try {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["remote", "get-url", "origin"],
|
||||
{ cwd: projectPath, timeout: 5000 }
|
||||
);
|
||||
|
||||
const remoteUrl = stdout.trim();
|
||||
if (remoteUrl) {
|
||||
const name = this.extractRepoName(remoteUrl);
|
||||
if (name) return name;
|
||||
}
|
||||
} catch {
|
||||
// Git not available or no remote - fall through to directory name
|
||||
}
|
||||
|
||||
// Fallback to directory name
|
||||
return basename(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract repository name from git remote URL.
|
||||
*
|
||||
* Handles formats:
|
||||
* - https://github.com/owner/repo.git → repo
|
||||
* - https://github.com/owner/repo → repo
|
||||
* - git@github.com:owner/repo.git → repo
|
||||
* - git@github.com:owner/repo → repo
|
||||
*/
|
||||
private extractRepoName(remoteUrl: string): string | null {
|
||||
// Remove .git suffix
|
||||
const withoutGit = remoteUrl.replace(/\.git$/, "");
|
||||
|
||||
// Handle SSH format: git@host:owner/repo
|
||||
const sshMatch = withoutGit.match(/:([^/:]+\/([^/]+))$/);
|
||||
if (sshMatch) {
|
||||
return sshMatch[2];
|
||||
}
|
||||
|
||||
// Handle HTTPS format: https://host/owner/repo
|
||||
const httpsMatch = withoutGit.match(/\/([^/]+)$/);
|
||||
if (httpsMatch) {
|
||||
return httpsMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory contains a valid kb project.
|
||||
*/
|
||||
private hasKbProject(dir: string): boolean {
|
||||
const kbDir = join(dir, ".kb");
|
||||
const dbPath = join(kbDir, "kb.db");
|
||||
|
||||
if (!existsSync(kbDir)) return false;
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const stat = statSync(dbPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultGlobalDir(): string {
|
||||
return join(homedir(), ".pi", "kb");
|
||||
}
|
||||
}
|
||||
|
||||
// ── MigrationCoordinator ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Coordinates migration and setup flows.
|
||||
*
|
||||
* Orchestrates:
|
||||
* - Auto-migration of existing single projects
|
||||
* - Setup wizard project registration
|
||||
* - Idempotent re-runs
|
||||
*/
|
||||
export class MigrationCoordinator {
|
||||
private readonly central: CentralCore;
|
||||
|
||||
/**
|
||||
* Create a MigrationCoordinator.
|
||||
* @param central — Initialized CentralCore instance
|
||||
*/
|
||||
constructor(central: CentralCore) {
|
||||
this.central = central;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate the full migration flow based on current state.
|
||||
*
|
||||
* Detects state and executes appropriate migration path:
|
||||
* - needs-migration → Auto-register existing project
|
||||
* - setup-wizard → No-op (call completeSetup separately)
|
||||
* - others → No-op
|
||||
*/
|
||||
async coordinateMigration(): Promise<MigrationResult> {
|
||||
const detector = new FirstRunDetector(this.central.getGlobalDir());
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
switch (state) {
|
||||
case "needs-migration": {
|
||||
// Find the project in cwd
|
||||
const projects = await detector.detectExistingProjects(process.cwd());
|
||||
if (projects.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
projectsRegistered: [],
|
||||
errors: ["No existing kb project found for migration"],
|
||||
};
|
||||
}
|
||||
return this.registerSingleProject(projects[0].path);
|
||||
}
|
||||
|
||||
case "fresh-install":
|
||||
return {
|
||||
success: true,
|
||||
projectsRegistered: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
case "setup-wizard":
|
||||
case "normal-operation":
|
||||
// No migration needed
|
||||
return {
|
||||
success: true,
|
||||
projectsRegistered: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single existing project (for auto-migration).
|
||||
*
|
||||
* @param projectPath — Absolute path to project
|
||||
* @returns Migration result
|
||||
*/
|
||||
async registerSingleProject(projectPath: string): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
success: false,
|
||||
projectsRegistered: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Validate path
|
||||
if (!isAbsolute(projectPath)) {
|
||||
result.errors.push(`Project path must be absolute: ${projectPath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
try {
|
||||
const existing = await this.central.getProjectByPath(projectPath);
|
||||
if (existing) {
|
||||
// Already registered - idempotent success
|
||||
result.success = true;
|
||||
result.projectsRegistered.push(existing.id);
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to check existing registration: ${(err as Error).message}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate unique name
|
||||
const detector = new FirstRunDetector(this.central.getGlobalDir());
|
||||
const baseName = await detector.generateProjectName(projectPath);
|
||||
const uniqueName = await this.ensureUniqueName(baseName);
|
||||
|
||||
// Register the project
|
||||
try {
|
||||
const project = await this.central.registerProject({
|
||||
name: uniqueName,
|
||||
path: projectPath,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
result.success = true;
|
||||
result.projectsRegistered.push(project.id);
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to register project: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete setup by registering multiple projects (from wizard).
|
||||
*
|
||||
* @param projects — Array of project setup inputs
|
||||
* @returns Migration result
|
||||
*/
|
||||
async completeSetup(projects: ProjectSetupInput[]): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
projectsRegistered: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (const input of projects) {
|
||||
try {
|
||||
// Check if already registered
|
||||
const existing = await this.central.getProjectByPath(input.path);
|
||||
if (existing) {
|
||||
result.projectsRegistered.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure unique name
|
||||
const uniqueName = await this.ensureUniqueName(input.name);
|
||||
|
||||
// Register
|
||||
const project = await this.central.registerProject({
|
||||
name: uniqueName,
|
||||
path: input.path,
|
||||
isolationMode: input.isolationMode ?? "in-process",
|
||||
});
|
||||
|
||||
result.projectsRegistered.push(project.id);
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Failed to register ${input.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a project name is unique by appending -N suffix if needed.
|
||||
*/
|
||||
private async ensureUniqueName(baseName: string): Promise<string> {
|
||||
const existing = await this.central.listProjects();
|
||||
const existingNames = new Set(existing.map((p) => p.name.toLowerCase()));
|
||||
|
||||
if (!existingNames.has(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// Find unique suffix
|
||||
let counter = 1;
|
||||
let candidate = `${baseName}-${counter}`;
|
||||
while (existingNames.has(candidate.toLowerCase())) {
|
||||
counter++;
|
||||
candidate = `${baseName}-${counter}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// ── BackwardCompat ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Backward compatibility layer for single-project workflows.
|
||||
*
|
||||
* Ensures existing users with single projects continue working
|
||||
* without needing to specify `--project` flags.
|
||||
*/
|
||||
export class BackwardCompat {
|
||||
private readonly central: CentralCore;
|
||||
|
||||
/**
|
||||
* Create a BackwardCompat helper.
|
||||
* @param central — Initialized CentralCore instance
|
||||
*/
|
||||
constructor(central: CentralCore) {
|
||||
this.central = central;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve project context for a command.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If `projectId` provided → look up that project
|
||||
* 2. If no `projectId` and single project registered → auto-use it
|
||||
* 3. If no `projectId` and multiple projects → throw ProjectRequiredError
|
||||
* 4. If no central DB → return legacy mode (use cwd directly)
|
||||
*
|
||||
* @param cwd — Current working directory
|
||||
* @param projectId — Optional explicit project ID/name
|
||||
* @returns Resolved context
|
||||
* @throws ProjectRequiredError when multiple projects and no selection
|
||||
*/
|
||||
async resolveProjectContext(
|
||||
cwd: string,
|
||||
projectId?: string
|
||||
): Promise<ResolvedContext> {
|
||||
// Check for legacy mode (no central DB)
|
||||
const detector = new FirstRunDetector(this.central.getGlobalDir());
|
||||
if (!detector.hasCentralDb()) {
|
||||
return {
|
||||
projectId: "legacy",
|
||||
workingDirectory: cwd,
|
||||
isLegacy: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit project ID provided
|
||||
if (projectId) {
|
||||
const project = await this.findProjectByIdOrName(projectId);
|
||||
if (!project) {
|
||||
throw new ProjectRequiredError(
|
||||
`Project not found: ${projectId}`,
|
||||
await this.getAvailableProjects()
|
||||
);
|
||||
}
|
||||
return {
|
||||
projectId: project.id,
|
||||
workingDirectory: project.path,
|
||||
isLegacy: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No explicit project - check how many are registered
|
||||
const projects = await this.central.listProjects();
|
||||
|
||||
if (projects.length === 0) {
|
||||
// No projects registered - check if cwd has a .kb/ directory
|
||||
if (this.hasKbProject(cwd)) {
|
||||
// Auto-migrate this project
|
||||
const coordinator = new MigrationCoordinator(this.central);
|
||||
const result = await coordinator.registerSingleProject(cwd);
|
||||
if (result.success && result.projectsRegistered.length > 0) {
|
||||
const newProject = await this.central.getProject(result.projectsRegistered[0]);
|
||||
if (newProject) {
|
||||
return {
|
||||
projectId: newProject.id,
|
||||
workingDirectory: newProject.path,
|
||||
isLegacy: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ProjectRequiredError(
|
||||
"No projects registered. Run 'fn init' or 'fn project add' to set up a project.",
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 1) {
|
||||
// Single project - auto-use it for backward compatibility
|
||||
const project = projects[0];
|
||||
return {
|
||||
projectId: project.id,
|
||||
workingDirectory: project.path,
|
||||
isLegacy: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple projects - require explicit selection
|
||||
throw new ProjectRequiredError(
|
||||
"Multiple projects registered. Use --project <name> to specify which project to use.",
|
||||
projects.map((p) => ({ id: p.id, name: p.name }))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in legacy mode (no central database).
|
||||
*/
|
||||
async isLegacyMode(): Promise<boolean> {
|
||||
const detector = new FirstRunDetector(this.central.getGlobalDir());
|
||||
return !detector.hasCentralDb();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project by ID or name (case-insensitive name match).
|
||||
*/
|
||||
private async findProjectByIdOrName(idOrName: string): Promise<import("./types.js").RegisteredProject | undefined> {
|
||||
// Try exact ID match first
|
||||
const byId = await this.central.getProject(idOrName);
|
||||
if (byId) return byId;
|
||||
|
||||
// Try name match (case-insensitive)
|
||||
const all = await this.central.listProjects();
|
||||
const lower = idOrName.toLowerCase();
|
||||
return all.find((p) => p.name.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available projects for error messages.
|
||||
*/
|
||||
private async getAvailableProjects(): Promise<Array<{ id: string; name: string }>> {
|
||||
const all = await this.central.listProjects();
|
||||
return all.map((p) => ({ id: p.id, name: p.name }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory contains a kb project.
|
||||
*/
|
||||
private hasKbProject(dir: string): boolean {
|
||||
const kbDir = join(dir, ".kb");
|
||||
const dbPath = join(kbDir, "kb.db");
|
||||
|
||||
if (!existsSync(kbDir)) return false;
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const stat = statSync(dbPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1066,6 +1066,68 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Get GitHub token from options or env
|
||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||
|
||||
// ── Setup and Migration Routes ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/setup-state
|
||||
* Returns the first-run state and detected projects for migration/setup wizard.
|
||||
* Response: { state: FirstRunState, detectedProjects: DetectedProject[], hasCentralDb: boolean }
|
||||
*/
|
||||
router.get("/setup-state", async (_req, res) => {
|
||||
try {
|
||||
const { FirstRunDetector } = await import("@fusion/core");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
const detector = new FirstRunDetector();
|
||||
const state = await detector.detectFirstRunState();
|
||||
const detectedProjects = await detector.detectExistingProjects(process.cwd());
|
||||
|
||||
res.json({
|
||||
state,
|
||||
detectedProjects,
|
||||
hasCentralDb: detector.hasCentralDb(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/complete-setup
|
||||
* Complete setup by registering selected projects.
|
||||
* Body: { projects: Array<{ path: string, name: string, isolationMode?: string }> }
|
||||
* Response: { success: boolean, registered: string[], errors: string[] }
|
||||
*/
|
||||
router.post("/complete-setup", async (req, res) => {
|
||||
try {
|
||||
const { projects } = req.body;
|
||||
if (!Array.isArray(projects)) {
|
||||
res.status(400).json({ error: "projects array is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { CentralCore, MigrationCoordinator } = await import("@fusion/core");
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.completeSetup(projects);
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
registered: result.projectsRegistered,
|
||||
errors: result.errors,
|
||||
});
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Scheduler config (includes persisted settings)
|
||||
router.get("/config", async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -64,3 +64,15 @@ export const reviewerLog = createLogger("reviewer");
|
||||
|
||||
/** Logger for the PR monitor subsystem. */
|
||||
export const prMonitorLog = createLogger("pr-monitor");
|
||||
|
||||
/** Logger for the project runtime subsystem. */
|
||||
export const runtimeLog = createLogger("runtime");
|
||||
|
||||
/** Logger for the IPC subsystem. */
|
||||
export const ipcLog = createLogger("ipc");
|
||||
|
||||
/** Logger for the project manager subsystem. */
|
||||
export const projectManagerLog = createLogger("project-manager");
|
||||
|
||||
/** Logger for the hybrid executor subsystem. */
|
||||
export const hybridExecutorLog = createLogger("hybrid-executor");
|
||||
|
||||
Reference in New Issue
Block a user