feat(KB-619): add multi-project CLI support
- Add project-resolver module with CentralCore integration for project resolution - Add project subcommands: list, add, remove, info with proper CLI integration - Add --project flag support across all task and settings commands - Refactor getStore to use project resolution with options pattern - Update bin.ts command routing to extract and propagate project context - Add project context propagation via FN_PROJECT environment variable
This commit is contained in:
@@ -45,7 +45,7 @@ const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } = await import("./commands/project.js");
|
||||
|
||||
const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
@@ -80,16 +80,10 @@ Usage:
|
||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||
Create a GitHub PR for an in-review task
|
||||
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
fn project list List all registered projects
|
||||
fn project add <name> <path> [opts] Register a new project
|
||||
fn project list [--json] List all registered projects
|
||||
fn project add [dir] [--name <name>] [--isolation <mode>] Register a project
|
||||
fn project remove <name> [--force] Unregister a project
|
||||
fn project show <name> Show project details
|
||||
fn project set-default <name> Set default project
|
||||
fn project detect Detect project from current directory
|
||||
fn settings Show current Fusion configuration
|
||||
fn settings set <key> <value> Update a configuration setting
|
||||
fn settings export [opts] Export settings to a JSON file
|
||||
fn settings import <file> [opts] Import settings from a JSON file
|
||||
fn project info [name] Show project details
|
||||
|
||||
fn git status Show current branch, commit, dirty state, ahead/behind
|
||||
fn git push Push current branch
|
||||
@@ -101,7 +95,7 @@ Usage:
|
||||
fn backup --cleanup Remove old backups exceeding retention limit
|
||||
|
||||
Options:
|
||||
--project, -P <name> Target a specific project (bypasses CWD detection)
|
||||
--project <name> Target a specific project (for task/settings commands)
|
||||
--port, -p <port> Dashboard port (default: 4040)
|
||||
--interactive Interactive mode (port selection for dashboard, issue selection for import)
|
||||
--paused Start with engine paused (automation disabled)
|
||||
@@ -123,7 +117,7 @@ Requires configured API keys — run "pi" first to set up authentication.
|
||||
`.trim();
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
console.log(HELP);
|
||||
@@ -140,6 +134,10 @@ async function main() {
|
||||
// Remove --project and its value from args
|
||||
args.splice(projectIdx, 2);
|
||||
}
|
||||
// Store for subcommands to access via resolveProject
|
||||
if (projectName) {
|
||||
process.env.FN_PROJECT = projectName;
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
|
||||
@@ -168,44 +166,42 @@ async function main() {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "list":
|
||||
case "ls":
|
||||
await runProjectList();
|
||||
case "ls": {
|
||||
const json = args.includes("--json");
|
||||
await runProjectList({ json });
|
||||
break;
|
||||
}
|
||||
case "add": {
|
||||
const name = args[2];
|
||||
const path = args[3];
|
||||
const dir = args[2];
|
||||
const nameIdx = args.indexOf("--name");
|
||||
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
|
||||
const isolationIdx = args.indexOf("--isolation");
|
||||
const isolation = isolationIdx !== -1 && isolationIdx + 1 < args.length
|
||||
? args[isolationIdx + 1]
|
||||
? args[isolationIdx + 1] as "in-process" | "child-process"
|
||||
: undefined;
|
||||
const force = args.includes("--force");
|
||||
await runProjectAdd(name, path, { isolation, force });
|
||||
await runProjectAdd(dir, { name, isolation });
|
||||
break;
|
||||
}
|
||||
case "remove":
|
||||
case "rm": {
|
||||
const name = args[2];
|
||||
if (!name) {
|
||||
console.error("Usage: fn project remove <name> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
const force = args.includes("--force");
|
||||
await runProjectRemove(name, force);
|
||||
await runProjectRemove(name, { force });
|
||||
break;
|
||||
}
|
||||
case "info":
|
||||
case "show": {
|
||||
const name = args[2];
|
||||
await runProjectShow(name);
|
||||
await runProjectInfo(name);
|
||||
break;
|
||||
}
|
||||
case "set-default":
|
||||
case "default": {
|
||||
const name = args[2];
|
||||
await runProjectSetDefault(name);
|
||||
break;
|
||||
}
|
||||
case "detect":
|
||||
await runProjectDetect();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown subcommand: project ${subcommand || ""}`);
|
||||
console.log("Try: fn project list | add | remove | show | set-default | detect");
|
||||
console.error("Try: fn project list | add [dir] | remove <name> | info [name]");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,79 +1,270 @@
|
||||
/**
|
||||
* Tests for project.ts commands
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } from "./project.js";
|
||||
|
||||
describe("project commands", () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
// Mock dependencies
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn(),
|
||||
GlobalSettingsStore: vi.fn(),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ProjectManager: vi.fn().mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
removeProject: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
vi.resetModules();
|
||||
});
|
||||
vi.mock("../project-resolver.js", () => ({
|
||||
getCentralCore: vi.fn(),
|
||||
getProjectManager: vi.fn(),
|
||||
findKbDir: vi.fn().mockReturnValue(null),
|
||||
isKbProject: vi.fn().mockReturnValue(true),
|
||||
suggestProjectName: vi.fn().mockReturnValue("test-project"),
|
||||
formatLastActivity: vi.fn().mockReturnValue("just now"),
|
||||
}));
|
||||
|
||||
describe("Project Commands", () => {
|
||||
describe("exports", () => {
|
||||
it("should export all project command functions", async () => {
|
||||
const project = await import("./project.js");
|
||||
expect(typeof project.runProjectList).toBe("function");
|
||||
expect(typeof project.runProjectAdd).toBe("function");
|
||||
expect(typeof project.runProjectRemove).toBe("function");
|
||||
expect(typeof project.runProjectShow).toBe("function");
|
||||
expect(typeof project.runProjectSetDefault).toBe("function");
|
||||
expect(typeof project.runProjectDetect).toBe("function");
|
||||
it("exports runProjectList as a function", () => {
|
||||
expect(typeof runProjectList).toBe("function");
|
||||
});
|
||||
|
||||
it("exports runProjectAdd as a function", () => {
|
||||
expect(typeof runProjectAdd).toBe("function");
|
||||
});
|
||||
|
||||
it("exports runProjectRemove as a function", () => {
|
||||
expect(typeof runProjectRemove).toBe("function");
|
||||
});
|
||||
|
||||
it("exports runProjectInfo as a function", () => {
|
||||
expect(typeof runProjectInfo).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation errors", () => {
|
||||
it("runProjectAdd should exit when name is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit");
|
||||
exitSpy.mockRestore();
|
||||
describe("runProjectList", () => {
|
||||
it("should handle empty project list", async () => {
|
||||
const { getCentralCore } = await import("../project-resolver.js");
|
||||
vi.mocked(getCentralCore).mockResolvedValue({
|
||||
listProjects: vi.fn().mockResolvedValue([]),
|
||||
getProjectHealth: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as import("@fusion/core").CentralCore);
|
||||
|
||||
const { getProjectManager } = await import("../project-resolver.js");
|
||||
vi.mocked(getProjectManager).mockResolvedValue({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as import("@fusion/engine").ProjectManager);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectList();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No projects registered"));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runProjectAdd should exit when path is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
it("should output JSON when --json flag is set", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "test-project",
|
||||
path: "/path/to/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const { getCentralCore } = await import("../project-resolver.js");
|
||||
vi.mocked(getCentralCore).mockResolvedValue({
|
||||
listProjects: vi.fn().mockResolvedValue([mockProject]),
|
||||
getProjectHealth: vi.fn().mockResolvedValue({
|
||||
lastActivityAt: "2024-01-01T00:00:00.000Z",
|
||||
inFlightAgentCount: 0,
|
||||
}),
|
||||
} as unknown as import("@fusion/core").CentralCore);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectList({ json: true });
|
||||
|
||||
// Check that JSON was output
|
||||
const jsonCall = consoleSpy.mock.calls.find(call => {
|
||||
try {
|
||||
JSON.parse(call[0] as string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await expect(runProjectAdd("name", "")).rejects.toThrow("process.exit");
|
||||
expect(jsonCall).toBeDefined();
|
||||
|
||||
const output = JSON.parse(jsonCall![0] as string);
|
||||
expect(output).toBeInstanceOf(Array);
|
||||
expect(output[0]).toHaveProperty("id", "proj_123");
|
||||
expect(output[0]).toHaveProperty("name", "test-project");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProjectAdd", () => {
|
||||
it("should exit if no directory provided in non-interactive mode", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runProjectAdd(undefined, { interactive: false });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage:"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runProjectRemove should exit when name is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const { runProjectRemove } = await import("./project.js");
|
||||
await expect(runProjectRemove("")).rejects.toThrow("process.exit");
|
||||
it("should validate isolation mode", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runProjectAdd("/tmp", { isolation: "invalid-mode" as any, interactive: false });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid isolation mode"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProjectRemove", () => {
|
||||
it("should exit if project not found", async () => {
|
||||
const { getCentralCore } = await import("../project-resolver.js");
|
||||
vi.mocked(getCentralCore).mockResolvedValue({
|
||||
listProjects: vi.fn().mockResolvedValue([]),
|
||||
getProject: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue(undefined),
|
||||
unregisterProject: vi.fn(),
|
||||
} as unknown as import("@fusion/core").CentralCore);
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runProjectRemove("nonexistent", { force: true, interactive: false });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runProjectShow should exit when name is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const { runProjectShow } = await import("./project.js");
|
||||
await expect(runProjectShow("")).rejects.toThrow("process.exit");
|
||||
exitSpy.mockRestore();
|
||||
it("should skip confirmation with --force flag", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "test-project",
|
||||
path: "/path/to/project",
|
||||
};
|
||||
|
||||
const { getCentralCore } = await import("../project-resolver.js");
|
||||
vi.mocked(getCentralCore).mockResolvedValue({
|
||||
listProjects: vi.fn().mockResolvedValue([mockProject]),
|
||||
getProject: vi.fn().mockResolvedValue(mockProject),
|
||||
getProjectByPath: vi.fn().mockResolvedValue(mockProject),
|
||||
unregisterProject: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as import("@fusion/core").CentralCore);
|
||||
|
||||
const { getProjectManager } = await import("../project-resolver.js");
|
||||
vi.mocked(getProjectManager).mockResolvedValue({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
removeProject: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as import("@fusion/engine").ProjectManager);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectRemove("test-project", { force: true, interactive: false });
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered"));
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProjectInfo", () => {
|
||||
it("should auto-detect project from cwd when no name provided", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "detected-project",
|
||||
path: "/current/dir",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const { getCentralCore, findKbDir } = await import("../project-resolver.js");
|
||||
vi.mocked(findKbDir).mockReturnValue("/current/dir");
|
||||
vi.mocked(getCentralCore).mockResolvedValue({
|
||||
listProjects: vi.fn().mockResolvedValue([mockProject]),
|
||||
getProject: vi.fn().mockResolvedValue(mockProject),
|
||||
getProjectByPath: vi.fn().mockResolvedValue(mockProject),
|
||||
getProjectHealth: vi.fn().mockResolvedValue({
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 100,
|
||||
totalTasksFailed: 5,
|
||||
lastActivityAt: "2024-01-01T00:00:00.000Z",
|
||||
}),
|
||||
} as unknown as import("@fusion/core").CentralCore);
|
||||
|
||||
const { getProjectManager } = await import("../project-resolver.js");
|
||||
vi.mocked(getProjectManager).mockResolvedValue({
|
||||
getRuntime: vi.fn().mockReturnValue({ getStatus: () => "active" }),
|
||||
removeProject: vi.fn(),
|
||||
} as unknown as import("@fusion/engine").ProjectManager);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectInfo(undefined, { interactive: false });
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("detected-project"));
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runProjectSetDefault should exit when name is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const { runProjectSetDefault } = await import("./project.js");
|
||||
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit");
|
||||
it("should exit if project not found by name", async () => {
|
||||
const { getCentralCore } = await import("../project-resolver.js");
|
||||
vi.mocked(getCentralCore).mockResolvedValue({
|
||||
listProjects: vi.fn().mockResolvedValue([]),
|
||||
getProject: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as import("@fusion/core").CentralCore);
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runProjectInfo("nonexistent", { interactive: false });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function for creating mock CentralCore
|
||||
describe("Project command helpers", () => {
|
||||
it("should export all required functions", () => {
|
||||
expect(runProjectList).toBeDefined();
|
||||
expect(runProjectAdd).toBeDefined();
|
||||
expect(runProjectRemove).toBeDefined();
|
||||
expect(runProjectInfo).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,67 +1,200 @@
|
||||
/**
|
||||
* Project command implementations for kb CLI.
|
||||
* Project subcommand implementations for kb CLI.
|
||||
*
|
||||
* Implements:
|
||||
* - fn project list [--json]
|
||||
* - fn project add [dir] [--name <name>] [--isolation <mode>]
|
||||
* - fn project remove <name> [--force]
|
||||
* - fn project info [name]
|
||||
*/
|
||||
|
||||
import { CentralCore, GlobalSettingsStore, type RegisteredProject, type IsolationMode } from "@fusion/core";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { CentralCore, type RegisteredProject, type IsolationMode } from "@fusion/core";
|
||||
import { resolve, isAbsolute, basename } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { formatProjectLine, detectProjectFromCwd, setDefaultProject } from "../project-context.js";
|
||||
import {
|
||||
getCentralCore,
|
||||
getProjectManager,
|
||||
findKbDir,
|
||||
isKbProject,
|
||||
suggestProjectName,
|
||||
formatLastActivity,
|
||||
type ResolvedProject,
|
||||
} from "../project-resolver.js";
|
||||
|
||||
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
|
||||
|
||||
export async function runProjectList(): Promise<void> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
/**
|
||||
* Run the `fn project list` command.
|
||||
*
|
||||
* Shows all registered projects with:
|
||||
* - Name, directory, status
|
||||
* - In-flight task count
|
||||
* - Last activity timestamp
|
||||
* - Optional JSON output with --json flag
|
||||
*/
|
||||
export async function runProjectList(options: { json?: boolean } = {}): Promise<void> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
|
||||
try {
|
||||
const projects = await central.listProjects();
|
||||
const defaultProject = await getDefaultProject();
|
||||
const projects = await central.listProjects();
|
||||
|
||||
if (projects.length === 0) {
|
||||
if (projects.length === 0) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify([], null, 2));
|
||||
} else {
|
||||
console.log("\n No projects registered.");
|
||||
console.log(" Register one with: kb project add <name> <path>\n");
|
||||
return;
|
||||
console.log(" Register one with: fn project add <path>\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get detailed info for each project
|
||||
const projectsWithInfo = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const runtime = pm.getRuntime(project.id);
|
||||
const runtimeStatus = runtime?.getStatus() ?? "not_started";
|
||||
|
||||
// Get task counts from store
|
||||
let taskCounts: Record<string, number> = {};
|
||||
let totalTasks = 0;
|
||||
try {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(project.path);
|
||||
await store.init();
|
||||
const tasks = await store.listTasks();
|
||||
totalTasks = tasks.length;
|
||||
for (const task of tasks) {
|
||||
taskCounts[task.column] = (taskCounts[task.column] || 0) + 1;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading tasks
|
||||
}
|
||||
|
||||
const health = await central.getProjectHealth(project.id);
|
||||
|
||||
return {
|
||||
project,
|
||||
runtimeStatus,
|
||||
taskCounts,
|
||||
totalTasks,
|
||||
lastActivity: health?.lastActivityAt,
|
||||
activeAgents: health?.inFlightAgentCount ?? 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by name alphabetically
|
||||
projectsWithInfo.sort((a, b) => a.project.name.localeCompare(b.project.name));
|
||||
|
||||
if (options.json) {
|
||||
// JSON output
|
||||
const jsonOutput = projectsWithInfo.map((p) => ({
|
||||
id: p.project.id,
|
||||
name: p.project.name,
|
||||
path: p.project.path,
|
||||
status: p.project.status,
|
||||
isolationMode: p.project.isolationMode,
|
||||
runtimeStatus: p.runtimeStatus,
|
||||
totalTasks: p.totalTasks,
|
||||
taskCounts: p.taskCounts,
|
||||
activeAgents: p.activeAgents,
|
||||
lastActivity: p.lastActivity,
|
||||
createdAt: p.project.createdAt,
|
||||
updatedAt: p.project.updatedAt,
|
||||
}));
|
||||
console.log(JSON.stringify(jsonOutput, null, 2));
|
||||
} else {
|
||||
// Table output
|
||||
console.log();
|
||||
console.log(" Registered Projects:");
|
||||
console.log();
|
||||
|
||||
for (const project of projects) {
|
||||
const isDefault = defaultProject?.id === project.id;
|
||||
const line = formatProjectLine(project, isDefault);
|
||||
console.log(` ${line}`);
|
||||
// Calculate column widths
|
||||
const nameWidth = Math.max(...projectsWithInfo.map((p) => p.project.name.length), 4);
|
||||
const pathWidth = Math.max(...projectsWithInfo.map((p) => p.project.path.length), 4);
|
||||
|
||||
// Header
|
||||
console.log(
|
||||
` ${"Name".padEnd(nameWidth)} ${"Path".padEnd(pathWidth)} ${"Status".padEnd(10)} ${"Tasks".padEnd(6)} ${"Agents".padEnd(6)} Last Activity`
|
||||
);
|
||||
console.log(
|
||||
` ${"-".repeat(nameWidth)} ${"-".repeat(pathWidth)} ${"-".repeat(10)} ${"-".repeat(6)} ${"-".repeat(6)} -------------`
|
||||
);
|
||||
|
||||
for (const p of projectsWithInfo) {
|
||||
const statusIcon = getStatusIcon(p.project.status);
|
||||
const lastActivity = formatLastActivity(p.lastActivity);
|
||||
console.log(
|
||||
` ${p.project.name.padEnd(nameWidth)} ${p.project.path.padEnd(pathWidth)} ${statusIcon} ${p.project.status.padEnd(8)} ${String(p.totalTasks).padEnd(6)} ${String(p.activeAgents).padEnd(6)} ${lastActivity}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
const activeCount = projects.filter((p) => p.status === "active").length;
|
||||
const activeCount = projectsWithInfo.filter((p) => p.project.status === "active").length;
|
||||
console.log(` ${projects.length} project${projects.length === 1 ? "" : "s"} registered, ${activeCount} active`);
|
||||
if (defaultProject) {
|
||||
console.log(` * indicates default project (${defaultProject.name})`);
|
||||
}
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `fn project add` command.
|
||||
*
|
||||
* Registers a new project with optional interactive prompts.
|
||||
*/
|
||||
export async function runProjectAdd(
|
||||
name: string,
|
||||
path: string,
|
||||
options?: { isolation?: string; force?: boolean }
|
||||
dir?: string,
|
||||
options: { name?: string; isolation?: "in-process" | "child-process"; interactive?: boolean } = {}
|
||||
): Promise<void> {
|
||||
if (!name || !path) {
|
||||
console.error("Usage: kb project add <name> <path> [--isolation <mode>]");
|
||||
const central = await getCentralCore();
|
||||
const interactive = options.interactive ?? true;
|
||||
|
||||
// Interactive wizard if no directory provided
|
||||
if (!dir && interactive) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
// Ask for directory
|
||||
const cwd = process.cwd();
|
||||
const dirInput = await rl.question(` Project directory [${cwd}]: `);
|
||||
dir = dirInput.trim() || cwd;
|
||||
|
||||
// Check if directory has .kb/
|
||||
const kbPath = resolve(dir, ".kb");
|
||||
if (!existsSync(kbPath)) {
|
||||
console.log(`\n No .kb/ directory found in ${dir}`);
|
||||
const shouldInit = await promptConfirm("Initialize kb here first?", true);
|
||||
|
||||
if (shouldInit) {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(dir);
|
||||
await store.init();
|
||||
console.log(` ✓ Initialized kb at ${dir}`);
|
||||
} else {
|
||||
console.log(" Cannot register project without .kb/ directory.");
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Ask for name
|
||||
const suggestedName = options.name || suggestProjectName(dir);
|
||||
const nameInput = await rl.question(` Project name [${suggestedName}]: `);
|
||||
options.name = nameInput.trim() || suggestedName;
|
||||
|
||||
// Ask for isolation mode
|
||||
const isolationInput = await rl.question(` Isolation mode [in-process]: `);
|
||||
options.isolation = (isolationInput.trim() as IsolationMode) || "in-process";
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
console.error("Usage: fn project add [dir] [--name <name>] [--isolation <mode>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isValidProjectName(name)) {
|
||||
console.error(`Error: Invalid project name '${name}'`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const absolutePath = isAbsolute(path) ? path : resolve(process.cwd(), path);
|
||||
// Resolve and validate directory
|
||||
const absolutePath = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
console.error(`Error: Path does not exist: ${absolutePath}`);
|
||||
@@ -73,172 +206,222 @@ export async function runProjectAdd(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
|
||||
if (!existsSync(kbDbPath) && !options?.force) {
|
||||
// Check for .kb/ directory
|
||||
if (!isKbProject(absolutePath)) {
|
||||
console.error(`Error: No kb project found at ${absolutePath}`);
|
||||
console.error("Run `fn init` first to initialize a kb project.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isolationMode = options?.isolation as IsolationMode | undefined;
|
||||
if (isolationMode && !VALID_ISOLATION_MODES.includes(isolationMode)) {
|
||||
// Validate isolation mode
|
||||
const isolationMode = options.isolation ?? "in-process";
|
||||
if (!VALID_ISOLATION_MODES.includes(isolationMode)) {
|
||||
console.error(`Error: Invalid isolation mode '${isolationMode}'`);
|
||||
console.error(`Valid modes: ${VALID_ISOLATION_MODES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
// Determine project name
|
||||
const name = options.name || suggestProjectName(absolutePath);
|
||||
|
||||
try {
|
||||
const existing = await findProjectByName(central, name);
|
||||
if (existing) {
|
||||
console.error(`Error: Project '${name}' already registered.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const project = await central.registerProject({
|
||||
name,
|
||||
path: absolutePath,
|
||||
isolationMode: isolationMode ?? "in-process",
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Registered project '${name}'`);
|
||||
console.log(` ID: ${project.id}`);
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
// Check for duplicate name
|
||||
const existing = await findProjectByName(central, name);
|
||||
if (existing) {
|
||||
console.error(`Error: Project '${name}' already registered.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for duplicate path
|
||||
const existingByPath = await central.getProjectByPath(absolutePath);
|
||||
if (existingByPath) {
|
||||
console.error(`Error: Project already registered at path: ${absolutePath}`);
|
||||
console.error(`Existing project: ${existingByPath.name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Register the project
|
||||
const project = await central.registerProject({
|
||||
name,
|
||||
path: absolutePath,
|
||||
isolationMode,
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Registered project '${name}'`);
|
||||
console.log(` ID: ${project.id}`);
|
||||
console.log(` Path: ${project.path}`);
|
||||
console.log(` Isolation: ${project.isolationMode}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runProjectRemove(name: string, force?: boolean): Promise<void> {
|
||||
/**
|
||||
* Run the `fn project remove` command.
|
||||
*
|
||||
* Unregisters a project from the central registry.
|
||||
*/
|
||||
export async function runProjectRemove(
|
||||
name: string,
|
||||
options: { force?: boolean; interactive?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
const interactive = options.interactive ?? true;
|
||||
|
||||
if (!name) {
|
||||
console.error("Usage: kb project remove <name> [--force]");
|
||||
console.error("Usage: fn project remove <name> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
// Check if runtime is active
|
||||
const runtime = pm.getRuntime(project.id);
|
||||
if (runtime) {
|
||||
console.log(` Stopping runtime for '${project.name}'...`);
|
||||
await pm.removeProject(project.id);
|
||||
}
|
||||
|
||||
// Confirmation prompt
|
||||
if (!options.force && interactive) {
|
||||
const confirmed = await promptConfirm(
|
||||
`Unregister "${project.name}"? Project data will be preserved, only the registry entry will be removed.`,
|
||||
false
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await central.unregisterProject(project.id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Unregistered project '${project.name}'`);
|
||||
console.log(` Project data at ${project.path} is preserved.`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `fn project info` command.
|
||||
*
|
||||
* Shows detailed information about a specific project.
|
||||
*/
|
||||
export async function runProjectInfo(name?: string, options: { interactive?: boolean } = {}): Promise<void> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
const interactive = options.interactive ?? true;
|
||||
|
||||
let project: RegisteredProject;
|
||||
|
||||
if (name) {
|
||||
const found = await findProjectByNameOrId(central, name);
|
||||
if (!found) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
project = found;
|
||||
} else {
|
||||
// Auto-detect from cwd
|
||||
const cwd = process.cwd();
|
||||
const kbDir = findKbDir(cwd);
|
||||
|
||||
if (!force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await rl.question(`Unregister project '${project.name}'? [y/N] `);
|
||||
rl.close();
|
||||
if (kbDir) {
|
||||
const found = await central.getProjectByPath(kbDir);
|
||||
if (found) {
|
||||
project = found;
|
||||
} else {
|
||||
console.error(`Error: Found kb project at ${kbDir} but it's not registered.`);
|
||||
console.error("Run `fn project add .` to register it.");
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// List projects and ask user to select
|
||||
const projects = await central.listProjects();
|
||||
if (projects.length === 0) {
|
||||
console.error("Error: No projects registered.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (answer.trim().toLowerCase() !== "y") {
|
||||
console.log("Cancelled.");
|
||||
return;
|
||||
if (projects.length === 1) {
|
||||
project = projects[0];
|
||||
} else if (interactive) {
|
||||
project = await promptProjectSelection(projects, "Select a project:");
|
||||
} else {
|
||||
console.error("Error: Multiple projects registered. Please specify a project name.");
|
||||
console.error("Run `fn project list` to see available projects.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await central.unregisterProject(project.id);
|
||||
console.log(` ✓ Unregistered project '${project.name}'`);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProjectShow(name: string): Promise<void> {
|
||||
if (!name) {
|
||||
console.error("Usage: kb project show <name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
// Get runtime status
|
||||
const runtime = pm.getRuntime(project.id);
|
||||
const runtimeStatus = runtime?.getStatus() ?? "not_started";
|
||||
|
||||
// Get task counts
|
||||
let taskCounts: Record<string, number> = {};
|
||||
let totalTasks = 0;
|
||||
try {
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(1);
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(project.path);
|
||||
await store.init();
|
||||
const tasks = await store.listTasks();
|
||||
totalTasks = tasks.length;
|
||||
for (const task of tasks) {
|
||||
taskCounts[task.column] = (taskCounts[task.column] || 0) + 1;
|
||||
}
|
||||
|
||||
const defaultProject = await getDefaultProject();
|
||||
const isDefault = defaultProject?.id === project.id;
|
||||
|
||||
console.log();
|
||||
console.log(` Project: ${project.name}${isDefault ? " (default)" : ""}`);
|
||||
console.log(` ID: ${project.id}`);
|
||||
console.log(` Path: ${project.path}`);
|
||||
console.log(` Status: ${project.status}`);
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProjectSetDefault(name: string): Promise<void> {
|
||||
if (!name) {
|
||||
console.error("Usage: kb project set-default <name>");
|
||||
process.exit(1);
|
||||
} catch {
|
||||
// Ignore errors reading tasks
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
// Get health metrics
|
||||
const health = await central.getProjectHealth(project.id);
|
||||
|
||||
try {
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(1);
|
||||
// Display info
|
||||
console.log();
|
||||
console.log(` Project: ${project.name}`);
|
||||
console.log(` ID: ${project.id}`);
|
||||
console.log(` Path: ${project.path}`);
|
||||
console.log(` Status: ${project.status}`);
|
||||
console.log(` Isolation Mode: ${project.isolationMode}`);
|
||||
console.log(` Runtime: ${runtimeStatus}`);
|
||||
console.log(` Created: ${new Date(project.createdAt).toLocaleString()}`);
|
||||
console.log(` Updated: ${new Date(project.updatedAt).toLocaleString()}`);
|
||||
console.log();
|
||||
|
||||
console.log(` Tasks (${totalTasks} total):`);
|
||||
const columns = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
for (const col of columns) {
|
||||
const count = taskCounts[col] || 0;
|
||||
if (count > 0 || col !== "archived") {
|
||||
const icon = getColumnIcon(col);
|
||||
console.log(` ${icon} ${col}: ${count}`);
|
||||
}
|
||||
|
||||
await setDefaultProject(project.id);
|
||||
console.log();
|
||||
console.log(` ✓ Set '${project.name}' as default project`);
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
export async function runProjectDetect(): Promise<void> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const project = await detectProjectFromCwd(process.cwd(), central);
|
||||
|
||||
if (project) {
|
||||
console.log();
|
||||
console.log(` Detected: ${project.name} (${project.path})`);
|
||||
console.log();
|
||||
} else {
|
||||
console.log();
|
||||
console.log(" No kb project detected from current directory.");
|
||||
console.log();
|
||||
if (health) {
|
||||
console.log(" Activity:");
|
||||
console.log(` Active tasks: ${health.activeTaskCount}`);
|
||||
console.log(` In-flight agents: ${health.inFlightAgentCount}`);
|
||||
console.log(` Total completed: ${health.totalTasksCompleted}`);
|
||||
console.log(` Total failed: ${health.totalTasksFailed}`);
|
||||
if (health.lastActivityAt) {
|
||||
console.log(` Last activity: ${formatLastActivity(health.lastActivityAt)}`);
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
if (health.averageTaskDurationMs) {
|
||||
const avgMins = Math.round(health.averageTaskDurationMs / 60000);
|
||||
console.log(` Avg task duration: ${avgMins}m`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
async function getDefaultProject(): Promise<RegisteredProject | undefined> {
|
||||
const globalStore = new GlobalSettingsStore();
|
||||
await globalStore.init();
|
||||
|
||||
const settings = await globalStore.getSettings();
|
||||
if (!settings.defaultProjectId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
return await central.getProject(settings.defaultProjectId);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
// Helper functions
|
||||
|
||||
async function findProjectByName(central: CentralCore, name: string): Promise<RegisteredProject | undefined> {
|
||||
const allProjects = await central.listProjects();
|
||||
@@ -247,16 +430,81 @@ async function findProjectByName(central: CentralCore, name: string): Promise<Re
|
||||
}
|
||||
|
||||
async function findProjectByNameOrId(central: CentralCore, nameOrId: string): Promise<RegisteredProject | undefined> {
|
||||
// First try exact ID match
|
||||
const byId = await central.getProject(nameOrId);
|
||||
if (byId) {
|
||||
return byId;
|
||||
}
|
||||
|
||||
// Then try case-insensitive name match
|
||||
return findProjectByName(central, nameOrId);
|
||||
}
|
||||
|
||||
function isValidProjectName(name: string): boolean {
|
||||
if (!name || name.length < 1 || name.length > 64) {
|
||||
return false;
|
||||
async function promptProjectSelection(
|
||||
projects: RegisteredProject[],
|
||||
message: string
|
||||
): Promise<RegisteredProject> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
console.log(`\n ${message}`);
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const answer = await rl.question("\n Enter number: ");
|
||||
const num = parseInt(answer.trim(), 10);
|
||||
|
||||
if (!isNaN(num) && num >= 1 && num <= projects.length) {
|
||||
rl.close();
|
||||
return projects[num - 1];
|
||||
}
|
||||
|
||||
console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptConfirm(message: string, defaultYes = false): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const prompt = defaultYes ? "[Y/n]" : "[y/N]";
|
||||
const answer = await rl.question(` ${message} ${prompt}: `);
|
||||
rl.close();
|
||||
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
if (trimmed === "" && defaultYes) return true;
|
||||
return trimmed === "y" || trimmed === "yes";
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "●";
|
||||
case "paused":
|
||||
return "⏸";
|
||||
case "errored":
|
||||
return "✗";
|
||||
case "initializing":
|
||||
return "◌";
|
||||
default:
|
||||
return "○";
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnIcon(column: string): string {
|
||||
switch (column) {
|
||||
case "triage":
|
||||
return "●";
|
||||
case "todo":
|
||||
return "○";
|
||||
case "in-progress":
|
||||
return "▸";
|
||||
case "in-review":
|
||||
return "◆";
|
||||
case "done":
|
||||
return "✓";
|
||||
case "archived":
|
||||
return "▪";
|
||||
default:
|
||||
return "•";
|
||||
}
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,74 @@ vi.mock("@fusion/core", () => {
|
||||
githubTokenConfigured: false,
|
||||
};
|
||||
|
||||
// Mock CentralCore for project-resolver
|
||||
const mockCentralCore = vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listProjects: vi.fn().mockResolvedValue([]),
|
||||
getProject: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue(undefined),
|
||||
registerProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
getProjectHealth: vi.fn().mockResolvedValue(undefined),
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
return {
|
||||
TaskStore: vi.fn(),
|
||||
CentralCore: mockCentralCore,
|
||||
DEFAULT_SETTINGS,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock project-resolver to return a simple getStore that returns a mock store
|
||||
vi.mock("../project-resolver.js", async () => {
|
||||
// Create a mock store with the methods tests expect
|
||||
const createMockStore = () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
requirePlanApproval: false,
|
||||
ntfyEnabled: false,
|
||||
taskPrefix: undefined,
|
||||
ntfyTopic: undefined,
|
||||
worktreeNaming: "random",
|
||||
githubTokenConfigured: false,
|
||||
defaultProvider: undefined,
|
||||
defaultModelId: undefined,
|
||||
defaultThinkingLevel: undefined,
|
||||
}),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
return {
|
||||
getStore: vi.fn().mockImplementation(createMockStore),
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("Not implemented in mock")),
|
||||
ProjectResolutionError: class ProjectResolutionError extends Error {
|
||||
code: string;
|
||||
context?: Record<string, unknown>;
|
||||
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "ProjectResolutionError";
|
||||
this.code = code;
|
||||
this.context = context;
|
||||
}
|
||||
},
|
||||
getCentralCore: vi.fn(),
|
||||
getProjectManager: vi.fn(),
|
||||
findKbDir: vi.fn().mockReturnValue(null),
|
||||
isKbProject: vi.fn().mockReturnValue(true),
|
||||
suggestProjectName: vi.fn().mockReturnValue("test-project"),
|
||||
formatLastActivity: vi.fn().mockReturnValue("just now"),
|
||||
resetProjectResolution: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { TaskStore, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
import { getStore } from "../project-resolver.js";
|
||||
import {
|
||||
runSettingsShow,
|
||||
runSettingsSet,
|
||||
@@ -223,10 +284,10 @@ describe("runSettingsShow", () => {
|
||||
taskPrefix: "CUSTOM",
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(mockSettings),
|
||||
}));
|
||||
});
|
||||
|
||||
await runSettingsShow();
|
||||
|
||||
@@ -254,10 +315,10 @@ describe("runSettingsShow", () => {
|
||||
githubTokenConfigured: true,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(mockSettings),
|
||||
}));
|
||||
});
|
||||
|
||||
await runSettingsShow();
|
||||
|
||||
@@ -272,10 +333,10 @@ describe("runSettingsShow", () => {
|
||||
githubTokenConfigured: false,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(mockSettings),
|
||||
}));
|
||||
});
|
||||
|
||||
await runSettingsShow();
|
||||
|
||||
@@ -302,7 +363,7 @@ describe("runSettingsSet", () => {
|
||||
maxWorktrees: 4,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
init: vi.fn(),
|
||||
updateSettings: mockUpdateSettings,
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
@@ -310,7 +371,7 @@ describe("runSettingsSet", () => {
|
||||
maxWorktrees: 4,
|
||||
taskPrefix: "TEST",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TaskStore, type Settings, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
import { getStore as getStoreFromContext } from "../project-context.js";
|
||||
import { getStore } from "../project-resolver.js";
|
||||
|
||||
// Settings that can be updated via CLI
|
||||
export const VALID_SETTINGS = [
|
||||
@@ -39,15 +39,6 @@ const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
|
||||
maxWorktrees: { min: 1, max: 20 },
|
||||
};
|
||||
|
||||
async function getStore(projectName?: string): Promise<TaskStore> {
|
||||
if (projectName) {
|
||||
return getStoreFromContext(projectName);
|
||||
}
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a setting value based on its key's expected type
|
||||
*/
|
||||
|
||||
@@ -7,19 +7,10 @@ import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node
|
||||
import { join } from "node:path";
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
|
||||
import { resolveProject, getStore as getStoreFromContext } from "../project-context.js";
|
||||
import { getStore, resolveProject } from "../project-resolver.js";
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
async function getStore(projectName?: string): Promise<TaskStore> {
|
||||
if (projectName) {
|
||||
return getStoreFromContext(projectName);
|
||||
}
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) {
|
||||
let description = descriptionArg;
|
||||
|
||||
@@ -34,7 +25,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.createTask({ description: description.trim(), dependencies: depends });
|
||||
|
||||
const label = task.description.length > 60
|
||||
@@ -82,7 +73,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
}
|
||||
|
||||
export async function runTaskList(projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const tasks = await store.listTasks();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
@@ -127,7 +118,7 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string,
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.updateStep(id, stepIndex, status as StepStatus);
|
||||
|
||||
const step = task.steps[stepIndex];
|
||||
@@ -138,7 +129,7 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string,
|
||||
}
|
||||
|
||||
export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
await store.logEntry(id, message, outcome);
|
||||
|
||||
console.log();
|
||||
@@ -220,7 +211,7 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog
|
||||
}
|
||||
|
||||
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Verify task exists
|
||||
try {
|
||||
@@ -244,8 +235,8 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
|
||||
|
||||
// Follow mode: watch for new entries
|
||||
if (options.follow) {
|
||||
const store = await getStore(projectName);
|
||||
const projectPath = (await resolveProject(projectName)).projectPath;
|
||||
const store = await getStore({ project: projectName });
|
||||
const projectPath = (await resolveProject({ project: projectName })).directory;
|
||||
const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log");
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
@@ -326,7 +317,7 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
|
||||
}
|
||||
|
||||
export async function runTaskShow(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.getTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -365,13 +356,13 @@ export async function runTaskShow(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskMerge(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const { projectPath } = await resolveProject(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const { directory } = await resolveProject({ project: projectName });
|
||||
|
||||
console.log(`\n Merging ${id} with AI...\n`);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, projectPath, id, {
|
||||
const result = await aiMergeTask(store, directory, id, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
});
|
||||
|
||||
@@ -432,7 +423,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const attachment = await store.addAttachment(id, filename, content, mimeType);
|
||||
|
||||
const sizeKB = (attachment.size / 1024).toFixed(1);
|
||||
@@ -444,7 +435,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
|
||||
}
|
||||
|
||||
export async function runTaskPause(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.pauseTask(id, true);
|
||||
|
||||
console.log();
|
||||
@@ -453,7 +444,7 @@ export async function runTaskPause(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskUnpause(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.pauseTask(id, false);
|
||||
|
||||
console.log();
|
||||
@@ -468,7 +459,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.moveTask(id, column as Column);
|
||||
|
||||
console.log();
|
||||
@@ -477,7 +468,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri
|
||||
}
|
||||
|
||||
export async function runTaskDuplicate(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const newTask = await store.duplicateTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -487,7 +478,7 @@ export async function runTaskDuplicate(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskRefine(id: string, feedbackArg?: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Get feedback interactively only if not provided (undefined)
|
||||
let feedback = feedbackArg;
|
||||
@@ -519,7 +510,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
|
||||
}
|
||||
|
||||
export async function runTaskArchive(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.archiveTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -528,7 +519,7 @@ export async function runTaskArchive(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskUnarchive(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.unarchiveTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -537,7 +528,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskRetry(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Fetch task and validate it exists
|
||||
let task;
|
||||
@@ -567,7 +558,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Check if task exists first
|
||||
let task;
|
||||
@@ -620,7 +611,7 @@ export async function runTaskImportGitHubInteractive(
|
||||
|
||||
console.log(`\n Fetching issues from ${owner}/${repo}...\n`);
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const existingTasks = await store.listTasks();
|
||||
|
||||
// Build a set of already-imported issue URLs
|
||||
@@ -838,7 +829,7 @@ export async function runTaskImportFromGitHub(
|
||||
|
||||
console.log(`\n Importing issues from ${owner}/${repo}...\n`);
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const existingTasks = await store.listTasks();
|
||||
|
||||
// Build a set of already-imported issue URLs
|
||||
@@ -902,7 +893,7 @@ export async function runTaskImportFromGitHub(
|
||||
}
|
||||
|
||||
export async function runTaskComment(id: string, message?: string, author = "user", projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
let text = message;
|
||||
if (text === undefined) {
|
||||
@@ -934,7 +925,7 @@ export async function runTaskComment(id: string, message?: string, author = "use
|
||||
}
|
||||
|
||||
export async function runTaskComments(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const task = await store.getTask(id);
|
||||
const comments = task.comments || [];
|
||||
|
||||
@@ -954,7 +945,7 @@ export async function runTaskComments(id: string, projectName?: string) {
|
||||
}
|
||||
|
||||
export async function runTaskSteer(id: string, message?: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Get message interactively if not provided as argument
|
||||
let text = message;
|
||||
@@ -1005,7 +996,7 @@ export interface PrCreateOptions {
|
||||
}
|
||||
|
||||
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Fetch task and validate it exists
|
||||
let task;
|
||||
@@ -1341,7 +1332,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
|
||||
}
|
||||
}
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
// Create planning session
|
||||
let sessionId: string;
|
||||
@@ -1381,7 +1372,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
|
||||
try {
|
||||
while (!cancelled) {
|
||||
// Get user response based on question type
|
||||
let response: unknown;
|
||||
let response: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
switch (currentQuestion.type) {
|
||||
|
||||
415
packages/cli/src/project-resolver.test.ts
Normal file
415
packages/cli/src/project-resolver.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
getCentralCore,
|
||||
getProjectManager,
|
||||
findKbDir,
|
||||
resolveProject,
|
||||
ProjectResolutionError,
|
||||
isKbProject,
|
||||
suggestProjectName,
|
||||
resolveAbsolutePath,
|
||||
formatLastActivity,
|
||||
resetProjectResolution,
|
||||
} from "./project-resolver.js";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve, dirname, normalize } from "node:path";
|
||||
|
||||
// Mock fs and path modules
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:path", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:path")>("node:path");
|
||||
return {
|
||||
...actual,
|
||||
resolve: vi.fn((...args: string[]) => args.join("/").replace(/\/+/g, "/")),
|
||||
normalize: vi.fn((p: string) => p),
|
||||
dirname: vi.fn((p: string) => p.split("/").slice(0, -1).join("/") || "/"),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @fusion/core
|
||||
vi.mock("@fusion/core", () => {
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockListProjects = vi.fn();
|
||||
const mockGetProject = vi.fn();
|
||||
const mockGetProjectByPath = vi.fn();
|
||||
const mockRegisterProject = vi.fn();
|
||||
const mockUnregisterProject = vi.fn();
|
||||
const mockGetProjectHealth = vi.fn();
|
||||
|
||||
const CentralCoreMock = vi.fn().mockImplementation(() => ({
|
||||
init: mockInit,
|
||||
close: mockClose,
|
||||
listProjects: mockListProjects,
|
||||
getProject: mockGetProject,
|
||||
getProjectByPath: mockGetProjectByPath,
|
||||
registerProject: mockRegisterProject,
|
||||
unregisterProject: mockUnregisterProject,
|
||||
getProjectHealth: mockGetProjectHealth,
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Store references on the mock constructor for tests to access
|
||||
(CentralCoreMock as any).mockFunctions = {
|
||||
mockInit,
|
||||
mockClose,
|
||||
mockListProjects,
|
||||
mockGetProject,
|
||||
mockGetProjectByPath,
|
||||
mockRegisterProject,
|
||||
mockUnregisterProject,
|
||||
mockGetProjectHealth,
|
||||
};
|
||||
|
||||
return {
|
||||
CentralCore: CentralCoreMock,
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import CentralCore to access mock functions
|
||||
import { CentralCore } from "@fusion/core";
|
||||
const getMockFunctions = () => (CentralCore as any).mockFunctions;
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ProjectManager: vi.fn().mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
removeProject: vi.fn().mockResolvedValue(undefined),
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Project Resolver", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetProjectResolution();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("findKbDir", () => {
|
||||
it("should find .kb directory in current path", () => {
|
||||
vi.mocked(existsSync)
|
||||
.mockReturnValueOnce(true) // First call for /project/.kb
|
||||
.mockReturnValue(false);
|
||||
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
|
||||
const result = findKbDir("/project");
|
||||
expect(result).toBe("/project");
|
||||
});
|
||||
|
||||
it("should walk up parent directories to find .kb", async () => {
|
||||
// First call: /a/b/c - no .kb
|
||||
// Second call: /a/b - has .kb
|
||||
vi.mocked(existsSync)
|
||||
.mockReturnValueOnce(false) // /a/b/c/.kb - not found
|
||||
.mockReturnValueOnce(true) // /a/b/.kb - found
|
||||
.mockReturnValue(false);
|
||||
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
|
||||
// Mock dirname to return parent directory
|
||||
const { dirname } = await import("node:path");
|
||||
vi.mocked(dirname)
|
||||
.mockReturnValueOnce("/a/b")
|
||||
.mockReturnValueOnce("/a")
|
||||
.mockReturnValue("/a"); // Stop at root
|
||||
|
||||
const result = findKbDir("/a/b/c");
|
||||
expect(result).toBe("/a/b");
|
||||
});
|
||||
|
||||
it("should return null if no .kb found", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const { dirname } = await import("node:path");
|
||||
vi.mocked(dirname).mockReturnValue("/");
|
||||
|
||||
const result = findKbDir("/some/path");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if .kb is not a directory", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
|
||||
const result = findKbDir("/project");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKbProject", () => {
|
||||
it("should return true if .kb directory exists", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
|
||||
expect(isKbProject("/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if .kb directory does not exist", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
expect(isKbProject("/project")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestProjectName", () => {
|
||||
it("should return last path segment as project name", () => {
|
||||
expect(suggestProjectName("/path/to/my-project")).toBe("my-project");
|
||||
});
|
||||
|
||||
it("should handle paths without separators", () => {
|
||||
expect(suggestProjectName("project")).toBe("project");
|
||||
});
|
||||
|
||||
it("should return 'unnamed' for empty path", () => {
|
||||
expect(suggestProjectName("")).toBe("unnamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAbsolutePath", () => {
|
||||
it("should resolve and validate existing directory", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
|
||||
const result = resolveAbsolutePath("/existing/path");
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("should throw ProjectResolutionError for non-existent path", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
expect(() => resolveAbsolutePath("/nonexistent")).toThrow(ProjectResolutionError);
|
||||
});
|
||||
|
||||
it("should throw ProjectResolutionError for non-directory path", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
|
||||
expect(() => resolveAbsolutePath("/file.txt")).toThrow(ProjectResolutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProject", () => {
|
||||
it("should resolve by explicit --project flag", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "my-project",
|
||||
path: "/path/to/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([mockProject]);
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
|
||||
const result = await resolveProject({ project: "my-project", interactive: false });
|
||||
|
||||
expect(result.name).toBe("my-project");
|
||||
expect(result.projectId).toBe("proj_123");
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND if --project project not found", async () => {
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([]);
|
||||
|
||||
await expect(resolveProject({ project: "nonexistent", interactive: false })).rejects.toThrow(
|
||||
ProjectResolutionError
|
||||
);
|
||||
});
|
||||
|
||||
it("should auto-detect from cwd with matching registered project", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "detected-project",
|
||||
path: "/detected/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([mockProject]);
|
||||
getMockFunctions().mockGetProjectByPath.mockResolvedValue(mockProject);
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
|
||||
const result = await resolveProject({ cwd: "/detected/path", interactive: false });
|
||||
|
||||
expect(result.name).toBe("detected-project");
|
||||
});
|
||||
|
||||
it("should throw NOT_REGISTERED if .kb exists but project not registered", async () => {
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([]);
|
||||
getMockFunctions().mockGetProjectByPath.mockResolvedValue(undefined);
|
||||
vi.mocked(existsSync)
|
||||
.mockReturnValueOnce(true) // .kb exists
|
||||
.mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
|
||||
await expect(resolveProject({ cwd: "/unregistered", interactive: false })).rejects.toThrow(
|
||||
ProjectResolutionError
|
||||
);
|
||||
});
|
||||
|
||||
it("should use default project when no .kb found and only one project", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "only-project",
|
||||
path: "/only/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([mockProject]);
|
||||
vi.mocked(existsSync).mockReturnValue(false); // No .kb found
|
||||
|
||||
const result = await resolveProject({ interactive: false });
|
||||
|
||||
expect(result.name).toBe("only-project");
|
||||
});
|
||||
|
||||
it("should throw NO_PROJECTS when no projects registered and no .kb found", async () => {
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([]);
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
await expect(resolveProject({ interactive: false })).rejects.toThrow(
|
||||
ProjectResolutionError
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw MULTIPLE_MATCHES when multiple projects and no match", async () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "project1", path: "/path1", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "project2", path: "/path2", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
getMockFunctions().mockListProjects.mockResolvedValue(projects);
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
await expect(resolveProject({ interactive: false })).rejects.toThrow(
|
||||
ProjectResolutionError
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw PATH_MISMATCH if registered project directory moved", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "moved-project",
|
||||
path: "/old/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
getMockFunctions().mockListProjects.mockResolvedValue([mockProject]);
|
||||
vi.mocked(existsSync).mockReturnValue(false); // Path doesn't exist
|
||||
|
||||
await expect(resolveProject({ project: "moved-project", interactive: false })).rejects.toThrow(
|
||||
ProjectResolutionError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectResolutionError", () => {
|
||||
it("should create error with code and context", () => {
|
||||
const error = new ProjectResolutionError("Test error", "NOT_FOUND", { id: "123" });
|
||||
|
||||
expect(error.message).toBe("Test error");
|
||||
expect(error.code).toBe("NOT_FOUND");
|
||||
expect(error.context).toEqual({ id: "123" });
|
||||
expect(error.name).toBe("ProjectResolutionError");
|
||||
});
|
||||
|
||||
it("should include all error codes", () => {
|
||||
const codes = [
|
||||
"NOT_FOUND",
|
||||
"NOT_REGISTERED",
|
||||
"MULTIPLE_MATCHES",
|
||||
"NO_PROJECTS",
|
||||
"PATH_MISMATCH",
|
||||
"NOT_INITIALIZED",
|
||||
"CANCELLED",
|
||||
];
|
||||
|
||||
for (const code of codes) {
|
||||
const error = new ProjectResolutionError("test", code as any);
|
||||
expect(error.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLastActivity", () => {
|
||||
it("should format 'just now' for recent timestamps", () => {
|
||||
const now = new Date().toISOString();
|
||||
expect(formatLastActivity(now)).toBe("just now");
|
||||
});
|
||||
|
||||
it("should format minutes ago", () => {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60000).toISOString();
|
||||
expect(formatLastActivity(fiveMinutesAgo)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("should format hours ago", () => {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 3600000).toISOString();
|
||||
expect(formatLastActivity(twoHoursAgo)).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("should format days ago", () => {
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 86400000).toISOString();
|
||||
expect(formatLastActivity(threeDaysAgo)).toBe("3d ago");
|
||||
});
|
||||
|
||||
it("should return 'never' for undefined timestamp", () => {
|
||||
expect(formatLastActivity(undefined)).toBe("never");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetProjectResolution", () => {
|
||||
it("should reset singleton instances", async () => {
|
||||
// First call to initialize
|
||||
await getCentralCore();
|
||||
|
||||
// Reset
|
||||
resetProjectResolution();
|
||||
|
||||
// After reset, getCentralCore should create a new instance
|
||||
const core = await getCentralCore();
|
||||
expect(getMockFunctions().mockInit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCentralCore singleton", () => {
|
||||
it("should return same instance on multiple calls", async () => {
|
||||
const core1 = await getCentralCore();
|
||||
const core2 = await getCentralCore();
|
||||
|
||||
// Both should be the same object
|
||||
expect(core1).toBe(core2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectManager singleton", () => {
|
||||
it("should return same instance on multiple calls", async () => {
|
||||
const pm1 = await getProjectManager();
|
||||
const pm2 = await getProjectManager();
|
||||
|
||||
// Both should be the same object
|
||||
expect(pm1).toBe(pm2);
|
||||
});
|
||||
});
|
||||
915
packages/cli/src/project-resolver.ts
Normal file
915
packages/cli/src/project-resolver.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
/**
|
||||
* Project Resolution Module
|
||||
*
|
||||
* Handles determination of which project to use for CLI commands based on:
|
||||
* - Explicit `--project <name>` flag
|
||||
* - Current working directory auto-detection (walking up to find `.kb/`)
|
||||
* - Default project when only one is registered
|
||||
* - Interactive prompts when ambiguous
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { dirname, resolve, normalize } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core";
|
||||
import { ProjectManager } from "@fusion/engine";
|
||||
|
||||
// Singleton instances for reuse across commands
|
||||
let centralCoreInstance: CentralCore | null = null;
|
||||
let projectManagerInstance: ProjectManager | null = null;
|
||||
|
||||
/**
|
||||
* Error thrown when project resolution fails with actionable context.
|
||||
*/
|
||||
export class ProjectResolutionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code:
|
||||
| "NOT_FOUND"
|
||||
| "NOT_REGISTERED"
|
||||
| "MULTIPLE_MATCHES"
|
||||
| "NO_PROJECTS"
|
||||
| "PATH_MISMATCH"
|
||||
| "NOT_INITIALIZED"
|
||||
| "CANCELLED",
|
||||
public readonly context?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProjectResolutionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved project with all necessary references for command execution.
|
||||
*/
|
||||
export interface ResolvedProject {
|
||||
/** Project ID from CentralCore */
|
||||
projectId: string;
|
||||
/** Project display name */
|
||||
name: string;
|
||||
/** Absolute path to project directory */
|
||||
directory: string;
|
||||
/** Project status */
|
||||
status: string;
|
||||
/** Isolation mode */
|
||||
isolationMode: string;
|
||||
/** Reference to ProjectRuntime (if started) */
|
||||
runtime?: import("@fusion/engine").ProjectRuntime;
|
||||
/** Initialized TaskStore for the project */
|
||||
store: TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for project resolution.
|
||||
*/
|
||||
export interface ResolveOptions {
|
||||
/** Explicit project name from --project flag */
|
||||
project?: string;
|
||||
/** Starting directory for cwd-based resolution (defaults to process.cwd()) */
|
||||
cwd?: string;
|
||||
/** Allow interactive prompts (set to false for non-interactive environments) */
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and return CentralCore singleton.
|
||||
* Reuses the same instance across multiple calls for efficiency.
|
||||
*/
|
||||
export async function getCentralCore(): Promise<CentralCore> {
|
||||
if (!centralCoreInstance) {
|
||||
centralCoreInstance = new CentralCore();
|
||||
await centralCoreInstance.init();
|
||||
}
|
||||
return centralCoreInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and return ProjectManager singleton.
|
||||
* Creates the instance on first call, reuses thereafter.
|
||||
*/
|
||||
export async function getProjectManager(): Promise<ProjectManager> {
|
||||
if (!projectManagerInstance) {
|
||||
const central = await getCentralCore();
|
||||
projectManagerInstance = new ProjectManager(central);
|
||||
}
|
||||
return projectManagerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from the given path to find a `.kb/` directory.
|
||||
*
|
||||
* @param startPath - Directory to start searching from
|
||||
* @returns Absolute path to the directory containing `.kb/`, or null if not found
|
||||
*/
|
||||
export function findKbDir(startPath: string): string | null {
|
||||
let current = resolve(startPath);
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const kbPath = resolve(current, ".kb");
|
||||
if (existsSync(kbPath) && statSync(kbPath).isDirectory()) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
// Reached root
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to select from a list of projects.
|
||||
*/
|
||||
async function promptProjectSelection(
|
||||
projects: RegisteredProject[],
|
||||
message = "Select a project:"
|
||||
): Promise<RegisteredProject> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
console.log(`\n ${message}`);
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const answer = await rl.question("\n Enter number: ");
|
||||
const num = parseInt(answer.trim(), 10);
|
||||
|
||||
if (!isNaN(num) && num >= 1 && num <= projects.length) {
|
||||
rl.close();
|
||||
return projects[num - 1];
|
||||
}
|
||||
|
||||
console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for yes/no confirmation.
|
||||
*/
|
||||
async function promptConfirm(message: string, defaultYes = false): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const prompt = defaultYes ? "[Y/n]" : "[y/N]";
|
||||
const answer = await rl.question(` ${message} ${prompt}: `);
|
||||
rl.close();
|
||||
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
if (trimmed === "" && defaultYes) return true;
|
||||
return trimmed === "y" || trimmed === "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which project to use based on options and cwd.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If --project <name> flag given, look up by name in registry
|
||||
* 2. Walk up from cwd to find .kb/ directory
|
||||
* 3. If found, match path against registered projects
|
||||
* 4. If not registered but has .kb/, prompt to register or error
|
||||
* 5. If no .kb/ found and exactly one project registered, use it
|
||||
* 6. If multiple projects and no match, error with list
|
||||
*
|
||||
* @param options - Resolution options
|
||||
* @returns Resolved project with store and runtime references
|
||||
* @throws ProjectResolutionError with specific error codes
|
||||
*/
|
||||
export async function resolveProject(options: ResolveOptions = {}): Promise<ResolvedProject> {
|
||||
const central = await getCentralCore();
|
||||
const interactive = options.interactive ?? true;
|
||||
|
||||
// 1. Check explicit --project flag
|
||||
if (options.project) {
|
||||
const projects = await central.listProjects();
|
||||
const match = projects.find((p) => p.name === options.project);
|
||||
|
||||
if (!match) {
|
||||
// Suggest similar names if available
|
||||
const similar = projects
|
||||
.filter((p) => p.name.toLowerCase().includes(options.project!.toLowerCase()))
|
||||
.map((p) => p.name);
|
||||
|
||||
let suggestion = "";
|
||||
if (similar.length > 0) {
|
||||
suggestion = ` Did you mean: ${similar.join(", ")}?`;
|
||||
}
|
||||
|
||||
throw new ProjectResolutionError(
|
||||
`Project "${options.project}" not found.${suggestion}`,
|
||||
"NOT_FOUND",
|
||||
{ searchedName: options.project, availableProjects: projects.map((p) => p.name) }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if path still exists
|
||||
if (!existsSync(match.path)) {
|
||||
throw new ProjectResolutionError(
|
||||
`Project "${match.name}" is registered but the directory no longer exists: ${match.path}\n\n` +
|
||||
"Run `fn project remove " + match.name + "` to clean up the registry entry.",
|
||||
"PATH_MISMATCH",
|
||||
{ projectId: match.id, path: match.path }
|
||||
);
|
||||
}
|
||||
|
||||
return createResolvedProject(match);
|
||||
}
|
||||
|
||||
// 2. Walk up from cwd to find .kb/
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const kbDir = findKbDir(cwd);
|
||||
|
||||
if (kbDir) {
|
||||
// 3. Match path against registered projects
|
||||
const allProjects = await central.listProjects();
|
||||
const normalizedKbDir = normalize(kbDir);
|
||||
|
||||
const match = allProjects.find((p) => normalize(p.path) === normalizedKbDir);
|
||||
|
||||
if (match) {
|
||||
// Check if path still exists
|
||||
if (!existsSync(match.path)) {
|
||||
throw new ProjectResolutionError(
|
||||
`Project "${match.name}" is registered but the directory no longer exists: ${match.path}\n\n` +
|
||||
"Run `fn project remove " + match.name + "` to clean up the registry entry.",
|
||||
"PATH_MISMATCH",
|
||||
{ projectId: match.id, path: match.path }
|
||||
);
|
||||
}
|
||||
|
||||
return createResolvedProject(match);
|
||||
}
|
||||
|
||||
// 4. Has .kb/ but not registered
|
||||
if (interactive) {
|
||||
console.log(`\n Found kb project at ${kbDir} but it's not registered.`);
|
||||
const shouldRegister = await promptConfirm("Register this project now?", true);
|
||||
|
||||
if (shouldRegister) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const defaultName = kbDir.split("/").pop() || "unnamed";
|
||||
const name = await rl.question(` Project name [${defaultName}]: `);
|
||||
rl.close();
|
||||
|
||||
const finalName = name.trim() || defaultName;
|
||||
|
||||
try {
|
||||
const newProject = await central.registerProject({
|
||||
name: finalName,
|
||||
path: kbDir,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
console.log(`\n ✓ Registered project "${newProject.name}"`);
|
||||
return createResolvedProject(newProject);
|
||||
} catch (err: any) {
|
||||
throw new ProjectResolutionError(
|
||||
`Failed to register project: ${err.message}`,
|
||||
"NOT_REGISTERED",
|
||||
{ directory: kbDir, error: err.message }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new ProjectResolutionError(
|
||||
"Project not registered. Run `fn project add <path>` to register.",
|
||||
"NOT_REGISTERED",
|
||||
{ directory: kbDir }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new ProjectResolutionError(
|
||||
`Found kb project at ${kbDir} but it's not registered.\n\n` +
|
||||
"Run `fn project add " + kbDir + "` to register it, or use --project <name>.",
|
||||
"NOT_REGISTERED",
|
||||
{ directory: kbDir }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. No .kb/ found - check registered projects
|
||||
const allProjects = await central.listProjects();
|
||||
|
||||
if (allProjects.length === 0) {
|
||||
// 6a. No projects at all
|
||||
throw new ProjectResolutionError(
|
||||
"No projects registered.\n\n" +
|
||||
"To get started:\n" +
|
||||
" 1. Navigate to your project directory\n" +
|
||||
" 2. Run `fn init` to initialize kb\n" +
|
||||
" 3. Run `fn project add .` to register it\n" +
|
||||
"\nOr: `fn project add <path>` to register from anywhere.",
|
||||
"NO_PROJECTS"
|
||||
);
|
||||
}
|
||||
|
||||
if (allProjects.length === 1) {
|
||||
// 6b. Exactly one project - use as default
|
||||
const project = allProjects[0];
|
||||
|
||||
// Check if path still exists
|
||||
if (!existsSync(project.path)) {
|
||||
throw new ProjectResolutionError(
|
||||
`The only registered project "${project.name}" has a missing directory: ${project.path}\n\n` +
|
||||
"Run `fn project remove " + project.name + "` to clean up, then register a valid project.",
|
||||
"PATH_MISMATCH",
|
||||
{ projectId: project.id, path: project.path }
|
||||
);
|
||||
}
|
||||
|
||||
return createResolvedProject(project);
|
||||
}
|
||||
|
||||
// 6c. Multiple projects - need explicit selection
|
||||
if (interactive) {
|
||||
const selected = await promptProjectSelection(
|
||||
allProjects,
|
||||
"Multiple projects registered. Please select one:"
|
||||
);
|
||||
return createResolvedProject(selected);
|
||||
} else {
|
||||
const projectList = allProjects.map((p) => ` - ${p.name}: ${p.path}`).join("\n");
|
||||
throw new ProjectResolutionError(
|
||||
`Multiple projects registered. Use --project <name> to specify one.\n\nAvailable projects:\n${projectList}`,
|
||||
"MULTIPLE_MATCHES",
|
||||
{ availableProjects: allProjects.map((p) => ({ name: p.name, path: p.path })) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ResolvedProject from a RegisteredProject.
|
||||
* Initializes the TaskStore for the project.
|
||||
*/
|
||||
async function createResolvedProject(project: RegisteredProject): Promise<ResolvedProject> {
|
||||
// Initialize TaskStore for this project
|
||||
const store = new (await import("@fusion/core")).TaskStore(project.path);
|
||||
await store.init();
|
||||
|
||||
// Try to get runtime from ProjectManager if available
|
||||
let runtime: import("@fusion/engine").ProjectRuntime | undefined;
|
||||
try {
|
||||
const pm = await getProjectManager();
|
||||
runtime = pm.getRuntime(project.id);
|
||||
} catch {
|
||||
// ProjectManager not initialized or runtime not started - that's ok
|
||||
runtime = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
name: project.name,
|
||||
directory: project.path,
|
||||
status: project.status,
|
||||
isolationMode: project.isolationMode,
|
||||
runtime,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up singleton instances.
|
||||
* Call this on CLI exit to close database connections.
|
||||
*/
|
||||
export async function cleanupProjectResolution(): Promise<void> {
|
||||
if (projectManagerInstance) {
|
||||
// ProjectManager doesn't have a close method, but we should stop all runtimes
|
||||
try {
|
||||
await projectManagerInstance.stopAll();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
projectManagerInstance = null;
|
||||
}
|
||||
|
||||
if (centralCoreInstance) {
|
||||
await centralCoreInstance.close();
|
||||
centralCoreInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved project without needing to use it immediately.
|
||||
* Useful for commands that just need to verify the project exists.
|
||||
*/
|
||||
export async function getResolvedProject(options: ResolveOptions = {}): Promise<ResolvedProject> {
|
||||
return resolveProject(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format project resolution error for CLI display.
|
||||
*/
|
||||
export function formatResolutionError(error: ProjectResolutionError): string {
|
||||
let output = `\n ✗ ${error.message}\n`;
|
||||
|
||||
if (error.code === "NO_PROJECTS") {
|
||||
// Message already includes detailed instructions
|
||||
} else if (error.code === "MULTIPLE_MATCHES" && error.context?.availableProjects) {
|
||||
// List already included in message
|
||||
} else if (error.code === "NOT_FOUND" && error.context?.availableProjects) {
|
||||
output += `\n Available projects:\n`;
|
||||
for (const name of error.context.availableProjects as string[]) {
|
||||
output += ` - ${name}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project is registered at the given path.
|
||||
* Returns the project if found, undefined otherwise.
|
||||
*/
|
||||
export async function findProjectByPath(
|
||||
path: string,
|
||||
central?: CentralCore
|
||||
): Promise<RegisteredProject | undefined> {
|
||||
const core = central ?? (await getCentralCore());
|
||||
const normalizedPath = normalize(resolve(path));
|
||||
const projects = await core.listProjects();
|
||||
|
||||
return projects.find((p) => normalize(p.path) === normalizedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project name is already registered.
|
||||
*/
|
||||
export async function isProjectNameTaken(
|
||||
name: string,
|
||||
central?: CentralCore
|
||||
): Promise<boolean> {
|
||||
const core = central ?? (await getCentralCore());
|
||||
const projects = await core.listProjects();
|
||||
|
||||
return projects.some((p) => p.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path contains an initialized kb project (.kb/ directory exists).
|
||||
*/
|
||||
export function isKbProject(path: string): boolean {
|
||||
const kbPath = resolve(path, ".kb");
|
||||
return existsSync(kbPath) && statSync(kbPath).isDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested project name from directory path.
|
||||
*/
|
||||
export function suggestProjectName(path: string): string {
|
||||
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || "unnamed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve absolute path and validate it exists.
|
||||
*/
|
||||
export function resolveAbsolutePath(inputPath: string): string {
|
||||
const resolved = resolve(inputPath);
|
||||
if (!existsSync(resolved)) {
|
||||
throw new ProjectResolutionError(
|
||||
`Path does not exist: ${inputPath}`,
|
||||
"NOT_FOUND",
|
||||
{ path: inputPath }
|
||||
);
|
||||
}
|
||||
if (!statSync(resolved).isDirectory()) {
|
||||
throw new ProjectResolutionError(
|
||||
`Path is not a directory: ${inputPath}`,
|
||||
"NOT_FOUND",
|
||||
{ path: inputPath }
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a quick summary of all registered projects.
|
||||
* Used for CLI hints and error messages.
|
||||
*/
|
||||
export async function getProjectSummary(): Promise<
|
||||
Array<{ name: string; path: string; status: string }>
|
||||
> {
|
||||
const central = await getCentralCore();
|
||||
const projects = await central.listProjects();
|
||||
|
||||
return projects.map((p) => ({
|
||||
name: p.name,
|
||||
path: p.path,
|
||||
status: p.status,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instances. Used primarily for testing.
|
||||
*/
|
||||
export function resetProjectResolution(): void {
|
||||
centralCoreInstance = null;
|
||||
projectManagerInstance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CentralCore is initialized.
|
||||
*/
|
||||
export function isCentralCoreInitialized(): boolean {
|
||||
return centralCoreInstance?.isInitialized() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered projects from CentralCore.
|
||||
*/
|
||||
export async function listRegisteredProjects(): Promise<RegisteredProject[]> {
|
||||
const central = await getCentralCore();
|
||||
return central.listProjects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project by name.
|
||||
*/
|
||||
export async function getProjectByName(name: string): Promise<RegisteredProject | undefined> {
|
||||
const central = await getCentralCore();
|
||||
const projects = await central.listProjects();
|
||||
return projects.find((p) => p.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new project with interactive prompts for missing info.
|
||||
*/
|
||||
export async function registerProjectInteractive(
|
||||
dir: string,
|
||||
options: {
|
||||
name?: string;
|
||||
isolation?: "in-process" | "child-process";
|
||||
interactive?: boolean;
|
||||
} = {}
|
||||
): Promise<ResolvedProject> {
|
||||
const central = await getCentralCore();
|
||||
const interactive = options.interactive ?? true;
|
||||
|
||||
// Validate directory
|
||||
const absPath = resolveAbsolutePath(dir);
|
||||
|
||||
// Check for .kb/ directory
|
||||
if (!isKbProject(absPath)) {
|
||||
if (interactive) {
|
||||
console.log(`\n No .kb/ directory found in ${absPath}`);
|
||||
const shouldInit = await promptConfirm("Initialize kb here first?", true);
|
||||
|
||||
if (shouldInit) {
|
||||
// Initialize the project (create .kb/)
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(absPath);
|
||||
await store.init();
|
||||
console.log(` ✓ Initialized kb at ${absPath}`);
|
||||
} else {
|
||||
throw new ProjectResolutionError(
|
||||
"Cannot register project without .kb/ directory. Run `fn init` first.",
|
||||
"NOT_INITIALIZED",
|
||||
{ directory: absPath }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new ProjectResolutionError(
|
||||
`No .kb/ directory found in ${absPath}. Run \`fn init\` first.`,
|
||||
"NOT_INITIALIZED",
|
||||
{ directory: absPath }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine project name
|
||||
let name = options.name;
|
||||
if (!name && interactive) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const suggested = suggestProjectName(absPath);
|
||||
const input = await rl.question(` Project name [${suggested}]: `);
|
||||
rl.close();
|
||||
name = input.trim() || suggested;
|
||||
}
|
||||
name = name || suggestProjectName(absPath);
|
||||
|
||||
// Check for duplicate name
|
||||
const isTaken = await isProjectNameTaken(name, central);
|
||||
if (isTaken) {
|
||||
throw new ProjectResolutionError(
|
||||
`A project named "${name}" is already registered. Choose a different name.`,
|
||||
"NOT_REGISTERED",
|
||||
{ name }
|
||||
);
|
||||
}
|
||||
|
||||
// Register the project
|
||||
const project = await central.registerProject({
|
||||
name,
|
||||
path: absPath,
|
||||
isolationMode: options.isolation ?? "in-process",
|
||||
});
|
||||
|
||||
return createResolvedProject(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a project by name.
|
||||
*/
|
||||
export async function unregisterProject(
|
||||
name: string,
|
||||
options: { force?: boolean; interactive?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
const interactive = options.interactive ?? true;
|
||||
|
||||
// Find the project
|
||||
const project = await getProjectByName(name);
|
||||
if (!project) {
|
||||
throw new ProjectResolutionError(
|
||||
`Project "${name}" not found.`,
|
||||
"NOT_FOUND",
|
||||
{ name }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if runtime is active
|
||||
const runtime = pm.getRuntime(project.id);
|
||||
if (runtime) {
|
||||
// Stop the runtime first
|
||||
await pm.removeProject(project.id);
|
||||
}
|
||||
|
||||
// Confirmation prompt
|
||||
if (!options.force && interactive) {
|
||||
const confirmed = await promptConfirm(
|
||||
`Unregister "${project.name}" from the registry? The project data will be preserved.`,
|
||||
false
|
||||
);
|
||||
if (!confirmed) {
|
||||
throw new ProjectResolutionError("Cancelled by user.", "CANCELLED");
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister from CentralCore
|
||||
await central.unregisterProject(project.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed project info including runtime metrics and task counts.
|
||||
*/
|
||||
export async function getProjectInfo(name?: string): Promise<{
|
||||
project: ResolvedProject;
|
||||
health: import("@fusion/core").ProjectHealth | undefined;
|
||||
taskCounts: Record<string, number>;
|
||||
}> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
|
||||
let project: ResolvedProject;
|
||||
|
||||
if (name) {
|
||||
const registered = await getProjectByName(name);
|
||||
if (!registered) {
|
||||
throw new ProjectResolutionError(`Project "${name}" not found.`, "NOT_FOUND", { name });
|
||||
}
|
||||
project = await createResolvedProject(registered);
|
||||
} else {
|
||||
project = await resolveProject();
|
||||
}
|
||||
|
||||
// Get health metrics
|
||||
const health = await central.getProjectHealth(project.projectId);
|
||||
|
||||
// Get task counts by column
|
||||
const tasks = await project.store.listTasks();
|
||||
const taskCounts: Record<string, number> = {};
|
||||
for (const task of tasks) {
|
||||
taskCounts[task.column] = (taskCounts[task.column] || 0) + 1;
|
||||
}
|
||||
|
||||
// Ensure runtime is tracked in ProjectManager
|
||||
const runtime = pm.getRuntime(project.projectId);
|
||||
if (runtime) {
|
||||
project.runtime = runtime;
|
||||
}
|
||||
|
||||
return { project, health, taskCounts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a project runtime if not already running.
|
||||
*/
|
||||
export async function startProjectRuntime(projectId: string): Promise<import("@fusion/engine").ProjectRuntime> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
|
||||
// Check if already running
|
||||
const existing = pm.getRuntime(projectId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Get project config from registry
|
||||
const project = await central.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new ProjectResolutionError(`Project "${projectId}" not found.`, "NOT_FOUND", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add and start the runtime
|
||||
const runtime = await pm.addProject({
|
||||
projectId: project.id,
|
||||
workingDirectory: project.path,
|
||||
isolationMode: project.isolationMode,
|
||||
maxConcurrent: project.settings?.maxConcurrent ?? 2,
|
||||
maxWorktrees: project.settings?.maxWorktrees ?? 4,
|
||||
});
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a project runtime if running.
|
||||
*/
|
||||
export async function stopProjectRuntime(projectId: string): Promise<void> {
|
||||
const pm = await getProjectManager();
|
||||
await pm.removeProject(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current runtime status for a project.
|
||||
*/
|
||||
export async function getProjectRuntimeStatus(
|
||||
projectId: string
|
||||
): Promise<import("@fusion/engine").RuntimeStatus | "not_started"> {
|
||||
const pm = await getProjectManager();
|
||||
const runtime = pm.getRuntime(projectId);
|
||||
|
||||
if (!runtime) {
|
||||
return "not_started";
|
||||
}
|
||||
|
||||
return runtime.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last activity timestamp for a project.
|
||||
*/
|
||||
export async function getProjectLastActivity(projectId: string): Promise<string | undefined> {
|
||||
const central = await getCentralCore();
|
||||
const health = await central.getProjectHealth(projectId);
|
||||
return health?.lastActivityAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects with their runtime status.
|
||||
*/
|
||||
export async function getProjectsWithStatus(): Promise<
|
||||
Array<{
|
||||
project: RegisteredProject;
|
||||
runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started";
|
||||
taskCount: number;
|
||||
}>
|
||||
> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
|
||||
const projects = await central.listProjects();
|
||||
|
||||
const results = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const runtime = pm.getRuntime(project.id);
|
||||
const runtimeStatus = runtime?.getStatus() ?? "not_started";
|
||||
|
||||
// Get task count from store
|
||||
let taskCount = 0;
|
||||
try {
|
||||
const store = new (await import("@fusion/core")).TaskStore(project.path);
|
||||
await store.init();
|
||||
const tasks = await store.listTasks();
|
||||
taskCount = tasks.length;
|
||||
} catch {
|
||||
// If we can't read tasks, just report 0
|
||||
}
|
||||
|
||||
return { project, runtimeStatus, taskCount } as {
|
||||
project: RegisteredProject;
|
||||
runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started";
|
||||
taskCount: number;
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display (relative or absolute).
|
||||
*/
|
||||
export function formatLastActivity(timestamp?: string): string {
|
||||
if (!timestamp) return "never";
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of tasks by column for a project.
|
||||
*/
|
||||
export async function getProjectTaskCounts(
|
||||
projectId: string,
|
||||
store?: TaskStore
|
||||
): Promise<Record<string, number>> {
|
||||
const taskStore =
|
||||
store ??
|
||||
(await (async () => {
|
||||
const central = await getCentralCore();
|
||||
const project = await central.getProject(projectId);
|
||||
if (!project) return undefined;
|
||||
const s = new (await import("@fusion/core")).TaskStore(project.path);
|
||||
await s.init();
|
||||
return s;
|
||||
})());
|
||||
|
||||
if (!taskStore) return {};
|
||||
|
||||
const tasks = await taskStore.listTasks();
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
counts[task.column] = (counts[task.column] || 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary information for a project.
|
||||
*/
|
||||
export async function getProjectSummaryInfo(
|
||||
project: ResolvedProject
|
||||
): Promise<{
|
||||
taskCounts: Record<string, number>;
|
||||
lastActivity: string | undefined;
|
||||
runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started";
|
||||
}> {
|
||||
const central = await getCentralCore();
|
||||
const pm = await getProjectManager();
|
||||
|
||||
const [taskCounts, lastActivity, runtime] = await Promise.all([
|
||||
getProjectTaskCounts(project.projectId, project.store),
|
||||
getProjectLastActivity(project.projectId),
|
||||
Promise.resolve(pm.getRuntime(project.projectId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
taskCounts,
|
||||
lastActivity,
|
||||
runtimeStatus: runtime?.getStatus() ?? "not_started",
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup on process exit (skip in test environment)
|
||||
if (process.env.NODE_ENV !== "test" && process.env.VITEST === undefined) {
|
||||
process.on("exit", () => {
|
||||
// Note: cleanupProjectResolution is async, but process.exit doesn't await
|
||||
// This is a best-effort cleanup - the OS will clean up resources anyway
|
||||
void cleanupProjectResolution();
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void cleanupProjectResolution().then(() => process.exit(0));
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void cleanupProjectResolution().then(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
// Add getStore export for backward compatibility with existing code
|
||||
export async function getStore(options?: { project?: string; cwd?: string }): Promise<TaskStore> {
|
||||
const resolved = await resolveProject({
|
||||
project: options?.project,
|
||||
cwd: options?.cwd,
|
||||
interactive: true,
|
||||
});
|
||||
return resolved.store;
|
||||
}
|
||||
|
||||
// Export getStore as default for backward compatibility
|
||||
export { getStore as default };
|
||||
|
||||
// Re-export types from @fusion/core
|
||||
export type { RegisteredProject } from "@fusion/core";
|
||||
|
||||
// Export internal helpers for tests
|
||||
export { promptConfirm, promptProjectSelection, createResolvedProject };
|
||||
Reference in New Issue
Block a user