feat(KB-620): add multi-project first-run migration flow
- Add first-run migration detection and registration helpers for central project setup - Wire CLI and dashboard setup routes to auto-migrate existing projects and complete setup - Harden path resolution, overlap checks, and backward-compat project selection behavior - Add regression tests for migration, nested project detection, and single-project compatibility
This commit is contained in:
@@ -164,46 +164,44 @@ async function main() {
|
||||
// Extract command early (needed for migration check)
|
||||
const command = args[0];
|
||||
|
||||
// ── First-Run Auto-Migration ─────────────────────────────────────────────
|
||||
// Check if this is a fresh installation or if projects need to be migrated
|
||||
// Skip migration check for 'project' commands to avoid circular issues
|
||||
if (command !== "project" && !process.env.KB_SKIP_MIGRATION) {
|
||||
// Migration check for first-run experience
|
||||
// Skip for init command and help flags
|
||||
if (command !== "init" && command !== "--help" && command !== "-h") {
|
||||
try {
|
||||
const { createMigrationOrchestrator, createFirstRunExperience, CentralCore } = await import("@fusion/core");
|
||||
const { FirstRunDetector, MigrationCoordinator } = await import("@fusion/core");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
const centralCore = new CentralCore();
|
||||
await centralCore.init();
|
||||
const detector = new FirstRunDetector();
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
if (state === "needs-migration") {
|
||||
const cwd = process.cwd();
|
||||
const detected = await detector.detectExistingProjects(cwd);
|
||||
const projectRoot = detected[0]?.path;
|
||||
|
||||
const migration = createMigrationOrchestrator(centralCore);
|
||||
if (projectRoot) {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
if (await migration.needsMigration()) {
|
||||
const firstRun = createFirstRunExperience(centralCore);
|
||||
const state = await firstRun.getSetupState();
|
||||
try {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.registerSingleProject(projectRoot);
|
||||
|
||||
if (state.isFirstRun && state.hasDetectedProjects) {
|
||||
console.log("[kb] First run detected. Auto-registering projects...");
|
||||
const result = await migration.runMigration({
|
||||
startPath: process.cwd(),
|
||||
autoRegister: true
|
||||
});
|
||||
|
||||
if (result.projectsRegistered.length > 0) {
|
||||
console.log(`[kb] Auto-registered ${result.projectsRegistered.length} project(s):`);
|
||||
for (const p of result.projectsRegistered) {
|
||||
console.log(` - ${p.name}: ${p.path}`);
|
||||
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]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.projectsSkipped.length > 0) {
|
||||
console.log(`[kb] Skipped ${result.projectsSkipped.length} project(s) (already registered or invalid)`);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await centralCore.close();
|
||||
} catch (err) {
|
||||
// Migration is best-effort: log warning but don't block command execution
|
||||
console.warn("[kb] Warning: Migration check failed:", (err as Error).message);
|
||||
} catch {
|
||||
// Silently ignore migration errors - user can manually run fn init
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,24 @@ describe("Backward Compatibility Layer", () => {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should auto-resolve the single registered project even when cwd is unrelated", async () => {
|
||||
const tempProjectDir = tempDir("kb-single-path-match-");
|
||||
const unrelatedDir = tempDir("kb-single-unrelated-");
|
||||
const project = await central.registerProject({
|
||||
name: "Path Match Project",
|
||||
path: tempProjectDir,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
const context = await compat.resolveProjectContext(unrelatedDir);
|
||||
|
||||
expect(context.projectId).toBe(project.id);
|
||||
expect(context.workingDirectory).toBe(tempProjectDir);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
rmSync(unrelatedDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should use explicit project ID when provided", async () => {
|
||||
const tempProjectDir = tempDir("kb-explicit-compat-");
|
||||
const project = await central.registerProject({
|
||||
@@ -150,6 +168,48 @@ describe("Backward Compatibility Layer", () => {
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should require explicit selection when cwd is inside one of multiple projects", async () => {
|
||||
const tempProjectDir1 = tempDir("kb-multi-cwd1-");
|
||||
const tempProjectDir2 = tempDir("kb-multi-cwd2-");
|
||||
const nestedDir = join(tempProjectDir1, "src", "nested");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
await central.registerProject({
|
||||
name: "Project One",
|
||||
path: tempProjectDir1,
|
||||
});
|
||||
await central.registerProject({
|
||||
name: "Project Two",
|
||||
path: tempProjectDir2,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
await expect(compat.resolveProjectContext(nestedDir)).rejects.toThrow(ProjectRequiredError);
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should require explicit selection when cwd is outside all registered projects", async () => {
|
||||
const tempProjectDir1 = tempDir("kb-multi-outside1-");
|
||||
const tempProjectDir2 = tempDir("kb-multi-outside2-");
|
||||
const unrelatedDir = tempDir("kb-multi-outside-unrelated-");
|
||||
await central.registerProject({
|
||||
name: "Project One",
|
||||
path: tempProjectDir1,
|
||||
});
|
||||
await central.registerProject({
|
||||
name: "Project Two",
|
||||
path: tempProjectDir2,
|
||||
});
|
||||
|
||||
const compat = new BackwardCompat(central);
|
||||
await expect(compat.resolveProjectContext(unrelatedDir)).rejects.toThrow(ProjectRequiredError);
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
rmSync(unrelatedDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy mode without central database", () => {
|
||||
@@ -186,23 +246,17 @@ describe("Backward Compatibility Layer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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-");
|
||||
describe("no implicit mutation during resolve", () => {
|
||||
it("should not auto-register a project found in cwd when no projects are registered", async () => {
|
||||
const projectDir = tempDir("kb-no-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);
|
||||
await expect(compat.resolveProjectContext(projectDir)).rejects.toThrow(ProjectRequiredError);
|
||||
|
||||
// Verify project was registered
|
||||
const isRegistered = await central.isProjectRegistered(projectDir);
|
||||
expect(isRegistered).toBe(true);
|
||||
expect(isRegistered).toBe(false);
|
||||
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -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, basename } from "node:path";
|
||||
import { isAbsolute, join, basename, resolve } from "node:path";
|
||||
import type {
|
||||
RegisteredProject,
|
||||
ProjectHealth,
|
||||
@@ -961,6 +961,24 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
async autoRegisterProject(projectPath: string): Promise<RegisteredProject> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const normalizedProjectPath = resolve(projectPath);
|
||||
const existingProjects = await this.listProjects();
|
||||
const overlappingProject = existingProjects.find((project) => {
|
||||
const existingPath = resolve(project.path);
|
||||
return (
|
||||
existingPath === normalizedProjectPath ||
|
||||
existingPath.startsWith(`${normalizedProjectPath}/`) ||
|
||||
normalizedProjectPath.startsWith(`${existingPath}/`)
|
||||
);
|
||||
});
|
||||
|
||||
if (overlappingProject) {
|
||||
if (resolve(overlappingProject.path) === normalizedProjectPath) {
|
||||
return overlappingProject;
|
||||
}
|
||||
throw new Error(`Project path overlaps an existing registered project: ${overlappingProject.path}`);
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
const existing = await this.getProjectByPath(projectPath);
|
||||
if (existing) {
|
||||
@@ -973,12 +991,14 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
||||
// Ensure unique name
|
||||
const uniqueName = await this.ensureUniqueName(name);
|
||||
|
||||
// Register with in-process isolation
|
||||
return this.registerProject({
|
||||
// Register with in-process isolation, then mark active for migration/init flows.
|
||||
const project = await this.registerProject({
|
||||
name: uniqueName,
|
||||
path: projectPath,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
return this.updateProject(project.id, { status: "active" });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { readFile, readdir, rename, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import type { Database } from "./db.js";
|
||||
import { toJson, toJsonNullable, normalizeTaskComments } from "./db.js";
|
||||
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry } from "./types.js";
|
||||
@@ -544,16 +545,36 @@ async function createBackups(kbDir: string): Promise<void> {
|
||||
* @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()) {
|
||||
const centralDbPath = join(globalDir ?? join(homedir(), ".pi", "kb"), "kb-central.db");
|
||||
if (existsSync(centralDbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if cwd has a kb project
|
||||
return detector["hasKbProject"](cwd);
|
||||
let current = resolve(cwd);
|
||||
const home = homedir();
|
||||
const root = dirname(current) === current ? current : "/";
|
||||
|
||||
while (true) {
|
||||
const dbPath = join(current, ".kb", "kb.db");
|
||||
if (existsSync(dbPath)) {
|
||||
try {
|
||||
const stat = statSync(dbPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (current === home || current === root) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -567,7 +588,7 @@ export async function detectExistingProjects(
|
||||
cwd?: string,
|
||||
globalDir?: string
|
||||
): Promise<Array<{ path: string; name: string; hasDb: boolean }>> {
|
||||
const { FirstRunDetector } = require("./migration.js");
|
||||
const { FirstRunDetector } = await import("./migration.js");
|
||||
const detector = new FirstRunDetector(globalDir);
|
||||
return detector.detectExistingProjects(cwd);
|
||||
}
|
||||
@@ -583,7 +604,7 @@ export async function autoMigrateToCentral(
|
||||
existingProjectPath: string,
|
||||
central: import("./central-core.js").CentralCore
|
||||
): Promise<import("./migration.js").MigrationResult> {
|
||||
const { MigrationCoordinator } = require("./migration.js");
|
||||
const { MigrationCoordinator } = await import("./migration.js");
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
return coordinator.registerSingleProject(existingProjectPath);
|
||||
}
|
||||
|
||||
@@ -124,25 +124,28 @@ export type {
|
||||
ProjectHealth,
|
||||
CentralActivityLogEntry,
|
||||
GlobalConcurrencyState,
|
||||
DetectedProject,
|
||||
MigrationOptions,
|
||||
MigrationResult,
|
||||
ProjectSetupInput,
|
||||
SetupState,
|
||||
SetupCompletionResult,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Migration & First-Run (Multi-Project Support) ───────────────────────────
|
||||
// ── Migration and First-Run Experience ────────────────────────────────
|
||||
|
||||
export {
|
||||
MigrationOrchestrator,
|
||||
createMigrationOrchestrator,
|
||||
MAX_AUTO_REGISTER_PROJECTS,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
EXCLUDED_DIRS,
|
||||
} from "./migration-orchestrator.js";
|
||||
|
||||
FirstRunDetector,
|
||||
MigrationCoordinator,
|
||||
BackwardCompat,
|
||||
ProjectRequiredError,
|
||||
} from "./migration.js";
|
||||
export type {
|
||||
FirstRunState,
|
||||
DetectedProject,
|
||||
MigrationResult,
|
||||
ProjectSetupInput,
|
||||
ResolvedContext,
|
||||
} from "./migration.js";
|
||||
export {
|
||||
FirstRunExperience,
|
||||
createFirstRunExperience,
|
||||
} from "./first-run.js";
|
||||
needsCentralMigration,
|
||||
detectExistingProjects,
|
||||
autoMigrateToCentral,
|
||||
} from "./db-migrate.js";
|
||||
|
||||
27
packages/core/src/migration-stubs.ts
Normal file
27
packages/core/src/migration-stubs.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface CentralCoreStub {
|
||||
registerProject(input: {
|
||||
name: string;
|
||||
path: string;
|
||||
isolationMode?: "in-process" | "child-process";
|
||||
}): Promise<{ id: string; name: string; path: string }>;
|
||||
/** Compatibility overload matching the PROMPT.md fallback contract. */
|
||||
registerProjectLegacy?(
|
||||
name: string,
|
||||
workingDir: string,
|
||||
options?: { isolationMode?: string }
|
||||
): Promise<{ id: string; name: string; workingDirectory: string }>;
|
||||
listProjects(): Promise<Array<{ id: string; name: string; path: string; status?: string }>>;
|
||||
getProject(id: string): Promise<{ id: string; name: string; path: string; status?: string } | undefined>;
|
||||
getProjectByPath(path: string): Promise<{ id: string; name: string; path: string; status?: string } | undefined>;
|
||||
isProjectRegistered?(workingDir: string): Promise<boolean> | boolean;
|
||||
updateProject?(id: string, updates: { status?: "active" | "paused" | "errored" | "initializing" }): Promise<{ id: string; name: string; path: string; status?: string }>;
|
||||
getGlobalDir(): string;
|
||||
}
|
||||
|
||||
export interface ProjectInfoStub {
|
||||
id: string;
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
status: "active" | "paused" | "errored";
|
||||
isolationMode: "in-process" | "child-process";
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ProjectRequiredError,
|
||||
type ProjectSetupInput,
|
||||
} from "./migration.js";
|
||||
import { needsCentralMigration, autoMigrateToCentral, detectExistingProjects as detectExistingProjectsFromDbMigrate } from "./db-migrate.js";
|
||||
import { CentralCore } from "./central-core.js";
|
||||
|
||||
// Helper to create temp directories
|
||||
@@ -88,6 +89,21 @@ describe("FirstRunDetector", () => {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should detect needs-migration from nested directory inside an existing project", async () => {
|
||||
const tempProjectDir = tempDir("kb-needs-migration-nested-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
const nestedDir = join(tempProjectDir, "src", "features", "deep");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
process.chdir(nestedDir);
|
||||
|
||||
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);
|
||||
@@ -136,6 +152,37 @@ describe("FirstRunDetector", () => {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(testGlobalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should fall back to needs-migration when central DB exists but is unreadable", async () => {
|
||||
const tempProjectDir = tempDir("kb-corrupt-central-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
process.chdir(tempProjectDir);
|
||||
|
||||
mkdirSync(tempGlobalDir, { recursive: true });
|
||||
writeFileSync(join(tempGlobalDir, "kb-central.db"), "not a sqlite database");
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
expect(state).toBe("needs-migration");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should return fresh-install when central DB exists but is unreadable and no local project is found", async () => {
|
||||
const tempProjectDir = tempDir("kb-corrupt-central-no-local-");
|
||||
process.chdir(tempProjectDir);
|
||||
|
||||
mkdirSync(tempGlobalDir, { recursive: true });
|
||||
writeFileSync(join(tempGlobalDir, "kb-central.db"), "not a sqlite database");
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const state = await detector.detectFirstRunState();
|
||||
|
||||
expect(state).toBe("fresh-install");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCentralDb", () => {
|
||||
@@ -184,6 +231,27 @@ describe("FirstRunDetector", () => {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should stop safely at home/root boundaries when no project is found", async () => {
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const projects = await detector.detectExistingProjects(tmpdir());
|
||||
|
||||
expect(Array.isArray(projects)).toBe(true);
|
||||
expect(projects.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should still check the starting directory when cwd matches the stop boundary", async () => {
|
||||
const fakeHome = tempDir("kb-home-boundary-");
|
||||
createFakeKbProject(fakeHome);
|
||||
|
||||
const detector = new FirstRunDetector(fakeHome);
|
||||
const projects = await detector.detectExistingProjects(fakeHome);
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0].path).toBe(fakeHome);
|
||||
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should return empty array when no project found", async () => {
|
||||
const emptyDir = tempDir("kb-empty-");
|
||||
|
||||
@@ -241,6 +309,96 @@ describe("FirstRunDetector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("db-migrate wrappers", () => {
|
||||
it("should forward detectExistingProjects through db-migrate wrapper", async () => {
|
||||
const tempGlobalDir = tempDir("kb-dbmigrate-detect-global-");
|
||||
const tempProjectDir = tempDir("kb-dbmigrate-detect-project-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
const nestedDir = join(tempProjectDir, "src", "nested");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const detected = await detectExistingProjectsFromDbMigrate(nestedDir, tempGlobalDir);
|
||||
|
||||
expect(detected).toHaveLength(1);
|
||||
expect(detected[0].path).toBe(tempProjectDir);
|
||||
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should autoMigrateToCentral and register the project", async () => {
|
||||
const tempGlobalDir = tempDir("kb-dbmigrate-auto-global-");
|
||||
const tempProjectDir = tempDir("kb-dbmigrate-auto-project-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const result = await autoMigrateToCentral(tempProjectDir, central);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(1);
|
||||
|
||||
const project = await central.getProject(result.projectsRegistered[0]);
|
||||
expect(project).toBeDefined();
|
||||
expect(project!.path).toBe(tempProjectDir);
|
||||
expect(project!.status).toBe("active");
|
||||
} finally {
|
||||
await central.close();
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should autoMigrateToCentral idempotently on repeat runs", async () => {
|
||||
const tempGlobalDir = tempDir("kb-dbmigrate-idempotent-global-");
|
||||
const tempProjectDir = tempDir("kb-dbmigrate-idempotent-project-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
const central = new CentralCore(tempGlobalDir);
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const result1 = await autoMigrateToCentral(tempProjectDir, central);
|
||||
const result2 = await autoMigrateToCentral(tempProjectDir, central);
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(true);
|
||||
expect(result1.projectsRegistered[0]).toBe(result2.projectsRegistered[0]);
|
||||
} finally {
|
||||
await central.close();
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsCentralMigration", () => {
|
||||
it("should detect migration need from nested directory inside a project", () => {
|
||||
const tempGlobalDir = tempDir("kb-needs-central-global-");
|
||||
const tempProjectDir = tempDir("kb-needs-central-project-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
const nestedDir = join(tempProjectDir, "src", "nested");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
expect(needsCentralMigration(nestedDir, tempGlobalDir)).toBe(true);
|
||||
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should detect migration need from the project root itself", () => {
|
||||
const tempGlobalDir = tempDir("kb-needs-central-root-global-");
|
||||
const tempProjectDir = tempDir("kb-needs-central-root-project-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
expect(needsCentralMigration(tempProjectDir, tempGlobalDir)).toBe(true);
|
||||
|
||||
rmSync(tempGlobalDir, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MigrationCoordinator", () => {
|
||||
let tempGlobalDir: string;
|
||||
let central: CentralCore;
|
||||
@@ -276,6 +434,7 @@ describe("MigrationCoordinator", () => {
|
||||
const project = await central.getProject(result.projectsRegistered[0]);
|
||||
expect(project).toBeDefined();
|
||||
expect(project!.path).toBe(tempProjectDir);
|
||||
expect(project!.status).toBe("active");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -308,34 +467,80 @@ describe("MigrationCoordinator", () => {
|
||||
expect(result.errors[0]).toContain("must be absolute");
|
||||
});
|
||||
|
||||
it("should reject absolute paths that are not valid kb projects", async () => {
|
||||
const tempProjectDir = tempDir("kb-invalid-project-");
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.registerSingleProject(tempProjectDir);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.projectsRegistered).toHaveLength(0);
|
||||
expect(result.errors[0]).toContain("not a valid kb project");
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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`);
|
||||
const tempRoot = tempDir("kb-duplicate-names-");
|
||||
const tempProjectDir1 = join(tempRoot, "same-project");
|
||||
const tempProjectDir2 = join(tempRoot, "group", "same-project");
|
||||
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 result1 = await coordinator.registerSingleProject(tempProjectDir1);
|
||||
const result2 = await coordinator.registerSingleProject(tempProjectDir2);
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(true);
|
||||
|
||||
const project1 = await central.getProject(result1.projectsRegistered[0]);
|
||||
const project2 = await central.getProject(result2.projectsRegistered[0]);
|
||||
expect(project2!.name).toMatch(/-\d+$/); // Should have -1, -2, etc. suffix
|
||||
expect(project1!.name).toBe("same-project");
|
||||
expect(project2!.name).toBe("same-project-1");
|
||||
|
||||
rmSync(tempProjectDir1, { recursive: true, force: true });
|
||||
rmSync(tempProjectDir2, { recursive: true, force: true });
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should reject nested project registration when parent is already registered", async () => {
|
||||
const parentProjectDir = tempDir("kb-parent-project-");
|
||||
createFakeKbProject(parentProjectDir);
|
||||
const nestedProjectDir = join(parentProjectDir, "apps", "nested-project");
|
||||
mkdirSync(nestedProjectDir, { recursive: true });
|
||||
createFakeKbProject(nestedProjectDir);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const parentResult = await coordinator.registerSingleProject(parentProjectDir);
|
||||
const nestedResult = await coordinator.registerSingleProject(nestedProjectDir);
|
||||
|
||||
expect(parentResult.success).toBe(true);
|
||||
expect(nestedResult.success).toBe(false);
|
||||
expect(nestedResult.errors[0]).toContain("overlaps an existing registered project");
|
||||
|
||||
rmSync(parentProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should register the detected ancestor project root when called from a nested directory", async () => {
|
||||
const tempProjectDir = tempDir("kb-nested-register-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
const nestedDir = join(tempProjectDir, "packages", "feature");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
const detector = new FirstRunDetector(tempGlobalDir);
|
||||
const detected = await detector.detectExistingProjects(nestedDir);
|
||||
expect(detected).toHaveLength(1);
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.registerSingleProject(detected[0].path);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const projects = await central.listProjects();
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0].path.endsWith(tempProjectDir)).toBe(true);
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,9 +585,56 @@ describe("MigrationCoordinator", () => {
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should reject invalid setup project paths", async () => {
|
||||
const validProjectDir = tempDir("kb-setup-valid-");
|
||||
const invalidProjectDir = tempDir("kb-setup-invalid-");
|
||||
createFakeKbProject(validProjectDir);
|
||||
|
||||
const inputs: ProjectSetupInput[] = [
|
||||
{ path: validProjectDir, name: "Valid Project" },
|
||||
{ path: invalidProjectDir, name: "Invalid Project" },
|
||||
];
|
||||
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.completeSetup(inputs);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.projectsRegistered).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toContain("not a valid kb project");
|
||||
|
||||
rmSync(validProjectDir, { recursive: true, force: true });
|
||||
rmSync(invalidProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("coordinateMigration", () => {
|
||||
it("should auto-register an existing local project when no projects are registered", async () => {
|
||||
const tempProjectDir = tempDir("kb-coordinate-migration-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
const nestedDir = join(tempProjectDir, "src", "feature");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(nestedDir);
|
||||
|
||||
try {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.coordinateMigration();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
const registered = await central.listProjects();
|
||||
expect(registered).toHaveLength(1);
|
||||
expect(registered[0].path.endsWith(tempProjectDir)).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return success for fresh-install state", async () => {
|
||||
// Close and remove central to simulate fresh state
|
||||
await central.close();
|
||||
@@ -403,10 +655,58 @@ describe("MigrationCoordinator", () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempFreshDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should be a no-op in setup-wizard state when no local project exists", async () => {
|
||||
const tempFreshDir = tempDir("kb-setup-wizard-coord-");
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempFreshDir);
|
||||
|
||||
try {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.coordinateMigration();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempFreshDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should be a no-op in normal-operation when projects already exist", async () => {
|
||||
const existingProjectDir = tempDir("kb-normal-op-existing-");
|
||||
await central.registerProject({
|
||||
name: "Existing Project",
|
||||
path: existingProjectDir,
|
||||
});
|
||||
|
||||
const localProjectDir = tempDir("kb-normal-op-local-");
|
||||
createFakeKbProject(localProjectDir);
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(localProjectDir);
|
||||
|
||||
try {
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.coordinateMigration();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectsRegistered).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
const registered = await central.listProjects();
|
||||
expect(registered).toHaveLength(1);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
rmSync(existingProjectDir, { recursive: true, force: true });
|
||||
rmSync(localProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -560,11 +860,26 @@ describe("CentralCore migration helpers", () => {
|
||||
expect(project).toBeDefined();
|
||||
expect(project.path).toBe(tempProjectDir);
|
||||
expect(project.isolationMode).toBe("in-process");
|
||||
expect(project.status).toBe("active");
|
||||
expect(project.name).toContain("kb-autoreg"); // Based on directory name
|
||||
|
||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should reject nested auto-registration when parent project is already registered", async () => {
|
||||
const parentProjectDir = tempDir("kb-central-parent-");
|
||||
createFakeKbProject(parentProjectDir);
|
||||
const nestedProjectDir = join(parentProjectDir, "packages", "nested");
|
||||
mkdirSync(nestedProjectDir, { recursive: true });
|
||||
createFakeKbProject(nestedProjectDir);
|
||||
|
||||
await central.autoRegisterProject(parentProjectDir);
|
||||
|
||||
await expect(central.autoRegisterProject(nestedProjectDir)).rejects.toThrow(/overlaps an existing registered project/);
|
||||
|
||||
rmSync(parentProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should be idempotent - return existing project if already registered", async () => {
|
||||
const tempProjectDir = tempDir("kb-autoreg-dup-");
|
||||
createFakeKbProject(tempProjectDir);
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { CentralCoreStub } from "./migration-stubs.js";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface ProjectSetupInput {
|
||||
|
||||
/** Resolved project context for backward compatibility */
|
||||
export interface ResolvedContext {
|
||||
/** Project ID in central registry */
|
||||
/** Project ID in central registry, or the legacy sentinel value `"legacy"` when isLegacy is true. */
|
||||
projectId: string;
|
||||
/** Absolute path to project working directory */
|
||||
workingDirectory: string;
|
||||
@@ -82,8 +82,9 @@ export class ProjectRequiredError extends Error {
|
||||
/**
|
||||
* Detects the first-run state and existing projects for migration.
|
||||
*
|
||||
* This class determines which startup path to take:
|
||||
* - Fresh install → Show setup wizard
|
||||
* This class determines which startup path to take using cwd-ancestor scoped
|
||||
* project discovery (it intentionally does not scan the wider filesystem):
|
||||
* - Fresh install → No central DB and no kb project found from cwd upward
|
||||
* - Existing single project → Auto-migrate
|
||||
* - Already migrated → Normal operation
|
||||
*/
|
||||
@@ -102,43 +103,51 @@ export class FirstRunDetector {
|
||||
* 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
|
||||
* - `"fresh-install"` — No central DB and no kb project found from cwd upward
|
||||
* - `"needs-migration"` — No central DB, but a `.kb/kb.db` project exists in cwd ancestry
|
||||
* - `"setup-wizard"` — Central DB exists and can be read, 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 detectLocalState = async (): Promise<"fresh-install" | "needs-migration"> => {
|
||||
const detectedProjects = await this.detectExistingProjects(process.cwd());
|
||||
return detectedProjects.length > 0 ? "needs-migration" : "fresh-install";
|
||||
};
|
||||
|
||||
const detectFallbackState = async (): Promise<FirstRunState> => {
|
||||
const localState = await detectLocalState();
|
||||
return localState === "needs-migration" ? localState : "fresh-install";
|
||||
};
|
||||
|
||||
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";
|
||||
return detectLocalState();
|
||||
}
|
||||
|
||||
// Central DB exists - check if it has projects
|
||||
// Central DB exists - check if it has projects.
|
||||
// If the central DB is present but unreadable/corrupt, fall back to local
|
||||
// project detection so upgrade migration remains backward-compatible.
|
||||
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";
|
||||
return detectFallbackState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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";
|
||||
return detectFallbackState();
|
||||
} finally {
|
||||
if (shouldClose && central) {
|
||||
await central.close();
|
||||
@@ -179,7 +188,7 @@ export class FirstRunDetector {
|
||||
const home = homedir();
|
||||
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
|
||||
|
||||
while (current !== home && current !== root) {
|
||||
while (true) {
|
||||
if (visited.has(current)) break;
|
||||
visited.add(current);
|
||||
|
||||
@@ -194,6 +203,10 @@ export class FirstRunDetector {
|
||||
break;
|
||||
}
|
||||
|
||||
if (current === home || current === root) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
@@ -300,13 +313,13 @@ export class FirstRunDetector {
|
||||
* - Idempotent re-runs
|
||||
*/
|
||||
export class MigrationCoordinator {
|
||||
private readonly central: CentralCore;
|
||||
private readonly central: CentralCoreStub;
|
||||
|
||||
/**
|
||||
* Create a MigrationCoordinator.
|
||||
* @param central — Initialized CentralCore instance
|
||||
* @param central — Initialized central project registry contract
|
||||
*/
|
||||
constructor(central: CentralCore) {
|
||||
constructor(central: CentralCoreStub) {
|
||||
this.central = central;
|
||||
}
|
||||
|
||||
@@ -320,38 +333,18 @@ export class MigrationCoordinator {
|
||||
*/
|
||||
async coordinateMigration(): Promise<MigrationResult> {
|
||||
const detector = new FirstRunDetector(this.central.getGlobalDir());
|
||||
const state = await detector.detectFirstRunState();
|
||||
const projects = await detector.detectExistingProjects(process.cwd());
|
||||
const registeredProjects = await this.central.listProjects();
|
||||
|
||||
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: [],
|
||||
};
|
||||
if (projects.length > 0 && registeredProjects.length === 0) {
|
||||
return this.registerSingleProject(projects[0].path);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectsRegistered: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,6 +366,31 @@ export class MigrationCoordinator {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!this.hasKbProject(projectPath)) {
|
||||
result.errors.push(`Project path is not a valid kb project: ${projectPath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingProjects = await this.central.listProjects();
|
||||
const overlappingProject = existingProjects.find((project) => this.pathsOverlap(project.path, projectPath));
|
||||
if (overlappingProject) {
|
||||
if (this.normalizePath(overlappingProject.path) === this.normalizePath(projectPath)) {
|
||||
result.success = true;
|
||||
result.projectsRegistered.push(overlappingProject.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.errors.push(
|
||||
`Project path overlaps an existing registered project: ${overlappingProject.path}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to check existing registrations: ${(err as Error).message}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
try {
|
||||
const existing = await this.central.getProjectByPath(projectPath);
|
||||
@@ -394,12 +412,16 @@ export class MigrationCoordinator {
|
||||
|
||||
// Register the project
|
||||
try {
|
||||
const project = await this.central.registerProject({
|
||||
let project = await this.central.registerProject({
|
||||
name: uniqueName,
|
||||
path: projectPath,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
if ("updateProject" in this.central && typeof (this.central as CentralCore & { updateProject?: unknown }).updateProject === "function") {
|
||||
project = await (this.central as CentralCore).updateProject(project.id, { status: "active" });
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.projectsRegistered.push(project.id);
|
||||
} catch (err) {
|
||||
@@ -424,6 +446,14 @@ export class MigrationCoordinator {
|
||||
|
||||
for (const input of projects) {
|
||||
try {
|
||||
if (!isAbsolute(input.path)) {
|
||||
throw new Error(`Project path must be absolute: ${input.path}`);
|
||||
}
|
||||
|
||||
if (!this.hasKbProject(input.path)) {
|
||||
throw new Error(`Project path is not a valid kb project: ${input.path}`);
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
const existing = await this.central.getProjectByPath(input.path);
|
||||
if (existing) {
|
||||
@@ -472,6 +502,35 @@ export class MigrationCoordinator {
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
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 pathsOverlap(a: string, b: string): boolean {
|
||||
const normalizedA = this.normalizePath(a);
|
||||
const normalizedB = this.normalizePath(b);
|
||||
return (
|
||||
normalizedA === normalizedB ||
|
||||
normalizedA.startsWith(`${normalizedB}/`) ||
|
||||
normalizedB.startsWith(`${normalizedA}/`)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizePath(pathValue: string): string {
|
||||
return resolve(pathValue).replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
// ── BackwardCompat ───────────────────────────────────────────────────
|
||||
@@ -541,23 +600,6 @@ export class BackwardCompat {
|
||||
const projects = await this.central.listProjects();
|
||||
|
||||
if (projects.length === 0) {
|
||||
// No projects registered - check if cwd has a current .fusion project or legacy .kb project
|
||||
if (this.hasProjectData(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.",
|
||||
[]
|
||||
@@ -565,7 +607,7 @@ export class BackwardCompat {
|
||||
}
|
||||
|
||||
if (projects.length === 1) {
|
||||
// Single project - auto-use it for backward compatibility
|
||||
// Single project - auto-use it for backward compatibility.
|
||||
const project = projects[0];
|
||||
return {
|
||||
projectId: project.id,
|
||||
@@ -574,7 +616,7 @@ export class BackwardCompat {
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple projects - require explicit selection
|
||||
// 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 }))
|
||||
@@ -611,25 +653,4 @@ export class BackwardCompat {
|
||||
return all.map((p) => ({ id: p.id, name: p.name }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory contains a current .fusion project or legacy .kb project.
|
||||
*/
|
||||
private hasProjectData(dir: string): boolean {
|
||||
return this.hasProjectDb(dir, ".fusion") || this.hasProjectDb(dir, ".kb");
|
||||
}
|
||||
|
||||
private hasProjectDb(dir: string, folderName: ".fusion" | ".kb"): boolean {
|
||||
const projectDir = join(dir, folderName);
|
||||
const dbPath = join(projectDir, "kb.db");
|
||||
|
||||
if (!existsSync(projectDir)) return false;
|
||||
if (!existsSync(dbPath)) return false;
|
||||
|
||||
try {
|
||||
const stat = statSync(dbPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1083,28 +1083,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.get("/setup-state", async (_req, res) => {
|
||||
try {
|
||||
const { createFirstRunExperience, CentralCore } = await import("@fusion/core");
|
||||
const { createMigrationOrchestrator } = await import("@fusion/core");
|
||||
const { FirstRunDetector } = await import("@fusion/core");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const detector = new FirstRunDetector();
|
||||
const state = await detector.detectFirstRunState();
|
||||
const detectedProjects = await detector.detectExistingProjects(process.cwd());
|
||||
|
||||
try {
|
||||
const migration = createMigrationOrchestrator(central);
|
||||
const firstRun = createFirstRunExperience(central);
|
||||
|
||||
const needsMigration = await migration.needsMigration();
|
||||
const state = await firstRun.getSetupState();
|
||||
const detectedProjects = state.detectedProjects || [];
|
||||
|
||||
res.json({
|
||||
state: needsMigration ? "needs-migration" : state.isFirstRun ? "setup-wizard" : "normal-operation",
|
||||
detectedProjects,
|
||||
hasCentralDb: true,
|
||||
});
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
res.json({
|
||||
state,
|
||||
detectedProjects,
|
||||
hasCentralDb: detector.hasCentralDb(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -1124,19 +1114,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
return;
|
||||
}
|
||||
|
||||
const { CentralCore, createFirstRunExperience } = await import("@fusion/core");
|
||||
const { CentralCore, MigrationCoordinator } = await import("@fusion/core");
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const firstRun = createFirstRunExperience(central);
|
||||
const result = await firstRun.completeSetup(projects);
|
||||
const coordinator = new MigrationCoordinator(central);
|
||||
const result = await coordinator.completeSetup(projects);
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
registered: result.projects.map(p => p.id),
|
||||
errors: [],
|
||||
registered: result.projectsRegistered,
|
||||
errors: result.errors,
|
||||
});
|
||||
} finally {
|
||||
await central.close();
|
||||
|
||||
Reference in New Issue
Block a user