Files
fusion/packages/cli/src/commands/init.ts
2026-06-06 01:01:24 -07:00

342 lines
11 KiB
TypeScript

/**
* Init command for fn CLI.
*
* Initializes a new fn 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, readFileSync } from "node:fs";
import { join, resolve, basename } from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import {
CentralCore,
GitRepositoryInitializationError,
QMD_INSTALL_COMMAND,
isQmdAvailable,
isValidSqliteDatabaseFile,
readProjectIdentity,
writeProjectIdentity,
} from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import { isGitRepo } from "./git.js";
import {
installBundledFusionSkill,
type SkillInstallResult,
} from "./skill-installation.js";
/** Options for the init command */
export interface InitOptions {
/** Override the auto-detected project name */
name?: string;
/** Path to initialize (defaults to cwd) */
path?: string;
/** Initialize a git repository if one does not exist */
git?: boolean;
}
/**
* 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");
const hasDbPath = existsSync(dbPath);
const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath);
// Check if already initialized
if (existsSync(fusionDir) && hasDbPath && hasValidDb) {
// Check if registered in central DB
const central = new CentralCore();
await central.init();
const existing = await central.getProjectByPath(cwd);
if (existing) {
try {
writeProjectIdentity(join(cwd, ".fusion"), {
id: existing.id,
createdAt: existing.createdAt,
});
} catch {
// Best-effort backfill only.
}
console.log(`✓ fn 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 ?? await 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;
}
if (existsSync(fusionDir) && hasDbPath && !hasValidDb) {
throw new Error(
`Existing database at ${dbPath} is not a valid SQLite database. ` +
"Restore it from .fusion/backups or move it aside before re-running fn init.",
);
}
// Get or generate project name
const projectName = options.name ?? await detectProjectName(cwd);
console.log(`Initializing fn project: "${projectName}"`);
console.log(` Path: ${cwd}`);
// Create .fusion/ directory
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
console.log(` ✓ Created .fusion/ directory`);
}
if (options.git && !(await isGitRepo(cwd))) {
await initializeGitRepo(cwd);
console.log(` ✓ Initialized git repository`);
}
// Add local Fusion/Pi storage directories to .gitignore
await addLocalStorageToGitignore(cwd);
await warnIfQmdMissing();
// Create fusion.db (empty SQLite file)
if (!existsSync(dbPath)) {
// A zero-byte bootstrap file is a valid SQLite starting point.
writeFileSync(dbPath, "");
console.log(` ✓ Created fusion.db`);
}
const bundledSkillInstall = installBundledFusionSkill();
logBundledSkillInstallResults(bundledSkillInstall.results);
// 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`);
maybeInstallClaudeSkillForNewProject(cwd);
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;
}
const identity = existsSync(dbPath) ? readProjectIdentity(fusionDir) : null;
const ensured = await central.ensureProjectForPath({
path: cwd,
identity: identity ?? undefined,
name: projectName,
});
const project = ensured.project;
// Activate the project (registration sets it to 'initializing')
await central.updateProject(project.id, { status: "active" });
try {
writeProjectIdentity(join(cwd, ".fusion"), {
id: project.id,
createdAt: project.createdAt,
});
} catch (identityError) {
console.warn(` ⚠ Could not persist project identity: ${identityError instanceof Error ? identityError.message : String(identityError)}`);
}
maybeInstallClaudeSkillForNewProject(cwd);
if (ensured.gitRepository === "initialized") {
console.log(` ✓ Initialized git repository`);
}
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 (err instanceof GitRepositoryInitializationError) {
await central.close();
throw 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.
*/
async function detectProjectName(dir: string): Promise<string> {
// Fast-path for non-git directories to avoid spawning git unnecessarily.
// (This also prevents occasional command stalls in constrained CI envs.)
if (!existsSync(join(dir, ".git"))) {
return basename(dir) || "my-project";
}
// Try git remote first
try {
const { stdout: remoteUrl } = await execAsync("git remote get-url origin", {
cwd: dir,
timeout: 10_000,
});
const trimmed = remoteUrl.trim();
if (trimmed) {
// Extract repo name from URL
// Handles: https://github.com/user/repo.git, git@github.com:user/repo.git
const match = trimmed.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";
}
/**
* Add local Fusion/Pi storage directories to .gitignore if not already present.
* Idempotent: only adds missing entries.
*/
async function addLocalStorageToGitignore(cwd: string): Promise<void> {
const gitignorePath = join(cwd, ".gitignore");
let content = "";
if (existsSync(gitignorePath)) {
try {
content = readFileSync(gitignorePath, "utf-8");
} catch {
// Best-effort: if we can't read, treat as empty
}
}
const lines = content.split(/\r?\n/);
const existingEntries = new Set(lines.map((line) => line.trim()));
const missingEntries = [".fusion", ".pi", "fusion.db", "fusion.db-wal", "fusion.db-shm"]
.filter((entry) => !existingEntries.has(entry));
if (missingEntries.length === 0) {
return;
}
const prefix = content.length === 0 || content.endsWith("\n") ? "" : "\n";
const newContent = `${content}${prefix}${missingEntries.join("\n")}\n`;
try {
writeFileSync(gitignorePath, newContent);
console.log(` ✓ Updated .gitignore (added: ${missingEntries.join(", ")})`);
} catch {
// Best-effort: don't fail init if we can't write to .gitignore
console.log(` ⚠ Could not update .gitignore (best-effort)`);
}
}
async function initializeGitRepo(cwd: string): Promise<void> {
await execAsync("git init", { cwd, timeout: 10_000 });
try {
const { stdout } = await execAsync("git symbolic-ref --quiet --short HEAD", {
cwd,
timeout: 10_000,
});
if (stdout.trim() !== "main") {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
}
} catch {
// Older git versions or detached/unborn states may fail symbolic-ref.
// Best-effort: create/switch to main.
try {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
} catch {
await execAsync("git checkout main", { cwd, timeout: 10_000 });
}
}
await ensureGitConfig(cwd, "user.name", "Fusion");
await ensureGitConfig(cwd, "user.email", "noreply@runfusion.ai");
const gitkeepPath = join(cwd, ".gitkeep");
if (!existsSync(gitkeepPath)) {
writeFileSync(gitkeepPath, "\n");
}
await execAsync("git add .gitkeep", { cwd, timeout: 10_000 });
await execAsync('git commit --allow-empty -m "chore: initial commit"', {
cwd,
timeout: 10_000,
});
}
async function ensureGitConfig(cwd: string, key: string, value: string): Promise<void> {
try {
const { stdout } = await execAsync(`git config --get ${key}`, { cwd, timeout: 10_000 });
if (stdout.trim().length > 0) {
return;
}
} catch {
// Missing config; set a local default.
}
await execAsync(`git config ${key} "${value}"`, { cwd, timeout: 10_000 });
}
async function warnIfQmdMissing(): Promise<void> {
if (await isQmdAvailable()) {
console.log(` ✓ qmd available for memory search`);
return;
}
console.log(` ⚠ qmd not found; memory search will use local file fallback`);
console.log(` Install qmd for indexed retrieval: ${QMD_INSTALL_COMMAND}`);
}
function logBundledSkillInstallResults(results: SkillInstallResult[]): void {
for (const result of results) {
const clientLabel = result.client[0].toUpperCase() + result.client.slice(1);
if (result.outcome === "installed") {
console.log(` ✓ Installed bundled Fusion skill for ${clientLabel}: ${result.targetDir}`);
continue;
}
if (result.outcome === "skipped") {
console.log(` ✓ Existing ${clientLabel} Fusion skill preserved: ${result.targetDir}`);
continue;
}
console.warn(
` ⚠ Could not install bundled Fusion skill for ${clientLabel}: ${result.reason ?? "unknown error"}`,
);
}
}