feat(KB-503): merge kb/kb-503

- feat(KB-503): complete Step 5 other command project support
- fix(KB-503): align task resolution with shared project context
- fix(KB-503): preserve local task command fallback
- test(KB-503): complete Step 4 task command coverage
- test(KB-503): harden Step 3 CLI parser coverage
- fix(KB-503): keep settings set scope explicit
- fix(KB-503): allow implicit project resolution for project settings
- fix(KB-503): enforce explicit settings scope rules
- fix(KB-503): restore shared settings resolution behavior
- fix(KB-503): align settings scope and project flag behavior
- feat(KB-503): complete Step 5 — add project-aware settings, git, and backup behavior
- fix(KB-503): enforce settings scope for global and project modes
- fix(KB-503): preserve project-scoped settings and cwd-aware git helpers
- fix(KB-503): add scoped settings behavior and git project tests
- feat(KB-503): complete Step 5 — add project-aware git and backup coverage
- test(KB-503): cover remaining project-aware task command paths
- fix(KB-503): use shared resolution flow for all task commands
- fix(KB-503): restore legacy task fallback and correct task mocks
- fix(KB-503): align task resolution order and kb storage paths
- fix(KB-503): restore local task store fallback without project flag
- test(KB-503): cover remaining project-aware task handlers
- fix(KB-503): preserve branch naming and avoid duplicate log resolution
- test(KB-503): expand project-aware task command coverage
- fix(KB-503): restore cwd fallback for path-based task commands
- fix(KB-503): add project-aware task output and tests
- fix(KB-503): harden project command output and coverage
- fix(KB-503): preserve legacy task resolution without project flag
- test(KB-503): add bin routing coverage for project flag parsing
- feat(KB-503): complete Step 3 — CLI argument parsing updates
- fix(KB-503): add defaultProjectId to GlobalSettings type and fix TypeScript errors
- test(KB-503): fix resolveProject mock in task tests for runTaskLogs follow mode
- docs(KB-503): add multi-project CLI documentation and changeset
- test(KB-503): add project-context mock to task tests
- test(KB-503): update git tests for new cwd parameter
- feat(KB-503): Step 3 — CLI argument parsing updates with --project flag support
- feat(KB-503): Steps 1-2 — project context utilities and project subcommands
This commit is contained in:
gsxdsm
2026-04-01 15:06:58 -07:00
parent c6acedb432
commit 07adb42d64
37 changed files with 2500 additions and 5763 deletions

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const runTaskCreate = vi.fn();
const runTaskList = vi.fn();
const runTaskPlan = vi.fn();
const runTaskImportFromGitHub = vi.fn();
const runSettingsShow = vi.fn();
const runSettingsExport = vi.fn();
const runSettingsImport = vi.fn();
const runGitStatus = vi.fn();
const runGitFetch = vi.fn();
const runBackupList = vi.fn();
const runProjectList = vi.fn();
const runProjectAdd = vi.fn();
const runProjectRemove = vi.fn();
const runProjectShow = vi.fn();
const runProjectSetDefault = vi.fn();
const runProjectDetect = vi.fn();
vi.mock("../commands/dashboard.js", () => ({
runDashboard: vi.fn(),
}));
vi.mock("../commands/task.js", () => ({
runTaskCreate,
runTaskList,
runTaskMove: vi.fn(),
runTaskMerge: vi.fn(),
runTaskUpdate: vi.fn(),
runTaskLog: vi.fn(),
runTaskLogs: vi.fn(),
runTaskShow: vi.fn(),
runTaskAttach: vi.fn(),
runTaskPause: vi.fn(),
runTaskUnpause: vi.fn(),
runTaskImportFromGitHub,
runTaskImportGitHubInteractive: vi.fn(),
runTaskDuplicate: vi.fn(),
runTaskArchive: vi.fn(),
runTaskUnarchive: vi.fn(),
runTaskRefine: vi.fn(),
runTaskPlan,
runTaskDelete: vi.fn(),
runTaskRetry: vi.fn(),
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
runTaskPrCreate: vi.fn(),
}));
vi.mock("../commands/settings.js", () => ({
runSettingsShow,
runSettingsSet: vi.fn(),
}));
vi.mock("../commands/settings-export.js", () => ({ runSettingsExport }));
vi.mock("../commands/settings-import.js", () => ({ runSettingsImport }));
vi.mock("../commands/git.js", () => ({
runGitStatus,
runGitFetch,
runGitPull: vi.fn(),
runGitPush: vi.fn(),
}));
vi.mock("../commands/backup.js", () => ({
runBackupCreate: vi.fn(),
runBackupList,
runBackupRestore: vi.fn(),
runBackupCleanup: vi.fn(),
}));
vi.mock("../commands/project.js", () => ({
runProjectList,
runProjectAdd,
runProjectRemove,
runProjectShow,
runProjectSetDefault,
runProjectDetect,
}));
describe("bin", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let originalArgv: string[];
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
originalArgv = process.argv;
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
process.argv = originalArgv;
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
async function runBin(args: string[]) {
process.argv = ["node", "bin", ...args];
return import("../bin.ts?" + Math.random());
}
it("routes task list with --project before subcommand", async () => {
await runBin(["--project", "my-app", "task", "list"]);
expect(runTaskList).toHaveBeenCalledWith("my-app");
});
it("preserves legacy task list behavior when project flag is absent", async () => {
await runBin(["task", "list"]);
expect(runTaskList).toHaveBeenCalledWith(undefined);
});
it("routes task import with short -P and preserves other flags", async () => {
await runBin(["task", "import", "owner/repo", "-P", "my-app", "--limit", "10", "--labels", "bug,help-wanted"]);
expect(runTaskImportFromGitHub).toHaveBeenCalledWith("owner/repo", { limit: 10, labels: ["bug", "help-wanted"] }, "my-app");
});
it("strips --project before parsing free-form task create arguments", async () => {
await runBin(["task", "create", "Fix", "login", "--attach", "spec.md", "--project", "demo"]);
expect(runTaskCreate).toHaveBeenCalledWith("Fix login", ["spec.md"], undefined, "demo");
});
it("strips --project before parsing free-form task plan arguments", async () => {
await runBin(["task", "plan", "Fix", "auth", "--project", "demo", "--yes"]);
expect(runTaskPlan).toHaveBeenCalledWith("Fix auth", true, "demo");
});
it("passes projectName to settings, git, and backup handlers", async () => {
await runBin(["settings", "--project", "my-app"]);
expect(runSettingsShow).toHaveBeenCalledWith("my-app");
await runBin(["git", "status", "--project", "my-app"]);
expect(runGitStatus).toHaveBeenCalledWith("my-app");
await runBin(["backup", "--list", "--project", "my-app"]);
expect(runBackupList).toHaveBeenCalledWith("my-app");
});
it("passes projectName through to settings export and import handlers", async () => {
await runBin(["settings", "export", "--project", "demo", "--scope", "project"]);
expect(runSettingsExport).toHaveBeenCalledWith({ scope: "project", output: undefined, projectName: "demo" });
await runBin(["settings", "import", "settings.json", "--project", "demo", "--yes"]);
expect(runSettingsImport).toHaveBeenCalledWith("settings.json", { scope: "both", merge: false, yes: true, projectName: "demo" });
});
it("passes projectName through to git fetch handler without leaking args", async () => {
await runBin(["git", "fetch", "origin", "--project", "demo"]);
expect(runGitFetch).toHaveBeenCalledWith("origin", "demo");
});
it("routes project subcommands and aliases", async () => {
await runBin(["project", "list"]);
await runBin(["project", "ls"]);
expect(runProjectList).toHaveBeenCalledTimes(2);
await runBin(["project", "add", "my-app", "/tmp/my-app", "--isolation", "child-process", "--force"]);
expect(runProjectAdd).toHaveBeenCalledWith("my-app", "/tmp/my-app", { isolation: "child-process", force: true });
await runBin(["project", "remove", "my-app", "--force"]);
await runBin(["project", "rm", "my-app", "--force"]);
expect(runProjectRemove).toHaveBeenCalledWith("my-app", true);
await runBin(["project", "show", "my-app"]);
expect(runProjectShow).toHaveBeenCalledWith("my-app");
await runBin(["project", "set-default", "my-app"]);
await runBin(["project", "default", "my-app"]);
expect(runProjectSetDefault).toHaveBeenCalledWith("my-app");
await runBin(["project", "detect"]);
expect(runProjectDetect).toHaveBeenCalled();
});
it("rejects unknown project subcommands", async () => {
await expect(runBin(["project", "wat"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: project wat");
});
it("rejects duplicate --project flags", async () => {
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
.rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Duplicate --project flag. Specify a project only once.");
});
it("rejects missing --project value", async () => {
await expect(runBin(["task", "list", "--project"]))
.rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Usage: --project <name>");
});
it("shows help when --project is combined with global help", async () => {
await expect(runBin(["--project", "demo", "--help"]))
.rejects.toThrow("process.exit:0");
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(help).toContain("fn project list | ls");
expect(runTaskList).not.toHaveBeenCalled();
});
it("prioritizes subcommand help after stripping --project", async () => {
await expect(runBin(["task", "list", "--project", "demo", "-h"]))
.rejects.toThrow("process.exit:0");
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(help).toContain("--project, -P <name>");
expect(runTaskList).not.toHaveBeenCalled();
});
it("help output documents project commands, task comments, and project flag", async () => {
await expect(runBin(["--help"]))
.rejects.toThrow("process.exit:0");
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(help).toContain("fn project list | ls");
expect(help).toContain("fn task comments <id>");
expect(help).toContain("--project, -P <name>");
});
});

View File

@@ -86,6 +86,16 @@ describe("project-context", () => {
expect(found).toBeUndefined();
});
it("should detect unregistered local project for legacy single-project usage", async () => {
const projectPath = createMockProject("legacy-project");
const found = await detectProjectFromCwd(projectPath, central);
expect(found).toBeDefined();
expect(found?.path).toBe(resolve(projectPath));
expect(found?.name).toBe("legacy-project");
});
});
describe("formatProjectLine", () => {
@@ -127,13 +137,23 @@ describe("project-context", () => {
});
});
describe("resolveProject errors", () => {
describe("resolveProject", () => {
it("should throw for unknown project name", async () => {
await expect(resolveProject("unknown-project", tempDir)).rejects.toThrow(
"not found"
);
});
it("should resolve unregistered local project from cwd", async () => {
const projectPath = createMockProject("legacy-project");
const context = await resolveProject(undefined, projectPath);
expect(context.projectPath).toBe(resolve(projectPath));
expect(context.projectName).toBe("legacy-project");
expect(context.isRegistered).toBe(false);
});
it("should throw when no project can be resolved", async () => {
const randomDir = join(tempDir, "no-project-here");
mkdirSync(randomDir, { recursive: true });

View File

@@ -45,14 +45,12 @@ 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 { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } = await import("./commands/project.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const HELP = `
fn — AI-orchestrated task board
Usage:
fn init Initialize a new kb project in current directory
fn dashboard Start the board web UI
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
@@ -77,17 +75,19 @@ Usage:
fn task unpause <id> Unpause a task (resumes automation)
fn task comment <id> [message] Add task comment (prompts if message omitted)
fn task comments <id> List task comments
fn task steer <id> [message] Alias for 'task comment'
fn task steer <id> [message] Add steering comment (prompts if message omitted)
fn task retry <id> Retry a failed task (clears error, moves to todo)
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 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 project list | ls List all registered projects
fn project add <name> <path> [opts] Register a new project
fn project remove | rm <name> [--force]
Unregister a project
fn project show <name> Show project details
fn project set-default | 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
@@ -101,14 +101,9 @@ Usage:
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn mission create [title] [description...] Create a new mission
fn mission list List all missions
fn mission show <id> Show mission with hierarchy
fn mission delete <id> [--force] Delete mission
fn mission activate-slice <slice-id> Activate a pending slice
Options:
--project, -P <name> Target a specific project (for task/settings commands)
--project, -P <name> Target a specific project (bypasses CWD detection)
--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)
@@ -129,132 +124,42 @@ The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
Requires configured API keys — run "pi" first to set up authentication.
`.trim();
async function main() {
let args = process.argv.slice(2);
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
const cleanedArgs: string[] = [];
let projectName: string | undefined;
// Extract --project flag before command dispatch
let projectFlag: string | undefined;
const projectIdx = args.indexOf("--project");
if (projectIdx !== -1 && projectIdx + 1 < args.length) {
projectFlag = args[projectIdx + 1];
// Remove --project and its value from args so subcommands don't see it
args.splice(projectIdx, 2);
}
// Store for subcommands to access via resolveProject
if (projectFlag) {
process.env.FN_PROJECT = projectFlag;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--project" || arg === "-P") {
if (projectName) {
throw new Error("Duplicate --project flag. Specify a project only once.");
}
const value = argv[i + 1];
if (!value || value.startsWith("-")) {
throw new Error("Usage: --project <name>");
}
projectName = value;
i++;
continue;
}
cleanedArgs.push(arg);
}
return { cleanedArgs, projectName };
}
async function main() {
const { cleanedArgs: args, projectName } = extractGlobalProjectFlag(process.argv.slice(2));
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
console.log(HELP);
process.exit(0);
}
// Extract --project flag before command routing
let projectName: string | undefined;
const projectFlagIdx = args.indexOf("--project");
const projectFlagShortIdx = args.indexOf("-P");
const resolvedProjectIdx = projectFlagIdx !== -1 ? projectFlagIdx : projectFlagShortIdx;
if (resolvedProjectIdx !== -1 && resolvedProjectIdx + 1 < args.length) {
projectName = args[resolvedProjectIdx + 1];
// Remove --project and its value from args
args.splice(resolvedProjectIdx, 2);
}
// Extract command early (needed for migration check)
const command = args[0];
// Migration check for first-run experience
// Skip for init command and help flags
if (command !== "init" && command !== "--help" && command !== "-h") {
try {
const { FirstRunDetector, MigrationCoordinator } = await import("@fusion/core");
const { CentralCore } = await import("@fusion/core");
const detector = new FirstRunDetector();
const state = await detector.detectFirstRunState();
if (state === "needs-migration") {
const cwd = process.cwd();
const detected = await detector.detectExistingProjects(cwd);
const projectRoot = detected[0]?.path;
if (projectRoot) {
const central = new CentralCore();
await central.init();
try {
const coordinator = new MigrationCoordinator(central);
const result = await coordinator.registerSingleProject(projectRoot);
if (result.success && result.projectsRegistered.length > 0) {
const project = await central.getProject(result.projectsRegistered[0]);
if (project) {
console.log(`✓ Auto-registered project: ${project.name}`);
}
} else if (result.errors.length > 0) {
console.warn(`Migration warning: ${result.errors[0]}`);
}
} finally {
await central.close();
}
}
}
} catch {
// Silently ignore migration errors - user can manually run fn init
}
}
try {
switch (command) {
case "init": {
// Initialize a new kb project
const { existsSync, mkdirSync } = await import("node:fs");
const { join } = await import("node:path");
const { CentralCore } = await import("@fusion/core");
const { Database } = await import("@fusion/core");
const cwd = process.cwd();
const kbDir = join(cwd, ".kb");
// Check if already initialized
if (existsSync(kbDir)) {
// Check if already registered in central
const central = new CentralCore();
await central.init();
const existing = await central.getProjectByPath(cwd);
if (existing) {
console.log(`Project "${existing.name}" already initialized at ${cwd}`);
await central.close();
break;
}
// Directory exists but not registered - just register it
const project = await central.autoRegisterProject(cwd);
console.log(`✓ Registered project "${project.name}" at ${cwd}`);
await central.close();
break;
}
// Create .kb directory
mkdirSync(kbDir, { recursive: true });
// Initialize SQLite database
const db = new Database(kbDir);
db.init();
db.close();
// Register in central
const central = new CentralCore();
await central.init();
const project = await central.autoRegisterProject(cwd);
console.log(`✓ Initialized kb project "${project.name}" at ${cwd}`);
await central.close();
break;
}
case "dashboard": {
// Initialize native module resolution for Bun binary before starting dashboard
// This sets up the paths so node-pty can find its native assets
@@ -301,17 +206,17 @@ async function main() {
}
case "show": {
const name = args[2];
await runProjectInfo(name, { interactive: false });
await runProjectShow(name);
break;
}
case "set-default":
case "default": {
const name = args[2];
await runProjectInfo(name, { setAsDefault: true, interactive: false });
await runProjectSetDefault(name);
break;
}
case "detect":
await runProjectInfo(undefined, { detect: true, interactive: false });
await runProjectDetect();
break;
default:
console.error(`Unknown subcommand: project ${subcommand || ""}`);
@@ -626,7 +531,7 @@ async function main() {
? args[outputIdx + 1]
: undefined;
await runSettingsExport({ scope, output });
await runSettingsExport({ scope, output, projectName });
break;
}
if (subcommand === "import") {
@@ -646,7 +551,7 @@ async function main() {
const merge = args.includes("--merge");
const yes = args.includes("--yes");
await runSettingsImport(file, { scope, merge, yes });
await runSettingsImport(file, { scope, merge, yes, projectName });
break;
}
console.error(`Unknown settings subcommand: ${subcommand}`);
@@ -705,57 +610,6 @@ async function main() {
break;
}
case "mission": {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const title = args[2];
const description = args.length > 3 ? args.slice(3).join(" ") : undefined;
await runMissionCreate(title, description, projectName);
break;
}
case "list":
case "ls":
await runMissionList(projectName);
break;
case "show":
case "info": {
const id = args[2];
if (!id) {
console.error("Usage: fn mission show <id>");
process.exit(1);
}
await runMissionShow(id, projectName);
break;
}
case "delete":
case "rm": {
const id = args[2];
if (!id) {
console.error("Usage: fn mission delete <id> [--force]");
process.exit(1);
}
const force = args.includes("--force");
await runMissionDelete(id, force, projectName);
break;
}
case "activate-slice": {
const id = args[2];
if (!id) {
console.error("Usage: fn mission activate-slice <slice-id>");
process.exit(1);
}
await runMissionActivateSlice(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: mission ${subcommand || ""}`);
console.error("Try: fn mission create | list | show <id> | delete <id> | activate-slice <id>");
process.exit(1);
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log(HELP);

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockListBackups = vi.fn();
const mockRestoreBackup = vi.fn();
const mockCleanupOldBackups = vi.fn();
const mockGetSettings = vi.fn();
const mockRunBackupCommand = vi.fn();
const mockResolveProject = vi.fn();
vi.mock("@fusion/core", () => ({
BackupManager: vi.fn(),
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGetSettings,
kbDir: "/cwd/.kb",
})),
createBackupManager: vi.fn(() => ({
listBackups: mockListBackups,
restoreBackup: mockRestoreBackup,
cleanupOldBackups: mockCleanupOldBackups,
})),
runBackupCommand: mockRunBackupCommand,
}));
vi.mock("../project-context.js", () => ({
resolveProject: mockResolveProject,
}));
import { TaskStore } from "@fusion/core";
import { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } from "./backup.js";
describe("backup commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetSettings.mockResolvedValue({ autoBackupDir: ".kb/backups" });
mockRunBackupCommand.mockResolvedValue({ success: true, output: "backup created" });
mockListBackups.mockResolvedValue([]);
mockRestoreBackup.mockResolvedValue(undefined);
mockCleanupOldBackups.mockResolvedValue(0);
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings: mockGetSettings, kbDir: "/projects/demo/.kb" },
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("runBackupCreate uses resolved project store with --project", async () => {
await expect(runBackupCreate("demo-project")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockRunBackupCommand).toHaveBeenCalledWith("/projects/demo/.kb", expect.anything());
});
it("runBackupList uses resolved project store with --project", async () => {
mockListBackups.mockResolvedValue([{ filename: "kb.db.bak", size: 1024, createdAt: new Date().toISOString() }]);
await runBackupList("demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 backup"));
});
it("runBackupRestore uses resolved project store with --project", async () => {
await runBackupRestore("kb.db.bak", "demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockRestoreBackup).toHaveBeenCalledWith("kb.db.bak", { createPreRestoreBackup: true });
});
it("runBackupCleanup uses resolved project store with --project", async () => {
mockCleanupOldBackups.mockResolvedValue(2);
await runBackupCleanup("demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith("Removed 2 old backup(s).");
});
it("runBackupList without project uses shared resolution flow", async () => {
await runBackupList();
expect(mockResolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).not.toHaveBeenCalled();
});
it("runBackupList without project falls back to current cwd task store when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
mockResolveProject.mockRejectedValueOnce(new Error("No kb project found"));
await runBackupList();
expect(mockResolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/local/project");
cwdSpy.mockRestore();
});
it("propagates project resolution errors for project-targeted backup commands", async () => {
mockResolveProject.mockRejectedValue(new Error("Project 'missing' not found. Run 'kb project list' to see registered projects."));
await expect(runBackupList("missing")).rejects.toThrow("Project 'missing' not found");
});
});

View File

@@ -6,6 +6,16 @@ import {
} from "@fusion/core";
import { resolveProject } from "../project-context.js";
async function resolveBackupStore(projectName?: string): Promise<TaskStore> {
try {
return (await resolveProject(projectName)).store;
} catch {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
}
/**
* Find the project root and create a backup manager.
*/
@@ -14,12 +24,7 @@ async function getBackupManager(projectName?: string): Promise<{
store: TaskStore;
kbDir: string;
}> {
const store = projectName
? (await resolveProject(projectName)).store
: new TaskStore(process.cwd());
if (!projectName) {
await store.init();
}
const store = await resolveBackupStore(projectName);
// Access the private kbDir property via type assertion
const kbDir = (store as unknown as { kbDir: string }).kbDir;
const settings = await store.getSettings();

View File

@@ -1,11 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock node:child_process before importing the module under test
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
// Mock node:readline/promises
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
question: vi.fn(),
@@ -13,8 +11,13 @@ vi.mock("node:readline/promises", () => ({
})),
}));
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { execSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../project-context.js";
import {
isGitRepo,
getGitStatus,
@@ -32,626 +35,134 @@ import {
const mockExecSync = vi.mocked(execSync);
const mockCreateInterface = vi.mocked(createInterface);
describe("isGitRepo", () => {
it("returns true when in a git repository", () => {
describe("git commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: {} as any,
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("core helpers work", () => {
mockExecSync.mockReturnValueOnce(".git");
expect(isGitRepo()).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd: process.cwd() });
});
it("returns false when not in a git repository", () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error("not a git repository");
});
expect(isGitRepo()).toBe(false);
});
});
describe("isValidBranchName", () => {
it("returns true for valid branch names", () => {
expect(isValidBranchName("main")).toBe(true);
expect(isValidBranchName("feature/my-feature")).toBe(true);
expect(isValidBranchName("bugfix-123")).toBe(true);
expect(isValidBranchName("hotfix_v1.0")).toBe(true);
expect(isValidBranchName("--bad")).toBe(false);
});
it("returns false for empty names", () => {
expect(isValidBranchName("")).toBe(false);
});
it("returns false for names starting with dash", () => {
expect(isValidBranchName("-main")).toBe(false);
expect(isValidBranchName("--help")).toBe(false);
});
it("returns false for names with shell metacharacters", () => {
expect(isValidBranchName("main; rm -rf")).toBe(false);
expect(isValidBranchName("main|cat")).toBe(false);
expect(isValidBranchName("main`cmd`")).toBe(false);
});
it("returns false for names with spaces", () => {
expect(isValidBranchName("my branch")).toBe(false);
});
it("returns false for names with double dots", () => {
expect(isValidBranchName("main..feature")).toBe(false);
});
it("returns false for reserved git refs", () => {
expect(isValidBranchName("HEAD")).toBe(false);
expect(isValidBranchName("FETCH_HEAD")).toBe(false);
expect(isValidBranchName("ORIG_HEAD")).toBe(false);
});
});
describe("getGitStatus", () => {
it("returns status data for normal branch", () => {
mockExecSync
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("2\t1\n"); // rev-list ahead/behind
const status = getGitStatus();
expect(status).toEqual({
branch: "main",
commit: "a1b2c3d",
isDirty: false,
ahead: 2,
behind: 1,
});
});
it("handles detached HEAD state", () => {
mockExecSync
.mockReturnValueOnce("") // branch --show-current returns empty for detached
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n");
const status = getGitStatus();
expect(status?.branch).toBe("HEAD detached");
});
it("detects dirty state", () => {
it("runGitStatus uses resolved project path", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce(" M file.ts\n?? new.txt\n") // dirty
.mockReturnValueOnce("0\t0\n");
.mockReturnValueOnce(" M file.ts\n")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce(" M file.ts\n");
const status = getGitStatus();
expect(status?.isDirty).toBe(true);
await runGitStatus("demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockExecSync).toHaveBeenCalledWith("git status --porcelain", expect.objectContaining({ cwd: "/projects/demo" }));
});
it("returns null on error", () => {
mockExecSync.mockImplementation(() => {
throw new Error("git error");
});
expect(getGitStatus()).toBeNull();
});
});
describe("getDirtyFileCount", () => {
it("returns zero counts for clean repo", () => {
mockExecSync.mockReturnValueOnce("");
expect(getDirtyFileCount()).toEqual({ added: 0, modified: 0, deleted: 0 });
});
it("counts added files correctly", () => {
mockExecSync.mockReturnValueOnce("?? new1.txt\n?? new2.txt\nA staged.txt\n");
expect(getDirtyFileCount()).toEqual({ added: 3, modified: 0, deleted: 0 });
});
it("counts modified files correctly", () => {
mockExecSync.mockReturnValueOnce(" M file1.ts\nM file2.ts\nMM file3.ts\n");
expect(getDirtyFileCount()).toEqual({ added: 0, modified: 3, deleted: 0 });
});
it("counts deleted files correctly", () => {
mockExecSync.mockReturnValueOnce(" D deleted.txt\nD staged_del.txt\n");
expect(getDirtyFileCount()).toEqual({ added: 0, modified: 0, deleted: 2 });
});
it("handles mixed changes", () => {
mockExecSync.mockReturnValueOnce(" M modified.ts\n?? new.txt\n D deleted.txt\nA added.txt\n");
expect(getDirtyFileCount()).toEqual({ added: 2, modified: 1, deleted: 1 });
});
});
describe("fetchGitRemote", () => {
it("fetches successfully from origin", () => {
mockExecSync.mockReturnValueOnce("");
const result = fetchGitRemote("origin");
expect(result.fetched).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith("git fetch origin", { encoding: "utf-8", timeout: 30000 });
});
it("fetches from specified remote", () => {
mockExecSync.mockReturnValueOnce("");
const result = fetchGitRemote("upstream");
expect(result.fetched).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith("git fetch upstream", { encoding: "utf-8", timeout: 30000 });
});
it("throws for invalid remote name", () => {
expect(() => fetchGitRemote("; rm -rf")).toThrow("Invalid remote name");
});
it("throws on connection failure", () => {
mockExecSync.mockImplementation(() => {
const error = new Error("Could not resolve host github.com");
throw error;
});
expect(() => fetchGitRemote("origin")).toThrow("Failed to connect to remote");
});
it("returns not fetched on other errors", () => {
mockExecSync.mockImplementation(() => {
throw new Error("some other error");
});
const result = fetchGitRemote("origin");
expect(result.fetched).toBe(false);
});
});
describe("pullGitBranch", () => {
it("pulls successfully", () => {
mockExecSync.mockReturnValueOnce("Already up to date.");
const result = pullGitBranch();
expect(result.success).toBe(true);
expect(result.conflict).toBeUndefined();
});
it("detects merge conflicts", () => {
mockExecSync.mockImplementation(() => {
const error = new Error("CONFLICT (content): Merge conflict in file.ts");
throw error;
});
const result = pullGitBranch();
expect(result.success).toBe(false);
expect(result.conflict).toBe(true);
});
it("throws with original error message on other errors", () => {
mockExecSync.mockImplementation(() => {
throw new Error("not possible to fast-forward");
});
expect(() => pullGitBranch()).toThrow("not possible to fast-forward");
});
});
describe("pushGitBranch", () => {
it("pushes successfully", () => {
mockExecSync.mockReturnValueOnce("");
const result = pushGitBranch();
expect(result.success).toBe(true);
});
it("throws when push is rejected", () => {
mockExecSync.mockImplementation(() => {
const error = new Error("rejected: non-fast-forward");
throw error;
});
expect(() => pushGitBranch()).toThrow("Push rejected. Pull latest changes first.");
});
it("throws on connection failure", () => {
mockExecSync.mockImplementation(() => {
const error = new Error("Could not resolve host");
throw error;
});
expect(() => pushGitBranch()).toThrow("Failed to connect to remote");
});
});
describe("runGitStatus", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays clean status correctly", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n"); // rev-list
await runGitStatus();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Branch: main"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Commit: a1b2c3d"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Status: clean"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Remote: up to date"));
});
it("displays dirty status with counts", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce(" M file.ts\n?? new.txt\n D old.txt\n") // dirty (getGitStatus)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce(" M file.ts\n?? new.txt\n D old.txt\n"); // dirty (getDirtyFileCount)
await runGitStatus();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Status: dirty"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("+1"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("~1"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("-1"));
});
it("displays ahead/behind counts", async () => {
it("runGitStatus without project uses shared resolution flow", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("2\t3\n"); // ahead 2, behind 3
.mockReturnValueOnce("0\t0\n");
await runGitStatus();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("↑2"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("↓3"));
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", expect.objectContaining({ cwd: "/projects/demo" }));
});
it("exits with error when not a git repo", async () => {
mockExecSync.mockImplementation(() => {
throw new Error("not a git repo");
});
// Since process.exit is mocked and doesn't actually exit,
// the function will continue and throw when accessing status.branch on null.
// We just verify the expected error was logged and exit was called.
try {
await runGitStatus();
} catch {
// Ignore the TypeError from accessing null.status
}
expect(errorSpy).toHaveBeenCalledWith("Error: Not a git repository");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits with error when status fails", async () => {
it("runGitStatus without project falls back to current working directory when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
vi.mocked(resolveProject).mockRejectedValueOnce(new Error("No kb project found"));
mockExecSync
.mockReturnValueOnce(".git")
.mockImplementation(() => {
throw new Error("git error");
});
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n");
// Since process.exit is mocked and doesn't actually exit,
// the function will continue and throw when accessing status.branch on null.
try {
await runGitStatus();
} catch {
// Ignore the TypeError from accessing null.status
}
await runGitStatus();
expect(errorSpy).toHaveBeenCalledWith("Error: Failed to get git status");
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe("runGitFetch", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", expect.objectContaining({ cwd: "/local/project" }));
cwdSpy.mockRestore();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("fetches from origin by default", async () => {
it("runGitFetch uses resolved project path", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("");
await runGitFetch();
await runGitFetch("origin", "demo-project");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Fetched from origin"));
expect(mockExecSync).toHaveBeenCalledWith("git fetch origin", expect.objectContaining({ cwd: "/projects/demo" }));
expect(fetchGitRemote("origin", "/projects/demo")).toEqual(expect.objectContaining({ fetched: true }));
});
it("fetches from specified remote", async () => {
it("propagates project resolution errors for git commands", async () => {
vi.mocked(resolveProject).mockRejectedValue(new Error("Project 'missing' not found. Run 'kb project list' to see registered projects."));
await expect(runGitFetch("origin", "missing")).rejects.toThrow("Project 'missing' not found");
});
it("runGitPull uses resolved project path", async () => {
const question = vi.fn().mockResolvedValue("y");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as any);
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce("Already up to date.");
await runGitPull({ projectName: "demo-project" });
expect(mockExecSync).toHaveBeenCalledWith("git pull", expect.objectContaining({ cwd: "/projects/demo" }));
expect(pullGitBranch("/projects/demo")).toEqual(expect.objectContaining({ success: true }));
});
it("runGitPush uses resolved project path", async () => {
const question = vi.fn().mockResolvedValue("y");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as any);
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce("");
await runGitFetch("upstream");
await runGitPush({ projectName: "demo-project" });
expect(mockExecSync).toHaveBeenLastCalledWith("git fetch upstream", { encoding: "utf-8", timeout: 30000, cwd: process.cwd() });
});
it("exits with error when not a git repo", async () => {
mockExecSync.mockImplementation(() => {
throw new Error("not a git repo");
});
await runGitFetch();
expect(errorSpy).toHaveBeenCalledWith("Error: Not a git repository");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits with error for invalid remote name", async () => {
mockExecSync.mockReturnValueOnce(".git");
await runGitFetch("; rm -rf");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid remote name"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe("runGitPull", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("pulls successfully with clean repo", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce("Already up to date."); // git pull
await runGitPull();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Pulled latest changes"));
});
it("exits on merge conflict", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockImplementationOnce(() => {
throw new Error("CONFLICT (content): Merge conflict in file.ts");
}); // git pull
await runGitPull();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Merge conflict detected"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("prompts for confirmation when dirty", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce(" M file.ts\n") // dirty (getGitStatus)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce(" M file.ts\n") // dirty (getDirtyFileCount)
.mockReturnValueOnce("Already up to date."); // git pull
const questionMock = vi.fn().mockResolvedValue("y");
const closeMock = vi.fn();
mockCreateInterface.mockReturnValue({
question: questionMock,
close: closeMock,
} as any);
await runGitPull();
expect(mockCreateInterface).toHaveBeenCalled();
expect(questionMock).toHaveBeenCalledWith(expect.stringContaining("Continue with pull?"));
});
it("cancels when user declines confirmation", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce(" M file.ts\n") // dirty (getGitStatus)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce(" M file.ts\n"); // dirty (getDirtyFileCount)
const questionMock = vi.fn().mockResolvedValue("n");
const closeMock = vi.fn();
mockCreateInterface.mockReturnValue({
question: questionMock,
close: closeMock,
} as any);
await runGitPull();
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("skips confirmation with skipConfirm option", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce(" M file.ts\n") // dirty (getGitStatus)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce(" M file.ts\n") // dirty (getDirtyFileCount)
.mockReturnValueOnce("Already up to date."); // git pull
await runGitPull({ skipConfirm: true });
expect(mockCreateInterface).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Pulled latest changes"));
});
});
describe("runGitPush", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("pushes successfully", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce("origin/main\n") // upstream exists
.mockReturnValueOnce(""); // git push
const questionMock = vi.fn().mockResolvedValue("y");
const closeMock = vi.fn();
mockCreateInterface.mockReturnValue({
question: questionMock,
close: closeMock,
} as any);
await runGitPush();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Pushed main to origin"));
});
it("exits when no upstream configured", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockImplementationOnce(() => { // upstream check
throw new Error("no upstream");
});
await runGitPush({ skipConfirm: true });
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("No upstream configured"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits in detached HEAD state", async () => {
// Mock detached HEAD detection - empty branch string means detached
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("") // branch - empty means detached
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n"); // rev-list
await runGitPush({ skipConfirm: true });
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("detached HEAD state"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("prompts for confirmation by default", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce("origin/main\n") // upstream exists
.mockReturnValueOnce(""); // git push
const questionMock = vi.fn().mockResolvedValue("y");
const closeMock = vi.fn();
mockCreateInterface.mockReturnValue({
question: questionMock,
close: closeMock,
} as any);
await runGitPush();
expect(questionMock).toHaveBeenCalledWith(expect.stringContaining("Push branch main to remote?"));
});
it("skips confirmation with skipConfirm option", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce("origin/main\n") // upstream exists
.mockReturnValueOnce(""); // git push
await runGitPush({ skipConfirm: true });
expect(mockCreateInterface).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Pushed main to origin"));
});
it("cancels when user declines confirmation", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce("origin/main\n"); // upstream exists
const questionMock = vi.fn().mockResolvedValue("n");
const closeMock = vi.fn();
mockCreateInterface.mockReturnValue({
question: questionMock,
close: closeMock,
} as any);
await runGitPush();
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("exits on push error", async () => {
mockExecSync
.mockReturnValueOnce(".git") // isGitRepo
.mockReturnValueOnce("main\n") // branch
.mockReturnValueOnce("a1b2c3d\n") // commit
.mockReturnValueOnce("") // status --porcelain (clean)
.mockReturnValueOnce("0\t0\n") // rev-list
.mockReturnValueOnce("origin/main\n") // upstream exists
.mockImplementationOnce(() => { // git push
throw new Error("push failed");
});
await runGitPush({ skipConfirm: true });
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("push failed"));
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockExecSync).toHaveBeenCalledWith("git push", expect.objectContaining({ cwd: "/projects/demo" }));
expect(pushGitBranch("/projects/demo")).toEqual(expect.objectContaining({ success: true }));
});
});

View File

@@ -2,6 +2,18 @@ import { execSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../project-context.js";
async function resolveGitCwd(projectName?: string): Promise<string> {
if (projectName) {
return (await resolveProject(projectName)).projectPath;
}
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
// ── Types ────────────────────────────────────────────────────────────────
/** Git status data structure */
@@ -109,9 +121,9 @@ export function getGitStatus(cwd: string = process.cwd()): GitStatus | null {
/**
* Count dirty files by parsing git status output.
*/
export function getDirtyFileCount(): { added: number; modified: number; deleted: number } {
export function getDirtyFileCount(cwd: string = process.cwd()): { added: number; modified: number; deleted: number } {
try {
const output = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000 }).trim();
const output = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd }).trim();
if (!output) return { added: 0, modified: 0, deleted: 0 };
const lines = output.split("\n").filter(Boolean);
@@ -136,12 +148,12 @@ export function getDirtyFileCount(): { added: number; modified: number; deleted:
/**
* Fetch from origin or specified remote.
*/
export function fetchGitRemote(remote: string = "origin"): GitFetchResult {
export function fetchGitRemote(remote: string = "origin", cwd: string = process.cwd()): GitFetchResult {
if (!isValidBranchName(remote)) {
throw new Error("Invalid remote name");
}
try {
const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000 });
const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000, cwd });
return { fetched: true, message: output.trim() || "Fetch completed" };
} catch (err: any) {
const message = err.message || String(err);
@@ -156,9 +168,9 @@ export function fetchGitRemote(remote: string = "origin"): GitFetchResult {
/**
* Pull the current branch.
*/
export function pullGitBranch(): GitPullResult {
export function pullGitBranch(cwd: string = process.cwd()): GitPullResult {
try {
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000 });
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd });
return { success: true, message: output.trim() };
} catch (err: any) {
const message = err.message || String(err);
@@ -172,9 +184,9 @@ export function pullGitBranch(): GitPullResult {
/**
* Push the current branch.
*/
export function pushGitBranch(): GitPushResult {
export function pushGitBranch(cwd: string = process.cwd()): GitPushResult {
try {
const output = execSync("git push", { encoding: "utf-8", timeout: 30000 });
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd });
return { success: true, message: output.trim() || "Push completed" };
} catch (err: any) {
const message = err.message || String(err);
@@ -194,8 +206,7 @@ export function pushGitBranch(): GitPushResult {
* Run the git status command and display formatted output.
*/
export async function runGitStatus(projectName?: string): Promise<void> {
// Resolve project path
const { projectPath } = projectName ? await resolveProject(projectName) : { projectPath: process.cwd() };
const projectPath = await resolveGitCwd(projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
@@ -215,7 +226,7 @@ export async function runGitStatus(projectName?: string): Promise<void> {
// Status line
if (status.isDirty) {
const counts = getDirtyFileCount();
const counts = getDirtyFileCount(projectPath);
const parts: string[] = [];
if (counts.added) parts.push(`+${counts.added}`);
if (counts.modified) parts.push(`~${counts.modified}`);
@@ -246,8 +257,7 @@ export async function runGitStatus(projectName?: string): Promise<void> {
export async function runGitFetch(remote?: string, projectName?: string): Promise<void> {
const targetRemote = remote || "origin";
// Resolve project path
const { projectPath } = projectName ? await resolveProject(projectName) : { projectPath: process.cwd() };
const projectPath = await resolveGitCwd(projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
@@ -278,8 +288,7 @@ export async function runGitFetch(remote?: string, projectName?: string): Promis
* @param options.projectName - Optional project name to target
*/
export async function runGitPull(options: { skipConfirm?: boolean; projectName?: string } = {}): Promise<void> {
// Resolve project path
const { projectPath } = options.projectName ? await resolveProject(options.projectName) : { projectPath: process.cwd() };
const projectPath = await resolveGitCwd(options.projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
@@ -336,8 +345,7 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
* @param options.projectName - Optional project name to target
*/
export async function runGitPush(options: { skipConfirm?: boolean; projectName?: string } = {}): Promise<void> {
// Resolve project path
const { projectPath } = options.projectName ? await resolveProject(options.projectName) : { projectPath: process.cwd() };
const projectPath = await resolveGitCwd(options.projectName);
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {

View File

@@ -1,288 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Tests for project.ts commands
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
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();
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();
const mockGetProject = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockInit = vi.fn();
const mockClose = vi.fn();
const mockQuestion = vi.fn();
const mockRlClose = vi.fn();
const mockSetDefaultProject = vi.fn();
const mockDetectProjectFromCwd = vi.fn();
const mockFormatProjectLine = vi.fn();
const mockGetSettings = vi.fn();
const mockGlobalInit = vi.fn();
vi.mock("../project-resolver.js", () => ({
getCentralCore: vi.fn(async () => ({
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockInit.mockResolvedValue(undefined),
close: mockClose.mockResolvedValue(undefined),
listProjects: mockListProjects,
getProject: mockGetProject,
getProjectByPath: mockGetProjectByPath,
registerProject: mockRegisterProject,
unregisterProject: mockUnregisterProject,
getProjectHealth: mockGetProjectHealth,
getProject: mockGetProject,
getProjectByPath: mockGetProjectByPath,
})),
getProjectManager: vi.fn(async () => ({
getRuntime: mockGetRuntime,
removeProject: mockRemoveRuntime,
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
init: mockGlobalInit.mockResolvedValue(undefined),
getSettings: mockGetSettings,
})),
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(),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => true),
statSync: vi.fn(() => ({ isDirectory: () => true })),
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
question: mockQuestion,
close: mockRlClose,
})),
}));
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,
})),
};
});
const { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } = await import("./project.js");
vi.mock("../project-context.js", () => ({
formatProjectLine: mockFormatProjectLine,
detectProjectFromCwd: mockDetectProjectFromCwd,
setDefaultProject: mockSetDefaultProject,
}));
describe("project commands", () => {
beforeEach(() => {
vi.clearAllMocks();
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
mockListProjects.mockResolvedValue([]);
mockGetProject.mockResolvedValue(undefined);
mockGetProjectByPath.mockResolvedValue(undefined);
mockRegisterProject.mockImplementation(async (config: { name: string; path: string; isolationMode: string }) => ({
id: "proj_new",
name: config.name,
path: config.path,
status: "initializing",
isolationMode: config.isolationMode,
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");
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetSettings.mockResolvedValue({});
mockFormatProjectLine.mockImplementation((project, isDefault) => `${isDefault ? "* " : " "}${project.name}`);
mockQuestion.mockResolvedValue("y");
});
it("prints empty state when no projects are registered", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
afterEach(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
exitSpy.mockRestore();
});
it("exports 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("runProjectList prints registered projects and summary", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
{ id: "proj-2", name: "app-two", path: "/tmp/app-two", status: "paused", isolationMode: "child-process" },
]);
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
mockGetProject.mockImplementation(async (id: string) => (
id === "proj-1"
? { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }
: undefined
));
const { runProjectList } = await import("./project.js");
await runProjectList();
expect(logSpy).toHaveBeenCalledWith("\n No projects registered.");
expect(mockFormatProjectLine).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 projects registered, 1 active"));
});
it("emits json output for project list", async () => {
mockListProjects.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",
},
]);
mockGetProjectHealth.mockResolvedValue({ inFlightAgentCount: 1, lastActivityAt: "2026-03-31T00:00:00.000Z" });
mockTaskStoreListTasks.mockResolvedValue([{ column: "todo" }, { column: "done" }]);
it("runProjectAdd registers project and prints sanitized path output", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", isolationMode: "in-process" });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { runProjectAdd } = await import("./project.js");
await runProjectAdd("demo", ".", { force: true });
await runProjectList({ json: true });
const payload = JSON.parse(String(logSpy.mock.calls[0][0]));
expect(payload).toEqual([
expect.objectContaining({
id: "proj_123",
name: "alpha",
totalTasks: 2,
activeAgents: 1,
}),
]);
expect(mockRegisterProject).toHaveBeenCalled();
const lines = consoleSpy.mock.calls.map((call) => String(call[0]));
expect(lines.some((line) => line.includes("Registered project 'demo'"))).toBe(true);
expect(lines.some((line) => line.includes("Location:"))).toBe(true);
expect(lines.some((line) => line.includes("/tmp/demo"))).toBe(false);
});
it("registers a project with explicit path and name", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
it("runProjectRemove unregisters project after confirmation", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
await runProjectAdd("/tmp/my-project", {
name: "my-project",
isolation: "child-process",
interactive: false,
});
const { runProjectRemove } = await import("./project.js");
await runProjectRemove("proj-1", false);
expect(mockRegisterProject).toHaveBeenCalledWith({
name: "my-project",
path: "/tmp/my-project",
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered project 'demo'"));
});
it("runProjectShow prints detailed project metadata without absolute path leakage", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "child-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Registered project 'my-project'"));
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
const { runProjectShow } = await import("./project.js");
await runProjectShow("proj-1");
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Project: demo (default)");
expect(output).toContain("Isolation: child-process");
expect(output).toContain("Created:");
expect(output).not.toContain("/tmp/demo");
});
it("rejects invalid isolation modes", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
it("runProjectSetDefault sets default project", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
await runProjectAdd("/tmp/my-project", {
name: "my-project",
isolation: "invalid-mode" as never,
interactive: false,
});
const { runProjectSetDefault } = await import("./project.js");
await runProjectSetDefault("proj-1");
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid isolation mode 'invalid-mode'");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockSetDefaultProject).toHaveBeenCalledWith("proj-1");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Set 'demo' as default project"));
});
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);
it("runProjectDetect prints detected project without absolute path leakage", async () => {
mockDetectProjectFromCwd.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo" });
await runProjectAdd("/tmp/not-kb", { interactive: false });
const { runProjectDetect } = await import("./project.js");
await runProjectDetect();
expect(errorSpy).toHaveBeenCalledWith("Error: No kb project found at /tmp/not-kb");
expect(exitSpy).toHaveBeenCalledWith(1);
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Detected: demo");
expect(output).toContain("Location:");
expect(output).not.toContain("/tmp/demo");
});
it("errors when registering a duplicate project name", async () => {
mockListProjects.mockResolvedValue([
{
id: "proj_existing",
name: "my-project",
path: "/tmp/existing",
status: "active",
isolationMode: "in-process",
createdAt: "2026-03-31T00:00:00.000Z",
updatedAt: "2026-03-31T00:00:00.000Z",
},
]);
it("validation exits on missing required args", async () => {
const { runProjectAdd, runProjectRemove, runProjectShow, runProjectSetDefault } = await import("./project.js");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
await runProjectAdd("/tmp/my-project", { name: "my-project", interactive: false });
expect(errorSpy).toHaveBeenCalledWith("Error: Project 'my-project' already registered.");
expect(exitSpy).toHaveBeenCalledWith(1);
});
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" });
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);
});
});
describe("Project command helpers", () => {
it("should export all required functions", () => {
expect(runProjectList).toBeDefined();
expect(runProjectAdd).toBeDefined();
expect(runProjectRemove).toBeDefined();
expect(runProjectInfo).toBeDefined();
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
await expect(runProjectShow("")).rejects.toThrow("process.exit:1");
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
});
});

View File

@@ -1,420 +1,259 @@
/**
* 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]
* Project command implementations for kb CLI.
*/
import { CentralCore, type RegisteredProject, type IsolationMode } from "@fusion/core";
import { resolve, isAbsolute } from "node:path";
import { CentralCore, GlobalSettingsStore, type RegisteredProject, type IsolationMode } from "@fusion/core";
import { resolve, isAbsolute, relative, basename } from "node:path";
import { existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import {
getCentralCore,
getProjectManager,
resolveProject,
isKbProject,
suggestProjectName,
formatLastActivity,
} from "../project-resolver.js";
import { formatProjectLine, detectProjectFromCwd, setDefaultProject } from "../project-context.js";
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
/**
* 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();
const projects = await central.listProjects();
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: fn project add <path>\n");
}
return;
function formatDisplayPath(projectPath: string): string {
const rel = relative(process.cwd(), projectPath);
if (rel && !rel.startsWith("..") && rel !== "") {
return rel;
}
return basename(projectPath) || ".";
}
// 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";
export async function runProjectList(): Promise<void> {
const central = new CentralCore();
await central.init();
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);
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 (error: any) {
readWarning = error?.message || "Failed to read project tasks";
}
try {
const projects = await central.listProjects();
const defaultProject = await getDefaultProject();
const health = await central.getProjectHealth(project.id);
if (projects.length === 0) {
console.log("\n No projects registered.");
console.log(" Register one with: kb project add <name> <path>\n");
return;
}
return {
project,
runtimeStatus,
taskCounts,
totalTasks,
lastActivity: health?.lastActivityAt,
activeAgents: health?.inFlightAgentCount ?? 0,
readWarning,
};
})
);
// 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();
// 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}`
);
if (p.readWarning) {
console.log(` warning: ${p.readWarning}`);
}
for (const project of projects) {
const isDefault = defaultProject?.id === project.id;
const line = formatProjectLine(project, isDefault);
console.log(` ${line}`);
}
console.log();
const activeCount = projectsWithInfo.filter((p) => p.project.status === "active").length;
const activeCount = projects.filter((p) => p.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(
dir?: string,
options: { name?: string; isolation?: "in-process" | "child-process"; interactive?: boolean } = {}
name: string,
path: string,
options?: { isolation?: string; force?: boolean }
): Promise<void> {
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 promptConfirmWithRl(rl, "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>]");
if (!name || !path) {
console.error("Usage: kb project add <name> <path> [--isolation <mode>]");
process.exit(1);
}
// Resolve and validate directory
const absolutePath = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
if (!isValidProjectName(name)) {
console.error(`Error: Invalid project name '${name}'`);
process.exit(1);
}
const absolutePath = isAbsolute(path) ? path : resolve(process.cwd(), path);
if (!existsSync(absolutePath)) {
console.error(`Error: Path does not exist: ${absolutePath}`);
console.error(`Error: Path does not exist: ${formatDisplayPath(absolutePath)}`);
process.exit(1);
}
if (!statSync(absolutePath).isDirectory()) {
console.error(`Error: Path is not a directory: ${absolutePath}`);
console.error(`Error: Path is not a directory: ${formatDisplayPath(absolutePath)}`);
process.exit(1);
}
// 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.");
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
if (!existsSync(kbDbPath) && !options?.force) {
console.error(`Error: No kb project found at ${formatDisplayPath(absolutePath)}`);
process.exit(1);
}
// Validate isolation mode
const isolationMode = options.isolation ?? "in-process";
if (!VALID_ISOLATION_MODES.includes(isolationMode)) {
const isolationMode = options?.isolation as IsolationMode | undefined;
if (isolationMode && !VALID_ISOLATION_MODES.includes(isolationMode)) {
console.error(`Error: Invalid isolation mode '${isolationMode}'`);
console.error(`Valid modes: ${VALID_ISOLATION_MODES.join(", ")}`);
process.exit(1);
}
// Determine project name
const name = options.name || suggestProjectName(absolutePath);
const central = new CentralCore();
await central.init();
// 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();
}
/**
* 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: fn project remove <name> [--force]");
process.exit(1);
}
const project = await findProjectByNameOrId(central, name);
if (!project) {
console.error(`Error: Project '${name}' not found.`);
process.exit(1);
}
// 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;
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(` Location: ${formatDisplayPath(project.path)}`);
console.log(` ID: ${project.id}`);
console.log(` Isolation: ${project.isolationMode}`);
console.log();
} finally {
await central.close();
}
// 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();
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();
export async function runProjectRemove(name: string, force?: boolean): Promise<void> {
if (!name) {
console.error("Usage: kb project remove <name> [--force]");
process.exit(1);
}
let project: RegisteredProject;
const central = new CentralCore();
await central.init();
if (name) {
const found = await findProjectByNameOrId(central, name);
if (!found) {
try {
const project = await findProjectByNameOrId(central, name);
if (!project) {
console.error(`Error: Project '${name}' not found.`);
process.exit(1);
}
project = found;
} else {
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: "",
};
const storedProject = await central.getProject(resolved.projectId);
if (storedProject) {
project = storedProject;
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 (answer.trim().toLowerCase() !== "y") {
console.log("Cancelled.");
return;
}
}
}
// Get runtime status
const runtime = pm.getRuntime(project.id);
const runtimeStatus = runtime?.getStatus() ?? "not_started";
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);
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 (error: any) {
taskReadWarning = error?.message || "Failed to read project tasks";
}
// Get health metrics
const health = await central.getProjectHealth(project.id);
// 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}`);
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):`);
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}`);
}
}
if (taskReadWarning) {
console.log(` Warning: ${taskReadWarning}`);
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)}`);
}
if (health.averageTaskDurationMs) {
const avgMins = Math.round(health.averageTaskDurationMs / 60000);
console.log(` Avg task duration: ${avgMins}m`);
}
console.log();
await central.unregisterProject(project.id);
console.log(` ✓ Unregistered project '${project.name}'`);
console.log(` Location: ${formatDisplayPath(project.path)}`);
} finally {
await central.close();
}
}
// Helper functions
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();
try {
const project = await findProjectByNameOrId(central, name);
if (!project) {
console.error(`Error: Project '${name}' not found.`);
process.exit(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(` Location: ${formatDisplayPath(project.path)}`);
console.log(` Status: ${project.status}`);
console.log(` Isolation: ${project.isolationMode}`);
console.log(` Created: ${project.createdAt ?? "unknown"}`);
console.log(` Updated: ${project.updatedAt ?? "unknown"}`);
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);
}
const central = new CentralCore();
await central.init();
try {
const project = await findProjectByNameOrId(central, name);
if (!project) {
console.error(`Error: Project '${name}' not found.`);
process.exit(1);
}
await setDefaultProject(project.id);
console.log();
console.log(` ✓ Set '${project.name}' as default project`);
console.log();
} finally {
await central.close();
}
}
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}`);
console.log(` Location: ${formatDisplayPath(project.path)}`);
console.log();
} else {
console.log();
console.log(" No kb project detected from current directory.");
console.log();
}
} finally {
await central.close();
}
}
// 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();
}
}
async function findProjectByName(central: CentralCore, name: string): Promise<RegisteredProject | undefined> {
const allProjects = await central.listProjects();
@@ -423,67 +262,16 @@ 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);
}
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 });
try {
return await promptConfirmWithRl(rl, message, defaultYes);
} finally {
rl.close();
}
}
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 "•";
function isValidProjectName(name: string): boolean {
if (!name || name.length < 1 || name.length > 64) {
return false;
}
return /^[a-zA-Z0-9_-]+$/.test(name);
}

View File

@@ -1,7 +1,7 @@
import { writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { resolve, join } from "node:path";
import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core";
import { resolveProject } from "../project-context.js";
/**
* Run settings export command.
@@ -9,40 +9,37 @@ import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core"
*
* @param options.output - Custom output file path (optional, auto-generates if not provided)
* @param options.scope - Which settings to export: 'global', 'project', or 'both' (default: 'both')
* @param options.projectName - Optional project name for project-scoped export
*/
export async function runSettingsExport(options: {
output?: string;
scope?: "global" | "project" | "both";
projectName?: string;
} = {}): Promise<void> {
const store = new TaskStore(process.cwd());
await store.init();
const scope = options.scope ?? "both";
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
const store = new TaskStore(project?.projectPath ?? process.cwd());
await store.init();
const outputPath = options.output;
try {
// Export settings
const exportData = await exportSettings(store, { scope });
// Determine output file path
let targetPath: string;
if (outputPath) {
targetPath = resolve(outputPath);
} else {
// Generate timestamped filename in current directory
const filename = generateExportFilename();
targetPath = join(process.cwd(), filename);
}
// Write to file with pretty-printed JSON
const jsonContent = JSON.stringify(exportData, null, 2);
await writeFile(targetPath, jsonContent);
// Output success message
console.log();
console.log(` ✓ Settings exported to ${targetPath}`);
// Show what was exported
const parts: string[] = [];
if (exportData.global) {
const globalKeys = Object.keys(exportData.global).filter(
@@ -60,12 +57,12 @@ export async function runSettingsExport(options: {
parts.push(`${projectKeys.length} project setting(s)`);
}
}
if (parts.length > 0) {
console.log(` Exported: ${parts.join(", ")}`);
}
console.log();
process.exit(0);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);

View File

@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProject } from "../project-context.js";
/**
* Run settings import command.
@@ -11,6 +11,7 @@ import { TaskStore, importSettings, readExportFile, validateImportData } from "@
* @param options.scope - Which settings to import: 'global', 'project', or 'both' (default: 'both')
* @param options.merge - Whether to merge (true, default) or replace (false) existing settings
* @param options.yes - Skip confirmation prompt
* @param options.projectName - Optional project name for project-scoped import
*/
export async function runSettingsImport(
filePath: string,
@@ -18,24 +19,24 @@ export async function runSettingsImport(
scope?: "global" | "project" | "both";
merge?: boolean;
yes?: boolean;
projectName?: string;
} = {}
): Promise<void> {
const store = new TaskStore(process.cwd());
await store.init();
const scope = options.scope ?? "both";
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
const store = new TaskStore(project?.projectPath ?? process.cwd());
await store.init();
const merge = options.merge ?? true;
const skipConfirm = options.yes ?? false;
try {
// Resolve and verify file exists
const resolvedPath = resolve(filePath);
if (!existsSync(resolvedPath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
// Read and parse the file
let importData;
try {
importData = await readExportFile(resolvedPath);
@@ -44,7 +45,6 @@ export async function runSettingsImport(
process.exit(1);
}
// Validate the import data
const validationErrors = validateImportData(importData);
if (validationErrors.length > 0) {
console.error("Error: Invalid import file:");
@@ -54,9 +54,8 @@ export async function runSettingsImport(
process.exit(1);
}
// Show summary of what will be imported
const summary: string[] = [];
if ((scope === "global" || scope === "both") && importData.global) {
const globalKeys = Object.keys(importData.global).filter(
(k) => importData.global?.[k as keyof typeof importData.global] !== undefined
@@ -65,7 +64,7 @@ export async function runSettingsImport(
summary.push(` Global: ${globalKeys.length} setting(s)`);
}
}
if ((scope === "project" || scope === "both") && importData.project) {
const projectKeys = Object.keys(importData.project).filter(
(k) => importData.project?.[k as keyof typeof importData.project] !== undefined
@@ -80,7 +79,6 @@ export async function runSettingsImport(
process.exit(1);
}
// Show preview
console.log();
console.log(" Import Summary:");
console.log(` Source: ${resolvedPath}`);
@@ -92,17 +90,12 @@ export async function runSettingsImport(
}
console.log();
// Ask for confirmation unless --yes flag
if (!skipConfirm) {
// In a real CLI, we'd use readline or prompts here
// For now, we'll proceed since we don't have an interactive prompt library
// and the --yes flag provides an escape hatch
console.log(" Use --yes to confirm this import operation");
console.log();
process.exit(1);
}
// Perform the import
const result = await importSettings(store, importData, { scope, merge });
if (!result.success) {
@@ -110,7 +103,6 @@ export async function runSettingsImport(
process.exit(1);
}
// Show success message
console.log(` ✓ Settings imported successfully`);
if (result.globalCount > 0) {
console.log(` Imported ${result.globalCount} global setting(s)`);
@@ -119,7 +111,7 @@ export async function runSettingsImport(
console.log(` Imported ${result.projectCount} project setting(s)`);
}
console.log();
process.exit(0);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock @fusion/core before importing the module under test
vi.mock("@fusion/core", () => {
const DEFAULT_SETTINGS = {
maxConcurrent: 2,
@@ -9,485 +8,135 @@ vi.mock("@fusion/core", () => {
smartConflictResolution: true,
requirePlanApproval: false,
ntfyEnabled: false,
taskPrefix: undefined,
ntfyTopic: undefined,
worktreeNaming: "random",
githubTokenConfigured: false,
defaultProvider: undefined,
defaultModelId: undefined,
};
// 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,
GlobalSettingsStore: vi.fn(),
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),
});
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn(),
}));
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,
parseValue,
VALID_SETTINGS,
} from "./settings.js";
import { GlobalSettingsStore, DEFAULT_SETTINGS } from "@fusion/core";
import { resolveProject } from "../project-context.js";
import { runSettingsShow, runSettingsSet, parseValue, VALID_SETTINGS } from "./settings.js";
function makeSettings(overrides: Record<string, unknown> = {}) {
return {
...DEFAULT_SETTINGS,
...overrides,
};
return { ...DEFAULT_SETTINGS, ...overrides };
}
describe("VALID_SETTINGS", () => {
it("contains all expected CLI-updatable settings", () => {
expect(VALID_SETTINGS).toContain("maxConcurrent");
expect(VALID_SETTINGS).toContain("maxWorktrees");
expect(VALID_SETTINGS).toContain("worktreeNaming");
expect(VALID_SETTINGS).toContain("taskPrefix");
expect(VALID_SETTINGS).toContain("ntfyTopic");
expect(VALID_SETTINGS).toContain("autoResolveConflicts");
expect(VALID_SETTINGS).toContain("smartConflictResolution");
expect(VALID_SETTINGS).toContain("requirePlanApproval");
expect(VALID_SETTINGS).toContain("ntfyEnabled");
expect(VALID_SETTINGS).toContain("defaultModel");
});
});
describe("parseValue", () => {
describe("boolean settings", () => {
const booleanSettings = [
"autoResolveConflicts",
"smartConflictResolution",
"requirePlanApproval",
"ntfyEnabled",
] as const;
for (const setting of booleanSettings) {
describe(setting, () => {
it('returns true for "true"', () => {
expect(parseValue(setting, "true")).toBe(true);
});
it('returns true for "TRUE" (case-insensitive)', () => {
expect(parseValue(setting, "TRUE")).toBe(true);
});
it('returns true for "yes"', () => {
expect(parseValue(setting, "yes")).toBe(true);
});
it('returns true for "YES" (case-insensitive)', () => {
expect(parseValue(setting, "YES")).toBe(true);
});
it('returns false for "false"', () => {
expect(parseValue(setting, "false")).toBe(false);
});
it('returns false for "FALSE" (case-insensitive)', () => {
expect(parseValue(setting, "FALSE")).toBe(false);
});
it('returns false for "no"', () => {
expect(parseValue(setting, "no")).toBe(false);
});
it('returns false for "NO" (case-insensitive)', () => {
expect(parseValue(setting, "NO")).toBe(false);
});
it("throws for invalid boolean values", () => {
expect(() => parseValue(setting, "invalid")).toThrow(
`Invalid boolean value for ${setting}: "invalid"`
);
});
it("throws for empty strings", () => {
expect(() => parseValue(setting, "")).toThrow();
});
});
}
});
describe("number settings", () => {
it("parses maxConcurrent as integer", () => {
expect(parseValue("maxConcurrent", "4")).toBe(4);
});
it("parses maxWorktrees as integer", () => {
expect(parseValue("maxWorktrees", "8")).toBe(8);
});
it("rejects non-numeric values for maxConcurrent", () => {
expect(() => parseValue("maxConcurrent", "abc")).toThrow(
'Invalid numeric value for maxConcurrent: "abc"'
);
});
it("rejects non-numeric values for maxWorktrees", () => {
expect(() => parseValue("maxWorktrees", "xyz")).toThrow(
'Invalid numeric value for maxWorktrees: "xyz"'
);
});
it("enforces maxConcurrent range (1-10)", () => {
expect(() => parseValue("maxConcurrent", "0")).toThrow(
"Value out of range for maxConcurrent: 0. Must be between 1 and 10."
);
expect(() => parseValue("maxConcurrent", "11")).toThrow(
"Value out of range for maxConcurrent: 11. Must be between 1 and 10."
);
});
it("enforces maxWorktrees range (1-20)", () => {
expect(() => parseValue("maxWorktrees", "0")).toThrow(
"Value out of range for maxWorktrees: 0. Must be between 1 and 20."
);
expect(() => parseValue("maxWorktrees", "21")).toThrow(
"Value out of range for maxWorktrees: 21. Must be between 1 and 20."
);
});
it("accepts boundary values", () => {
expect(parseValue("maxConcurrent", "1")).toBe(1);
expect(parseValue("maxConcurrent", "10")).toBe(10);
expect(parseValue("maxWorktrees", "1")).toBe(1);
expect(parseValue("maxWorktrees", "20")).toBe(20);
});
it("handles whitespace", () => {
expect(parseValue("maxConcurrent", " 5 ")).toBe(5);
});
});
describe("enum settings", () => {
describe("worktreeNaming", () => {
it('accepts "random"', () => {
expect(parseValue("worktreeNaming", "random")).toBe("random");
});
it('accepts "task-id"', () => {
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
});
it('accepts "task-title"', () => {
expect(parseValue("worktreeNaming", "task-title")).toBe("task-title");
});
it("rejects invalid enum values", () => {
expect(() => parseValue("worktreeNaming", "invalid")).toThrow(
'Invalid value for worktreeNaming: "invalid". Valid options: random, task-id, task-title'
);
});
it("handles whitespace", () => {
expect(parseValue("worktreeNaming", " task-id ")).toBe("task-id");
});
});
});
describe("string settings", () => {
it("returns taskPrefix as trimmed string", () => {
expect(parseValue("taskPrefix", " TASK ")).toBe("TASK");
});
it("returns ntfyTopic as trimmed string", () => {
expect(parseValue("ntfyTopic", " my-topic ")).toBe("my-topic");
});
it("returns defaultModel as trimmed string", () => {
expect(parseValue("defaultModel", " anthropic/claude-4 ")).toBe("anthropic/claude-4");
});
it("allows empty strings to clear values", () => {
expect(parseValue("taskPrefix", "")).toBe("");
expect(parseValue("ntfyTopic", "")).toBe("");
});
});
});
describe("runSettingsShow", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays settings in formatted output", async () => {
const mockSettings = makeSettings({
maxConcurrent: 3,
maxWorktrees: 6,
autoResolveConflicts: false,
taskPrefix: "CUSTOM",
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
init: vi.fn(),
getSettings: vi.fn().mockResolvedValue(mockSettings),
});
await runSettingsShow();
// Check for header
const headerLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("kb Configuration Settings")
);
expect(headerLine).toBeDefined();
// Check that maxConcurrent appears
const maxConcurrentLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Max Concurrent")
);
expect(maxConcurrentLine).toBeDefined();
// Check that group headers appear
const engineGroup = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Engine:")
);
expect(engineGroup).toBeDefined();
});
it("shows githubTokenConfigured as configured indicator", async () => {
const mockSettings = makeSettings({
githubTokenConfigured: true,
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
init: vi.fn(),
getSettings: vi.fn().mockResolvedValue(mockSettings),
});
await runSettingsShow();
const configuredLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("(configured)")
);
expect(configuredLine).toBeDefined();
});
it("shows githubTokenConfigured as not configured indicator", async () => {
const mockSettings = makeSettings({
githubTokenConfigured: false,
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
init: vi.fn(),
getSettings: vi.fn().mockResolvedValue(mockSettings),
});
await runSettingsShow();
const notConfiguredLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("(not configured)")
);
expect(notConfiguredLine).toBeDefined();
});
});
describe("runSettingsSet", () => {
describe("settings commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let mockUpdateSettings: ReturnType<typeof vi.fn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
mockUpdateSettings = vi.fn().mockResolvedValue({
maxConcurrent: 4,
maxWorktrees: 4,
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
init: vi.fn(),
updateSettings: mockUpdateSettings,
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 4,
maxWorktrees: 4,
taskPrefix: "TEST",
}),
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
vi.restoreAllMocks();
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("updates maxConcurrent with valid value", async () => {
await runSettingsSet("maxConcurrent", "4");
expect(mockUpdateSettings).toHaveBeenCalledWith({ maxConcurrent: 4 });
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Updated")
);
expect(successLine).toBeDefined();
expect(successLine![0]).toContain("4");
it("exposes expected valid settings and parser behavior", () => {
expect(VALID_SETTINGS).toContain("maxConcurrent");
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
expect(parseValue("maxConcurrent", "4")).toBe(4);
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
});
it("updates autoResolveConflicts with boolean true", async () => {
await runSettingsSet("autoResolveConflicts", "true");
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: true });
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Updated")
);
expect(successLine).toBeDefined();
});
it("updates autoResolveConflicts with boolean false", async () => {
await runSettingsSet("autoResolveConflicts", "false");
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: false });
});
it("updates autoResolveConflicts with 'yes'", async () => {
await runSettingsSet("autoResolveConflicts", "yes");
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: true });
});
it("updates autoResolveConflicts with 'no'", async () => {
await runSettingsSet("autoResolveConflicts", "no");
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: false });
});
it("updates worktreeNaming with valid enum", async () => {
await runSettingsSet("worktreeNaming", "task-id");
expect(mockUpdateSettings).toHaveBeenCalledWith({ worktreeNaming: "task-id" });
});
it("updates taskPrefix with string value", async () => {
await runSettingsSet("taskPrefix", "CUSTOM");
expect(mockUpdateSettings).toHaveBeenCalledWith({ taskPrefix: "CUSTOM" });
});
it("updates ntfyTopic with string value", async () => {
await runSettingsSet("ntfyTopic", "my-notifications");
expect(mockUpdateSettings).toHaveBeenCalledWith({ ntfyTopic: "my-notifications" });
});
it("handles defaultModel split into provider and modelId", async () => {
await runSettingsSet("defaultModel", "anthropic/claude-sonnet-4-5");
expect(mockUpdateSettings).toHaveBeenCalledWith({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
it("runSettingsShow without project uses global settings even if a project could resolve", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true }));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings,
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings: vi.fn() } as any,
});
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("anthropic/claude-sonnet-4-5")
);
expect(successLine).toBeDefined();
await runSettingsShow();
expect(getSettings).toHaveBeenCalled();
expect(resolveProject).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(" kb Global Settings");
});
it("exits with error for unknown setting key", async () => {
await runSettingsSet("unknownSetting", "value");
it("runSettingsShow with project uses project store", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 5 }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings } as any,
});
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown setting"));
expect(exitSpy).toHaveBeenCalledWith(1);
await runSettingsShow("demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith(" kb Settings for project 'demo-project'");
});
it("exits with error for invalid boolean value", async () => {
await runSettingsSet("autoResolveConflicts", "invalid");
it("runSettingsSet without project updates global-only settings", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true }));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid boolean value"));
expect(exitSpy).toHaveBeenCalledWith(1);
await runSettingsSet("ntfyEnabled", "true");
expect(updateSettings).toHaveBeenCalledWith({ ntfyEnabled: true });
expect(resolveProject).not.toHaveBeenCalled();
});
it("exits with error for out-of-range number", async () => {
await runSettingsSet("maxConcurrent", "99");
it("runSettingsSet with project updates project-only settings", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettings } as any,
});
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range"));
expect(exitSpy).toHaveBeenCalledWith(1);
await runSettingsSet("maxConcurrent", "6", "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(updateSettings).toHaveBeenCalledWith({ maxConcurrent: 6 });
});
it("exits with error for invalid enum value", async () => {
await runSettingsSet("worktreeNaming", "invalid");
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid value for worktreeNaming"));
expect(exitSpy).toHaveBeenCalledWith(1);
it("rejects global-only settings for project scope", async () => {
await expect(runSettingsSet("ntfyEnabled", "true", "demo-project")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith('Error: Setting "ntfyEnabled" is global-only. Omit --project to update it.');
});
it("handles defaultModel with invalid format (no slash)", async () => {
await runSettingsSet("defaultModel", "invalid-format");
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid format for defaultModel"));
expect(exitSpy).toHaveBeenCalledWith(1);
it("rejects project-only settings without explicit project scope", async () => {
await expect(runSettingsSet("maxConcurrent", "4")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith('Error: Setting "maxConcurrent" is project-only. Use --project or run from a project directory.');
expect(resolveProject).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { TaskStore, type Settings, DEFAULT_SETTINGS } from "@fusion/core";
import { getStore } from "../project-resolver.js";
import { GlobalSettingsStore, type Settings, DEFAULT_SETTINGS } from "@fusion/core";
import { resolveProject } from "../project-context.js";
// Settings that can be updated via CLI
export const VALID_SETTINGS = [
@@ -15,6 +15,17 @@ export const VALID_SETTINGS = [
"defaultModel",
] as const;
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
const PROJECT_ONLY_SETTINGS = [
"maxConcurrent",
"maxWorktrees",
"worktreeNaming",
"taskPrefix",
"autoResolveConflicts",
"smartConflictResolution",
"requirePlanApproval",
] as const;
type ValidSettingKey = (typeof VALID_SETTINGS)[number];
// Type guards for setting categories
@@ -39,6 +50,20 @@ const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
maxWorktrees: { min: 1, max: 20 },
};
async function getGlobalSettingsStore(): Promise<GlobalSettingsStore> {
const store = new GlobalSettingsStore();
await store.init();
return store;
}
function isGlobalOnlySetting(key: ValidSettingKey): boolean {
return GLOBAL_ONLY_SETTINGS.includes(key as (typeof GLOBAL_ONLY_SETTINGS)[number]);
}
function isProjectOnlySetting(key: ValidSettingKey): boolean {
return PROJECT_ONLY_SETTINGS.includes(key as (typeof PROJECT_ONLY_SETTINGS)[number]);
}
/**
* Parse and validate a setting value based on its key's expected type
*/
@@ -83,11 +108,9 @@ export function parseValue(key: ValidSettingKey, value: string): unknown {
// String settings (default)
if (STRING_SETTINGS.includes(key)) {
// Allow empty string to clear the value (will be stored as empty string, merged with default)
return trimmed;
}
// Fallback for any other settings - treat as string
return trimmed;
}
@@ -99,34 +122,27 @@ function formatSettingValue(
value: unknown,
settings: Settings
): string {
// Special case for githubTokenConfigured - show as indicator
if (key === "githubTokenConfigured") {
return value ? "(configured)" : "(not configured)";
}
// Handle arrays
if (Array.isArray(value)) {
return value.length > 0 ? `[${value.join(", ")}]` : "[]";
}
// Handle undefined
if (value === undefined) {
return "(not set)";
}
// Handle booleans
if (typeof value === "boolean") {
return value ? "true" : "false";
}
// Handle numbers
if (typeof value === "number") {
return String(value);
}
// Handle strings
if (typeof value === "string") {
// Check if this is the same as default
const defaultValue = DEFAULT_SETTINGS[key];
if (value === defaultValue) {
return `"${value}" (default)`;
@@ -141,28 +157,33 @@ function formatSettingValue(
* Get display name for a setting (convert camelCase to readable)
*/
function getSettingLabel(key: string): string {
// Special cases
if (key === "ntfyEnabled") return "ntfy Enabled";
if (key === "ntfyTopic") return "ntfy Topic";
// Convert camelCase to space-separated words with capital first letters
return key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase());
}
/**
* Run settings show command - displays all settings
* Run settings show command.
*
* Behavior:
* - `kb settings` shows global settings
* - `kb settings --project <name>` shows project settings for that project
*/
export async function runSettingsShow(projectName?: string): Promise<void> {
const store = await getStore({ project: projectName });
const settings = await store.getSettings();
const project = projectName ? await resolveProject(projectName) : undefined;
const settings = project
? await project.store.getSettings()
: await (await getGlobalSettingsStore()).getSettings();
console.log();
console.log(" kb Configuration Settings");
console.log(project
? ` kb Settings for project '${project.projectName}'`
: " kb Global Settings");
console.log(" " + "─".repeat(50));
// Define the order and grouping of settings for display
const settingGroups = [
{
title: "Engine",
@@ -191,7 +212,6 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
];
for (const group of settingGroups) {
// Check if any setting in this group has a value
const hasValues = group.keys.some((key) => settings[key as keyof Settings] !== undefined);
if (!hasValues) continue;
@@ -210,23 +230,41 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
}
/**
* Run settings set command - updates a single setting
* Run settings set command - updates a single setting.
*
* Scope rules:
* - Global-only settings (`ntfy*`, `defaultModel`) update global settings
* - Project-only settings require an explicit `--project` target
*/
export async function runSettingsSet(key: string, value: string, projectName?: string): Promise<void> {
// Validate the setting key is allowed
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
console.error(`Error: Unknown setting "${key}"`);
console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`);
process.exit(1);
return; // Required for tests where process.exit is mocked
return;
}
const store = await getStore({ project: projectName });
const validKey = key as ValidSettingKey;
if (projectName && isGlobalOnlySetting(validKey)) {
console.error(`Error: Setting "${key}" is global-only. Omit --project to update it.`);
process.exit(1);
return;
}
if (!projectName && isProjectOnlySetting(validKey)) {
console.error(`Error: Setting "${key}" is project-only. Use --project or run from a project directory.`);
process.exit(1);
return;
}
const projectContext = projectName ? await resolveProject(projectName) : undefined;
const store = projectContext?.store;
const globalStore = store ? undefined : await getGlobalSettingsStore();
try {
const parsedValue = parseValue(key as ValidSettingKey, value);
const parsedValue = parseValue(validKey, value);
// Special handling for defaultModel - splits into provider and modelId
if (key === "defaultModel") {
const parts = (parsedValue as string).split("/");
if (parts.length !== 2) {
@@ -234,26 +272,34 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
`Error: Invalid format for defaultModel. Use "provider/model-id" (e.g., "anthropic/claude-sonnet-4-5")`
);
process.exit(1);
return; // Required for tests where process.exit is mocked
return;
}
const [provider, modelId] = parts;
await store.updateSettings({ defaultProvider: provider, defaultModelId: modelId });
if (store) {
await store.updateSettings({ defaultProvider: provider, defaultModelId: modelId });
} else {
await globalStore!.updateSettings({ defaultProvider: provider, defaultModelId: modelId });
}
console.log();
console.log(` ✓ Updated default model to ${provider}/${modelId}`);
console.log();
return;
}
// Normal single-setting update
const patch: Partial<Settings> = { [key]: parsedValue };
await store.updateSettings(patch);
if (store) {
await store.updateSettings(patch);
} else {
await globalStore!.updateSettings(patch);
}
const currentSettings = store ? await store.getSettings() : await globalStore!.getSettings();
console.log();
console.log(` ✓ Updated ${getSettingLabel(key)} to ${formatSettingValue(key as keyof Settings, parsedValue, await store.getSettings())}`);
console.log(` ✓ Updated ${getSettingLabel(key)} to ${formatSettingValue(key as keyof Settings, parsedValue, currentSettings as Settings)}`);
console.log();
} catch (err: any) {
console.error(`Error: ${err.message}`);
process.exit(1);
return; // Required for tests where process.exit is mocked
return;
}
}

View File

@@ -52,6 +52,14 @@ vi.mock("@fusion/dashboard", () => ({
})),
}));
vi.mock("@fusion/dashboard/planning", () => ({
createSession: vi.fn(),
submitResponse: vi.fn(),
RateLimitError: class RateLimitError extends Error {},
SessionNotFoundError: class SessionNotFoundError extends Error {},
InvalidSessionStateError: class InvalidSessionStateError extends Error {},
}));
// Mock @fusion/core/gh-cli
vi.mock("@fusion/core/gh-cli", () => ({
isGhAvailable: vi.fn(),
@@ -65,6 +73,7 @@ vi.mock("../project-context.js", () => ({
projectId: "proj_test",
projectPath: "/test",
projectName: "test",
isRegistered: true,
store: {},
}),
getStore: vi.fn().mockResolvedValue({}),
@@ -75,10 +84,12 @@ vi.mock("../project-context.js", () => ({
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, type LogsOptions } from "./task.js";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "./task.js";
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
import { GitHubClient } from "@fusion/dashboard";
import { createSession } from "@fusion/dashboard/planning";
import { resolveProject } from "../project-context.js";
import { aiMergeTask } from "@fusion/engine";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -154,6 +165,490 @@ vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),
}));
describe("project-aware task command behavior", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("runTaskList prints project header when project name is provided", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }) as (code?: number) => never);
const mockListTasks = vi.fn().mockResolvedValue([
makeTask({ id: "FN-001", column: "triage", description: "Task one" }),
]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { listTasks: mockListTasks } as unknown as TaskStore,
});
await expect(runTaskList("demo-project")).rejects.toThrow("process.exit");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockListTasks).toHaveBeenCalledOnce();
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Tasks for project 'demo-project':"))).toBe(true);
logSpy.mockRestore();
exitSpy.mockRestore();
});
it("runTaskList without project flag uses shared resolution flow before local fallback", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }) as (code?: number) => never);
const mockListTasks = vi.fn().mockResolvedValue([
makeTask({ id: "FN-001", column: "triage", description: "Default task" }),
]);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_default",
projectPath: "/default/project",
projectName: "default-project",
isRegistered: true,
store: { listTasks: mockListTasks } as unknown as TaskStore,
});
await expect(runTaskList()).rejects.toThrow("process.exit");
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockListTasks).toHaveBeenCalledOnce();
exitSpy.mockRestore();
});
it("runTaskList without project flag falls back to TaskStore(process.cwd()) when shared resolution fails", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }) as (code?: number) => never);
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/current/project");
const mockListTasks = vi.fn().mockResolvedValue([
makeTask({ id: "FN-009", column: "triage", description: "Detected local task" }),
]);
const init = vi.fn();
vi.mocked(resolveProject).mockRejectedValueOnce(
new Error("No kb project found in current directory. Use --project or run from a project directory.")
);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation((projectPath: string) => ({
init,
listTasks: mockListTasks,
projectPath,
}));
await expect(runTaskList()).rejects.toThrow("process.exit");
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/current/project");
expect(init).toHaveBeenCalledOnce();
expect(mockListTasks).toHaveBeenCalledOnce();
cwdSpy.mockRestore();
exitSpy.mockRestore();
});
it("runTaskCreate uses resolved project store and prints project context", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-002", description: "test task" }));
const mockAddAttachment = vi.fn();
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: mockAddAttachment } as unknown as TaskStore,
});
await runTaskCreate("test task", undefined, undefined, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockCreateTask).toHaveBeenCalledWith({ description: "test task", dependencies: undefined });
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Project: demo-project"))).toBe(true);
logSpy.mockRestore();
});
it("runTaskCreate without project flag uses shared resolution flow", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-003", description: "default task" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_default",
projectPath: "/default/project",
projectName: "default-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn() } as unknown as TaskStore,
});
await runTaskCreate("default task");
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined });
});
it("runTaskCreate without project flag falls back to TaskStore(process.cwd()) when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/current/project");
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-004", description: "local task" }));
const init = vi.fn();
vi.mocked(resolveProject).mockRejectedValueOnce(
new Error("No kb project found in current directory. Use --project or run from a project directory.")
);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation((projectPath: string) => ({
init,
createTask: mockCreateTask,
addAttachment: vi.fn(),
projectPath,
}));
await runTaskCreate("local task");
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/current/project");
expect(init).toHaveBeenCalledOnce();
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined });
cwdSpy.mockRestore();
});
it("runTaskLogs uses resolved project path in follow mode", async () => {
const mockGetTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-001" }));
const mockGetAgentLogs = vi.fn().mockResolvedValue([]);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }) as (code?: number) => never);
const sigintHandlers: Array<() => void> = [];
vi.spyOn(process, "on").mockImplementation((event: string, handler: () => void) => {
if (event === "SIGINT") {
sigintHandlers.push(handler);
}
return process;
});
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/resolved/project",
projectName: "demo-project",
isRegistered: true,
store: { getTask: mockGetTask, getAgentLogs: mockGetAgentLogs } as unknown as TaskStore,
});
vi.mocked(existsSync).mockReturnValue(false);
const promise = runTaskLogs("FN-001", { follow: true }, "demo-project");
await Promise.resolve();
expect(vi.mocked(watchFile)).toHaveBeenCalledWith(
expect.stringContaining("/resolved/project/.kb/tasks/FN-001/agent.log"),
expect.any(Function),
);
expect(sigintHandlers).toHaveLength(1);
expect(() => sigintHandlers[0]()).toThrow("process.exit");
await expect(promise).rejects.toThrow("process.exit");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Logs for project 'demo-project':"))).toBe(true);
logSpy.mockRestore();
exitSpy.mockRestore();
});
it("runTaskPrCreate falls back to current working directory without project flag", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
const mockCreatePr = vi.fn().mockResolvedValue({ number: 123, url: "https://example.com/pr/123" });
vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "acme", repo: "demo" });
vi.mocked(GitHubClient).mockImplementation(() => ({ createPr: mockCreatePr }) as never);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-001", column: "in-review", branchName: "fusion/fn-001" })),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
}));
await runTaskPrCreate("FN-001", {});
expect(getCurrentRepo).toHaveBeenCalledWith("/local/project");
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({ head: "fusion/fn-001" }));
cwdSpy.mockRestore();
});
it("runTaskPlan uses resolved project path only when project name is provided", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-010", description: "planned task" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
}));
vi.mocked(createSession).mockResolvedValue({
sessionId: "sess-1",
summary: { description: "planned task", steps: [], reviewLevel: 1, sizeEstimate: "M", clarifications: [] },
questions: [],
isComplete: true,
} as never);
await runTaskPlan("planned task", true, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(createSession).toHaveBeenCalledWith("127.0.0.1", "planned task", expect.anything(), "/test");
});
it("runTaskShow uses resolved project store when project name is provided", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const mockGetTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", description: "from project store" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { getTask: mockGetTask } as unknown as TaskStore,
});
await runTaskShow("FN-123", "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockGetTask).toHaveBeenCalledWith("FN-123");
logSpy.mockRestore();
});
it("runTaskMove uses resolved project store when project name is provided", async () => {
const mockMoveTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "done" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { moveTask: mockMoveTask } as unknown as TaskStore,
});
await runTaskMove("FN-123", "done", "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockMoveTask).toHaveBeenCalledWith("FN-123", "done");
});
it("runTaskAttach uses resolved project store when project name is provided", async () => {
const mockAddAttachment = vi.fn().mockResolvedValue({
originalName: "notes.txt",
size: 10,
});
const readFileMock = vi.fn().mockResolvedValue(Buffer.from("hello"));
vi.doMock("node:fs/promises", () => ({ readFile: readFileMock }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { addAttachment: mockAddAttachment } as unknown as TaskStore,
});
await runTaskAttach("FN-123", "/tmp/notes.txt", "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockAddAttachment).toHaveBeenCalled();
});
it("runTaskPause and runTaskUnpause use resolved project store", async () => {
const pauseTask = vi.fn()
.mockResolvedValueOnce(makeTask({ id: "FN-123", status: "paused" }))
.mockResolvedValueOnce(makeTask({ id: "FN-123", status: undefined }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { pauseTask } as unknown as TaskStore,
});
await runTaskPause("FN-123", "demo-project");
await runTaskUnpause("FN-123", "demo-project");
expect(pauseTask).toHaveBeenNthCalledWith(1, "FN-123", true);
expect(pauseTask).toHaveBeenNthCalledWith(2, "FN-123", false);
});
it("runTaskArchive and runTaskUnarchive use resolved project store", async () => {
const archiveTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "archived" }));
const unarchiveTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "done" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { archiveTask, unarchiveTask } as unknown as TaskStore,
});
await runTaskArchive("FN-123", "demo-project");
await runTaskUnarchive("FN-123", "demo-project");
expect(archiveTask).toHaveBeenCalledWith("FN-123");
expect(unarchiveTask).toHaveBeenCalledWith("FN-123");
});
it("runTaskRetry uses resolved project store", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", status: "failed", column: "in-progress" }));
const updateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", status: undefined }));
const moveTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "todo" }));
const logEntry = vi.fn().mockResolvedValue(undefined);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { getTask, updateTask, moveTask, logEntry } as unknown as TaskStore,
});
await runTaskRetry("FN-123", "demo-project");
expect(getTask).toHaveBeenCalledWith("FN-123");
expect(updateTask).toHaveBeenCalled();
expect(moveTask).toHaveBeenCalledWith("FN-123", "todo");
expect(logEntry).toHaveBeenCalled();
});
it("runTaskDelete uses resolved project store", async () => {
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123" }));
const deleteTask = vi.fn().mockResolvedValue(undefined);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { getTask, deleteTask } as unknown as TaskStore,
});
await runTaskDelete("FN-123", true, "demo-project");
expect(getTask).toHaveBeenCalledWith("FN-123");
expect(deleteTask).toHaveBeenCalledWith("FN-123");
});
it("runTaskComment, runTaskComments, and runTaskSteer use resolved project store", async () => {
const addTaskComment = vi.fn().mockResolvedValue({ author: "user", message: "hello" });
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", steeringComments: [], comments: [{ author: "user", message: "hello", createdAt: new Date().toISOString() }] }));
const addSteeringComment = vi.fn().mockResolvedValue(makeTask({ id: "FN-123" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { addTaskComment, getTask, addSteeringComment } as unknown as TaskStore,
});
await runTaskComment("FN-123", "hello", "user", "demo-project");
await runTaskComments("FN-123", "demo-project");
await runTaskSteer("FN-123", "steer", "demo-project");
expect(addTaskComment).toHaveBeenCalled();
expect(getTask).toHaveBeenCalledWith("FN-123");
expect(addSteeringComment).toHaveBeenCalled();
});
it("runTaskUpdate, runTaskLog, runTaskMerge, runTaskDuplicate, and runTaskRefine use resolved project context", async () => {
const updateStep = vi.fn().mockResolvedValue({ ...makeTask({ id: "FN-123" }), steps: [{ name: "Step 1", status: "done" }] });
const logEntry = vi.fn().mockResolvedValue(undefined);
const getTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-123", column: "in-review" }));
const duplicateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-124" }));
const refineTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-125" }));
const resolvedStore = { updateStep, logEntry, getTask, duplicateTask, refineTask } as unknown as TaskStore;
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: resolvedStore,
});
vi.mocked(aiMergeTask).mockResolvedValue({
merged: true,
task: makeTask({ id: "FN-123" }),
branch: "fusion/fn-123",
worktreeRemoved: true,
branchDeleted: true,
} as never);
await runTaskUpdate("FN-123", "0", "done", "demo-project");
await runTaskLog("FN-123", "hello", undefined, "demo-project");
await runTaskMerge("FN-123", "demo-project");
await runTaskDuplicate("FN-123", "demo-project");
await runTaskRefine("FN-123", "more tests", "demo-project");
expect(updateStep).toHaveBeenCalled();
expect(logEntry).toHaveBeenCalled();
expect(aiMergeTask).toHaveBeenCalledWith(resolvedStore, "/test", "FN-123", expect.any(Object));
expect(duplicateTask).toHaveBeenCalledWith("FN-123");
expect(refineTask).toHaveBeenCalledWith("FN-123", "more tests");
});
it("routes GitHub import commands through the resolved project store", async () => {
const listTasks = vi.fn().mockResolvedValue([]);
const createTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-200" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { listTasks, createTask } as unknown as TaskStore,
});
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: true,
json: async () => ([{ number: 1, title: "Issue 1", body: "Body", html_url: "https://github.com/acme/demo/issues/1", labels: [] }]),
} as Response);
await runTaskImportFromGitHub("acme/demo", { limit: 1 }, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(listTasks).toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("routes interactive GitHub import through the resolved project store", async () => {
const listTasks = vi.fn().mockResolvedValue([]);
const createTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-201" }));
const mockQuestion = vi.fn().mockResolvedValue("all");
vi.mocked(createInterface).mockReturnValue({
question: mockQuestion,
close: vi.fn(),
} as never);
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { listTasks, createTask } as unknown as TaskStore,
});
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: true,
json: async () => ([{ number: 2, title: "Issue 2", body: "Body", html_url: "https://github.com/acme/demo/issues/2", labels: [] }]),
} as Response);
await runTaskImportGitHubInteractive("acme/demo", { limit: 1 }, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(listTasks).toHaveBeenCalled();
expect(createTask).toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("surfaces project resolution failures from shared context when project flag is explicit", async () => {
vi.mocked(resolveProject).mockRejectedValueOnce(
new Error("Project 'demo-project' not found. Run 'kb project list' to see registered projects.")
);
await expect(runTaskList("demo-project")).rejects.toThrow(
"Project 'demo-project' not found. Run 'kb project list' to see registered projects."
);
});
});
describe("runTaskCreate with --attach", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
@@ -1384,6 +1879,7 @@ describe("runTaskLogs", () => {
projectId: "proj_test",
projectPath: "/test",
projectName: "test",
isRegistered: true,
store: {} as TaskStore,
});

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus, type AgentLogType, type AgentLogEntry, type Task } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -7,19 +7,83 @@ 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 { getStore as getStoreFromResolver, resolveProject } from "../project-resolver.js";
import { resolveProject, type ProjectContext } from "../project-context.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
async function getStore(projectName?: string): Promise<TaskStore> {
interface CommandContext {
store: TaskStore;
projectPath: string;
projectName?: string;
explicit: boolean;
}
function asLocalProjectContext(store: TaskStore): ProjectContext {
const cwd = process.cwd();
return {
projectId: cwd,
projectPath: cwd,
projectName: cwd.split("/").filter(Boolean).at(-1) ?? "current-project",
isRegistered: false,
store,
};
}
async function getCommandContext(projectName?: string): Promise<CommandContext> {
if (projectName) {
return getStoreFromResolver({ project: projectName });
const context = await resolveProject(projectName);
return {
store: context.store,
projectPath: context.projectPath,
projectName: context.projectName,
explicit: true,
};
}
return getStoreFromResolver();
try {
const context = await resolveProject(undefined);
return {
store: context.store,
projectPath: context.projectPath,
projectName: context.projectName,
explicit: false,
};
} catch (error) {
const store = new TaskStore(process.cwd());
await store.init();
return {
store,
projectPath: process.cwd(),
projectName: undefined,
explicit: false,
};
}
}
async function getStore(projectName?: string): Promise<TaskStore> {
return (await getCommandContext(projectName)).store;
}
async function getProjectContext(projectName?: string): Promise<ProjectContext | undefined> {
if (projectName) {
return resolveProject(projectName);
}
try {
return await resolveProject(undefined);
} catch {
const store = await getStore();
return asLocalProjectContext(store);
}
}
async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath;
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) {
let description = descriptionArg;
const projectContext = await getProjectContext(projectName);
if (!description) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -32,7 +96,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
process.exit(1);
}
const store = await getStore(projectName);
const store = projectContext?.store ?? await getStore(projectName);
const task = await store.createTask({ description: description.trim(), dependencies: depends });
const label = task.description.length > 60
@@ -40,12 +104,15 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
: task.description;
console.log();
if (projectContext) {
console.log(` Project: ${projectContext.projectName}`);
}
console.log(` ✓ Created ${task.id}: ${label}`);
console.log(` Column: triage`);
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Path: .fusion/tasks/${task.id}/`);
console.log(` Path: .kb/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
const { readFile } = await import("node:fs/promises");
@@ -80,7 +147,8 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
}
export async function runTaskList(projectName?: string) {
const store = await getStore(projectName);
const projectContext = await getProjectContext(projectName);
const store = projectContext?.store ?? await getStore(projectName);
const tasks = await store.listTasks();
if (tasks.length === 0) {
@@ -89,6 +157,10 @@ export async function runTaskList(projectName?: string) {
}
console.log();
if (projectContext && projectName) {
console.log(` Tasks for project '${projectContext.projectName}':`);
console.log();
}
for (const col of COLUMNS) {
const colTasks = tasks.filter((t) => t.column === col);
@@ -218,7 +290,8 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog
}
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
const store = await getStore(projectName);
const projectContext = await getProjectContext(projectName);
const store = projectContext?.store ?? await getStore(projectName);
// Verify task exists
try {
@@ -236,15 +309,19 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
return;
}
if (projectContext && projectName) {
console.log(` Logs for project '${projectContext.projectName}':`);
}
// Print existing entries (filtered)
const filteredEntries = filterEntries(entries, options);
printEntries(filteredEntries);
// Follow mode: watch for new entries
if (options.follow) {
const store = await getStore(projectName);
const projectPath = (await resolveProject(projectName)).projectPath;
const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log");
const followStore = store;
const projectPath = projectContext?.projectPath ?? process.cwd();
const logPath = join(projectPath, ".kb", "tasks", id, "agent.log");
if (!existsSync(logPath)) {
console.log(`\n Waiting for log file to be created...`);
@@ -364,7 +441,7 @@ 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 projectPath = await getProjectPath(projectName);
console.log(`\n Merging ${id} with AI...\n`);
@@ -437,7 +514,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
console.log();
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
console.log(` File: ${attachment.filename} (${sizeKB} KB)`);
console.log(` Path: .fusion/tasks/${id}/attachments/${attachment.filename}`);
console.log(` Path: .kb/tasks/${id}/attachments/${attachment.filename}`);
console.log();
}
@@ -480,7 +557,7 @@ export async function runTaskDuplicate(id: string, projectName?: string) {
console.log();
console.log(` ✓ Duplicated ${id}${newTask.id}`);
console.log(` Path: .fusion/tasks/${newTask.id}/`);
console.log(` Path: .kb/tasks/${newTask.id}/`);
console.log();
}
@@ -512,7 +589,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
console.log(` Column: triage`);
console.log(` Dependency: ${id}`);
console.log(` Path: .fusion/tasks/${newTask.id}/`);
console.log(` Path: .kb/tasks/${newTask.id}/`);
console.log();
}
@@ -977,7 +1054,7 @@ export async function runTaskSteer(id: string, message?: string, projectName?: s
// Add steering comment
let task;
try {
task = await store.addComment(id, trimmed, "user");
task = await store.addSteeringComment(id, trimmed, "user");
} catch (err: any) {
if (err.code === "ENOENT") {
console.error(`Error: Task not found: ${id}`);
@@ -989,7 +1066,7 @@ export async function runTaskSteer(id: string, message?: string, projectName?: s
// Show success with preview
const preview = trimmed.length > 60 ? trimmed.slice(0, 60) + "…" : trimmed;
console.log();
console.log(`Comment added to ${task.id}`);
console.log(`Steering comment added to ${task.id}`);
console.log(` "${preview}"`);
console.log();
}
@@ -1043,7 +1120,8 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
owner = o;
repo = r;
} else {
const gitRepo = getCurrentRepo(process.cwd());
const projectPath = await getProjectPath(projectName);
const gitRepo = getCurrentRepo(projectPath);
if (!gitRepo) {
console.error("Error: Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
process.exit(1);
@@ -1060,8 +1138,8 @@ export async function runTaskPrCreate(id: string, options: PrCreateOptions = {},
process.exit(1);
}
// Build branch name
const branchName = `kb/${id.toLowerCase()}`;
// Build branch name using the established project convention
const branchName = `fusion/${id.toLowerCase()}`;
// Build PR title
let title: string;
@@ -1347,7 +1425,8 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
try {
showThinking();
const result = await createSession("127.0.0.1", initialPlan.trim(), store, process.cwd());
const projectPath = await getProjectPath(projectName);
const result = await createSession("127.0.0.1", initialPlan.trim(), store, projectPath);
clearThinking();
sessionId = result.sessionId;
firstQuestion = result.firstQuestion;
@@ -1421,7 +1500,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
try {
showThinking();
result = await submitResponse(sessionId, response as Record<string, unknown>) as typeof result;
result = await submitResponse(sessionId, response) as typeof result;
clearThinking();
} catch (err) {
clearThinking();
@@ -1468,7 +1547,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Path: .fusion/tasks/${task.id}/`);
console.log(` Path: .kb/tasks/${task.id}/`);
console.log();
} else {
console.log("\n Task creation cancelled.\n");

View File

@@ -17,6 +17,8 @@ export interface ProjectContext {
projectPath: string;
/** Project name */
projectName: string;
/** Whether the project is registered in the central registry */
isRegistered: boolean;
/** TaskStore instance for this project */
store: TaskStore;
}
@@ -71,12 +73,24 @@ export async function resolveProject(
// 3. Auto-detect from CWD
if (!project) {
project = await detectProjectFromCwd(cwd, central);
if (!project) {
const detected = await detectProjectFromCwd(cwd, central);
if (!detected) {
throw new Error(
`No kb project found in current directory. Use --project or run from a project directory.`
);
}
const store = detected.id
? await getStoreForProject(detected.id, detected.path)
: await createLocalStore(detected.path);
return {
projectId: detected.id,
projectPath: detected.path,
projectName: detected.name,
isRegistered: Boolean(detected.id),
store,
};
}
const store = await getStoreForProject(project.id, project.path);
@@ -85,6 +99,7 @@ export async function resolveProject(
projectId: project.id,
projectPath: project.path,
projectName: project.name,
isRegistered: true,
store,
};
} finally {
@@ -160,7 +175,7 @@ export async function clearDefaultProject(): Promise<void> {
export async function detectProjectFromCwd(
cwd: string,
central: CentralCore
): Promise<RegisteredProject | undefined> {
): Promise<RegisteredProject | { id: string; name: string; path: string } | undefined> {
let currentDir = resolve(cwd);
// Walk up the directory tree
@@ -174,8 +189,12 @@ export async function detectProjectFromCwd(
return project;
}
// Not registered, but has .kb/kb.db - still use it as a valid project
// This allows unregistered projects to work with the CLI
return undefined;
// This preserves legacy single-project CLI behavior.
return {
id: currentDir,
name: currentDir.split("/").filter(Boolean).at(-1) ?? "current-project",
path: currentDir,
};
}
// Move up to parent
@@ -243,6 +262,12 @@ export function clearStoreCache(): void {
storeCache.clear();
}
async function createLocalStore(projectPath: string): Promise<TaskStore> {
const store = new TaskStore(projectPath);
await store.init();
return store;
}
/**
* Format a project for display in CLI output.
*