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:
@@ -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
|
||||
|
||||
94
packages/cli/src/commands/init.test.ts
Normal file
94
packages/cli/src/commands/init.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
155
packages/cli/src/commands/init.ts
Normal file
155
packages/cli/src/commands/init.ts
Normal 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";
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user