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

@@ -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";
}