feat(FN-943): add file browser context menu with copy, move, delete, rename, and download operations

- Add backend file operations (copy, move, delete, rename, download) in file-service.ts with workspace root protection
- Add API routes for file operations including directory download as zip via archiver
- Add client API functions in api.ts for all file operation endpoints
- Add context menu UI in FileBrowser component with operations dialog for confirming destructive actions
- Add CSS styles for context menu including danger items and dividers
- Add 33 FileBrowser context menu tests and 485+ file-service tests
- Fix pre-existing test failures in routes-diff, mission-e2e, and theme sync tests
This commit is contained in:
gsxdsm
2026-04-05 20:54:11 -07:00
parent ec36033005
commit 81e4eb002b
13 changed files with 2580 additions and 29 deletions

View File

@@ -10,16 +10,26 @@ import {
listWorkspaceFiles,
readWorkspaceFile,
writeWorkspaceFile,
copyWorkspaceFile,
moveWorkspaceFile,
deleteWorkspaceFile,
renameWorkspaceFile,
getWorkspaceFileForDownload,
getWorkspaceFolderForZip,
MAX_FILE_SIZE,
} from "../file-service.js";
import type { TaskStore } from "@fusion/core";
// Mock node:fs/promises - use vi.hoisted for proper hoisting with ES modules
const { mockReaddir, mockReadFile, mockWriteFile, mockStat } = vi.hoisted(() => ({
const { mockReaddir, mockReadFile, mockWriteFile, mockStat, mockCopyFile, mockRename, mockRm, mockMkdir } = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockCopyFile: vi.fn(),
mockRename: vi.fn(),
mockRm: vi.fn(),
mockMkdir: vi.fn(),
}));
// Mock node:fs
@@ -36,11 +46,19 @@ vi.mock("node:fs/promises", async (importOriginal) => {
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
copyFile: mockCopyFile,
rename: mockRename,
rm: mockRm,
mkdir: mockMkdir,
},
readdir: mockReaddir,
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
copyFile: mockCopyFile,
rename: mockRename,
rm: mockRm,
mkdir: mockMkdir,
};
});
@@ -826,3 +844,468 @@ describe("URL-encoded characters handling", () => {
);
});
});
// ── File Operation Tests (Copy, Move, Delete, Rename) ──────────────
describe("copyWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockCopyFile.mockReset();
mockReaddir.mockReset();
mockMkdir.mockReset();
});
it("copies a file within the workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat for source (exists), stat for destination (ENOENT), stat for dest parent (exists)
mockStat
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false, size: 100 }) // source
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
mockCopyFile.mockResolvedValue(undefined);
const result = await copyWorkspaceFile(mockStore, "project", "src/file.ts", "src/file-copy.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Copied");
expect(mockCopyFile).toHaveBeenCalledWith("/project/src/file.ts", "/project/src/file-copy.ts");
});
it("copies a directory recursively", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true }) // source is dir
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
// readdir for the source directory
mockReaddir.mockResolvedValue([
{ name: "sub.ts", isDirectory: () => false },
]);
// copyFile for the file inside
mockCopyFile.mockResolvedValue(undefined);
mockMkdir.mockResolvedValue(undefined);
const result = await copyWorkspaceFile(mockStore, "project", "src", "src-copy");
expect(result.success).toBe(true);
expect(mockMkdir).toHaveBeenCalledWith("/project/src-copy", { recursive: true });
expect(mockCopyFile).toHaveBeenCalledWith("/project/src/sub.ts", "/project/src-copy/sub.ts");
});
it("rejects path traversal in source", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "../secret", "dest")).rejects.toThrow("Path traversal");
});
it("rejects path traversal in destination", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "../outside")).rejects.toThrow("Path traversal");
});
it("rejects missing source path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "", "dest")).rejects.toThrow("Source path is required");
});
it("rejects missing destination path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "")).rejects.toThrow("Destination path is required");
});
it("rejects when source does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(copyWorkspaceFile(mockStore, "project", "missing.ts", "dest.ts")).rejects.toThrow("Source not found");
});
it("rejects when destination already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("Destination already exists");
});
it("rejects when destination parent does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockRejectedValueOnce({ code: "ENOENT" }); // dest parent doesn't exist
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "nonexistent/file.ts")).rejects.toThrow("Destination parent directory does not exist");
});
it("rejects operating on workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
// Even though validatePath would resolve "." to the root, the function checks for it
await expect(copyWorkspaceFile(mockStore, "project", ".", "dest")).rejects.toThrow("Cannot operate on workspace root");
});
});
describe("moveWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
mockCopyFile.mockReset();
mockRm.mockReset();
});
it("moves a file within the workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
mockRename.mockResolvedValue(undefined);
const result = await moveWorkspaceFile(mockStore, "project", "old.ts", "new.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Moved");
expect(mockRename).toHaveBeenCalledWith("/project/old.ts", "/project/new.ts");
});
it("rejects path traversal in source", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "../secret", "dest")).rejects.toThrow("Path traversal");
});
it("rejects path traversal in destination", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "file.ts", "../outside")).rejects.toThrow("Path traversal");
});
it("rejects when source does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(moveWorkspaceFile(mockStore, "project", "missing.ts", "dest.ts")).rejects.toThrow("Source not found");
});
it("rejects when destination already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(moveWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("Destination already exists");
});
it("rejects missing source path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "", "dest")).rejects.toThrow("Source path is required");
});
});
describe("deleteWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRm.mockReset();
});
it("deletes a file", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false });
mockRm.mockResolvedValue(undefined);
const result = await deleteWorkspaceFile(mockStore, "project", "src/old.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Deleted");
expect(mockRm).toHaveBeenCalledWith("/project/src/old.ts");
});
it("deletes a directory recursively", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true });
mockRm.mockResolvedValue(undefined);
const result = await deleteWorkspaceFile(mockStore, "project", "src/olddir");
expect(result.success).toBe(true);
expect(mockRm).toHaveBeenCalledWith("/project/src/olddir", { recursive: true });
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects deleting workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", ".")).rejects.toThrow("Cannot delete workspace root");
});
it("rejects missing file path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", "")).rejects.toThrow("File path is required");
});
it("rejects when file does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(deleteWorkspaceFile(mockStore, "project", "missing.ts")).rejects.toThrow("Not found");
});
});
describe("renameWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
});
it("renames a file", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat: source exists, dest doesn't exist
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockRejectedValueOnce({ code: "ENOENT" }); // dest doesn't exist
mockRename.mockResolvedValue(undefined);
const result = await renameWorkspaceFile(mockStore, "project", "old.ts", "new.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Renamed");
expect(mockRename).toHaveBeenCalledWith("/project/old.ts", "/project/new.ts");
});
it("renames a directory", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isDirectory: () => true })
.mockRejectedValueOnce({ code: "ENOENT" });
mockRename.mockResolvedValue(undefined);
const result = await renameWorkspaceFile(mockStore, "project", "src/olddir", "newdir");
expect(result.success).toBe(true);
expect(mockRename).toHaveBeenCalledWith("/project/src/olddir", "/project/src/newdir");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "../secret", "newname")).rejects.toThrow("Path traversal");
});
it("rejects new name with path separator", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "sub/name.ts")).rejects.toThrow("path separators");
});
it("rejects new name with backslash", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "sub\\name.ts")).rejects.toThrow("path separators");
});
it("rejects empty new name", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "")).rejects.toThrow("New name is required");
});
it("rejects when file does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(renameWorkspaceFile(mockStore, "project", "missing.ts", "new.ts")).rejects.toThrow("Not found");
});
it("rejects when a file with the new name already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("already exists");
});
it("rejects renaming workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", ".", "newname")).rejects.toThrow("Cannot rename workspace root");
});
it("rejects null bytes in new name", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "bad\0name")).rejects.toThrow("path separators");
});
});
describe("getWorkspaceFileForDownload", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("returns file info for download", async () => {
const mtime = new Date("2024-01-15");
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 2048,
mtime,
});
const result = await getWorkspaceFileForDownload(mockStore, "project", "src/file.ts");
expect(result.absolutePath).toBe("/project/src/file.ts");
expect(result.fileName).toBe("file.ts");
expect(result.stats.size).toBe(2048);
expect(result.stats.isFile).toBe(true);
});
it("rejects directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
await expect(getWorkspaceFileForDownload(mockStore, "project", "src")).rejects.toThrow("Not a file");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects empty path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", "")).rejects.toThrow("File path is required");
});
it("rejects non-existent file", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValue({ code: "ENOENT" });
await expect(getWorkspaceFileForDownload(mockStore, "project", "missing.ts")).rejects.toThrow("File not found");
});
it("rejects downloading workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", ".")).rejects.toThrow("Cannot download workspace root");
});
});
describe("getWorkspaceFolderForZip", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("returns directory info for zip download", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
const result = await getWorkspaceFolderForZip(mockStore, "project", "src");
expect(result.absolutePath).toBe("/project/src");
expect(result.dirName).toBe("src");
});
it("rejects files (not directories)", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
});
await expect(getWorkspaceFolderForZip(mockStore, "project", "file.ts")).rejects.toThrow("Not a directory");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects empty path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", "")).rejects.toThrow("Directory path is required");
});
it("rejects downloading workspace root as ZIP", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", ".")).rejects.toThrow("Cannot download workspace root as ZIP");
});
});
describe("moveWorkspaceFile EXDEV fallback", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
mockCopyFile.mockReset();
mockRm.mockReset();
mockReaddir.mockReset();
mockMkdir.mockReset();
});
it("falls back to copy+delete on cross-device move (EXDEV)", async () => {
mockGetRootDir.mockReturnValue("/project");
// Source exists, destination doesn't exist, dest parent exists
mockStat
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }) // source exists (move check)
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist (move check)
.mockResolvedValueOnce({ isDirectory: () => true }) // dest parent (move check)
// copyWorkspaceFile will be called with same paths
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }) // source (copy)
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist (copy)
.mockResolvedValueOnce({ isDirectory: () => true }) // dest parent (copy)
// deleteWorkspaceFile will validate
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }); // source exists (delete)
// rename throws EXDEV
mockRename.mockRejectedValue({ code: "EXDEV" });
mockCopyFile.mockResolvedValue(undefined);
mockRm.mockResolvedValue(undefined);
const result = await moveWorkspaceFile(mockStore, "project", "file.ts", "moved.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Moved");
expect(mockCopyFile).toHaveBeenCalledWith("/project/file.ts", "/project/moved.ts");
expect(mockRm).toHaveBeenCalledWith("/project/file.ts");
});
});

View File

@@ -386,15 +386,15 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha set, so Priority 2: merge-base with base branch
if (cmd.includes("git merge-base merge789 origin/main") || cmd.includes("git merge-base merge789 main")) {
// No baseCommitSha set, so Priority 2: rev-parse first parent
if (cmd === "git rev-parse merge789^") {
return "base456\n" as any;
}
// git diff --name-status base456..merge789 → only this task's files
if (cmd === "git diff --name-status base456..merge789") {
return "A\tfile-b.txt\n" as any;
}
// Per-file diff using merge base
// Per-file diff using first parent
if (cmd === 'git diff base456..merge789 -- "file-b.txt"') {
return "diff --git a/file-b.txt b/file-b.txt\n--- /dev/null\n+++ b/file-b.txt\n+hello\n" as any;
}
@@ -422,8 +422,8 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha, so Priority 2: merge-base with base branch
if (cmd.includes("git merge-base sha_with_modifications origin/main") || cmd.includes("git merge-base sha_with_modifications main")) {
// No baseCommitSha, so Priority 2: rev-parse first parent
if (cmd === "git rev-parse sha_with_modifications^") {
return "base_xyz\n" as any;
}
if (cmd === "git diff --name-status base_xyz..sha_with_modifications") {
@@ -468,11 +468,7 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha — Priority 1 skipped
// Priority 2: merge-base with base branch fails
if (cmd.includes("git merge-base merge_ff origin/main") || cmd.includes("git merge-base merge_ff main")) {
throw new Error("fatal: not a git repository");
}
// Priority 3: fall back to first parent
// Priority 2: rev-parse first parent (used to be merge-base)
if (cmd === "git rev-parse merge_ff^") {
return "parent_ff\n" as any;
}
@@ -528,8 +524,8 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha — Priority 2: merge-base with base branch
if (cmd.includes("git merge-base multi_merge origin/main") || cmd.includes("git merge-base multi_merge main")) {
// No baseCommitSha — Priority 2: rev-parse first parent
if (cmd === "git rev-parse multi_merge^") {
return "multi_base\n" as any;
}
if (cmd === "git diff --name-status multi_base..multi_merge") {
@@ -615,11 +611,11 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
if (cmd === "git merge-base --is-ancestor stale_base merged_commit") {
throw new Error("not an ancestor");
}
// Priority 2: merge-base with base branch succeeds
if (cmd.includes("git merge-base merged_commit origin/develop") || cmd.includes("git merge-base merged_commit develop")) {
// Priority 2: rev-parse first parent of merge commit
if (cmd === "git rev-parse merged_commit^") {
return "branch_base\n" as any;
}
// Diff from branch merge-base
// Diff from first parent
if (cmd === "git diff --name-status branch_base..merged_commit") {
return "M\tsrc/app.ts\n" as any;
}
@@ -640,9 +636,9 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
expect.stringContaining("git merge-base --is-ancestor stale_base merged_commit"),
expect.any(Object),
);
// Verify branch merge-base was called as fallback
// Verify rev-parse first parent was called as fallback
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("git merge-base merged_commit"),
expect.stringContaining("git rev-parse merged_commit^"),
expect.any(Object),
);
});
@@ -658,8 +654,8 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// Priority 2: merge-base with custom base branch
if (cmd.includes("git merge-base custom_merge origin/release/v2") || cmd.includes("git merge-base custom_merge release/v2")) {
// No baseCommitSha — Priority 2: rev-parse first parent
if (cmd === "git rev-parse custom_merge^") {
return "release_base\n" as any;
}
if (cmd === "git diff --name-status release_base..custom_merge") {
@@ -677,9 +673,9 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
expect(response.status).toBe(200);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].path).toBe("release-file.ts");
// Verify the custom base branch was used
// Verify the first parent was resolved via rev-parse
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("release/v2"),
expect.stringContaining("git rev-parse custom_merge^"),
expect.any(Object),
);
});

View File

@@ -1,6 +1,8 @@
import { join, resolve, relative, dirname } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve, relative, dirname, basename } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, copyFile as fsCopyFile, rename as fsRename, rm as fsRm, mkdir } from "node:fs/promises";
import { existsSync, createReadStream, statSync } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
import type { TaskStore } from "@fusion/core";
/**
@@ -39,6 +41,14 @@ export interface SaveFileResponse {
size: number;
}
/**
* File operation response for copy/move/delete/rename operations.
*/
export interface FileOperationResponse {
success: true;
message?: string;
}
/**
* Maximum file size for reading/writing (1MB).
*/
@@ -467,3 +477,431 @@ export async function writeWorkspaceFile(
const workspaceBase = await getWorkspaceBasePath(store, workspace);
return writeFileForBasePath(workspaceBase, filePath, content);
}
// ── Workspace File Operations (Copy, Move, Delete, Rename) ─────────
/**
* Validate that both source and destination paths are within the allowed workspace.
* Prevents copying/moving files outside the workspace boundary.
*/
function validateSourceAndDestination(basePath: string, sourcePath: string, destinationPath: string): { resolvedSource: string; resolvedDest: string } {
const resolvedSource = validatePath(basePath, sourcePath);
const resolvedDest = validatePath(basePath, destinationPath);
// Prevent operating on the workspace root itself
const sourceRelative = relative(resolve(basePath), resolvedSource);
if (!sourceRelative || sourceRelative === "." || sourceRelative === "") {
throw new FileServiceError("Cannot operate on workspace root directory", "EINVAL");
}
return { resolvedSource, resolvedDest };
}
/**
* Copy a file or directory within a workspace.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param sourcePath - Relative source path within the workspace
* @param destinationPath - Relative destination path within the workspace
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function copyWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
sourcePath: string,
destinationPath: string,
): Promise<FileOperationResponse> {
if (!sourcePath) {
throw new FileServiceError("Source path is required", "EINVAL");
}
if (!destinationPath) {
throw new FileServiceError("Destination path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath);
// Verify source exists
let sourceStats;
try {
sourceStats = await stat(resolvedSource);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Source not found: ${sourcePath}`, "ENOENT");
}
throw err;
}
// Check destination doesn't already exist
try {
await stat(resolvedDest);
throw new FileServiceError(`Destination already exists: ${destinationPath}`, "EEXIST");
} catch (err: any) {
if (err.code !== "ENOENT") {
if (err instanceof FileServiceError) throw err;
}
// ENOENT is expected - destination should not exist
}
// Ensure destination parent directory exists
const destParent = dirname(resolvedDest);
try {
const parentStats = await stat(destParent);
if (!parentStats.isDirectory()) {
throw new FileServiceError("Destination parent is not a directory", "ENOTDIR");
}
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError("Destination parent directory does not exist", "ENOENT");
}
throw err;
}
try {
if (sourceStats.isFile()) {
await fsCopyFile(resolvedSource, resolvedDest);
} else if (sourceStats.isDirectory()) {
await copyDirectoryRecursive(resolvedSource, resolvedDest);
}
return { success: true, message: `Copied "${sourcePath}" to "${destinationPath}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${err.message}`, "EACCES");
}
throw err;
}
}
/**
* Move a file or directory within a workspace.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param sourcePath - Relative source path within the workspace
* @param destinationPath - Relative destination path within the workspace
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function moveWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
sourcePath: string,
destinationPath: string,
): Promise<FileOperationResponse> {
if (!sourcePath) {
throw new FileServiceError("Source path is required", "EINVAL");
}
if (!destinationPath) {
throw new FileServiceError("Destination path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath);
// Verify source exists
try {
await stat(resolvedSource);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Source not found: ${sourcePath}`, "ENOENT");
}
throw err;
}
// Check destination doesn't already exist
try {
await stat(resolvedDest);
throw new FileServiceError(`Destination already exists: ${destinationPath}`, "EEXIST");
} catch (err: any) {
if (err.code !== "ENOENT") {
if (err instanceof FileServiceError) throw err;
}
}
// Ensure destination parent directory exists
const destParent = dirname(resolvedDest);
try {
const parentStats = await stat(destParent);
if (!parentStats.isDirectory()) {
throw new FileServiceError("Destination parent is not a directory", "ENOTDIR");
}
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError("Destination parent directory does not exist", "ENOENT");
}
throw err;
}
try {
await fsRename(resolvedSource, resolvedDest);
return { success: true, message: `Moved "${sourcePath}" to "${destinationPath}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${err.message}`, "EACCES");
}
if (err.code === "EXDEV") {
// Cross-device move: copy then delete
await copyWorkspaceFile(store, workspace, sourcePath, destinationPath);
await deleteWorkspaceFile(store, workspace, sourcePath);
return { success: true, message: `Moved "${sourcePath}" to "${destinationPath}"` };
}
throw err;
}
}
/**
* Delete a file or directory within a workspace.
* Directories are deleted recursively.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param filePath - Relative file/directory path within the workspace
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function deleteWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
filePath: string,
): Promise<FileOperationResponse> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, filePath);
// Prevent operating on the workspace root itself
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot delete workspace root directory", "EINVAL");
}
// Verify the file/directory exists
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Not found: ${filePath}`, "ENOENT");
}
throw err;
}
try {
if (stats.isDirectory()) {
await fsRm(resolvedPath, { recursive: true });
} else {
await fsRm(resolvedPath);
}
return { success: true, message: `Deleted "${filePath}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
}
throw err;
}
}
/**
* Rename a file or directory within a workspace.
* The new name must not contain path separators.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param filePath - Relative file/directory path within the workspace
* @param newName - New name for the file/directory (no path separators)
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function renameWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
filePath: string,
newName: string,
): Promise<FileOperationResponse> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
if (!newName || !newName.trim()) {
throw new FileServiceError("New name is required", "EINVAL");
}
// Reject new names with path separators
if (newName.includes("/") || newName.includes("\\") || newName.includes("\0")) {
throw new FileServiceError("New name must not contain path separators", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, filePath);
// Prevent operating on the workspace root itself
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot rename workspace root directory", "EINVAL");
}
// Verify source exists
try {
await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Not found: ${filePath}`, "ENOENT");
}
throw err;
}
// Build destination path by replacing the basename
const destPath = join(dirname(resolvedPath), newName);
// Validate destination stays within workspace
const destRelative = relative(resolve(workspaceBase), destPath);
if (destRelative.startsWith("..") || destRelative.startsWith("../") || destRelative === "..") {
throw new FileServiceError("Destination would be outside workspace", "EINVAL");
}
if (!destPath.startsWith(resolve(workspaceBase))) {
throw new FileServiceError("Destination would be outside workspace", "EINVAL");
}
// Check destination doesn't already exist
try {
await stat(destPath);
throw new FileServiceError(`A file or directory named "${newName}" already exists`, "EEXIST");
} catch (err: any) {
if (err.code !== "ENOENT") {
if (err instanceof FileServiceError) throw err;
}
}
try {
await fsRename(resolvedPath, destPath);
return { success: true, message: `Renamed to "${newName}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
}
throw err;
}
}
/**
* Get the absolute path for a file to download, along with file stats.
* Used by the download endpoint to create a stream response.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param filePath - Relative file path within the workspace
* @returns Object with resolved absolute path, stats, and basename
* @throws FileServiceError on validation or filesystem errors
*/
export async function getWorkspaceFileForDownload(
store: TaskStore,
workspace: WorkspaceId,
filePath: string,
): Promise<{ absolutePath: string; stats: { size: number; mtime: Date; isFile: boolean }; fileName: string }> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, filePath);
// Prevent downloading the workspace root itself (it's not a file)
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot download workspace root", "EINVAL");
}
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
}
throw err;
}
if (!stats.isFile()) {
throw new FileServiceError(`Not a file: ${filePath}`, "EISDIR");
}
return {
absolutePath: resolvedPath,
stats: {
size: stats.size,
mtime: stats.mtime,
isFile: true,
},
fileName: basename(resolvedPath),
};
}
/**
* Get the absolute path for a folder to download as ZIP.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param dirPath - Relative directory path within the workspace
* @returns Object with resolved absolute path and directory name
* @throws FileServiceError on validation or filesystem errors
*/
export async function getWorkspaceFolderForZip(
store: TaskStore,
workspace: WorkspaceId,
dirPath: string,
): Promise<{ absolutePath: string; dirName: string }> {
if (!dirPath) {
throw new FileServiceError("Directory path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, dirPath);
// Prevent downloading the workspace root as ZIP (too broad)
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot download workspace root as ZIP", "EINVAL");
}
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Directory not found: ${dirPath}`, "ENOENT");
}
throw err;
}
if (!stats.isDirectory()) {
throw new FileServiceError(`Not a directory: ${dirPath}`, "ENOTDIR");
}
return {
absolutePath: resolvedPath,
dirName: basename(resolvedPath),
};
}
/**
* Recursively copy a directory and all its contents.
*/
async function copyDirectoryRecursive(source: string, destination: string): Promise<void> {
await mkdir(destination, { recursive: true });
const entries = await readdir(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = join(source, entry.name);
const destPath = join(destination, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destPath);
} else {
await fsCopyFile(sourcePath, destPath);
}
}
}

View File

@@ -89,6 +89,15 @@ function createMockMissionStore() {
)
),
getMissionSummary: vi.fn((_missionId: string) => ({
totalMilestones: 0,
completedMilestones: 0,
totalSlices: 0,
completedSlices: 0,
totalFeatures: 0,
completedFeatures: 0,
})),
updateMission: vi.fn((id: string, updates: Partial<Mission>) => {
const mission = missions.get(id);
if (!mission) throw new Error("Mission " + id + " not found");

View File

@@ -12,7 +12,7 @@ import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse, type FileOperationResponse } from "./file-service.js";
import { fetchAllProviderUsage } from "./usage.js";
import {
getGitHubAppConfig,
@@ -4898,6 +4898,210 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── File Operation Routes ─────────────────────────────────────────────
/**
* Helper to extract filepath and workspace from request.
*/
function extractFileParams(req: Request): { filePath: string; workspace: string } {
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
return { filePath, workspace };
}
/**
* POST /api/files/{*filepath}/copy
* Copy a file or directory to a new location within the workspace.
* Query param: ?workspace=project|TASK-ID
* Body: { destination: string }
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/copy", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
if (!destination || typeof destination !== "string") {
res.status(400).json({ error: "destination is required and must be a string" });
return;
}
const result = await copyWorkspaceFile(store, workspace, filePath, destination);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* POST /api/files/{*filepath}/move
* Move a file or directory to a new location within the workspace.
* Query param: ?workspace=project|TASK-ID
* Body: { destination: string }
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/move", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
if (!destination || typeof destination !== "string") {
res.status(400).json({ error: "destination is required and must be a string" });
return;
}
const result = await moveWorkspaceFile(store, workspace, filePath, destination);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* DELETE /api/files/{*filepath}
* Note: This conflicts with the existing GET endpoint for files.
* Instead, use POST /api/files/{*filepath}/delete to avoid route collision.
* Delete a file or directory within the workspace.
* Query param: ?workspace=project|TASK-ID
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/delete", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const result = await deleteWorkspaceFile(store, workspace, filePath);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* POST /api/files/{*filepath}/rename
* Rename a file or directory within the workspace.
* Query param: ?workspace=project|TASK-ID
* Body: { newName: string }
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/rename", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { newName } = req.body;
if (!newName || typeof newName !== "string") {
res.status(400).json({ error: "newName is required and must be a string" });
return;
}
const result = await renameWorkspaceFile(store, workspace, filePath, newName);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/files/{*filepath}/download
* Download a single file from the workspace.
* Query param: ?workspace=project|TASK-ID
* Streams the file with Content-Disposition header.
*/
router.get("/files/{*filepath}/download", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(store, workspace, filePath);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
res.setHeader("Content-Length", stats.size);
res.setHeader("Last-Modified", stats.mtime.toUTCString());
const stream = createReadStream(absolutePath);
stream.pipe(res);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EISDIR" ? 400
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/files/{*filepath}/download-zip
* Download a folder as a ZIP archive from the workspace.
* Query param: ?workspace=project|TASK-ID
* Streams the ZIP archive with Content-Disposition header.
*/
router.get("/files/{*filepath}/download-zip", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, dirName } = await getWorkspaceFolderForZip(store, workspace, filePath);
const archiver = await import("archiver");
const archive = archiver.default("zip", { zlib: { level: 6 } });
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${dirName}.zip"`);
archive.pipe(res);
archive.directory(absolutePath, dirName);
await archive.finalize();
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "ENOTDIR" ? 400
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
// ── Planning Mode Routes ──────────────────────────────────────────────────
router.post("/subtasks/start-streaming", async (req, res) => {