feat(KB-619): add multi-project CLI resolution and commands
- Add CLI project resolution with --project targeting and project subcommands - Implement project list/add/remove/info flows with central registry integration - Expand resolver and command test coverage, including cancellation and explicit selection paths - Include published CLI changeset metadata for the multi-project command feature
This commit is contained in:
@@ -47,7 +47,6 @@ const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./co
|
||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } = await import("./commands/project.js");
|
||||
const { getResolvedProject } = await import("./project-resolver.js");
|
||||
|
||||
const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } from "./project.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Create mock functions at module level
|
||||
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 mockGetRuntime = vi.fn().mockReturnValue(undefined);
|
||||
const mockRemoveProject = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFindKbDir = vi.fn().mockReturnValue(null);
|
||||
const mockGetRuntime = vi.fn();
|
||||
const mockRemoveRuntime = vi.fn();
|
||||
const mockTaskStoreInit = vi.fn();
|
||||
const mockTaskStoreListTasks = vi.fn();
|
||||
const mockFindKbDir = vi.fn();
|
||||
const mockIsKbProject = vi.fn();
|
||||
const mockSuggestProjectName = vi.fn();
|
||||
const mockFormatLastActivity = vi.fn();
|
||||
|
||||
// Mock project-resolver module - define inline
|
||||
vi.mock("../project-resolver.js", () => ({
|
||||
getCentralCore: vi.fn().mockImplementation(() => ({
|
||||
getCentralCore: vi.fn(async () => ({
|
||||
listProjects: mockListProjects,
|
||||
getProject: mockGetProject,
|
||||
getProjectByPath: mockGetProjectByPath,
|
||||
@@ -22,205 +24,257 @@ vi.mock("../project-resolver.js", () => ({
|
||||
unregisterProject: mockUnregisterProject,
|
||||
getProjectHealth: mockGetProjectHealth,
|
||||
})),
|
||||
getProjectManager: vi.fn().mockImplementation(() => ({
|
||||
getProjectManager: vi.fn(async () => ({
|
||||
getRuntime: mockGetRuntime,
|
||||
removeProject: mockRemoveProject,
|
||||
removeProject: mockRemoveRuntime,
|
||||
})),
|
||||
findKbDir: vi.fn().mockImplementation((path: string) => mockFindKbDir(path)),
|
||||
isKbProject: vi.fn().mockReturnValue(true),
|
||||
suggestProjectName: vi.fn().mockReturnValue("test-project"),
|
||||
formatLastActivity: vi.fn().mockReturnValue("just now"),
|
||||
findKbDir: vi.fn((path: string) => mockFindKbDir(path)),
|
||||
isKbProject: vi.fn((path: string) => mockIsKbProject(path)),
|
||||
suggestProjectName: vi.fn((path: string) => mockSuggestProjectName(path)),
|
||||
formatLastActivity: vi.fn((timestamp?: string) => mockFormatLastActivity(timestamp)),
|
||||
resolveProject: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
statSync: vi.fn().mockReturnValue({ isDirectory: () => true }),
|
||||
existsSync: vi.fn(() => true),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => true })),
|
||||
}));
|
||||
|
||||
// Mock @fusion/core TaskStore
|
||||
vi.mock("@fusion/core", async () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: mockTaskStoreInit,
|
||||
listTasks: mockTaskStoreListTasks,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Project Commands", () => {
|
||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } = await import("./project.js");
|
||||
|
||||
describe("project commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset default mock return values
|
||||
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockGetProject.mockResolvedValue(undefined);
|
||||
mockGetProjectByPath.mockResolvedValue(undefined);
|
||||
mockGetProjectHealth.mockResolvedValue(undefined);
|
||||
mockGetRuntime.mockReturnValue(undefined);
|
||||
mockRemoveProject.mockResolvedValue(undefined);
|
||||
mockFindKbDir.mockReturnValue(null);
|
||||
mockRegisterProject.mockImplementation((config: any) => ({
|
||||
mockRegisterProject.mockImplementation(async (config: { name: string; path: string; isolationMode: string }) => ({
|
||||
id: "proj_new",
|
||||
name: config.name,
|
||||
path: config.path,
|
||||
status: "initializing",
|
||||
isolationMode: config.isolationMode,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
}));
|
||||
mockUnregisterProject.mockResolvedValue(undefined);
|
||||
mockGetProjectHealth.mockResolvedValue(undefined);
|
||||
mockGetRuntime.mockReturnValue(undefined);
|
||||
mockRemoveRuntime.mockResolvedValue(undefined);
|
||||
mockTaskStoreInit.mockResolvedValue(undefined);
|
||||
mockTaskStoreListTasks.mockResolvedValue([]);
|
||||
mockFindKbDir.mockReturnValue(null);
|
||||
mockIsKbProject.mockReturnValue(true);
|
||||
mockSuggestProjectName.mockReturnValue("test-project");
|
||||
mockFormatLastActivity.mockReturnValue("just now");
|
||||
});
|
||||
|
||||
describe("exports", () => {
|
||||
it("exports runProjectList as a function", () => {
|
||||
expect(typeof runProjectList).toBe("function");
|
||||
});
|
||||
it("prints empty state when no projects are registered", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
it("exports runProjectAdd as a function", () => {
|
||||
expect(typeof runProjectAdd).toBe("function");
|
||||
});
|
||||
await runProjectList();
|
||||
|
||||
it("exports runProjectRemove as a function", () => {
|
||||
expect(typeof runProjectRemove).toBe("function");
|
||||
});
|
||||
|
||||
it("exports runProjectInfo as a function", () => {
|
||||
expect(typeof runProjectInfo).toBe("function");
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith("\n No projects registered.");
|
||||
});
|
||||
|
||||
describe("runProjectList", () => {
|
||||
it("should handle empty project list", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectList();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No projects registered"));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should output JSON when --json flag is set", async () => {
|
||||
const mockProject = {
|
||||
it("emits json output for project list", async () => {
|
||||
mockListProjects.mockResolvedValue([
|
||||
{
|
||||
id: "proj_123",
|
||||
name: "test-project",
|
||||
path: "/path/to/project",
|
||||
name: "alpha",
|
||||
path: "/tmp/alpha",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
createdAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
mockGetProjectHealth.mockResolvedValue({ inFlightAgentCount: 1, lastActivityAt: "2026-03-31T00:00:00.000Z" });
|
||||
mockTaskStoreListTasks.mockResolvedValue([{ column: "todo" }, { column: "done" }]);
|
||||
|
||||
mockListProjects.mockResolvedValue([mockProject]);
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
lastActivityAt: "2024-01-01T00:00:00.000Z",
|
||||
inFlightAgentCount: 0,
|
||||
});
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await runProjectList({ json: true });
|
||||
|
||||
await runProjectList({ json: true });
|
||||
|
||||
const jsonCall = consoleSpy.mock.calls.find((call) => {
|
||||
try {
|
||||
JSON.parse(call[0] as string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
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();
|
||||
});
|
||||
const payload = JSON.parse(String(logSpy.mock.calls[0][0]));
|
||||
expect(payload).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "proj_123",
|
||||
name: "alpha",
|
||||
totalTasks: 2,
|
||||
activeAgents: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("runProjectAdd", () => {
|
||||
it.skip("should exit if no directory provided - type check issue", async () => {
|
||||
// Skipped: TypeScript prevents passing undefined, runtime check not needed
|
||||
it("registers a project with explicit path and name", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectAdd("/tmp/my-project", {
|
||||
name: "my-project",
|
||||
isolation: "child-process",
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
it("should validate isolation mode", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith({
|
||||
name: "my-project",
|
||||
path: "/tmp/my-project",
|
||||
isolationMode: "child-process",
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Registered project 'my-project'"));
|
||||
});
|
||||
|
||||
await runProjectAdd("/tmp", { isolation: "invalid-mode" as any, interactive: false });
|
||||
it("rejects invalid isolation modes", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid isolation mode"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
await runProjectAdd("/tmp/my-project", {
|
||||
name: "my-project",
|
||||
isolation: "invalid-mode" as never,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
it("should register project with valid inputs", async () => {
|
||||
const { existsSync } = await import("node:fs");
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid isolation mode 'invalid-mode'");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
it("errors when directory is not a kb project", async () => {
|
||||
mockIsKbProject.mockReturnValue(false);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
await runProjectAdd("/tmp/test-project", { name: "my-project", interactive: false });
|
||||
await runProjectAdd("/tmp/not-kb", { interactive: false });
|
||||
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith({
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: No kb project found at /tmp/not-kb");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("errors when registering a duplicate project name", async () => {
|
||||
mockListProjects.mockResolvedValue([
|
||||
{
|
||||
id: "proj_existing",
|
||||
name: "my-project",
|
||||
path: expect.any(String),
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProjectRemove", () => {
|
||||
it.skip("should exit if project not found - mock setup issue", async () => {
|
||||
// Skipped: mock setup requires vi.hoisted pattern that needs refactoring
|
||||
// The functionality is verified via integration tests
|
||||
});
|
||||
|
||||
it.skip("should skip confirmation with --force flag - mock setup issue", async () => {
|
||||
// Skipped: mock setup requires vi.hoisted pattern that needs refactoring
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
path: "/tmp/existing",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
createdAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
mockFindKbDir.mockReturnValue("/current/dir");
|
||||
mockGetProjectByPath.mockResolvedValue(mockProject);
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 100,
|
||||
totalTasksFailed: 5,
|
||||
lastActivityAt: "2024-01-01T00:00:00.000Z",
|
||||
});
|
||||
mockGetRuntime.mockReturnValue({ getStatus: () => "active" });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
await runProjectAdd("/tmp/my-project", { name: "my-project", interactive: false });
|
||||
|
||||
await runProjectInfo(undefined, { interactive: false });
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Project 'my-project' already registered.");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("detected-project"));
|
||||
|
||||
logSpy.mockRestore();
|
||||
it("unregisters a project and stops runtime first", async () => {
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "alpha",
|
||||
path: "/tmp/alpha",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
});
|
||||
mockGetRuntime.mockReturnValue({ getStatus: () => "active" });
|
||||
|
||||
it.skip("should exit if project not found by name - mock setup issue", async () => {
|
||||
// Skipped: mock setup requires vi.hoisted pattern that needs refactoring
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectRemove("proj_123", { force: true, interactive: false });
|
||||
|
||||
expect(mockRemoveRuntime).toHaveBeenCalledWith("proj_123");
|
||||
expect(mockUnregisterProject).toHaveBeenCalledWith("proj_123");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered project 'alpha'"));
|
||||
});
|
||||
|
||||
it("does not stop runtime when removal is cancelled", async () => {
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "alpha",
|
||||
path: "/tmp/alpha",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
});
|
||||
mockGetRuntime.mockReturnValue({ getStatus: () => "active" });
|
||||
|
||||
const readline = await import("node:readline/promises");
|
||||
const rlClose = vi.fn();
|
||||
vi.spyOn(readline, "createInterface").mockReturnValue({
|
||||
question: vi.fn().mockResolvedValue("n"),
|
||||
close: rlClose,
|
||||
} as never);
|
||||
|
||||
await runProjectRemove("proj_123", { force: false, interactive: true });
|
||||
|
||||
expect(mockRemoveRuntime).not.toHaveBeenCalled();
|
||||
expect(mockUnregisterProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows info for auto-detected cwd project", async () => {
|
||||
mockGetProject.mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "detected-project",
|
||||
path: "/workspace/app",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
});
|
||||
mockGetProjectHealth.mockResolvedValue({
|
||||
activeTaskCount: 2,
|
||||
inFlightAgentCount: 1,
|
||||
totalTasksCompleted: 10,
|
||||
totalTasksFailed: 1,
|
||||
lastActivityAt: "2026-03-31T00:00:00.000Z",
|
||||
});
|
||||
mockGetRuntime.mockReturnValue({ getStatus: () => "active" });
|
||||
mockTaskStoreListTasks.mockResolvedValue([{ column: "todo" }, { column: "todo" }, { column: "done" }]);
|
||||
|
||||
const resolver = await import("../project-resolver.js");
|
||||
vi.mocked(resolver.resolveProject).mockResolvedValue({
|
||||
projectId: "proj_123",
|
||||
name: "detected-project",
|
||||
directory: "/workspace/app",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
store: {} as never,
|
||||
} as never);
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runProjectInfo(undefined, { interactive: false });
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Project: detected-project"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Active tasks: 2"));
|
||||
});
|
||||
|
||||
it("errors when explicit project name is missing", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
await runProjectInfo("missing-project", { interactive: false });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Project 'missing-project' not found.");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,17 +9,16 @@
|
||||
*/
|
||||
|
||||
import { CentralCore, type RegisteredProject, type IsolationMode } from "@fusion/core";
|
||||
import { resolve, isAbsolute, basename } from "node:path";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import {
|
||||
getCentralCore,
|
||||
getProjectManager,
|
||||
findKbDir,
|
||||
resolveProject,
|
||||
isKbProject,
|
||||
suggestProjectName,
|
||||
formatLastActivity,
|
||||
type ResolvedProject,
|
||||
} from "../project-resolver.js";
|
||||
|
||||
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
|
||||
@@ -55,9 +54,9 @@ export async function runProjectList(options: { json?: boolean } = {}): Promise<
|
||||
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;
|
||||
let readWarning: string | undefined;
|
||||
try {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(project.path);
|
||||
@@ -67,8 +66,8 @@ export async function runProjectList(options: { json?: boolean } = {}): Promise<
|
||||
for (const task of tasks) {
|
||||
taskCounts[task.column] = (taskCounts[task.column] || 0) + 1;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading tasks
|
||||
} catch (error: any) {
|
||||
readWarning = error?.message || "Failed to read project tasks";
|
||||
}
|
||||
|
||||
const health = await central.getProjectHealth(project.id);
|
||||
@@ -80,6 +79,7 @@ export async function runProjectList(options: { json?: boolean } = {}): Promise<
|
||||
totalTasks,
|
||||
lastActivity: health?.lastActivityAt,
|
||||
activeAgents: health?.inFlightAgentCount ?? 0,
|
||||
readWarning,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -128,6 +128,9 @@ export async function runProjectList(options: { json?: boolean } = {}): Promise<
|
||||
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}`
|
||||
);
|
||||
if (p.readWarning) {
|
||||
console.log(` warning: ${p.readWarning}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
@@ -162,7 +165,7 @@ export async function runProjectAdd(
|
||||
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);
|
||||
const shouldInit = await promptConfirmWithRl(rl, "Initialize kb here first?", true);
|
||||
|
||||
if (shouldInit) {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
@@ -278,13 +281,6 @@ export async function runProjectRemove(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -297,6 +293,13 @@ export async function runProjectRemove(
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
await central.unregisterProject(project.id);
|
||||
|
||||
console.log();
|
||||
@@ -310,10 +313,9 @@ export async function runProjectRemove(
|
||||
*
|
||||
* Shows detailed information about a specific project.
|
||||
*/
|
||||
export async function runProjectInfo(name?: string, options: { interactive?: boolean } = {}): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -325,36 +327,20 @@ export async function runProjectInfo(name?: string, options: { interactive?: boo
|
||||
}
|
||||
project = found;
|
||||
} else {
|
||||
// Auto-detect from cwd
|
||||
const cwd = process.cwd();
|
||||
const kbDir = findKbDir(cwd);
|
||||
const resolved = await resolveProject({ interactive: false });
|
||||
project = {
|
||||
id: resolved.projectId,
|
||||
name: resolved.name,
|
||||
path: resolved.directory,
|
||||
status: resolved.status as RegisteredProject["status"],
|
||||
isolationMode: resolved.isolationMode as RegisteredProject["isolationMode"],
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
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 (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);
|
||||
}
|
||||
const storedProject = await central.getProject(resolved.projectId);
|
||||
if (storedProject) {
|
||||
project = storedProject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,9 +348,9 @@ export async function runProjectInfo(name?: string, options: { interactive?: boo
|
||||
const runtime = pm.getRuntime(project.id);
|
||||
const runtimeStatus = runtime?.getStatus() ?? "not_started";
|
||||
|
||||
// Get task counts
|
||||
let taskCounts: Record<string, number> = {};
|
||||
let totalTasks = 0;
|
||||
let taskReadWarning: string | undefined;
|
||||
try {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const store = new TaskStore(project.path);
|
||||
@@ -374,8 +360,8 @@ export async function runProjectInfo(name?: string, options: { interactive?: boo
|
||||
for (const task of tasks) {
|
||||
taskCounts[task.column] = (taskCounts[task.column] || 0) + 1;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading tasks
|
||||
} catch (error: any) {
|
||||
taskReadWarning = error?.message || "Failed to read project tasks";
|
||||
}
|
||||
|
||||
// Get health metrics
|
||||
@@ -389,8 +375,12 @@ export async function runProjectInfo(name?: string, options: { interactive?: boo
|
||||
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()}`);
|
||||
if (project.createdAt) {
|
||||
console.log(` Created: ${new Date(project.createdAt).toLocaleString()}`);
|
||||
}
|
||||
if (project.updatedAt) {
|
||||
console.log(` Updated: ${new Date(project.updatedAt).toLocaleString()}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
console.log(` Tasks (${totalTasks} total):`);
|
||||
@@ -402,7 +392,10 @@ export async function runProjectInfo(name?: string, options: { interactive?: boo
|
||||
console.log(` ${icon} ${col}: ${count}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
if (taskReadWarning) {
|
||||
console.log(` Warning: ${taskReadWarning}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (health) {
|
||||
console.log(" Activity:");
|
||||
@@ -440,39 +433,25 @@ async function findProjectByNameOrId(central: CentralCore, nameOrId: string): Pr
|
||||
return findProjectByName(central, nameOrId);
|
||||
}
|
||||
|
||||
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 promptConfirmWithRl(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
message: string,
|
||||
defaultYes = false
|
||||
): Promise<boolean> {
|
||||
const prompt = defaultYes ? "[Y/n]" : "[y/N]";
|
||||
const answer = await rl.question(` ${message} ${prompt}: `);
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
if (trimmed === "" && defaultYes) return true;
|
||||
return trimmed === "y" || trimmed === "yes";
|
||||
}
|
||||
|
||||
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";
|
||||
try {
|
||||
return await promptConfirmWithRl(rl, message, defaultYes);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
|
||||
@@ -147,9 +147,26 @@ describe("Project Resolver", () => {
|
||||
});
|
||||
|
||||
describe("resolveProject", () => {
|
||||
it.skip("should resolve by explicit --project flag - requires proper TaskStore mocking", async () => {
|
||||
// Skipped: requires proper mocking of dynamic TaskStore import
|
||||
// The resolveProject logic is tested via other tests
|
||||
it("should resolve by explicit --project flag", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "alpha",
|
||||
path: "/workspace/alpha",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path) => path === "/workspace/alpha");
|
||||
|
||||
const core = await getCentralCore();
|
||||
core.listProjects.mockResolvedValue([mockProject]);
|
||||
|
||||
const resolved = await resolveProject({ project: "alpha", interactive: false });
|
||||
expect(resolved.projectId).toBe("proj_123");
|
||||
expect(resolved.name).toBe("alpha");
|
||||
expect(resolved.directory).toBe("/workspace/alpha");
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND if --project project not found", async () => {
|
||||
|
||||
@@ -1162,75 +1162,3 @@ export interface AgentUpdateInput {
|
||||
role?: AgentCapability;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Migration & First-Run Types (Multi-Project Support) ───────────────────
|
||||
|
||||
/** A project detected during filesystem scanning for auto-migration */
|
||||
export interface DetectedProject {
|
||||
/** Absolute path to the project directory */
|
||||
path: string;
|
||||
/** Project name (derived from directory basename) */
|
||||
name: string;
|
||||
/** Whether the project has a valid kb database */
|
||||
hasDb: boolean;
|
||||
}
|
||||
|
||||
/** Options for migration orchestration */
|
||||
export interface MigrationOptions {
|
||||
/** Starting path for project detection (default: process.cwd()) */
|
||||
startPath?: string;
|
||||
/** Whether to auto-register detected projects (default: false) */
|
||||
autoRegister?: boolean;
|
||||
/** Whether to perform a dry run (detect only, don't register) */
|
||||
dryRun?: boolean;
|
||||
/** Maximum depth to scan (default: 5) */
|
||||
maxDepth?: number;
|
||||
/** Progress callback for UI feedback */
|
||||
onProgress?: (current: number, total: number, projectPath: string) => void;
|
||||
}
|
||||
|
||||
/** Result of migration execution */
|
||||
export interface MigrationResult {
|
||||
/** Projects detected during scan */
|
||||
projectsDetected: DetectedProject[];
|
||||
/** Projects successfully registered */
|
||||
projectsRegistered: RegisteredProject[];
|
||||
/** Projects skipped (already registered or invalid) */
|
||||
projectsSkipped: Array<{ path: string; reason: string }>;
|
||||
/** Errors encountered during migration */
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
/** Input for setting up a project during first-run wizard */
|
||||
export interface ProjectSetupInput {
|
||||
/** Absolute path to project directory */
|
||||
path: string;
|
||||
/** Display name for the project */
|
||||
name: string;
|
||||
/** Execution isolation mode (default: 'in-process') */
|
||||
isolationMode?: IsolationMode;
|
||||
}
|
||||
|
||||
/** Complete setup state for first-run experience */
|
||||
export interface SetupState {
|
||||
/** Whether this is a fresh installation (no projects registered) */
|
||||
isFirstRun: boolean;
|
||||
/** Whether any projects were detected during scan */
|
||||
hasDetectedProjects: boolean;
|
||||
/** Projects detected but not yet registered */
|
||||
detectedProjects: DetectedProject[];
|
||||
/** Projects already registered in the system */
|
||||
registeredProjects: RegisteredProject[];
|
||||
/** Recommended action based on current state */
|
||||
recommendedAction: 'auto-detect' | 'manual-setup' | 'create-new';
|
||||
}
|
||||
|
||||
/** Result of completing the setup wizard */
|
||||
export interface SetupCompletionResult {
|
||||
/** Whether setup completed successfully */
|
||||
success: boolean;
|
||||
/** Projects that were registered */
|
||||
projects: RegisteredProject[];
|
||||
/** Suggested next steps for the user */
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user