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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user