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:
227
packages/cli/src/__tests__/bin.test.ts
Normal file
227
packages/cli/src/__tests__/bin.test.ts
Normal 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>");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
110
packages/cli/src/commands/backup.test.ts
Normal file
110
packages/cli/src/commands/backup.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -327,6 +327,13 @@ export interface TaskAttachment {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SteeringComment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
author: "user" | "agent";
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -380,14 +387,9 @@ export interface Task {
|
||||
* unmerged branch. The executor reads this to branch from the
|
||||
* dependency's branch instead of HEAD. Cleared after worktree creation. */
|
||||
baseBranch?: string;
|
||||
/** Commit SHA of the base branch at worktree creation time.
|
||||
* Used for computing file diffs when reviewing task changes.
|
||||
* Set by the executor when creating the worktree. */
|
||||
baseCommitSha?: string;
|
||||
attachments?: TaskAttachment[];
|
||||
steeringComments?: SteeringComment[];
|
||||
comments?: TaskComment[];
|
||||
/** Steering comments injected during task execution for real-time guidance */
|
||||
steeringComments?: TaskComment[];
|
||||
/** PR information for tasks linked to GitHub pull requests */
|
||||
prInfo?: PrInfo;
|
||||
mergeDetails?: MergeDetails;
|
||||
@@ -426,12 +428,6 @@ export interface Task {
|
||||
error?: string;
|
||||
/** Optional summary of what was changed/fixed when task is completed */
|
||||
summary?: string;
|
||||
/** Files modified during agent execution, captured at task completion time */
|
||||
modifiedFiles?: string[];
|
||||
/** Optional ID of the mission this task is linked to (derived from its linked slice hierarchy) */
|
||||
missionId?: string;
|
||||
/** Optional ID of the slice this task is linked to (for mission-based work) */
|
||||
sliceId?: string;
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||
columnMovedAt?: string;
|
||||
@@ -527,17 +523,10 @@ export interface GlobalSettings {
|
||||
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
||||
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
||||
ntfyTopic?: string;
|
||||
/** Default project ID to use when no explicit project is specified and
|
||||
* no project can be auto-detected from the current directory.
|
||||
* Used for multi-project CLI workflows. */
|
||||
/** The default project ID for CLI operations when --project flag is not provided.
|
||||
* Used to determine which project to operate on when not in a project directory.
|
||||
* Set via `kb project set-default <name>`. */
|
||||
defaultProjectId?: string;
|
||||
/** Dashboard hostname for ntfy.sh deep links. When set along with ntfyEnabled
|
||||
* and ntfyTopic, notifications include a Click URL that opens the dashboard
|
||||
* directly to the task. Example: "http://localhost:3000" or "https://fusion.example.com" */
|
||||
ntfyDashboardHost?: string;
|
||||
/** When true, indicates the first-run setup wizard has been completed.
|
||||
* Set by FirstRunExperience.completeSetup() after successful migration. */
|
||||
setupComplete?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -671,16 +660,6 @@ export interface ProjectSettings {
|
||||
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
|
||||
* then defaultModelId if not specified. */
|
||||
titleSummarizerModelId?: string;
|
||||
/** Project-defined shell scripts for quick command execution.
|
||||
* Key is the script name, value is the shell command to execute.
|
||||
* Script names must be alphanumeric with hyphens and underscores only. */
|
||||
scripts?: Record<string, string>;
|
||||
/** Name of a script from the `scripts` map to run automatically when
|
||||
* fresh worktrees are created. Runs after `worktreeInitCommand` (if both
|
||||
* are configured). Has no effect on pooled/recycled worktrees.
|
||||
* If the referenced script doesn't exist, a warning is logged and
|
||||
* the task continues. */
|
||||
setupScript?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -707,8 +686,6 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
|
||||
defaultThinkingLevel: undefined,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
defaultProjectId: undefined,
|
||||
ntfyDashboardHost: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -746,8 +723,6 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
autoSummarizeTitles: false,
|
||||
titleSummarizerProvider: undefined,
|
||||
titleSummarizerModelId: undefined,
|
||||
scripts: {},
|
||||
setupScript: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -770,7 +745,6 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
"defaultProjectId",
|
||||
"ntfyDashboardHost",
|
||||
] as const;
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
@@ -810,8 +784,6 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
"autoSummarizeTitles",
|
||||
"titleSummarizerProvider",
|
||||
"titleSummarizerModelId",
|
||||
"scripts",
|
||||
"setupScript",
|
||||
] as const;
|
||||
|
||||
export interface BoardConfig {
|
||||
@@ -894,59 +866,8 @@ export interface ArchivedTaskEntry {
|
||||
breakIntoSubtasks?: boolean;
|
||||
paused?: boolean;
|
||||
baseBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
mergeRetries?: number;
|
||||
error?: string;
|
||||
/** Files modified during agent execution, captured at task completion time */
|
||||
modifiedFiles?: string[];
|
||||
}
|
||||
|
||||
/** Detected project from filesystem scanning */
|
||||
export interface DetectedProject {
|
||||
path: string;
|
||||
name: string;
|
||||
hasDb: boolean;
|
||||
}
|
||||
|
||||
/** State for the first-run setup wizard UI */
|
||||
export interface SetupState {
|
||||
isFirstRun: boolean;
|
||||
hasDetectedProjects: boolean;
|
||||
detectedProjects: DetectedProject[];
|
||||
registeredProjects: RegisteredProject[];
|
||||
recommendedAction: "auto-detect" | "create-new" | "manual-setup";
|
||||
}
|
||||
|
||||
/** Input type for completing project setup */
|
||||
export interface ProjectSetupInput {
|
||||
path: string;
|
||||
name: string;
|
||||
isolationMode?: IsolationMode;
|
||||
}
|
||||
|
||||
/** Result of setup completion */
|
||||
export interface SetupCompletionResult {
|
||||
success: boolean;
|
||||
projects: RegisteredProject[];
|
||||
errors?: Array<{ path: string; error: string }>;
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
/** Options for migration orchestration */
|
||||
export interface MigrationOptions {
|
||||
startPath?: string;
|
||||
maxDepth?: number;
|
||||
autoRegister?: boolean;
|
||||
dryRun?: boolean;
|
||||
onProgress?: (completed: number, total: number, phase: string) => void;
|
||||
}
|
||||
|
||||
/** Result of migration run */
|
||||
export interface MigrationResult {
|
||||
projectsDetected: DetectedProject[];
|
||||
projectsRegistered: RegisteredProject[];
|
||||
projectsSkipped: Array<{ path: string; reason: string }>;
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
/** Type of planning question presented to the user */
|
||||
@@ -980,9 +901,6 @@ export interface RegisteredProject {
|
||||
settings?: ProjectSettings;
|
||||
}
|
||||
|
||||
/** @deprecated Alias for RegisteredProject - use RegisteredProject instead */
|
||||
export type ProjectInfo = RegisteredProject;
|
||||
|
||||
/** Health metrics for a registered project */
|
||||
export interface ProjectHealth {
|
||||
/** Project ID reference */
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
loginProvider,
|
||||
logoutProvider,
|
||||
fetchModels,
|
||||
addComment,
|
||||
addSteeringComment,
|
||||
addTaskComment,
|
||||
updateTaskComment,
|
||||
deleteTaskComment,
|
||||
@@ -28,11 +28,6 @@ import {
|
||||
unregisterProject,
|
||||
fetchProjectHealth,
|
||||
fetchActivityFeed,
|
||||
fetchScripts,
|
||||
addScript,
|
||||
removeScript,
|
||||
runScript,
|
||||
waitForScriptCompletion,
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
fetchFirstRunStatus,
|
||||
@@ -499,7 +494,7 @@ describe("logoutProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addComment", () => {
|
||||
describe("addSteeringComment", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -516,7 +511,7 @@ describe("addComment", () => {
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
comments: [
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
@@ -529,12 +524,12 @@ describe("addComment", () => {
|
||||
it("sends POST with text and returns updated task", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await addComment("FN-001", "Please handle the edge case");
|
||||
const result = await addSteeringComment("FN-001", "Please handle the edge case");
|
||||
|
||||
expect(result.id).toBe("FN-001");
|
||||
expect(result.comments).toHaveLength(1);
|
||||
expect(result.comments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
|
||||
expect(result.steeringComments).toHaveLength(1);
|
||||
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||
@@ -546,7 +541,7 @@ describe("addComment", () => {
|
||||
mockFetchResponse(false, { error: "Task not found" })
|
||||
);
|
||||
|
||||
await expect(addComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
await expect(addSteeringComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2017,7 +2012,6 @@ describe("fetchActivityFeed", () => {
|
||||
expect(call[0]).toContain("since=2026-01-01T00%3A00%3A00.000Z");
|
||||
expect(call[0]).toContain("projectId=proj_abc123");
|
||||
expect(call[0]).toContain("type=task%3Acreated");
|
||||
expect(call[0]).not.toContain("types=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2161,94 +2155,3 @@ describe("fetchProjectConfig", () => {
|
||||
expect(result.rootDir).toBe("/path/to/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts API helpers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetchScripts uses GET /api/scripts", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }));
|
||||
|
||||
const result = await fetchScripts();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts",
|
||||
expect.objectContaining({ headers: { "Content-Type": "application/json" } }),
|
||||
);
|
||||
expect(result).toEqual({ build: "pnpm build" });
|
||||
});
|
||||
|
||||
it("addScript posts the expected payload", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }, 201));
|
||||
|
||||
await addScript("build", "pnpm build");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "build", command: "pnpm build" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removeScript URL-encodes the script name", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}, 200));
|
||||
|
||||
await removeScript("build_script");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts/build_script",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runScript returns the terminal session handle", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { command: "pnpm test", sessionId: "sess-1" }, 201));
|
||||
|
||||
const result = await runScript("test", ["--watch"]);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts/test/run",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args: ["--watch"] }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ command: "pnpm test", sessionId: "sess-1" });
|
||||
});
|
||||
|
||||
it("waitForScriptCompletion polls until the session finishes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockFetchResponse(true, {
|
||||
id: "sess-1",
|
||||
command: "pnpm test",
|
||||
running: true,
|
||||
exitCode: null,
|
||||
output: "starting",
|
||||
startTime: new Date().toISOString(),
|
||||
}))
|
||||
.mockReturnValueOnce(mockFetchResponse(true, {
|
||||
id: "sess-1",
|
||||
command: "pnpm test",
|
||||
running: false,
|
||||
exitCode: 1,
|
||||
output: "done",
|
||||
startTime: new Date().toISOString(),
|
||||
}));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const promise = waitForScriptCompletion("sess-1");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ output: "done", exitCode: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,17 +283,6 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
|
||||
return api<string[]>(`/tasks/${taskId}/session-files`);
|
||||
}
|
||||
|
||||
export interface TaskFileDiff {
|
||||
path: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
diff: string;
|
||||
oldPath?: string;
|
||||
}
|
||||
|
||||
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
|
||||
return api<TaskFileDiff[]>(`/tasks/${taskId}/file-diffs`);
|
||||
}
|
||||
|
||||
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(`/tasks/${id}/comments`);
|
||||
}
|
||||
@@ -318,15 +307,13 @@ export function deleteTaskComment(id: string, commentId: string): Promise<Task>
|
||||
});
|
||||
}
|
||||
|
||||
export function addComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments`, {
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export const addSteeringComment = addComment;
|
||||
|
||||
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/spec/revise`, {
|
||||
method: "POST",
|
||||
@@ -1344,56 +1331,6 @@ export function fetchWorkflowResults(taskId: string): Promise<WorkflowStepResult
|
||||
return api<WorkflowStepResult[]>(`/tasks/${encodeURIComponent(taskId)}/workflow-results`);
|
||||
}
|
||||
|
||||
// ── Scripts ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ScriptsMap = Record<string, string>;
|
||||
|
||||
export interface RunScriptResponse {
|
||||
command: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export async function waitForScriptCompletion(sessionId: string): Promise<{ output: string; exitCode: number }> {
|
||||
for (;;) {
|
||||
const session = await getTerminalSession(sessionId);
|
||||
if (!session.running) {
|
||||
return {
|
||||
output: session.output,
|
||||
exitCode: session.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch all project-defined scripts */
|
||||
export function fetchScripts(): Promise<ScriptsMap> {
|
||||
return api<ScriptsMap>("/scripts");
|
||||
}
|
||||
|
||||
/** Create a new project-defined script */
|
||||
export async function addScript(name: string, command: string): Promise<void> {
|
||||
await api<ScriptsMap>("/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a project-defined script */
|
||||
export async function removeScript(name: string): Promise<void> {
|
||||
await api<ScriptsMap>(`/scripts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute a script via the terminal service and return the created session */
|
||||
export function runScript(name: string, args?: string[]): Promise<RunScriptResponse> {
|
||||
return api<RunScriptResponse>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workflow Step Templates ──────────────────────────────────────────────
|
||||
|
||||
/** Re-export WorkflowStepTemplate type from core */
|
||||
@@ -1882,7 +1819,7 @@ export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
|
||||
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
|
||||
}
|
||||
|
||||
/** Fetch unified activity feed. Supports singular type filtering and server fallback to the current project's local activity log when the central feed is unavailable or empty. */
|
||||
/** Fetch unified activity feed */
|
||||
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
@@ -1908,37 +1845,6 @@ export function resumeProject(id: string): Promise<ProjectInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a specific project by ID */
|
||||
export function fetchProject(id: string): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Update a project */
|
||||
export function updateProject(
|
||||
id: string,
|
||||
updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" }
|
||||
): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Detected project from auto-scan */
|
||||
export interface DetectedProject {
|
||||
path: string;
|
||||
suggestedName: string;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
/** Auto-detect kb projects in a given base path */
|
||||
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
|
||||
return api<{ projects: DetectedProject[] }>("/projects/detect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ basePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch first run status to detect if user needs setup wizard */
|
||||
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
|
||||
return api<FirstRunStatus>("/first-run-status");
|
||||
@@ -1962,15 +1868,3 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
|
||||
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
|
||||
}
|
||||
|
||||
/** Diff information for a task */
|
||||
export interface TaskDiff {
|
||||
files: string[];
|
||||
diffs: Record<string, { stat: string; patch: string }>;
|
||||
}
|
||||
|
||||
/** Fetch diff information for a task */
|
||||
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
|
||||
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,6 @@ function truncatePath(path: string, maxLength: number = 40): string {
|
||||
return `${start}...${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two sets of ProjectCardProps for memo equality.
|
||||
* Checks project properties and health metrics to determine if re-render is needed.
|
||||
*/
|
||||
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
|
||||
if (previous.project.id !== next.project.id) return false;
|
||||
if (previous.project.status !== next.project.status) return false;
|
||||
@@ -70,28 +66,6 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual project card component showing project status, health metrics, and actions.
|
||||
*
|
||||
* Displays:
|
||||
* - Project name and truncated path
|
||||
* - Status badge (active, paused, errored, initializing)
|
||||
* - Health metrics: active tasks, running agents, completed tasks
|
||||
* - Last activity timestamp
|
||||
* - Action buttons: Open, Pause/Resume, Remove
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProjectCard
|
||||
* project={registeredProject}
|
||||
* health={projectHealth}
|
||||
* onSelect={(p) => setCurrentProject(p)}
|
||||
* onPause={(p) => pauseProject(p.id)}
|
||||
* onResume={(p) => resumeProject(p.id)}
|
||||
* onRemove={(p) => unregisterProject(p.id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function ProjectCardInner({
|
||||
project,
|
||||
health,
|
||||
|
||||
@@ -172,6 +172,9 @@ export function SettingsModal({
|
||||
};
|
||||
}, [activeSection, loadAuthStatus]);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
const handleLogin = useCallback(async (providerId: string) => {
|
||||
setAuthActionInProgress(providerId);
|
||||
try {
|
||||
|
||||
@@ -396,7 +396,7 @@ export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProje
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleValidate}
|
||||
disabled={state.isValidating || !!state.validationError}
|
||||
disabled={state.isValidating || state.validationError}
|
||||
>
|
||||
{state.isValidating ? (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@fusion/core";
|
||||
import { addComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
type CommentType = "user" | "steering";
|
||||
|
||||
interface TaskCommentsProps {
|
||||
task: Task;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
@@ -18,64 +16,21 @@ function formatCommentTimestamp(comment: TaskComment): string {
|
||||
return comment.updatedAt ? `${label} (edited)` : label;
|
||||
}
|
||||
|
||||
function formatRelativeTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [commentType, setCommentType] = useState<CommentType>("user");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Unified comments from task.comments (includes migrated steering comments)
|
||||
const comments = useMemo(() => task.comments || [], [task.comments]);
|
||||
|
||||
// Legacy steering comments (if any still exist on the task)
|
||||
const steeringComments = useMemo(() => task.steeringComments || [], [task.steeringComments]);
|
||||
|
||||
// All comments combined, sorted newest first
|
||||
const allComments = useMemo(() => {
|
||||
const combined = [...comments, ...steeringComments];
|
||||
return combined.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [comments, steeringComments]);
|
||||
|
||||
// Determine if a comment is a steering/AI guidance comment
|
||||
const isSteeringComment = useCallback((comment: TaskComment): boolean => {
|
||||
// Check if from the steeringComments array
|
||||
if (steeringComments.some(sc => sc.id === comment.id)) return true;
|
||||
// Check if the author indicates it's an agent/AI comment
|
||||
if (comment.author === "agent" || comment.author === "system") return true;
|
||||
return false;
|
||||
}, [steeringComments]);
|
||||
|
||||
const handleAddComment = useCallback(async () => {
|
||||
async function handleAddComment() {
|
||||
const text = draft.trim();
|
||||
if (!text || text.length > MAX_LENGTH || submitting) return;
|
||||
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let updated: Task;
|
||||
if (commentType === "steering") {
|
||||
updated = await addComment(task.id, text);
|
||||
} else {
|
||||
updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
}
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment added", "success");
|
||||
@@ -84,17 +39,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [draft, commentType, submitting, task.id, currentAuthor, onTaskUpdated, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleAddComment();
|
||||
}
|
||||
},
|
||||
[handleAddComment]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSaveEdit(commentId: string) {
|
||||
const text = editingText.trim();
|
||||
@@ -126,45 +71,23 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = draft.trim().length > 0 && draft.length <= MAX_LENGTH;
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Comments</h4>
|
||||
|
||||
{allComments.length === 0 ? (
|
||||
{comments.length === 0 ? (
|
||||
<div className="detail-log-empty">No comments yet.</div>
|
||||
) : (
|
||||
<div className="detail-activity-list">
|
||||
{allComments.map((comment) => {
|
||||
const isSteering = isSteeringComment(comment);
|
||||
const canEdit = !isSteering && comment.author === currentAuthor;
|
||||
{comments.map((comment) => {
|
||||
const canEdit = comment.author === currentAuthor;
|
||||
const isEditing = editingId === comment.id;
|
||||
return (
|
||||
<div key={comment.id} className="detail-log-entry">
|
||||
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{isSteering ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background: "var(--accent-secondary, #8b5cf6)",
|
||||
color: "#fff",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
data-testid="ai-guidance-badge"
|
||||
>
|
||||
AI Guidance
|
||||
</span>
|
||||
) : (
|
||||
<strong>{comment.author}</strong>
|
||||
)}
|
||||
<span className="detail-log-timestamp">
|
||||
{isSteering
|
||||
? formatRelativeTimestamp(comment.createdAt)
|
||||
: formatCommentTimestamp(comment)}
|
||||
<div>
|
||||
<strong>{comment.author}</strong>
|
||||
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
|
||||
{formatCommentTimestamp(comment)}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && !isEditing ? (
|
||||
@@ -214,16 +137,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="detail-log-outcome"
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
...(isSteering ? {
|
||||
borderLeft: "3px solid var(--accent-secondary, #8b5cf6)",
|
||||
paddingLeft: "12px",
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
<div className="detail-log-outcome" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{comment.text}
|
||||
</div>
|
||||
)}
|
||||
@@ -234,61 +148,16 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
|
||||
{/* Comment type selector */}
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button
|
||||
className={`btn btn-sm${commentType === "user" ? " btn-primary" : ""}`}
|
||||
onClick={() => setCommentType("user")}
|
||||
type="button"
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm${commentType === "steering" ? " btn-primary" : ""}`}
|
||||
onClick={() => setCommentType("steering")}
|
||||
type="button"
|
||||
>
|
||||
AI Guidance
|
||||
</button>
|
||||
</div>
|
||||
{commentType === "steering" && (
|
||||
<p style={{ fontSize: "12px", opacity: 0.7, margin: 0 }}>
|
||||
AI Guidance comments are injected into the task execution context to guide the agent.
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
placeholder={commentType === "steering"
|
||||
? "Add guidance for the AI agent… (Ctrl+Enter to submit)"
|
||||
: "Add a comment… (Ctrl+Enter to submit)"}
|
||||
placeholder="Add a comment"
|
||||
className="spec-editor-feedback"
|
||||
maxLength={MAX_LENGTH}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: draft.length > MAX_LENGTH ? 0.9 : 0.5,
|
||||
color: draft.length > MAX_LENGTH ? "var(--error, #ef4444)" : "inherit",
|
||||
}}
|
||||
>
|
||||
{draft.length} / {MAX_LENGTH}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleAddComment()}
|
||||
disabled={!isValid || submitting}
|
||||
>
|
||||
{submitting ? "Posting…" : commentType === "steering" ? "Add Guidance" : "Add Comment"}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
|
||||
{submitting ? "Posting…" : "Add Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,6 @@ vi.mock("lucide-react", () => ({
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Zap: () => null,
|
||||
ChevronDown: () => null,
|
||||
ChevronUp: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
@@ -90,12 +88,6 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
fireEvent.click(screen.getByText(optionText));
|
||||
}
|
||||
|
||||
// Helper to expand the InlineCreateCard by clicking the toggle button
|
||||
function expandInlineCreate() {
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
@@ -107,146 +99,47 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard toggle button", () => {
|
||||
it("toggle button expands the view", () => {
|
||||
renderCard();
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
describe("InlineCreateCard blur-to-cancel", () => {
|
||||
it("calls onCancel when focus leaves the card with empty input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Initially, footer controls are not visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(document.querySelector(".inline-create-card")?.className).toContain("inline-create-card");
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(toggleButton.getAttribute("aria-controls")).toBeNull();
|
||||
expect(textarea.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
// Click toggle to expand
|
||||
expandInlineCreate();
|
||||
|
||||
// Now footer controls should be visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--expanded")).toBe(true);
|
||||
expect(screen.getByText(/Deps/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Models/i })).toBeTruthy();
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Save/i })).toBeTruthy();
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(toggleButton.getAttribute("aria-controls")).toBe("inline-create-controls");
|
||||
expect(textarea.getAttribute("aria-controls")).toBe("inline-create-controls");
|
||||
});
|
||||
|
||||
it("toggle button collapses the view when expanded", () => {
|
||||
renderCard();
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
|
||||
// Expand first
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Click toggle again to collapse
|
||||
expandInlineCreate();
|
||||
|
||||
// Footer should be hidden
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("maintains the collapsed and expanded styling contract on the inline create container", () => {
|
||||
renderCard();
|
||||
const card = document.querySelector(".inline-create-card");
|
||||
|
||||
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(card?.classList.contains("inline-create-card--expanded")).toBe(false);
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
|
||||
expandInlineCreate();
|
||||
|
||||
expect(card?.classList.contains("inline-create-card--expanded")).toBe(true);
|
||||
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(false);
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT expand on focus", () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Focus should not expand the card
|
||||
textarea.focus();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onCancel on blur when collapsed and empty", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel on blur when collapsed and has content", () => {
|
||||
it("does NOT call onCancel when focus leaves with non-empty input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Keep collapsed draft" } });
|
||||
fireEvent.change(textarea, { target: { value: "Some task description" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel on blur when expanded and empty", () => {
|
||||
it("does NOT call onCancel when focus moves to another element inside the card", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const depsButton = screen.getByText(/Deps/);
|
||||
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: depsButton });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel when blur with only whitespace input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel on blur when expanded and has content", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expandInlineCreate();
|
||||
fireEvent.change(textarea, { target: { value: "Keep drafting" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard Escape key behavior", () => {
|
||||
it("calls onCancel when Escape is pressed", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes dropdowns on first Escape, cancels on second", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Open a dropdown
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
|
||||
// First Escape closes dropdown
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
@@ -256,7 +149,6 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
|
||||
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
@@ -264,12 +156,26 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
const prevented = !fireEvent.mouseDown(item);
|
||||
expect(prevented).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
|
||||
const { props } = renderCard(testTasks);
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
fireEvent.click(item);
|
||||
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard model selector", () => {
|
||||
it("opens and closes the model disclosure dropdown", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
expect(screen.getByText("Executor Model")).toBeTruthy();
|
||||
@@ -279,22 +185,8 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(screen.queryByText("Executor Model")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the shared model dropdown in the portal layer from the inline create surface", async () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
expect(portal).toBeTruthy();
|
||||
expect(portal.classList.contains("model-combobox-dropdown--portal")).toBe(true);
|
||||
expect(document.body.contains(portal)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates executor selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Executor Model", "Claude Sonnet 4.5");
|
||||
@@ -304,7 +196,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("updates validator selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Validator Model", "GPT-4o");
|
||||
@@ -314,7 +205,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("clears the model selection when Use default is chosen", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Executor Model", "Claude Sonnet 4.5");
|
||||
@@ -330,7 +220,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("omits model fields from the submit payload after clearing back to default", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task using defaults again" } });
|
||||
@@ -357,7 +246,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("includes selected models in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
|
||||
@@ -381,7 +269,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -391,17 +278,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when expanded, empty, and a dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
|
||||
@@ -409,7 +285,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
@@ -419,21 +294,19 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip("includes selected preset id in the submit payload", async () => {
|
||||
it("includes selected preset id in the submit payload", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard([], { availableModels: undefined });
|
||||
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -447,9 +320,8 @@ describe("InlineCreateCard model selector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onCancel after a model override is selected and focus leaves the card while empty", () => {
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -459,12 +331,11 @@ describe("InlineCreateCard model selector", () => {
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents default on model option mouseDown to retain focus while selecting", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
textarea.focus();
|
||||
@@ -498,7 +369,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
.mockResolvedValueOnce(MOCK_MODELS);
|
||||
|
||||
renderCard([], { availableModels: undefined });
|
||||
expandInlineCreate();
|
||||
openModelPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -523,7 +393,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -533,7 +402,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("preserves newest-first sort order when a search filter is applied", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -553,7 +421,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -563,7 +430,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -583,7 +449,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("shows search input when dropdown is opened", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
@@ -592,7 +457,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("filters tasks by search term", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "dark" } });
|
||||
@@ -606,7 +470,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("renders Plan and Subtask buttons disabled when description is empty", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
expect(planButton.disabled).toBe(true);
|
||||
@@ -615,7 +478,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
|
||||
it("enables Plan and Subtask buttons when description is entered", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Test task" } });
|
||||
|
||||
@@ -625,41 +487,34 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
expect(subtaskButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onPlanningMode and collapses after Plan clicked", () => {
|
||||
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { onPlanningMode });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Plan this task");
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("calls onSubtaskBreakdown and collapses after Subtask clicked", () => {
|
||||
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Break this down");
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("shows toast when Plan clicked with empty description (via direct handler call)", () => {
|
||||
const addToast = vi.fn();
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { addToast, onPlanningMode });
|
||||
expandInlineCreate();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
@@ -673,7 +528,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
const addToast = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { addToast, onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
@@ -717,10 +571,9 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears textarea and localStorage after successful task creation", async () => {
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Type something to set localStorage
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -735,30 +588,10 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows immediately re-expanding after successful submit", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears localStorage when cancelling via Escape key", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
@@ -46,7 +46,6 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -227,10 +226,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const columnHeader = screen.getByText("Column");
|
||||
fireEvent.click(columnHeader);
|
||||
|
||||
@@ -320,10 +315,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks in the table
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that all column badges are rendered in the table
|
||||
// Use getAllByText and check length since column names appear in both drop zones and badges
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -569,10 +560,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections including Done and Archived
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that section headers are rendered with column names
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -590,10 +577,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Find section headers by their structure
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6); // One for each column
|
||||
@@ -668,7 +651,6 @@ describe("ListView", () => {
|
||||
describe("ListView Column Filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("filters tasks by column when drop zone is clicked", () => {
|
||||
@@ -718,10 +700,6 @@ describe("ListView Column Filtering", () => {
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Show Done" to reveal all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All 6 section headers should be visible (one for each column)
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6);
|
||||
@@ -1041,26 +1019,13 @@ describe("ListView Hide Done Tasks", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders hide done tasks toggle button with 'Show Done' when done tasks are hidden by default", () => {
|
||||
it("renders hide done tasks toggle button", () => {
|
||||
renderListView();
|
||||
|
||||
const hideDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
expect(hideDoneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks by default when no localStorage value exists", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
createMockTask({ id: "FN-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks when toggle is activated", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
@@ -1069,15 +1034,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show done tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1094,15 +1055,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show archived tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide archived tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1120,16 +1077,12 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all completed tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All tasks should be visible now
|
||||
// All tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
expect(screen.getByText("FN-003")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide completed tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1148,13 +1101,16 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Completed tasks should be hidden by default
|
||||
// Click hide done button to hide completed tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Completed tasks should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
|
||||
// Click "Show Done" to show all tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
// Click again to show all tasks
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// All tasks should be visible again
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
@@ -1166,11 +1122,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" first (since default is now hidden)
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1207,7 +1159,14 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Stats should show filtered count with hidden indicator (default is now hidden)
|
||||
// Initial stats should show all tasks
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Stats should show filtered count with hidden indicator
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
expect(screen.getByText(/2 hidden/)).toBeDefined();
|
||||
});
|
||||
@@ -1221,7 +1180,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done and Archived sections should be hidden by default
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done and Archived sections should be hidden
|
||||
const doneSection = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Done")
|
||||
);
|
||||
@@ -1247,7 +1214,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done drop zone should still be visible with "X of Y" format
|
||||
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
|
||||
expect(doneZone).toBeDefined();
|
||||
expect(doneZone?.textContent).toContain("0 of 2");
|
||||
@@ -1261,7 +1232,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived drop zone should still be visible with "X of Y" format
|
||||
const archivedZone = document.querySelector('[data-column="archived"].list-drop-zone');
|
||||
expect(archivedZone).toBeDefined();
|
||||
expect(archivedZone?.textContent).toContain("0 of 2");
|
||||
@@ -1276,11 +1251,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Hide done tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Apply filter
|
||||
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||
fireEvent.change(filterInput, { target: { value: "Gamma" } });
|
||||
|
||||
// Completed tasks should remain hidden (hide done is active by default)
|
||||
// Completed tasks should remain hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
// Filtered task should be visible
|
||||
@@ -1295,7 +1274,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the done drop zone to select that column
|
||||
@@ -1315,7 +1298,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the archived drop zone to select that column
|
||||
@@ -1331,7 +1318,6 @@ describe("ListView Hide Done Tasks", () => {
|
||||
describe("ListView Quick Entry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders QuickEntryBox when onQuickCreate is provided", () => {
|
||||
@@ -1368,9 +1354,10 @@ describe("ListView Quick Entry", () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
// Click the toggle button to expand the QuickEntryBox
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus the input to expand the QuickEntryBox
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Model selector button should be visible
|
||||
const modelButton = await screen.findByTestId("quick-entry-models-button");
|
||||
@@ -1381,9 +1368,10 @@ describe("ListView Quick Entry", () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
// Click the toggle button to expand the QuickEntryBox
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus the input to expand the QuickEntryBox
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Dependency selector button should be visible
|
||||
const depsButton = await screen.findByTestId("quick-entry-deps-button");
|
||||
@@ -1777,10 +1765,6 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
// Click "Show Done" to make archived tasks visible
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1,162 +1,284 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SetupWizardModal } from "../SetupWizardModal";
|
||||
import { SetupWizard } from "../SetupWizard";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../../api";
|
||||
|
||||
// Mock the API
|
||||
const mockRegisterProject = vi.fn();
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
X: () => <span data-testid="close-icon">×</span>,
|
||||
ChevronRight: () => <span data-testid="next-icon">→</span>,
|
||||
ChevronLeft: () => <span data-testid="back-icon">←</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Check: () => <span data-testid="check-icon">✓</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
registerProject: (...args: unknown[]) => mockRegisterProject(...args),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="x-icon">×</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
FolderPlus: () => <span data-testid="folder-icon">📁</span>,
|
||||
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("SetupWizardModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders the wizard with manual step by default", async () => {
|
||||
describe("SetupWizard", () => {
|
||||
it("does not render when isOpen is false", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show welcome/manual screen
|
||||
expect(await screen.findByText("Welcome to kb")).toBeDefined();
|
||||
|
||||
// Should show manual entry form
|
||||
expect(screen.getByLabelText("Project Path")).toBeDefined();
|
||||
expect(screen.getByLabelText("Project Name")).toBeDefined();
|
||||
expect(screen.getByLabelText("Isolation Mode")).toBeDefined();
|
||||
expect(screen.queryByText("Add New Project")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows entering project details in manual step", async () => {
|
||||
it("renders when isOpen is true", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const pathInput = await screen.findByLabelText("Project Path");
|
||||
expect(screen.getByText("Add New Project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("starts at directory step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows step indicator with 5 steps", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Directory")).toBeDefined();
|
||||
expect(screen.getByText("Name")).toBeDefined();
|
||||
expect(screen.getByText("Mode")).toBeDefined();
|
||||
expect(screen.getByText("Validate")).toBeDefined();
|
||||
expect(screen.getByText("Confirm")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables Next button when directory is empty", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Next button when directory is filled", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("navigates to next step when Next is clicked", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Find the primary button (Next) in the actions area
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(screen.getByText("Project Name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("auto-suggests name from directory path", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/my-awesome-project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("My Project") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("my-awesome-project");
|
||||
});
|
||||
|
||||
it("allows navigation back to previous step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Go to step 2
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
// Go back
|
||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows isolation mode options", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Navigate to step 3 (isolation)
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
fireEvent.change(pathInput, { target: { value: "/path/to/project" } });
|
||||
// Go to name step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
// Go to isolation step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
expect(pathInput).toHaveValue("/path/to/project");
|
||||
expect(screen.getByText("In-Process (Default)")).toBeDefined();
|
||||
expect(screen.getByText("Child Process (Isolated)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
it("calls onClose when Cancel is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = await screen.findByLabelText("Close wizard");
|
||||
const cancelButton = screen.getByRole("button", { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when close icon is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables register button when form is incomplete", async () => {
|
||||
it("submits project data when created", async () => {
|
||||
const mockRegisterProject = vi.fn().mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "My Project",
|
||||
path: "/home/user/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
} as ProjectInfo);
|
||||
|
||||
const onProjectCreated = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={onProjectCreated}
|
||||
onRegisterProject={mockRegisterProject}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for form to render
|
||||
await screen.findByLabelText("Project Path");
|
||||
// Fill directory
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
// The wizard should be in directory step with a Next button
|
||||
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
|
||||
|
||||
const registerButton = screen.getByRole("button", { name: /register project/i });
|
||||
expect(registerButton).toBeDisabled();
|
||||
|
||||
// Fill only path
|
||||
fireEvent.change(screen.getByLabelText("Project Path"), {
|
||||
target: { value: "/path/to/project" },
|
||||
});
|
||||
|
||||
// Button should still be disabled
|
||||
expect(registerButton).toBeDisabled();
|
||||
// Note: Full wizard flow testing would require more complex setup
|
||||
// including mocking the validation API call
|
||||
});
|
||||
|
||||
it("enables register button when form is complete", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
it("resets state when reopened", () => {
|
||||
const { rerender } = render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for form to render
|
||||
await screen.findByLabelText("Project Path");
|
||||
// Fill some data
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Fill the form
|
||||
fireEvent.change(screen.getByLabelText("Project Path"), {
|
||||
target: { value: "/path/to/project" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Project Name"), {
|
||||
target: { value: "test-project" },
|
||||
});
|
||||
|
||||
// Wait for button to be enabled
|
||||
const registerButton = screen.getByRole("button", { name: /register project/i });
|
||||
await waitFor(() => expect(registerButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it("has isolation mode selector with correct options", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
// Close and reopen
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText("Isolation Mode") as HTMLSelectElement;
|
||||
expect(select.value).toBe("in-process");
|
||||
|
||||
// Check options exist
|
||||
const options = Array.from(select.options);
|
||||
expect(options.some(opt => opt.value === "in-process")).toBe(true);
|
||||
expect(options.some(opt => opt.value === "child-process")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows form hint for project path", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Absolute path to your project directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("has correct isolation mode default value", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText("Isolation Mode") as HTMLSelectElement;
|
||||
expect(select.value).toBe("in-process");
|
||||
// Should be back at step 1 with empty fields
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
|
||||
expect(newInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +382,6 @@ body {
|
||||
gap: var(--column-gap);
|
||||
padding: var(--board-padding);
|
||||
height: calc(100vh - 57px);
|
||||
height: calc(100dvh - 57px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
@@ -1141,566 +1140,6 @@ body {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* === ProjectOverview Component === */
|
||||
.project-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.project-overview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-overview__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-overview__stats {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-md);
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 120px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-stat--active {
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.project-stat--completed {
|
||||
border-color: rgba(63, 185, 80, 0.35);
|
||||
background: rgba(63, 185, 80, 0.08);
|
||||
}
|
||||
|
||||
.project-stat--error {
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
background: rgba(248, 81, 73, 0.08);
|
||||
}
|
||||
|
||||
.project-stat__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-stat--active .project-stat__icon {
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-stat--completed .project-stat__icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.project-stat--error .project-stat__icon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-stat__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-stat__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-stat__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-overview__add-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-overview__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-filter-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-filter-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-filter-tab:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-filter-tab.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors {
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors:not(.active) {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.project-sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-sort-select {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
min-width: 210px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-overview--empty {
|
||||
min-height: 60vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
max-width: 560px;
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.project-empty-state__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-empty-state__title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-empty-state__description {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-empty-state__cta {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-overview__no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-2xl);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
}
|
||||
|
||||
/* === ProjectSelector Component === */
|
||||
.project-selector {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.project-selector__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 220px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__trigger:hover,
|
||||
.project-selector__trigger.open {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__trigger.open {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-selector__trigger-icon {
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-selector__trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-selector__trigger-chevron {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__trigger-chevron.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.project-selector__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
min-width: 320px;
|
||||
max-width: min(420px, 90vw);
|
||||
max-height: min(70vh, 520px);
|
||||
overflow-y: auto;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.project-selector__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.project-selector__search-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-selector__search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.project-selector__search-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-selector__search-clear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-selector__section + .project-selector__section {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-selector__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.project-selector__item,
|
||||
.project-selector__view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__item:hover,
|
||||
.project-selector__item.highlighted,
|
||||
.project-selector__view-all:hover,
|
||||
.project-selector__view-all.highlighted {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.project-selector__item-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-selector__item-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-selector__item-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-selector__item-check {
|
||||
margin-left: auto;
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-selector__no-results {
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-selector__footer {
|
||||
padding: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-selector__view-all {
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.project-overview__header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.project-overview__stats {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.project-overview__filters {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-sort {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-sort-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.project-overview {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.project-overview__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.project-stat {
|
||||
flex: 1 1 calc(50% - var(--space-sm));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-selector,
|
||||
.project-selector__trigger,
|
||||
.project-selector__dropdown {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-selector__dropdown {
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* === ActivityFeed Component === */
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
@@ -3526,11 +2965,6 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition:
|
||||
padding var(--transition-normal),
|
||||
gap var(--transition-normal),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.inline-create-input {
|
||||
@@ -3550,52 +2984,6 @@ body {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Inline Create Card main row with toggle */
|
||||
.inline-create-main-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-create-main-row .inline-create-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inline-create-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed state - minimal appearance */
|
||||
.inline-create--collapsed,
|
||||
.inline-create-card--collapsed {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-create--collapsed .inline-create-main-row,
|
||||
.inline-create-card--collapsed .inline-create-main-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-create--collapsed .inline-create-input,
|
||||
.inline-create-card--collapsed .inline-create-input {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.inline-create-footer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -5404,18 +4792,11 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep the select column compact so the title can use more horizontal space */
|
||||
/* Reduce padding on ID column header to match data cells */
|
||||
.list-table th:first-child.list-header-cell {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Match the ID header width to the fixed ID cell width */
|
||||
.list-table th:nth-child(2).list-header-cell {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 70px;
|
||||
}
|
||||
|
||||
.list-header-cell:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -5477,19 +4858,16 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-id {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 70px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
padding: 12px 8px 12px 16px;
|
||||
padding: 12px 8px 12px 16px; /* Reduced right padding to tighten space with title */
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -5749,10 +5127,7 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.list-cell-date {
|
||||
@@ -6695,22 +6070,13 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 500;
|
||||
z-index: 100;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-combobox-dropdown--portal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
margin-top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.model-combobox-search-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -10061,10 +9427,6 @@ html .column.drag-over * {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
transition:
|
||||
padding var(--transition-normal),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-entry-input {
|
||||
@@ -10120,10 +9482,6 @@ html .column.drag-over * {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-entry-controls[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-entry-controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -10185,60 +9543,6 @@ html .column.drag-over * {
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick Entry Box main row with toggle */
|
||||
.quick-entry-main-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-entry-main-row .quick-entry-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-entry-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed state - minimal padding */
|
||||
.quick-entry--collapsed,
|
||||
.quick-entry-box--collapsed {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.quick-entry--collapsed .quick-entry-input,
|
||||
.quick-entry-box--collapsed .quick-entry-input {
|
||||
min-height: 32px;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.quick-entry--collapsed .quick-entry-input:focus,
|
||||
.quick-entry-box--collapsed .quick-entry-input:focus {
|
||||
border-bottom-color: var(--triage);
|
||||
box-shadow: 0 1px 0 0 var(--triage);
|
||||
}
|
||||
|
||||
.quick-entry--expanded,
|
||||
.quick-entry-box--expanded {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.inline-create--expanded,
|
||||
.inline-create-card--expanded {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* === New Task Modal === */
|
||||
.new-task-modal .modal-body {
|
||||
padding: 20px 24px;
|
||||
@@ -11250,9 +10554,6 @@ html .column.drag-over * {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Main layout: sidebar + content */
|
||||
@@ -11731,41 +11032,6 @@ html .column.drag-over * {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.changed-files-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.changed-files-sidebar {
|
||||
width: 30%;
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.changed-files-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.changed-files-entry {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.changed-files-entry.active {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.changed-files-badge {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
/* ── Commit Form ── */
|
||||
|
||||
.gm-commit-form {
|
||||
@@ -12385,7 +11651,7 @@ html .column.drag-over * {
|
||||
.gm-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -12422,7 +11688,7 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
.gm-content {
|
||||
min-height: 200px;
|
||||
min-height: 300px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -12535,134 +11801,3 @@ html .column.drag-over * {
|
||||
[data-theme="light"] .gm-load-more:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* ── Task Changes Tab Styles ─────────────────────────────────────────────── */
|
||||
|
||||
.task-changes-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.changes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.changes-header h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.changes-file-list {
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changes-file-item {
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.changes-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.changes-file-item.expanded {
|
||||
background: var(--bg-secondary, #161b22);
|
||||
}
|
||||
|
||||
.changes-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.changes-file-header:hover {
|
||||
background: var(--bg-hover, #1f242c);
|
||||
}
|
||||
|
||||
.changes-file-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.changes-file-stat {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.changes-file-content {
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
|
||||
.changes-diff-patch {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
}
|
||||
|
||||
.changes-diff-patch code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for diff */
|
||||
.changes-diff-patch .diff-add,
|
||||
.changes-diff-patch [data-prefix="+"] {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-del,
|
||||
.changes-diff-patch [data-prefix="-"] {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-hunk,
|
||||
.changes-diff-patch [data-prefix="@@"] {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ import type {
|
||||
MilestoneCreateInput,
|
||||
SliceCreateInput,
|
||||
FeatureCreateInput,
|
||||
MissionStatus,
|
||||
MilestoneStatus,
|
||||
SliceStatus,
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -39,29 +43,20 @@ function validateUuid(id: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
||||
}
|
||||
|
||||
function validateMissionId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^M-\d+$/.test(str);
|
||||
function validateMissionId(id: string): boolean {
|
||||
return /^M-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateMilestoneId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^MS-\d+$/.test(str);
|
||||
function validateMilestoneId(id: string): boolean {
|
||||
return /^MS-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateSliceId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^SL-\d+$/.test(str);
|
||||
function validateSliceId(id: string): boolean {
|
||||
return /^SL-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateFeatureId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^F-\d+$/.test(str);
|
||||
}
|
||||
|
||||
/** Helper to extract string from Express param (handles string | string[]) */
|
||||
function paramString(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
function validateFeatureId(id: string): boolean {
|
||||
return /^F-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
@@ -79,14 +74,14 @@ function validateDescription(desc: unknown): string | undefined {
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
|
||||
function validateStatus<TStatus extends string>(status: unknown, allowedStatuses: readonly TStatus[]): TStatus {
|
||||
function validateStatus(status: unknown, allowedStatuses: readonly string[]): string {
|
||||
if (!status || typeof status !== "string") {
|
||||
throw new Error(`Status is required and must be one of: ${allowedStatuses.join(", ")}`);
|
||||
}
|
||||
if (!allowedStatuses.includes(status as TStatus)) {
|
||||
if (!allowedStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status. Must be one of: ${allowedStatuses.join(", ")}`);
|
||||
}
|
||||
return status as TStatus;
|
||||
return status;
|
||||
}
|
||||
|
||||
function validateInterviewState(state: unknown): InterviewState {
|
||||
@@ -183,7 +178,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -207,7 +202,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
const { title, description, status } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -224,7 +219,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.description = validateDescription(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, MISSION_STATUSES);
|
||||
updates.status = validateStatus(status, MISSION_STATUSES) as MissionStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
@@ -252,7 +247,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -277,7 +272,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/status",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -304,7 +299,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -328,7 +323,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
const { state } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -360,7 +355,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/milestones",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -387,7 +382,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/milestones",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
const { title, description, dependencies } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -423,7 +418,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/milestones/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -465,7 +460,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -489,7 +484,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description, status, dependencies } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -506,7 +501,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.description = validateDescription(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, MILESTONE_STATUSES);
|
||||
updates.status = validateStatus(status, MILESTONE_STATUSES) as MilestoneStatus;
|
||||
}
|
||||
if (dependencies !== undefined) {
|
||||
updates.dependencies = validateStringArray(dependencies, "dependencies");
|
||||
@@ -537,7 +532,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -564,7 +559,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -588,7 +583,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
const { state } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -620,7 +615,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId/slices",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -647,7 +642,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/slices",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -681,7 +676,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/slices/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -723,7 +718,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -747,7 +742,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
const { title, description, status } = req.body;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
@@ -764,7 +759,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.description = validateDescription(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, SLICE_STATUSES);
|
||||
updates.status = validateStatus(status, SLICE_STATUSES) as SliceStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
@@ -792,7 +787,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -817,7 +812,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/slices/:sliceId/activate",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -846,7 +841,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/slices/:sliceId/features",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -871,7 +866,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/slices/:sliceId/features",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
const { title, description, acceptanceCriteria } = req.body;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
@@ -907,7 +902,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
@@ -931,7 +926,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
const { title, description, acceptanceCriteria, status } = req.body;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
@@ -951,7 +946,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.acceptanceCriteria = validateDescription(acceptanceCriteria);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, FEATURE_STATUSES);
|
||||
updates.status = validateStatus(status, FEATURE_STATUSES) as FeatureStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
@@ -979,7 +974,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
@@ -1004,7 +999,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/features/:featureId/link-task",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
const { taskId } = req.body;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
@@ -1043,7 +1038,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/features/:featureId/unlink-task",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { GitHubClient } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -25,24 +25,12 @@ vi.mock("@fusion/core", async () => {
|
||||
return {
|
||||
...actual,
|
||||
isGhAuthenticated: vi.fn(),
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { isGhAuthenticated, CentralCore, TaskStore as TaskStoreClass } from "@fusion/core";
|
||||
import { isGhAuthenticated } from "@fusion/core";
|
||||
|
||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||
const MockCentralCore = vi.mocked(CentralCore);
|
||||
const MockTaskStoreClass = vi.mocked(TaskStoreClass);
|
||||
|
||||
function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
@@ -53,18 +41,6 @@ function createMockGlobalSettingsStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore() {
|
||||
return {
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
|
||||
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
addAnswer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
@@ -81,11 +57,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updateGlobalSettings: vi.fn(),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
addComment: vi.fn(),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
@@ -97,7 +71,24 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
getMissionStore: vi.fn().mockReturnValue({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||||
createMilestone: vi.fn(),
|
||||
updateMilestone: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
deleteMilestone: vi.fn(),
|
||||
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||||
createMissionTask: vi.fn(),
|
||||
updateMissionTask: vi.fn(),
|
||||
getMissionTask: vi.fn(),
|
||||
deleteMissionTask: vi.fn(),
|
||||
}),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -140,163 +131,6 @@ function buildMultipart(fieldName: string, filename: string, contentType: string
|
||||
return { body, boundary };
|
||||
}
|
||||
|
||||
describe("GET /activity-feed", () => {
|
||||
function mockCentralCoreModule(options?: {
|
||||
entries?: unknown[];
|
||||
getRecentActivityError?: Error;
|
||||
}) {
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const getRecentActivity = options?.getRecentActivityError
|
||||
? vi.fn().mockRejectedValue(options.getRecentActivityError)
|
||||
: vi.fn().mockResolvedValue(options?.entries ?? []);
|
||||
|
||||
class MockCentralCore {
|
||||
init = vi.fn().mockResolvedValue(undefined);
|
||||
getRecentActivity = getRecentActivity;
|
||||
close = close;
|
||||
}
|
||||
|
||||
return { MockCentralCore, getRecentActivity, close };
|
||||
}
|
||||
|
||||
function buildApp(store: TaskStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@fusion/core");
|
||||
});
|
||||
|
||||
it("returns central activity when available", async () => {
|
||||
const store = createMockStore();
|
||||
const centralEntry = {
|
||||
id: "act_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Central Project",
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Created task",
|
||||
metadata: { source: "central" },
|
||||
};
|
||||
const mockCentral = mockCentralCoreModule({ entries: [centralEntry] });
|
||||
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, CentralCore: mockCentral.MockCentralCore };
|
||||
});
|
||||
|
||||
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createRoutesWithMock(store));
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=task:created");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([centralEntry]);
|
||||
expect(mockCentral.getRecentActivity).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
projectId: undefined,
|
||||
types: ["task:created"],
|
||||
});
|
||||
expect(mockCentral.close).toHaveBeenCalled();
|
||||
expect(store.getActivityLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to local activity log when central feed is empty", async () => {
|
||||
const store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/projects/dashboard-app"),
|
||||
getActivityLog: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "local_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
taskId: "FN-001",
|
||||
details: "Task created locally",
|
||||
metadata: { from: "triage", to: "todo" },
|
||||
},
|
||||
]),
|
||||
});
|
||||
const mockCentral = mockCentralCoreModule({ entries: [] });
|
||||
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, CentralCore: mockCentral.MockCentralCore };
|
||||
});
|
||||
|
||||
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createRoutesWithMock(store));
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=task:created");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "local_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "local-project",
|
||||
projectName: "dashboard-app",
|
||||
taskId: "FN-001",
|
||||
details: "Task created locally",
|
||||
metadata: { from: "triage", to: "todo" },
|
||||
},
|
||||
]);
|
||||
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:created" });
|
||||
expect(mockCentral.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to local activity log when central feed throws", async () => {
|
||||
const store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/projects/local-root"),
|
||||
getActivityLog: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "local_2",
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
type: "task:updated",
|
||||
taskId: "FN-002",
|
||||
details: "Task updated locally",
|
||||
},
|
||||
]),
|
||||
});
|
||||
const mockCentral = mockCentralCoreModule({ getRecentActivityError: new Error("require is not defined") });
|
||||
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, CentralCore: mockCentral.MockCentralCore };
|
||||
});
|
||||
|
||||
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createRoutesWithMock(store));
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=task:updated");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].projectName).toBe("local-root");
|
||||
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:updated" });
|
||||
expect(mockCentral.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid type filters", async () => {
|
||||
const store = createMockStore();
|
||||
const app = buildApp(store);
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=not-real");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /tasks", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
@@ -327,118 +161,6 @@ describe("GET /tasks", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("limit");
|
||||
});
|
||||
|
||||
describe("with projectId query parameter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 when project is not found", async () => {
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=nonexistent");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Project not found");
|
||||
});
|
||||
|
||||
it("returns tasks from the project store when project is found", async () => {
|
||||
const projectPath = "/test/project/path";
|
||||
const projectTasks = [FAKE_TASK_DETAIL];
|
||||
|
||||
const mockProjectStoreInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue(projectTasks),
|
||||
close: vi.fn(),
|
||||
};
|
||||
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
|
||||
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue({
|
||||
id: "proj_abc",
|
||||
name: "Test Project",
|
||||
path: projectPath,
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].id).toBe("FN-001");
|
||||
// Verify project store was initialized with correct path
|
||||
expect(MockTaskStoreClass).toHaveBeenCalledWith(projectPath);
|
||||
expect(mockProjectStoreInstance.init).toHaveBeenCalled();
|
||||
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: undefined, offset: undefined });
|
||||
expect(mockProjectStoreInstance.close).toHaveBeenCalled();
|
||||
// Verify CentralCore was properly closed
|
||||
expect(mockCentralInstance.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes limit and offset to the project store", async () => {
|
||||
const mockProjectStoreInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn(),
|
||||
};
|
||||
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
|
||||
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue({
|
||||
id: "proj_abc",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc&limit=5&offset=10");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: 5, offset: 10 });
|
||||
});
|
||||
|
||||
it("returns 200 with empty array on graceful degradation when CentralCore is unavailable", async () => {
|
||||
MockCentralCore.mockImplementation(() => {
|
||||
throw new Error("CentralCore unavailable");
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("still uses default store when projectId is not provided", async () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(store.listTasks).toHaveBeenCalled();
|
||||
// CentralCore should not be used when no projectId
|
||||
expect(MockCentralCore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /tasks/:id", () => {
|
||||
@@ -2124,16 +1846,16 @@ describe("Pause/Unpause endpoints", () => {
|
||||
it("adds a steering comment to a task", async () => {
|
||||
const mockComment = {
|
||||
id: "FN-001",
|
||||
comments: [
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
author: "user",
|
||||
author: "user" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
(store.addComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -2145,7 +1867,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockComment);
|
||||
expect(store.addComment).toHaveBeenCalledWith(
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Please handle the edge case",
|
||||
"user"
|
||||
@@ -2192,7 +1914,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
it("returns 404 when task not found", async () => {
|
||||
const error = new Error("Task not found") as Error & { code?: string };
|
||||
error.code = "ENOENT";
|
||||
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -2206,7 +1928,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
|
||||
@@ -3934,142 +3656,6 @@ describe("POST /tasks/:id/reject-plan", () => {
|
||||
// --- Git Management route tests ---
|
||||
// These are integration tests that run against the actual git repository
|
||||
|
||||
describe("GET /tasks/:id/file-diffs", () => {
|
||||
let store: TaskStore;
|
||||
let worktreeDir: string;
|
||||
let testRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
|
||||
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
|
||||
worktreeDir = join(testRoot, "repo");
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main", worktreeDir]);
|
||||
execFileSync("git", ["-C", worktreeDir, "config", "user.email", "kb-tests@example.com"]);
|
||||
execFileSync("git", ["-C", worktreeDir, "config", "user.name", "KB Tests"]);
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\n");
|
||||
writeFileSync(join(worktreeDir, "keep.txt"), "keep\n");
|
||||
execFileSync("git", ["-C", worktreeDir, "add", "."]);
|
||||
execFileSync("git", ["-C", worktreeDir, "commit", "-m", "base"]);
|
||||
|
||||
store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-651",
|
||||
worktree: worktreeDir,
|
||||
baseBranch: "main",
|
||||
}),
|
||||
getRootDir: vi.fn().mockReturnValue(worktreeDir),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns changed files with statuses and diffs", async () => {
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged\n");
|
||||
writeFileSync(join(worktreeDir, "added.txt"), "new file\n");
|
||||
execFileSync("git", ["-C", worktreeDir, "rm", "keep.txt"]);
|
||||
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", status: "modified", diff: expect.stringContaining("+changed") }),
|
||||
expect.objectContaining({ path: "added.txt", status: "added", diff: expect.stringContaining("+++ b/added.txt") }),
|
||||
expect.objectContaining({ path: "keep.txt", status: "deleted", diff: expect.stringContaining("--- a/keep.txt") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns renamed files with oldPath and diff content", async () => {
|
||||
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
|
||||
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "renamed.txt",
|
||||
oldPath: "keep.txt",
|
||||
status: "renamed",
|
||||
diff: expect.stringContaining("rename from keep.txt"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.skip("caches results for 10 seconds before refreshing", async () => {
|
||||
const originalDateNow = Date.now;
|
||||
let now = 1_000;
|
||||
Date.now = vi.fn(() => now);
|
||||
|
||||
try {
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged once\n");
|
||||
|
||||
const first = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
|
||||
]),
|
||||
);
|
||||
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged twice\n");
|
||||
|
||||
now += 5_000;
|
||||
const cached = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
expect(cached.status).toBe(200);
|
||||
expect(cached.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
|
||||
]),
|
||||
);
|
||||
|
||||
now += 5_001;
|
||||
const refreshed = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
expect(refreshed.status).toBe(200);
|
||||
expect(refreshed.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed twice") }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array when worktree is missing", async () => {
|
||||
store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when there are no changes", async () => {
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Git Management endpoints", () => {
|
||||
let store: TaskStore;
|
||||
let gitRepoDir: string;
|
||||
@@ -5150,13 +4736,9 @@ describe("Terminal session routes", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/terminal/sessions", () => {
|
||||
it("returns 503 when max sessions reached", async () => {
|
||||
it("returns 503 when max sessions reached (session is null)", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "max_sessions",
|
||||
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||||
}),
|
||||
createSession: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
@@ -5169,102 +4751,7 @@ describe("Terminal session routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe("Maximum terminal sessions reached. Please close an existing terminal and try again.");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when shell is not allowed", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "invalid_shell",
|
||||
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 503 when PTY module fails to load", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "pty_load_failed",
|
||||
error: "Terminal service unavailable. The PTY module could not be loaded.",
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe("Terminal service unavailable. The PTY module could not be loaded.");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 500 when PTY spawn fails", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "pty_spawn_failed",
|
||||
error: "Failed to start terminal shell process.",
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("Failed to start terminal shell process.");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 201 when session creation succeeds", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: "term-123", shell: "/bin/zsh", cwd: "/test" },
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/test" });
|
||||
expect(res.body.error).toContain("Max sessions");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -6319,143 +5806,3 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
expect(res.body.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Activity Feed Tests ─────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/activity-feed", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
it("returns empty array when both central and per-project activities are empty", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty array
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns central activity when available", async () => {
|
||||
const store = createMockStore();
|
||||
store.getActivityLog.mockResolvedValue([]);
|
||||
|
||||
const centralEntries = [
|
||||
{
|
||||
id: "central-1",
|
||||
timestamp: "2026-04-01T11:00:00.000Z",
|
||||
type: "task:moved" as const,
|
||||
projectId: "proj-123",
|
||||
projectName: "Test Project",
|
||||
taskId: "KB-002",
|
||||
details: "Moved task to done",
|
||||
},
|
||||
];
|
||||
|
||||
// Override the module-level CentralCore mock to return data
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue(centralEntries),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(centralEntries);
|
||||
|
||||
// Should not call per-project activity when central data exists
|
||||
expect(store.getActivityLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles CentralCore initialization failure gracefully", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to throw error on init
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockRejectedValue(new Error("CentralCore init failed")),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn(),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]); // Fallback returns empty array from mock store
|
||||
});
|
||||
|
||||
it("passes through limit query parameter", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed?limit=25");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles type filter query parameter", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed?types=task:moved");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates fallback route path exists", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty - this ensures fallback path is taken
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,16 +113,6 @@ function createMockStore() {
|
||||
return store as any;
|
||||
}
|
||||
|
||||
function createMockMissionStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getFeatureByTaskId: vi.fn(),
|
||||
getSlice: vi.fn(),
|
||||
computeSliceStatus: vi.fn(),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("TaskExecutor with semaphore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -1137,22 +1127,11 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
if (cmd === `git worktree remove "${conflictingPath}" --force`) {
|
||||
throw new Error("remove failed");
|
||||
}
|
||||
if (cmd === 'git branch -D "kb/fn-065"') {
|
||||
throw new Error("branch delete failed");
|
||||
}
|
||||
if (cmd === "git worktree list --porcelain") {
|
||||
return Buffer.from(`/tmp/test/.git/worktrees/sharp-stone\n`);
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await executor.execute(makeTask({ id: "FN-065" }));
|
||||
|
||||
// After 3 retry attempts, should fail with combined error message
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
error: expect.stringContaining("Worktree conflict"),
|
||||
});
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
|
||||
status: "failed",
|
||||
error: expect.stringContaining("automatic cleanup failed"),
|
||||
@@ -1601,8 +1580,6 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).toContain("## Project Commands");
|
||||
expect(result).toContain("- **Build:** `pnpm build`");
|
||||
expect(result).not.toContain("- **Test:**");
|
||||
expect(result).toContain("run that exact command in this worktree before calling `task_done()`");
|
||||
expect(result).toContain("Do not claim success without a real passing run");
|
||||
});
|
||||
|
||||
it("includes both commands when both are set", () => {
|
||||
@@ -1631,14 +1608,14 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).not.toContain("## Project Commands");
|
||||
});
|
||||
|
||||
it("includes Comments section when comments has entries", () => {
|
||||
it("includes Steering Comments section when steeringComments has entries", () => {
|
||||
const task = createMockTaskDetail({
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: new Date().toISOString(),
|
||||
author: "user",
|
||||
author: "user" as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1647,10 +1624,10 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).toContain("## Steering Comments");
|
||||
expect(result).toContain("**user**");
|
||||
expect(result).toContain("> Please handle the edge case");
|
||||
expect(result).toContain("The following comments were added");
|
||||
expect(result).toContain("The following steering comments were added by the user");
|
||||
});
|
||||
|
||||
it("formats multiple comments correctly", () => {
|
||||
it("formats multiple steering comments correctly", () => {
|
||||
const now = new Date();
|
||||
const task = createMockTaskDetail({
|
||||
steeringComments: [
|
||||
@@ -1658,13 +1635,13 @@ describe("buildExecutionPrompt", () => {
|
||||
id: "1",
|
||||
text: "First comment",
|
||||
createdAt: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
|
||||
author: "user",
|
||||
author: "user" as const,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
text: "Second comment",
|
||||
createdAt: now.toISOString(),
|
||||
author: "agent",
|
||||
author: "agent" as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1676,29 +1653,29 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).toContain("> Second comment");
|
||||
});
|
||||
|
||||
it("omits Comments section when steeringComments is empty", () => {
|
||||
it("omits Steering Comments section when steeringComments is empty", () => {
|
||||
const task = createMockTaskDetail({ steeringComments: [] });
|
||||
const result = buildExecutionPrompt(task);
|
||||
|
||||
expect(result).not.toContain("## Steering Comments");
|
||||
});
|
||||
|
||||
it("omits Comments section when comments is undefined", () => {
|
||||
it("omits Steering Comments section when steeringComments is undefined", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task);
|
||||
|
||||
expect(result).not.toContain("## Steering Comments");
|
||||
});
|
||||
|
||||
it("includes only the 10 most recent comments", () => {
|
||||
const comments = Array.from({ length: 15 }, (_, i) => ({
|
||||
it("includes only the 10 most recent steering comments", () => {
|
||||
const steeringComments = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
text: `Comment ${i}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: "user",
|
||||
author: "user" as const,
|
||||
}));
|
||||
|
||||
const task = createMockTaskDetail({ comments });
|
||||
const task = createMockTaskDetail({ steeringComments });
|
||||
const result = buildExecutionPrompt(task);
|
||||
|
||||
// Should include comments 5-14 (the 10 most recent), not 0-4
|
||||
@@ -1741,7 +1718,7 @@ describe("buildExecutionPrompt", () => {
|
||||
expect(result).toContain("## Steering Comments");
|
||||
|
||||
// Verify explanatory header text
|
||||
expect(result).toContain("The following comments were added by the user during execution");
|
||||
expect(result).toContain("The following steering comments were added by the user during execution");
|
||||
expect(result).toContain("Consider adjusting your approach or replanning remaining steps based on this feedback");
|
||||
|
||||
// Verify all three comments appear with correct author badges
|
||||
@@ -4041,144 +4018,6 @@ describe("Workflow Steps Execution", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
});
|
||||
|
||||
it("marks linked mission feature done when task reaches in-review", async () => {
|
||||
const store = createMockStore();
|
||||
const missionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
getSlice: vi.fn().mockReturnValueOnce({ id: "SL-001", status: "active" }).mockReturnValueOnce({ id: "SL-001", status: "complete" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("complete"),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
const onSliceComplete = vi.fn();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
sliceId: "SL-001",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { missionStore, onSliceComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
sliceId: "SL-001",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
expect(onSliceComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "SL-001", status: "complete" }));
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("Slice SL-001 completed"),
|
||||
"Mission feature implementation ready for review",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips mission updates when linked feature slice does not match task sliceId", async () => {
|
||||
const store = createMockStore();
|
||||
const missionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
sliceId: "SL-001",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { missionStore });
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
sliceId: "SL-001",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(missionStore.computeSliceStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update mission progress when agent finishes without task_done", async () => {
|
||||
const store = createMockStore();
|
||||
const missionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
computeSliceStatus: vi.fn(),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
const onSliceComplete = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { missionStore, onSliceComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
sliceId: "SL-001",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(missionStore.computeSliceStatus).not.toHaveBeenCalled();
|
||||
expect(onSliceComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips workflow steps with no prompt", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
@@ -4394,13 +4233,13 @@ describe("Real-time steering injection", () => {
|
||||
|
||||
// Verify steer was called with the formatted message
|
||||
expect(steerFn).toHaveBeenCalledOnce();
|
||||
expect(steerFn.mock.calls[0][0]).toContain("📣 **New feedback**");
|
||||
expect(steerFn.mock.calls[0][0]).toContain("📣 **New steering feedback**");
|
||||
expect(steerFn.mock.calls[0][0]).toContain("Please use a different approach");
|
||||
|
||||
// Verify log entry was created
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("Comment received mid-execution"),
|
||||
expect.stringContaining("Steering comment received mid-execution"),
|
||||
"by user"
|
||||
);
|
||||
|
||||
@@ -4618,13 +4457,6 @@ describe("Real-time steering injection", () => {
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
comments: [{
|
||||
id: "existing-comment",
|
||||
text: "Original",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
author: "user",
|
||||
}],
|
||||
steeringComments: [{
|
||||
id: "existing-comment",
|
||||
text: "Original",
|
||||
|
||||
Reference in New Issue
Block a user