feat(KB-620): complete Step 7 — Migration and first-run experience

- Add fn init command for initializing new kb projects
- Add auto-migration check at CLI startup
- Add /api/setup-state and /api/complete-setup dashboard endpoints
- Add SetupState and CompleteSetupInput types to dashboard API
- Add migration and rollback documentation to AGENTS.md
- Create changeset for multi-project migration feature
This commit is contained in:
gsxdsm
2026-04-02 09:18:45 -07:00
parent b05fbb7ecb
commit c03c9602b4
1427 changed files with 230046 additions and 2 deletions

View File

@@ -47,11 +47,13 @@ const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./co
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runInit } = await import("./commands/init.js");
const HELP = `
fn — AI-orchestrated task board
Usage:
fn init [opts] Initialize a new kb project in the 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)
@@ -155,6 +157,51 @@ function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; proj
return { cleanedArgs, projectName };
}
/**
* Check if migration is needed and run it automatically.
* This handles the transition from single-project to multi-project mode.
*/
async function checkAndMigrate(): Promise<void> {
// Skip if KB_SKIP_MIGRATION is set
if (process.env.KB_SKIP_MIGRATION === "1") {
return;
}
try {
const { needsCentralMigration, autoMigrateToCentral } = await import("@fusion/core");
// Check if migration is needed
if (!needsCentralMigration(process.cwd())) {
return;
}
console.log("\n🔄 Migrating to multi-project mode...");
// Get CentralCore and run migration
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
try {
const result = await autoMigrateToCentral(process.cwd(), central);
if (result.success) {
console.log(`✓ Registered project: ${result.projectsRegistered.join(", ")}`);
if (result.errors.length > 0) {
console.log(` Warnings: ${result.errors.join(", ")}`);
}
} else {
console.log(`⚠ Migration warnings: ${result.errors.join(", ")}`);
}
} finally {
await central.close();
}
} catch (err) {
// Migration errors are non-fatal - continue with legacy mode
console.log(`⚠ Migration check failed: ${(err as Error).message}`);
}
}
async function main() {
const { cleanedArgs: args, projectName } = extractGlobalProjectFlag(process.argv.slice(2));
@@ -165,8 +212,25 @@ async function main() {
const command = args[0];
// Migration check: run auto-migration for existing single-project users
// Skip for init command itself (user is explicitly initializing)
if (command !== "init" && command !== "dashboard") {
await checkAndMigrate();
}
try {
switch (command) {
case "init": {
// Parse init options
const nameIdx = args.indexOf("--name");
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
const pathIdx = args.indexOf("--path");
const path = pathIdx !== -1 && pathIdx + 1 < args.length ? args[pathIdx + 1] : undefined;
await runInit({ name, path });
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

View File

@@ -0,0 +1,94 @@
/**
* Tests for the init command
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInit } from "./init.js";
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
describe("init command", () => {
let tempProjectDir: string;
beforeEach(() => {
tempProjectDir = tempDir("fn-init-test-");
});
afterEach(() => {
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true });
}
});
it("should create .fusion/ directory when initializing", async () => {
const fusionDir = join(tempProjectDir, ".fusion");
expect(existsSync(fusionDir)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(fusionDir)).toBe(true);
});
it("should create fusion.db when initializing", async () => {
const dbPath = join(tempProjectDir, ".fusion", "fusion.db");
expect(existsSync(dbPath)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(dbPath)).toBe(true);
});
it("should be idempotent - report already initialized", async () => {
// First init
await runInit({ path: tempProjectDir });
// Capture console output for second run
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
// Second init - should report already initialized
await runInit({ path: tempProjectDir });
const logString = logs.join("\n");
expect(logString).toContain("already initialized");
} finally {
console.log = originalLog;
}
});
it("should use provided name option", async () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir, name: "custom-name" });
const logString = logs.join("\n");
expect(logString).toContain("custom-name");
} finally {
console.log = originalLog;
}
});
it("should not require .fusion directory to exist before init", async () => {
const fusionDir = join(tempProjectDir, ".fusion");
expect(existsSync(fusionDir)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(fusionDir)).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
});
});

View File

@@ -0,0 +1,155 @@
/**
* Init command for kb CLI.
*
* Initializes a new kb project in the current directory by:
* 1. Creating the .fusion/ directory with fusion.db
* 2. Registering the project in the central database
*
* Idempotent: if already initialized, reports success without recreating.
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, resolve, basename } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { CentralCore } from "@fusion/core";
import { resolveGlobalDir } from "@fusion/core";
/** Options for the init command */
export interface InitOptions {
/** Override the auto-detected project name */
name?: string;
/** Path to initialize (defaults to cwd) */
path?: string;
}
/**
* Run the init command.
*
* @param options - Optional configuration for init
* @returns Promise that resolves when initialization is complete
*/
export async function runInit(options: InitOptions = {}): Promise<void> {
const cwd = options.path ? resolve(options.path) : process.cwd();
const fusionDir = join(cwd, ".fusion");
const dbPath = join(fusionDir, "fusion.db");
// Check if already initialized
if (existsSync(fusionDir) && existsSync(dbPath)) {
// Check if registered in central DB
const central = new CentralCore();
await central.init();
const existing = await central.getProjectByPath(cwd);
if (existing) {
console.log(`✓ kb project already initialized: "${existing.name}"`);
console.log(` Path: ${cwd}`);
console.log(`\n Project is registered in the central registry.`);
console.log(` To re-initialize with a different name, run:`);
console.log(` fn project remove ${existing.name}`);
console.log(` fn init --name <new-name>`);
await central.close();
return;
}
// Has .fusion/ but not registered - offer to register
const projectName = options.name ?? detectProjectName(cwd);
console.log(`⚠ Project directory exists but not registered.`);
console.log(` Run: fn project add ${projectName} ${cwd}`);
console.log(` Or: rm -rf ${fusionDir} && fn init`);
await central.close();
return;
}
// Get or generate project name
const projectName = options.name ?? detectProjectName(cwd);
console.log(`Initializing kb project: "${projectName}"`);
console.log(` Path: ${cwd}`);
// Create .fusion/ directory
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
console.log(` ✓ Created .fusion/ directory`);
}
// Create fusion.db (empty SQLite file)
if (!existsSync(dbPath)) {
// SQLite database header for an empty database
const sqliteHeader = Buffer.from([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]);
writeFileSync(dbPath, sqliteHeader);
console.log(` ✓ Created fusion.db`);
}
// Register in central database
const central = new CentralCore();
await central.init();
try {
// Check if already registered
const existing = await central.getProjectByPath(cwd);
if (existing) {
console.log(` ✓ Already registered in central database`);
console.log(`\n✓ Project "${projectName}" is ready!`);
console.log(`\n Next steps:`);
console.log(` fn task list # View tasks`);
console.log(` fn task create # Create a task`);
console.log(` fn dashboard # Open the web UI`);
await central.close();
return;
}
// Register new project
const project = await central.registerProject({
name: projectName,
path: cwd,
isolationMode: "in-process",
});
console.log(` ✓ Registered in central database`);
console.log(`\n✓ Project "${project.name}" initialized successfully!`);
console.log(`\n Next steps:`);
console.log(` fn task list # View tasks`);
console.log(` fn task create # Create a task`);
console.log(` fn dashboard # Open the web UI`);
await central.close();
} catch (err) {
// If central DB registration fails, still report success since local files are created
console.log(` ⚠ Could not register in central database: ${(err as Error).message}`);
console.log(`\n✓ Project initialized locally (central registration can be done later)`);
console.log(`\n To register later, run:`);
console.log(` fn project add ${projectName} ${cwd}`);
await central.close();
}
}
/**
* Detect a project name from git remote or directory name.
*/
function detectProjectName(dir: string): string {
// Try git remote first
try {
const remoteUrl = execSync("git remote get-url origin 2>/dev/null", {
cwd: dir,
encoding: "utf-8",
}).trim();
if (remoteUrl) {
// Extract repo name from URL
// Handles: https://github.com/user/repo.git, git@github.com:user/repo.git
const match = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (match) {
return match[2];
}
}
} catch {
// Not a git repo or no origin remote
}
// Fallback to directory name
return basename(dir) || "my-project";
}

View File

@@ -1801,6 +1801,42 @@ export interface FirstRunStatus {
singleProjectPath: string | null;
}
/** Setup state for first-run wizard */
export interface SetupState {
/** The first-run state: fresh-install, needs-migration, setup-wizard, normal-operation */
state: "fresh-install" | "needs-migration" | "setup-wizard" | "normal-operation";
/** Projects detected on the filesystem (not yet registered) */
detectedProjects: Array<{
path: string;
name: string;
hasDb: boolean;
}>;
/** Whether the central database exists */
hasCentralDb: boolean;
/** Projects already registered in the central database */
registeredProjects: Array<{
id: string;
name: string;
path: string;
}>;
}
/** Input for completing setup */
export interface CompleteSetupInput {
projects: Array<{
path: string;
name: string;
isolationMode?: "in-process" | "child-process";
}>;
}
/** Result of completing setup */
export interface CompleteSetupResult {
success: boolean;
projectsRegistered: string[];
errors: string[];
}
/** Fetch all registered projects */
export function fetchProjects(): Promise<ProjectInfo[]> {
return api<ProjectInfo[]>("/projects");
@@ -1857,6 +1893,19 @@ export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
return api<FirstRunStatus>("/first-run-status");
}
/** Fetch detailed setup state including detected projects */
export function fetchSetupState(): Promise<SetupState> {
return api<SetupState>("/setup-state");
}
/** Complete first-run setup by registering projects */
export function completeSetup(input: CompleteSetupInput): Promise<CompleteSetupResult> {
return api<CompleteSetupResult>("/complete-setup", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch global concurrency state */
export function fetchGlobalConcurrency(): Promise<GlobalConcurrencyState> {
return api<GlobalConcurrencyState>("/global-concurrency");

View File

@@ -6197,6 +6197,80 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* GET /api/setup-state
* Returns the first-run state and any detected projects for migration.
* This is used by the dashboard to determine what UI to show on startup.
*/
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());
// Get central DB info
const central = new CentralCore();
await central.init();
const projects = await central.listProjects();
await central.close();
res.json({
state,
detectedProjects,
hasCentralDb: detector.hasCentralDb(),
registeredProjects: projects.map((p) => ({
id: p.id,
name: p.name,
path: p.path,
})),
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/complete-setup
* Complete the first-run setup by registering projects.
* Body: { projects: Array<{ path: string, name: string, isolationMode?: "in-process" | "child-process" }> }
*/
router.post("/complete-setup", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const { MigrationCoordinator } = await import("@fusion/core");
const { projects } = req.body as {
projects: Array<{ path: string; name: string; isolationMode?: "in-process" | "child-process" }>;
};
if (!Array.isArray(projects)) {
res.status(400).json({ error: "projects must be an array" });
return;
}
const central = new CentralCore();
await central.init();
try {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.completeSetup(projects);
res.json({
success: result.success,
projectsRegistered: result.projectsRegistered,
errors: result.errors,
});
} finally {
await central.close();
}
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/tasks/:id/diff
* Fetch git diff for a task's changes.