Archived
feat: Plugin API Completeness — file mutations, prompt editor, and attachment APIs
- WorkspaceFiles: writeFile, deleteFile, moveFile with path safety - writeFile: text/binary, auto-create dirs, overwrite option - deleteFile: idempotent, uses lstat (removes symlinks not targets) - moveFile: unix mv semantics, overwrite defaults to false - All mutations auto-refreshFiles() in File Explorer - Symlink escape prevention via realpath(dirname) check - PluginPromptEditor: insertText, getText, getSelection, onPaste, onKeyDown, focus - Uses CM6 EditorView.domEventHandlers() via Compartment (not raw DOM) - Handlers registered before mount are preserved and applied on mount - First-to-consume-wins ordering for multi-plugin scenarios - insertText replaces selection (not inserts after) - PluginAttachments: insertFileReference, getAttachedFiles, removeFileReference - insertFileReference validates file exists before inserting @path - Does not auto-focus editor (unlike prompt.insertText) - @file regex requires file extension to avoid matching emails - Server endpoints: PUT /file, DELETE /file, POST /file/move - All work for local and federated machines - Tests: 31 unit tests, 9 integration tests, 5 client tests - Docs: 3 new sections in plugins.md
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -147,6 +147,69 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace file write API", () => {
|
||||
it("sends text content with Content-Type text/plain", async () => {
|
||||
const fetchMock = stubJsonFetch({ path: "hello.txt", size: 11, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
|
||||
|
||||
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "hello.txt", "hello world");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
|
||||
});
|
||||
|
||||
it("sends binary content with Content-Type application/octet-stream", async () => {
|
||||
const fetchMock = stubJsonFetch({ path: "image.png", size: 4, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
|
||||
const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "image.png", binary);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("sends createDirs and overwrite query parameters", async () => {
|
||||
const fetchMock = stubJsonFetch({ path: "config/new.json", size: 10, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
|
||||
|
||||
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "config/new.json", "{\"a\":1}", { createDirs: false, overwrite: false });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url] = fetchCall(fetchMock, 0);
|
||||
expect(url).toContain("createDirs=false");
|
||||
expect(url).toContain("overwrite=false");
|
||||
});
|
||||
|
||||
it("parses WriteWorkspaceFileResponse correctly", async () => {
|
||||
const fetchMock = stubJsonFetch({ path: "output/result.txt", size: 42, modifiedAt: "2026-06-10T12:00:00.000Z", created: true });
|
||||
|
||||
const result = await workspacesApi.writeWorkspaceFile("p 1", "w/1", "output/result.txt", "content");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
|
||||
expect(result).toEqual({
|
||||
path: "output/result.txt",
|
||||
size: 42,
|
||||
modifiedAt: "2026-06-10T12:00:00.000Z",
|
||||
created: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes through machine prefix for remote machines", async () => {
|
||||
const fetchMock = stubJsonFetch({ path: "file.txt", size: 5, modifiedAt: "2026-06-10T00:00:00.000Z", created: false });
|
||||
|
||||
await workspacesApi.writeWorkspaceFile("p 1", "w/1", "file.txt", "data", undefined, "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url] = fetchCall(fetchMock, 0);
|
||||
expect(url).toContain("/api/machines/remote%20a/");
|
||||
});
|
||||
});
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseClosed,
|
||||
parseCommandResult,
|
||||
parseDeleted,
|
||||
parseDeleteWorkspaceFileResponse,
|
||||
parseDetached,
|
||||
parseFileContentResponse,
|
||||
parseFileSuggestion,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
parseMachinesResponse,
|
||||
parseMessagePage,
|
||||
parseModelSelectionResponse,
|
||||
parseMoveWorkspaceFileResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
parseTerminalCommandRun,
|
||||
parseTerminalInfo,
|
||||
parseThinkingLevelsResponse,
|
||||
parseWriteWorkspaceFileResponse,
|
||||
parseWorkspace,
|
||||
parseWorkspaceActivityResponse,
|
||||
} from "./parsers";
|
||||
@@ -118,6 +121,32 @@ export const workspacesApi = {
|
||||
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
writeWorkspaceFile: (projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId = "local") => {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.createDirs === false) params.set("createDirs", "false");
|
||||
if (options?.overwrite === false) params.set("overwrite", "false");
|
||||
const isBinary = content instanceof Uint8Array;
|
||||
const body: BodyInit = isBinary ? new Uint8Array(content) : new TextEncoder().encode(content);
|
||||
return request(
|
||||
`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`,
|
||||
parseWriteWorkspaceFileResponse,
|
||||
{ method: "PUT", body, headers: { "Content-Type": isBinary ? "application/octet-stream" : "text/plain" } },
|
||||
);
|
||||
},
|
||||
deleteWorkspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local"): Promise<DeleteWorkspaceFileResponse> => {
|
||||
const params = new URLSearchParams({ path });
|
||||
return request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, parseDeleteWorkspaceFileResponse, { method: "DELETE" });
|
||||
},
|
||||
moveWorkspaceFile: (projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId = "local") => {
|
||||
const params = new URLSearchParams({ fromPath, toPath });
|
||||
if (options?.createDirs === false) params.set("createDirs", "false");
|
||||
if (options?.overwrite === true) params.set("overwrite", "true");
|
||||
return request(
|
||||
`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/move?${params.toString()}`,
|
||||
parseMoveWorkspaceFileResponse,
|
||||
{ method: "POST" },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const sessionsApi = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined) headers.set("content-type", "application/json");
|
||||
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import { isPiWebCapability } from "../../../shared/capabilities";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -336,6 +336,34 @@ export function parseFileContentResponse(value: unknown): FileContentResponse {
|
||||
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
||||
}
|
||||
|
||||
export function parseWriteWorkspaceFileResponse(value: unknown): WriteWorkspaceFileResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
path: requireString(record, "path"),
|
||||
size: requireNumber(record, "size"),
|
||||
modifiedAt: requireString(record, "modifiedAt"),
|
||||
created: requireBoolean(record, "created"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDeleteWorkspaceFileResponse(value: unknown): DeleteWorkspaceFileResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
path: requireString(record, "path"),
|
||||
existed: requireBoolean(record, "existed"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMoveWorkspaceFileResponse(value: unknown): MoveWorkspaceFileResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
fromPath: requireString(record, "fromPath"),
|
||||
toPath: requireString(record, "toPath"),
|
||||
size: requireNumber(record, "size"),
|
||||
modifiedAt: requireString(record, "modifiedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== "image") throw new Error("Invalid file media type");
|
||||
|
||||
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { selectedMachineId } from "../controllers/types";
|
||||
import { RealtimeSocket } from "../sessionSocket";
|
||||
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import type { PiWebPluginRegistration, PluginMachine, PluginAttachments, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||
import { corePlugin } from "../plugins/core";
|
||||
import { themePackPlugin } from "../plugins/themes";
|
||||
@@ -1198,6 +1198,21 @@ export class PiWebApp extends LitElement {
|
||||
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
|
||||
return {
|
||||
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
|
||||
writeFile: async (path, content, options) => {
|
||||
const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId);
|
||||
void this.files.refreshFiles();
|
||||
return result;
|
||||
},
|
||||
deleteFile: async (path) => {
|
||||
const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId);
|
||||
void this.files.refreshFiles();
|
||||
return result;
|
||||
},
|
||||
moveFile: async (fromPath, toPath, options) => {
|
||||
const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId);
|
||||
void this.files.refreshFiles();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1369,9 +1384,95 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private createPromptEditor(): PluginPromptEditor {
|
||||
return {
|
||||
insertText: (text: string) => {
|
||||
const editor = this.promptEditor?.view;
|
||||
if (!editor) return;
|
||||
if (!editor.hasFocus) editor.focus();
|
||||
const sel = editor.state.selection.main;
|
||||
editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: text } });
|
||||
},
|
||||
getText: () => {
|
||||
return this.promptEditor?.view?.state.doc.toString() ?? "";
|
||||
},
|
||||
getSelection: () => {
|
||||
const editor = this.promptEditor?.view;
|
||||
if (!editor) return null;
|
||||
const sel = editor.state.selection.main;
|
||||
if (sel.empty) return null;
|
||||
return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) };
|
||||
},
|
||||
onPaste: (handler) => {
|
||||
if (!this.promptEditor) {
|
||||
console.warn("[pi-web] prompt.onPaste() called but prompt editor is not available. Handler will not be registered.");
|
||||
return () => undefined;
|
||||
}
|
||||
const id = this.promptEditor.addPluginHandler("paste", handler);
|
||||
return () => { this.promptEditor?.removePluginHandler(id); };
|
||||
},
|
||||
onKeyDown: (handler) => {
|
||||
if (!this.promptEditor) {
|
||||
console.warn("[pi-web] prompt.onKeyDown() called but prompt editor is not available. Handler will not be registered.");
|
||||
return () => undefined;
|
||||
}
|
||||
const id = this.promptEditor.addPluginHandler("keydown", handler);
|
||||
return () => { this.promptEditor?.removePluginHandler(id); };
|
||||
},
|
||||
focus: () => {
|
||||
this.promptEditor?.focusInput();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createPluginAttachments(): PluginAttachments {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
const machineId = selectedMachineId(this.state);
|
||||
return {
|
||||
insertFileReference: async (path: string) => {
|
||||
if (!workspace) throw new Error("No workspace selected");
|
||||
try {
|
||||
await workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId);
|
||||
} catch {
|
||||
throw new Error(`File not found in workspace: ${path}`);
|
||||
}
|
||||
const reference = `@${path}`;
|
||||
const editor = this.promptEditor?.view;
|
||||
if (editor) {
|
||||
const sel = editor.state.selection.main;
|
||||
editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: reference } });
|
||||
}
|
||||
return reference;
|
||||
},
|
||||
getAttachedFiles: () => {
|
||||
const text = this.promptEditor?.view?.state.doc.toString() ?? "";
|
||||
const matches: string[] = [];
|
||||
const atFilePattern = /@([\w./\-\u00C0-\u024F]+(?:\.[\w]+))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = atFilePattern.exec(text)) !== null) {
|
||||
if (m[1] !== undefined) matches.push(m[1]);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
removeFileReference: (path: string) => {
|
||||
const editor = this.promptEditor?.view;
|
||||
if (!editor) return;
|
||||
const text = editor.state.doc.toString();
|
||||
const reference = `@${path}`;
|
||||
const index = text.indexOf(reference);
|
||||
if (index === -1) return;
|
||||
editor.dispatch({
|
||||
changes: { from: index, to: index + reference.length, insert: "" },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createPluginRuntimeContext(): PluginRuntimeContext {
|
||||
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
|
||||
state: this.state,
|
||||
prompt: this.createPromptEditor(),
|
||||
attachments: this.createPluginAttachments(),
|
||||
piWebUnstable: {
|
||||
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
|
||||
openSettings: (section) => { this.openSettings(section); },
|
||||
|
||||
@@ -27,6 +27,9 @@ interface PendingAttachment {
|
||||
size: number;
|
||||
}
|
||||
|
||||
type PluginPasteHandler = (event: ClipboardEvent) => boolean;
|
||||
type PluginKeydownHandler = (event: KeyboardEvent) => boolean;
|
||||
|
||||
@customElement("prompt-editor")
|
||||
export class PromptEditor extends LitElement {
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@@ -56,6 +59,10 @@ export class PromptEditor extends LitElement {
|
||||
private editor: EditorView | undefined;
|
||||
private readonly editableCompartment = new Compartment();
|
||||
private readonly readOnlyCompartment = new Compartment();
|
||||
private readonly pluginHandlersCompartment = new Compartment();
|
||||
private nextHandlerId = 0;
|
||||
private readonly pasteHandlers = new Map<number, PluginPasteHandler>();
|
||||
private readonly keydownHandlers = new Map<number, PluginKeydownHandler>();
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>) {
|
||||
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
||||
@@ -79,6 +86,8 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.pasteHandlers.clear();
|
||||
this.keydownHandlers.clear();
|
||||
this.editor?.destroy();
|
||||
this.editor = undefined;
|
||||
super.disconnectedCallback();
|
||||
@@ -114,6 +123,57 @@ export class PromptEditor extends LitElement {
|
||||
this.editor?.focus();
|
||||
}
|
||||
|
||||
/** Get the underlying CM6 EditorView, or undefined if not yet mounted. */
|
||||
get view(): EditorView | undefined {
|
||||
return this.editor;
|
||||
}
|
||||
|
||||
/** Register a plugin event handler. Returns a numeric ID for later removal. */
|
||||
addPluginHandler(...args: ["paste", PluginPasteHandler] | ["keydown", PluginKeydownHandler]): number {
|
||||
const [type, handler] = args;
|
||||
const id = this.nextHandlerId++;
|
||||
if (type === "paste") {
|
||||
this.pasteHandlers.set(id, handler);
|
||||
} else {
|
||||
this.keydownHandlers.set(id, handler);
|
||||
}
|
||||
this.reconfigurePluginHandlers();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Remove a previously registered plugin event handler by ID. */
|
||||
removePluginHandler(id: number): void {
|
||||
this.pasteHandlers.delete(id);
|
||||
this.keydownHandlers.delete(id);
|
||||
this.reconfigurePluginHandlers();
|
||||
}
|
||||
|
||||
private reconfigurePluginHandlers(): void {
|
||||
const extension = this.buildPluginHandlersExtension();
|
||||
this.editor?.dispatch({
|
||||
effects: this.pluginHandlersCompartment.reconfigure(extension),
|
||||
});
|
||||
}
|
||||
|
||||
private buildPluginHandlersExtension() {
|
||||
const pasteHandlers = [...this.pasteHandlers.values()];
|
||||
const keydownHandlers = [...this.keydownHandlers.values()];
|
||||
return EditorView.domEventHandlers({
|
||||
paste(event) {
|
||||
for (const handler of pasteHandlers) {
|
||||
if (handler(event)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
keydown(event) {
|
||||
for (const handler of keydownHandlers) {
|
||||
if (handler(event)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private renderCompactStatus() {
|
||||
const status = this.status;
|
||||
if (status === undefined) return null;
|
||||
@@ -223,6 +283,7 @@ export class PromptEditor extends LitElement {
|
||||
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
|
||||
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
||||
this.pluginHandlersCompartment.of(this.buildPluginHandlersExtension()),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) this.updateDraft(update.state.doc.toString());
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { html } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api";
|
||||
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
@@ -14,6 +14,19 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
const calls: string[] = [];
|
||||
const context: PluginRuntimeContext = {
|
||||
state: { ...initialAppState(), ...statePatch },
|
||||
prompt: {
|
||||
insertText: vi.fn(),
|
||||
getText: vi.fn(() => ""),
|
||||
getSelection: vi.fn(() => null),
|
||||
onPaste: vi.fn(() => vi.fn()),
|
||||
onKeyDown: vi.fn(() => vi.fn()),
|
||||
focus: vi.fn(() => { calls.push("prompt.focus"); }),
|
||||
},
|
||||
attachments: {
|
||||
insertFileReference: vi.fn(),
|
||||
getAttachedFiles: vi.fn(() => []),
|
||||
removeFileReference: vi.fn(),
|
||||
},
|
||||
piWebUnstable: {
|
||||
terminalCommandRuns: {
|
||||
runCommand: vi.fn(),
|
||||
@@ -335,7 +348,7 @@ describe("PluginRegistry", () => {
|
||||
context.host.requestRender();
|
||||
return [{ type: "text", text: context.machine.id }];
|
||||
});
|
||||
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } });
|
||||
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
|
||||
|
||||
registry.register({
|
||||
id: "example",
|
||||
@@ -545,7 +558,7 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
|
||||
}
|
||||
|
||||
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
|
||||
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) };
|
||||
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
|
||||
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
|
||||
return {
|
||||
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
|
||||
@@ -562,7 +575,7 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
|
||||
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
|
||||
workspace,
|
||||
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
|
||||
files: { readFile: vi.fn() },
|
||||
files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() },
|
||||
terminal: { open: vi.fn(), runCommand: vi.fn() },
|
||||
host: { requestRender: vi.fn() },
|
||||
fileTree: [],
|
||||
@@ -613,6 +626,31 @@ function testStatus(patch: Partial<SessionStatus> = {}): SessionStatus {
|
||||
};
|
||||
}
|
||||
|
||||
function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse {
|
||||
return {
|
||||
path,
|
||||
size: 0,
|
||||
modifiedAt: "2026-05-20T00:00:00.000Z",
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
function testDeleteFileResponse(path = "README.md"): DeleteWorkspaceFileResponse {
|
||||
return {
|
||||
path,
|
||||
existed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function testMoveFileResponse(fromPath = "old.txt", toPath = "new.txt"): MoveWorkspaceFileResponse {
|
||||
return {
|
||||
fromPath,
|
||||
toPath,
|
||||
size: 0,
|
||||
modifiedAt: "2026-05-20T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function testMachine(id: string) {
|
||||
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { AppAction } from "../actions";
|
||||
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api";
|
||||
import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import type { SettingsSection } from "../settingsRoute";
|
||||
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
|
||||
@@ -50,6 +50,9 @@ export interface PluginMachine {
|
||||
|
||||
export interface WorkspaceFiles {
|
||||
readFile(path: string): Promise<FileContentResponse>;
|
||||
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
|
||||
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
|
||||
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
|
||||
}
|
||||
|
||||
export interface WorkspaceHost {
|
||||
@@ -83,8 +86,25 @@ export interface TerminalCommandRunsInternalRuntime {
|
||||
open(options?: { terminalId?: string | undefined }): void;
|
||||
}
|
||||
|
||||
export interface PluginPromptEditor {
|
||||
insertText(text: string): void;
|
||||
getText(): string;
|
||||
getSelection(): { start: number; end: number; text: string } | null;
|
||||
onPaste(handler: (event: ClipboardEvent) => boolean): () => void;
|
||||
onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export interface PluginAttachments {
|
||||
insertFileReference(path: string): Promise<string>;
|
||||
getAttachedFiles(): string[];
|
||||
removeFileReference(path: string): void;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
state: AppState;
|
||||
prompt: PluginPromptEditor;
|
||||
attachments: PluginAttachments;
|
||||
piWebUnstable?: PiWebUnstableRuntimeContext;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
|
||||
+54
-1
@@ -1,5 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js";
|
||||
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js";
|
||||
|
||||
export type {
|
||||
FileContentMediaType,
|
||||
@@ -20,6 +20,11 @@ export type {
|
||||
TerminalCommandRunFilter,
|
||||
TerminalCommandRunHandle,
|
||||
TerminalCommandRunStatus,
|
||||
WriteWorkspaceFileOptions,
|
||||
WriteWorkspaceFileResponse,
|
||||
DeleteWorkspaceFileResponse,
|
||||
MoveWorkspaceFileOptions,
|
||||
MoveWorkspaceFileResponse,
|
||||
} from "./shared/apiTypes.js";
|
||||
|
||||
export type PluginId = string;
|
||||
@@ -67,8 +72,46 @@ export interface PluginRuntimeState {
|
||||
piWebStatus?: PiWebStatusResponse;
|
||||
}
|
||||
|
||||
export interface PluginPromptEditor {
|
||||
/** Insert text at the current cursor position. Replaces any selection.
|
||||
* If the editor is not focused, focuses it first.
|
||||
* No-op if the editor is not mounted. */
|
||||
insertText(text: string): void;
|
||||
/** Get the current prompt text content. Returns "" if the editor is not mounted. */
|
||||
getText(): string;
|
||||
/** Get the current selection range, or null if no selection or editor not mounted. */
|
||||
getSelection(): { start: number; end: number; text: string } | null;
|
||||
/** Register a paste event handler scoped to the prompt editor.
|
||||
* Handlers run in registration order; first handler returning true consumes the event.
|
||||
* Returns an unsubscribe function. No-op if the editor is not mounted. */
|
||||
onPaste(handler: (event: ClipboardEvent) => boolean): () => void;
|
||||
/** Register a keydown handler scoped to the prompt editor.
|
||||
* Handlers run in registration order; first handler returning true consumes the event.
|
||||
* Returns unsubscribe. No-op if the editor is not mounted. */
|
||||
onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void;
|
||||
/** Focus the prompt editor. No-op if not mounted. */
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export interface PluginAttachments {
|
||||
/** Insert a file reference at the current cursor position in the chat prompt.
|
||||
* Validates that the file exists in the workspace before insertion.
|
||||
* Does not auto-focus the editor (unlike prompt.insertText). Use prompt.focus() first if needed.
|
||||
* @throws Error if no workspace is selected or the file doesn't exist
|
||||
* Returns the canonical @file reference string (e.g., "@path/to/file.png"). */
|
||||
insertFileReference(path: string): Promise<string>;
|
||||
/** List currently attached file paths in the prompt. Returns paths without the @ prefix.
|
||||
* Best-effort heuristic: matches @path/to/file.ext patterns. May match email-like patterns;
|
||||
* use insertFileReference() for guaranteed-accurate insertion. */
|
||||
getAttachedFiles(): string[];
|
||||
/** Remove a file reference from the prompt by path. Removes the first occurrence of @path. */
|
||||
removeFileReference(path: string): void;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
state: PluginRuntimeState;
|
||||
prompt: PluginPromptEditor;
|
||||
attachments: PluginAttachments;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
@@ -109,7 +152,17 @@ export interface Workspace {
|
||||
}
|
||||
|
||||
export interface WorkspaceFiles {
|
||||
/** Read a file from the workspace. Works for local and federated machines. */
|
||||
readFile(path: string): Promise<FileContentResponse>;
|
||||
/** Write content to a workspace file. Creates intermediate directories by default.
|
||||
* Works for local and federated machines. Auto-refreshes the file explorer after success. */
|
||||
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
|
||||
/** Delete a file from the workspace. Idempotent — returns { existed: false } if file doesn't exist.
|
||||
* Deletes the entry itself (for symlinks, removes the symlink not the target). */
|
||||
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
|
||||
/** Move or rename a file within the workspace. Unix mv semantics.
|
||||
* Default overwrite: false (safer than writeFile). Auto-refreshes the file explorer after success. */
|
||||
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
|
||||
}
|
||||
|
||||
export type WorkspacePanelFiles = WorkspaceFiles;
|
||||
|
||||
+267
-1
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
@@ -478,6 +478,272 @@ describe("buildApp", () => {
|
||||
expect(tooLargeResponse.statusCode).toBe(400);
|
||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||
});
|
||||
|
||||
it("writes workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "WriteTest", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
// Write a text file
|
||||
const writeTextResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
|
||||
payload: "hello world",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(writeTextResponse.statusCode).toBe(200);
|
||||
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
|
||||
const writeBody = writeTextResponse.json<Record<string, unknown>>();
|
||||
expect(typeof writeBody['size']).toBe("number");
|
||||
|
||||
// Read it back
|
||||
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
|
||||
const readBody = readResponse.json<Record<string, unknown>>();
|
||||
expect(readBody['content']).toBe("hello world");
|
||||
|
||||
// Write binary content
|
||||
const writeBinaryResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
|
||||
payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
expect(writeBinaryResponse.statusCode).toBe(200);
|
||||
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
|
||||
|
||||
// Create intermediate directories (default)
|
||||
const writeDeepResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
|
||||
payload: "deep content",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(writeDeepResponse.statusCode).toBe(200);
|
||||
|
||||
// Verify the nested file was written
|
||||
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
|
||||
const readDeepBody = readDeepResponse.json<Record<string, unknown>>();
|
||||
expect(readDeepBody['content']).toBe("deep content");
|
||||
|
||||
// Overwrite an existing file (default)
|
||||
const overwriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
|
||||
payload: "updated",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
|
||||
|
||||
// Reject overwrite=false when file exists
|
||||
const noOverwriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
|
||||
payload: "should fail",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noOverwriteResponse.statusCode).toBe(400);
|
||||
const noOverwriteBody = noOverwriteResponse.json<Record<string, unknown>>();
|
||||
expect(noOverwriteBody['error']).toContain("File already exists");
|
||||
|
||||
// Reject path traversal
|
||||
const traversalResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
|
||||
payload: "evil",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(traversalResponse.statusCode).toBe(400);
|
||||
const traversalBody = traversalResponse.json<Record<string, unknown>>();
|
||||
expect(traversalBody['error']).toContain("Path traversal");
|
||||
|
||||
// Reject missing path
|
||||
const noPathResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
payload: "no path",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
const noPathBody = noPathResponse.json<Record<string, unknown>>();
|
||||
expect(noPathBody['error']).toContain("path query parameter is required");
|
||||
|
||||
// Fail when createDirs=false and parent directory does not exist
|
||||
const noDirsResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
|
||||
payload: "should fail",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noDirsResponse.statusCode).toBe(400);
|
||||
|
||||
// Reject writing to a directory path
|
||||
await mkdir(join(projectDir, "subdir"), { recursive: true });
|
||||
const dirWriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`,
|
||||
payload: "should fail",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(dirWriteResponse.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("deletes workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "DeleteTest", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
// Write a file first so we can delete it
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
|
||||
payload: "delete me",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
// Delete existing file
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
|
||||
});
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
|
||||
|
||||
// Delete non-existent file (idempotent)
|
||||
const deleteMissingResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
|
||||
});
|
||||
expect(deleteMissingResponse.statusCode).toBe(200);
|
||||
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
|
||||
|
||||
// Reject path traversal
|
||||
const traversalResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
|
||||
});
|
||||
expect(traversalResponse.statusCode).toBe(400);
|
||||
const deleteTraversalBody = traversalResponse.json<Record<string, unknown>>();
|
||||
expect(deleteTraversalBody['error']).toContain("Path traversal");
|
||||
|
||||
// Reject missing path
|
||||
const noPathResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
const deleteNoPathBody = noPathResponse.json<Record<string, unknown>>();
|
||||
expect(deleteNoPathBody['error']).toContain("path query parameter is required");
|
||||
});
|
||||
|
||||
it("moves workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "MoveTest", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
// Write a file first so we can move it
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
|
||||
payload: "move me",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
// Move a file to a new path
|
||||
const moveResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
|
||||
});
|
||||
expect(moveResponse.statusCode).toBe(200);
|
||||
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
|
||||
const moveBody = moveResponse.json<Record<string, unknown>>();
|
||||
expect(typeof moveBody['size']).toBe("number");
|
||||
|
||||
// Verify source is gone
|
||||
const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
|
||||
expect(readSourceResponse.statusCode).toBe(400);
|
||||
|
||||
// Verify target exists
|
||||
const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
|
||||
expect(readTargetResponse.statusCode).toBe(200);
|
||||
const targetBody = readTargetResponse.json<Record<string, unknown>>();
|
||||
expect(targetBody['content']).toBe("move me");
|
||||
|
||||
// Write another file for overwrite test
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
|
||||
payload: "source",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`,
|
||||
payload: "target",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
// Move with overwrite=true succeeds
|
||||
const overwriteResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
|
||||
});
|
||||
expect(overwriteResponse.statusCode).toBe(200);
|
||||
|
||||
// Move with overwrite=false (default) fails when target exists
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
|
||||
payload: "s",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`,
|
||||
payload: "t",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const noOverwriteResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
|
||||
});
|
||||
expect(noOverwriteResponse.statusCode).toBe(400);
|
||||
const moveNoOverwriteBody = noOverwriteResponse.json<Record<string, unknown>>();
|
||||
expect(moveNoOverwriteBody['error']).toContain("File already exists");
|
||||
|
||||
// Reject path traversal in fromPath
|
||||
const traversalFromResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
|
||||
});
|
||||
expect(traversalFromResponse.statusCode).toBe(400);
|
||||
|
||||
// Reject missing params
|
||||
const noParamsResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
|
||||
});
|
||||
expect(noParamsResponse.statusCode).toBe(400);
|
||||
const noParamsBody = noParamsResponse.json<Record<string, unknown>>();
|
||||
expect(noParamsBody['error']).toContain("fromPath query parameter is required");
|
||||
});
|
||||
});
|
||||
|
||||
interface CapturedSessionDaemonRequest {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("PiWebPluginService", () => {
|
||||
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
await expect(service.manifest()).resolves.toEqual({
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
|
||||
@@ -41,7 +41,7 @@ describe("PiWebPluginService", () => {
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
|
||||
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
|
||||
@@ -74,7 +74,7 @@ describe("PiWebPluginService", () => {
|
||||
files: { "dist/pi-web-plugin.js": "export default { apiVersion: 1, name: 'Source Dev', activate: () => ({ contributions: {} }) };" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false });
|
||||
const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins).toEqual(expect.arrayContaining([
|
||||
@@ -92,7 +92,7 @@ describe("PiWebPluginService", () => {
|
||||
await mkdir(join(tempDir, "plugins"), { recursive: true });
|
||||
await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir");
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins).toHaveLength(1);
|
||||
@@ -135,7 +135,7 @@ describe("PiWebPluginService", () => {
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
|
||||
@@ -167,7 +167,7 @@ describe("PiWebPluginService", () => {
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]);
|
||||
@@ -181,7 +181,7 @@ describe("PiWebPluginService", () => {
|
||||
});
|
||||
await writeFile(join(tempDir, "plugins", "escape.js"), "nope");
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) });
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins).toHaveLength(1);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||
// Register content type parsers for workspace file writes.
|
||||
// Fastify's default parser only handles application/json.
|
||||
// Guard against re-registration since this function may be called multiple times.
|
||||
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_req, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/, { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
@@ -25,6 +32,40 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const options: WriteWorkspaceFileOptions = {
|
||||
createDirs: request.query.createDirs !== "false",
|
||||
overwrite: request.query.overwrite !== "false",
|
||||
};
|
||||
return await writeWorkspaceFile(context.root, request.query.path, request.body, options);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await deleteWorkspaceFile(context.root, request.query.path);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { projectId: string; workspaceId: string }; Querystring: { fromPath?: string; toPath?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/move`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await moveWorkspaceFile(context.root, request.query.fromPath, request.query.toPath, {
|
||||
createDirs: request.query.createDirs !== "false",
|
||||
overwrite: request.query.overwrite === "true",
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
|
||||
import { readWorkspaceFile } from "./fileContentService.js";
|
||||
import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js";
|
||||
import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js";
|
||||
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -96,3 +97,246 @@ describe("readWorkspaceFile", () => {
|
||||
expect(file.binary).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeWorkspaceFile", () => {
|
||||
it("writes text content to a new file with normalized paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
|
||||
|
||||
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
|
||||
expect(result.size).toBe(26);
|
||||
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
|
||||
|
||||
// Verify the file was actually written
|
||||
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
|
||||
expect(content).toBe("const greeting = 'hello';\n");
|
||||
});
|
||||
|
||||
it("writes binary content", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||
|
||||
const result = await writeWorkspaceFile(root, "image.png", binaryData);
|
||||
|
||||
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
|
||||
});
|
||||
|
||||
it("overwrites existing files by default", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "notes.txt"), "old content");
|
||||
|
||||
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
|
||||
|
||||
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
|
||||
const content = await readFile(join(root, "notes.txt"), "utf8");
|
||||
expect(content).toBe("new content");
|
||||
});
|
||||
|
||||
it("throws when overwrite is false and file exists", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "existing.txt"), "data");
|
||||
|
||||
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
|
||||
});
|
||||
|
||||
it("creates intermediate directories by default", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
|
||||
|
||||
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
|
||||
expect(content).toBe("deep content");
|
||||
});
|
||||
|
||||
it("fails when createDirs is false and parent directory does not exist", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing paths, traversal, and absolute paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
|
||||
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
|
||||
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("rejects writing to a directory path", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "mydir"), { recursive: true });
|
||||
|
||||
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
|
||||
});
|
||||
|
||||
it("prevents writing through symlinks that escape the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
// Create a symlink inside the workspace that points outside
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
|
||||
roots.push(outsideDir);
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
// Attempting to write through the symlink should be blocked
|
||||
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWorkspaceFile", () => {
|
||||
it("deletes an existing file and returns existed: true", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "notes.txt"), "hello");
|
||||
|
||||
const result = await deleteWorkspaceFile(root, "notes.txt");
|
||||
|
||||
expect(result).toMatchObject({ path: "notes.txt", existed: true });
|
||||
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
|
||||
});
|
||||
|
||||
it("returns existed: false when deleting a non-existent file", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
const result = await deleteWorkspaceFile(root, "missing.txt");
|
||||
|
||||
expect(result).toMatchObject({ path: "missing.txt", existed: false });
|
||||
});
|
||||
|
||||
it("rejects deleting a directory", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "mydir"), { recursive: true });
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
|
||||
});
|
||||
|
||||
it("rejects path traversal", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("rejects missing path", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
|
||||
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
|
||||
});
|
||||
|
||||
it("deletes a symlink itself, not its target", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-"));
|
||||
roots.push(outsideDir);
|
||||
await writeFile(join(outsideDir, "real.txt"), "real content");
|
||||
// Create a symlink inside the workspace pointing outside
|
||||
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
|
||||
|
||||
const result = await deleteWorkspaceFile(root, "link.txt");
|
||||
|
||||
expect(result).toMatchObject({ path: "link.txt", existed: true });
|
||||
// The symlink should be gone, but the target file should still exist
|
||||
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow();
|
||||
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
||||
expect(realContent).toBe("real content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveWorkspaceFile", () => {
|
||||
it("moves a file to a new path", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "original.txt"), "content");
|
||||
|
||||
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
|
||||
|
||||
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
|
||||
expect(result.size).toBe(7);
|
||||
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
|
||||
// Source should no longer exist
|
||||
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
|
||||
// Target should exist
|
||||
const target = await readWorkspaceFile(root, "moved.txt");
|
||||
expect(target.content).toBe("content");
|
||||
});
|
||||
|
||||
it("creates intermediate directories by default", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "file.txt"), "data");
|
||||
|
||||
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
|
||||
|
||||
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
|
||||
expect(target.content).toBe("data");
|
||||
});
|
||||
|
||||
it("fails when createDirs is false and parent directory does not exist", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "file.txt"), "data");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("overwrites target when overwrite is true", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "source content");
|
||||
await writeFile(join(root, "target.txt"), "target content");
|
||||
|
||||
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
|
||||
|
||||
expect(result.toPath).toBe("target.txt");
|
||||
const target = await readWorkspaceFile(root, "target.txt");
|
||||
expect(target.content).toBe("source content");
|
||||
});
|
||||
|
||||
it("throws when target exists and overwrite is false (default)", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "source");
|
||||
await writeFile(join(root, "target.txt"), "target");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
|
||||
// Source should still exist
|
||||
const source = await readWorkspaceFile(root, "source.txt");
|
||||
expect(source.content).toBe("source");
|
||||
});
|
||||
|
||||
it("rejects source path traversal", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||
});
|
||||
|
||||
it("rejects target path traversal", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "source.txt"), "data");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects moving a directory", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "mydir"), { recursive: true });
|
||||
|
||||
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
|
||||
});
|
||||
|
||||
it("rejects missing fromPath or toPath", async () => {
|
||||
const root = await tempWorkspace();
|
||||
|
||||
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
|
||||
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
|
||||
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
|
||||
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
|
||||
});
|
||||
|
||||
it("prevents moving through symlinks that escape the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
await writeFile(join(root, "subdir", "file.txt"), "data");
|
||||
// Create a symlink inside the workspace that points outside
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-"));
|
||||
roots.push(outsideDir);
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import type { FileContentResponse } from "../../shared/apiTypes.js";
|
||||
import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
|
||||
import { imageMimeTypeForPath } from "./imagePreviewService.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
@@ -39,6 +40,104 @@ async function readFilePrefix(target: string, bytesToRead: number): Promise<Buff
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorkspaceFile(rootPath: string, path: string | undefined, content: Buffer, options: WriteWorkspaceFileOptions = {}): Promise<WriteWorkspaceFileResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
|
||||
const createDirs = options.createDirs ?? true;
|
||||
const overwrite = options.overwrite ?? true;
|
||||
|
||||
let exists = false;
|
||||
try {
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
if (!overwrite) throw new Error(`File already exists: ${relativePath}`);
|
||||
exists = true;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message.startsWith("File already exists")) throw error;
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) { /* expected for creation — continue */ }
|
||||
else if (error instanceof Error && error.message === "Path does not exist") { /* expected for creation — continue */ }
|
||||
else throw error; // re-throw permission errors, "not a file", traversal errors, etc.
|
||||
}
|
||||
|
||||
// Use resolveParentInsideWorkspace for the actual write since the target may not exist yet
|
||||
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
||||
|
||||
if (createDirs) await mkdir(dirname(target), { recursive: true });
|
||||
|
||||
// Resolve symlinks in the parent path to prevent escape via symlink
|
||||
const realParent = await realpath(dirname(target));
|
||||
const realTarget = join(realParent, basename(target));
|
||||
ensureInside(root, realTarget);
|
||||
await writeFile(realTarget, content);
|
||||
|
||||
const s = await stat(realTarget);
|
||||
return {
|
||||
path: relativePath,
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
created: !exists,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceFile(rootPath: string, path: string | undefined): Promise<DeleteWorkspaceFileResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
// Use resolveParentInsideWorkspace + lstat so that deleting a symlink
|
||||
// deletes the symlink itself, not the target it points to.
|
||||
// resolveInsideWorkspace would call realpath on the target, following
|
||||
// symlinks and resolving the symlink's destination instead.
|
||||
const { target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
||||
try {
|
||||
const s = await lstat(target);
|
||||
// Allow deleting regular files and symlinks, but not directories
|
||||
if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead");
|
||||
await unlink(target);
|
||||
return { path: relativePath, existed: true };
|
||||
} catch (error: unknown) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false };
|
||||
if (error instanceof Error && error.message === "Path does not exist") return { path: relativePath, existed: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveWorkspaceFile(rootPath: string, fromPath: string | undefined, toPath: string | undefined, options: MoveWorkspaceFileOptions = {}): Promise<MoveWorkspaceFileResponse> {
|
||||
if (fromPath === undefined || fromPath === "") throw new Error("fromPath query parameter is required");
|
||||
if (toPath === undefined || toPath === "") throw new Error("toPath query parameter is required");
|
||||
|
||||
const createDirs = options.createDirs ?? true;
|
||||
const overwrite = options.overwrite ?? false;
|
||||
|
||||
// Source: must exist and be a file (uses realpath via resolveInsideWorkspace)
|
||||
const { target: source, relativePath: fromRelative } = await resolveInsideWorkspace(rootPath, fromPath);
|
||||
const sourceStat = await stat(source);
|
||||
if (!sourceStat.isFile()) throw new Error("Source path is not a file");
|
||||
|
||||
// Target: uses resolveParentInsideWorkspace + realpath(dirname) pattern (same as writeFile)
|
||||
const { root, target: dest, relativePath: destRelative } = await resolveParentInsideWorkspace(rootPath, toPath);
|
||||
|
||||
if (createDirs) await mkdir(dirname(dest), { recursive: true });
|
||||
|
||||
// Resolve symlinks in the parent path to prevent escape via symlink
|
||||
const realParent = await realpath(dirname(dest));
|
||||
const realDest = join(realParent, basename(dest));
|
||||
ensureInside(root, realDest);
|
||||
|
||||
if (!overwrite) {
|
||||
try {
|
||||
const destStat = await stat(realDest);
|
||||
if (destStat.isFile()) throw new Error(`File already exists: ${destRelative}`);
|
||||
} catch (error: unknown) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) { /* expected — target doesn't exist */ }
|
||||
else if (error instanceof Error && error.message.startsWith("File already exists")) throw error;
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await rename(source, realDest);
|
||||
const finalStat = await stat(realDest);
|
||||
return { fromPath: fromRelative, toPath: destRelative, size: finalStat.size, modifiedAt: finalStat.mtime.toISOString() };
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer): boolean {
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
|
||||
return sample.includes(0);
|
||||
|
||||
@@ -30,11 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string {
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string): void {
|
||||
export function ensureInside(root: string, target: string): void {
|
||||
const rel = relative(root, target);
|
||||
if (rel === "") return;
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace");
|
||||
|
||||
@@ -297,6 +297,35 @@ export interface FileContentResponse {
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export interface WriteWorkspaceFileOptions {
|
||||
createDirs?: boolean; // default: true — mkdir -p equivalent
|
||||
overwrite?: boolean; // default: true — throw if false and file exists
|
||||
}
|
||||
|
||||
export interface WriteWorkspaceFileResponse {
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
created: boolean; // true if file was created, false if overwritten
|
||||
}
|
||||
|
||||
export interface DeleteWorkspaceFileResponse {
|
||||
path: string;
|
||||
existed: boolean; // true if file existed and was deleted, false if file did not exist
|
||||
}
|
||||
|
||||
export interface MoveWorkspaceFileOptions {
|
||||
createDirs?: boolean; // default: true — mkdir -p equivalent for target parent directory
|
||||
overwrite?: boolean; // default: false — throw if target exists (safer default than writeFile)
|
||||
}
|
||||
|
||||
export interface MoveWorkspaceFileResponse {
|
||||
fromPath: string;
|
||||
toPath: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted";
|
||||
|
||||
export interface GitStatusFile {
|
||||
|
||||
Reference in New Issue
Block a user