feat(KB-029): add file browser for viewing and editing task files
- Add server-side file service with list, read, and write operations - Add REST API endpoints for file operations with tests - Add client-side API functions and React hooks for file browsing - Create FileBrowser, FileEditor, and FileBrowserModal components - Add CodeMirror 6 for syntax-highlighted file editing - Integrate Files tab into TaskDetailModal - Add comprehensive file browser styles and responsive design
This commit is contained in:
384
packages/dashboard/src/file-service.ts
Normal file
384
packages/dashboard/src/file-service.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
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 type { TaskStore } from "@kb/core";
|
||||
|
||||
/**
|
||||
* File node type representing a file or directory entry.
|
||||
*/
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
mtime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File listing response.
|
||||
*/
|
||||
export interface FileListResponse {
|
||||
path: string;
|
||||
entries: FileNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* File content response.
|
||||
*/
|
||||
export interface FileContentResponse {
|
||||
content: string;
|
||||
mtime: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file response.
|
||||
*/
|
||||
export interface SaveFileResponse {
|
||||
success: true;
|
||||
mtime: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum file size for reading/writing (1MB).
|
||||
*/
|
||||
export const MAX_FILE_SIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Error class for file service operations.
|
||||
*/
|
||||
export class FileServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text file extensions set.
|
||||
*/
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".txt", ".md", ".markdown",
|
||||
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".json", ".jsonc",
|
||||
".css", ".scss", ".sass", ".less",
|
||||
".html", ".htm", ".xml", ".svg",
|
||||
".yaml", ".yml",
|
||||
".toml",
|
||||
".ini", ".cfg", ".conf", ".config",
|
||||
".sh", ".bash", ".zsh", ".fish",
|
||||
".py", ".rb", ".php", ".pl", ".perl",
|
||||
".java", ".kt", ".scala", ".groovy",
|
||||
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh",
|
||||
".cs", ".fs", ".fsx",
|
||||
".go", ".rs", ".swift",
|
||||
".sql",
|
||||
".dockerfile", ".env", ".envrc", ".nvmrc",
|
||||
".gitignore", ".gitattributes", ".editorconfig",
|
||||
".lock", ".log",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Binary file extensions set.
|
||||
*/
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv",
|
||||
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
||||
".wasm", ".bin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a file is a binary file based on extension.
|
||||
*/
|
||||
function isBinaryFile(filename: string): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
if (BINARY_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base path for a task's files.
|
||||
* Returns the worktree path if it exists, otherwise the task directory.
|
||||
*/
|
||||
async function getTaskBasePath(store: TaskStore, taskId: string): Promise<string> {
|
||||
try {
|
||||
const task = await store.getTask(taskId);
|
||||
// Use worktree if available and exists
|
||||
if (task.worktree && existsSync(task.worktree)) {
|
||||
return resolve(task.worktree);
|
||||
}
|
||||
// Fall back to task directory
|
||||
const rootDir = store.getRootDir();
|
||||
return resolve(join(rootDir, ".kb", "tasks", taskId));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT" || err.message?.includes("not found")) {
|
||||
throw new FileServiceError(`Task ${taskId} not found`, "ENOTASK");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a file path to ensure it stays within the allowed directory.
|
||||
* Prevents directory traversal attacks.
|
||||
*/
|
||||
function validatePath(basePath: string, filePath: string): string {
|
||||
// Reject paths with null bytes
|
||||
if (filePath.includes("\0")) {
|
||||
throw new FileServiceError(`Access denied: Invalid characters in path`, "EINVAL");
|
||||
}
|
||||
|
||||
// Decode URL-encoded characters for security check
|
||||
const decodedPath = decodeURIComponent(filePath);
|
||||
|
||||
// Reject absolute paths
|
||||
if (decodedPath.startsWith("/") || decodedPath.match(/^[a-zA-Z]:/)) {
|
||||
throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
|
||||
}
|
||||
|
||||
// Resolve the path against base path
|
||||
const resolvedBase = resolve(basePath);
|
||||
const resolvedPath = resolve(join(resolvedBase, decodedPath));
|
||||
|
||||
// Ensure the resolved path is within the base path
|
||||
const relativePath = relative(resolvedBase, resolvedPath);
|
||||
|
||||
// Check for traversal - path starts with .. or is outside base
|
||||
if (relativePath.startsWith("..") || relativePath.startsWith("../") || relativePath === "..") {
|
||||
throw new FileServiceError(`Access denied: Path traversal detected`, "EINVAL");
|
||||
}
|
||||
|
||||
// Additional check: ensure resolved path actually starts with base
|
||||
if (!resolvedPath.startsWith(resolvedBase)) {
|
||||
throw new FileServiceError(`Access denied: Path outside allowed directory`, "EINVAL");
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a task directory or subdirectory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param subPath - Optional relative path within the task directory
|
||||
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function listFiles(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const targetPath = subPath ? validatePath(taskBase, subPath) : taskBase;
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(targetPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new FileServiceError(`Not a directory: ${subPath || "."}`, "ENOTDIR");
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
const fileNodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and directories
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(targetPath, entry.name);
|
||||
const entryStats = await stat(entryPath);
|
||||
|
||||
fileNodes.push({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isFile() ? entryStats.size : undefined,
|
||||
mtime: entryStats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
fileNodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Calculate relative path from task base
|
||||
const relativeBase = relative(taskBase, targetPath);
|
||||
|
||||
return {
|
||||
path: relativeBase || ".",
|
||||
entries: fileNodes,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${subPath || "."}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file contents from a task directory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param filePath - The relative file path
|
||||
* @returns File content response with content, mtime, and size
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function readFile(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const resolvedPath = validatePath(taskBase, filePath);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
// Check if it's a binary file
|
||||
const basename = filePath.split("/").pop() || filePath;
|
||||
if (isBinaryFile(basename)) {
|
||||
throw new FileServiceError(`Binary file, cannot edit: ${filePath}`, "EINVAL");
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fsReadFile(resolvedPath, "utf-8");
|
||||
|
||||
return {
|
||||
content,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file contents to a task directory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param filePath - The relative file path
|
||||
* @param content - The content to write
|
||||
* @returns Save file response with success, mtime, and size
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function writeFile(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
// Check content size
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const resolvedPath = validatePath(taskBase, filePath);
|
||||
|
||||
// Check if target is a directory
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// File doesn't exist, that's fine for writing
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -2380,4 +2380,174 @@ describe("Git Management endpoints", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ── File API tests ────────────────────────────────────────────────────
|
||||
describe("File API endpoints", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /tasks/:id/files", () => {
|
||||
it("returns 404 for non-existent task", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue({ code: "ENOENT" });
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-NONEXISTENT/files");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns 404 when task directory does not exist", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-001/files");
|
||||
// Will fail because task directory doesn't exist
|
||||
expect(res.status === 404 || res.status === 500).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts path query parameter", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-001/files?path=src");
|
||||
// Directory won't exist, but endpoint should process the query param
|
||||
expect(res.status === 404 || res.status === 500).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /tasks/:id/files/:filepath", () => {
|
||||
it("returns 404 for non-existent file", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/nonexistent.txt");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for empty filepath", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/");
|
||||
// Empty path should result in error
|
||||
expect(res.status === 400 || res.status === 404).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 415 for binary files", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/image.png");
|
||||
expect([415, 404, 500]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("rejects path traversal attempts", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-001/files/../etc/passwd");
|
||||
expect([400, 404, 500]).toContain(res.status);
|
||||
if (res.body?.error) {
|
||||
expect(res.body.error).toContain("traversal");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/files/:filepath", () => {
|
||||
it("requires content in body", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/files/test.txt",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("content is required");
|
||||
});
|
||||
|
||||
it("rejects non-string content", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/files/test.txt",
|
||||
JSON.stringify({ content: 123 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent parent directory", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/files/nonexistent/dir/file.txt",
|
||||
JSON.stringify({ content: "test" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects path traversal in write", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/files/../../../etc/passwd",
|
||||
JSON.stringify({ content: "evil" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect([400, 404, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { COLUMNS, VALID_TRANSITIONS, type PrInfo } from "@kb/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
|
||||
/**
|
||||
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
||||
@@ -1809,6 +1810,89 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── File API Routes ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id/files
|
||||
* List files in task directory (or worktree if available).
|
||||
* Query param: ?path=relative/path for subdirectory navigation.
|
||||
* Returns: { path: string; entries: FileNode[] }
|
||||
*/
|
||||
router.get("/tasks/:id/files", async (req, res) => {
|
||||
try {
|
||||
const { path: subPath } = req.query;
|
||||
const result = await listFiles(store, req.params.id, typeof subPath === "string" ? subPath : undefined);
|
||||
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" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id/files/:filepath
|
||||
* Read file contents.
|
||||
* Returns: { content: string; mtime: string; size: number }
|
||||
*/
|
||||
router.get("/tasks/:id/files/{*filepath}", async (req, res) => {
|
||||
try {
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const result = await readFile(store, req.params.id, filePath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
: err.code === "ENOTASK" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: err.code === "ETOOLARGE" ? 413
|
||||
: err.code === "EINVAL" && err.message.includes("Binary file") ? 415
|
||||
: 400;
|
||||
res.status(status).json({ error: err.message, code: err.code });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/:id/files/:filepath
|
||||
* Write file contents.
|
||||
* Body: { content: string }
|
||||
* Returns: { success: true; mtime: string; size: number }
|
||||
*/
|
||||
router.post("/tasks/:id/files/{*filepath}", async (req, res) => {
|
||||
try {
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const { content } = req.body;
|
||||
|
||||
if (typeof content !== "string") {
|
||||
res.status(400).json({ error: "content is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await writeFile(store, req.params.id, filePath, content);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
: err.code === "ENOTASK" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: err.code === "ETOOLARGE" ? 413
|
||||
: 400;
|
||||
res.status(status).json({ error: err.message, code: err.code });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user