Archived
Merge pull request #25 from jmfederico/cleanup/plugin-api-scope
feat: plugin API — workspace file mutations & prompt editor access
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer.
|
||||
+123
-3
@@ -439,6 +439,7 @@ interface PluginRuntimeContext {
|
||||
selectedSession?: unknown;
|
||||
piWebStatus?: PiWebStatusResponse;
|
||||
};
|
||||
prompt: PluginPromptEditor;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
@@ -464,6 +465,29 @@ Notes:
|
||||
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
|
||||
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear.
|
||||
|
||||
### Prompt editor API
|
||||
|
||||
The `prompt` helper on `PluginRuntimeContext` provides stable access to the chat prompt editor:
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `insertText(text)` | Insert text at cursor position. When text is selected, replaces the selection. Focuses the editor first if not focused. |
|
||||
| `getText()` | Returns the full prompt text. |
|
||||
| `getSelection()` | Returns `{ start, end, text }` if text is selected, or `null`. |
|
||||
|
||||
Usage:
|
||||
|
||||
```js
|
||||
// Insert text at the cursor (e.g. a file mention)
|
||||
context.prompt.insertText("@file.txt");
|
||||
|
||||
// Read the current prompt and selection
|
||||
const text = context.prompt.getText();
|
||||
const selection = context.prompt.getSelection(); // { start, end, text } | null
|
||||
```
|
||||
|
||||
Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor.
|
||||
|
||||
#### Keyboard shortcuts
|
||||
|
||||
- App-level keyboard shortcuts must be attached to actions. PI WEB does not support standalone plugin keyboard commands; contribute an action first, then add a `shortcut` if it needs a keybinding.
|
||||
@@ -521,6 +545,9 @@ interface WorkspacePanelContext {
|
||||
state?: PluginRuntimeState;
|
||||
files: {
|
||||
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>;
|
||||
};
|
||||
terminal: {
|
||||
open(options?: { terminalId?: string }): void;
|
||||
@@ -539,7 +566,7 @@ interface WorkspacePanelContext {
|
||||
|
||||
`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide.
|
||||
|
||||
`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
|
||||
`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
|
||||
|
||||
For compatibility, PI WEB still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`.
|
||||
|
||||
@@ -607,6 +634,9 @@ interface WorkspaceLabelContext {
|
||||
state?: PluginRuntimeState;
|
||||
files: {
|
||||
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>;
|
||||
};
|
||||
host: {
|
||||
requestRender(): void;
|
||||
@@ -614,7 +644,7 @@ interface WorkspaceLabelContext {
|
||||
}
|
||||
```
|
||||
|
||||
`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
|
||||
`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
|
||||
|
||||
Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes.
|
||||
|
||||
@@ -753,6 +783,96 @@ workspaceLabels: [
|
||||
|
||||
The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
|
||||
|
||||
## Writing, deleting, and moving workspace files
|
||||
|
||||
Workspace panels and workspace labels can write, delete, and move files through the documented `files` helper. Like `readFile`, PI WEB binds these helpers to the callback's machine and workspace, so they work the same for local and federated machines.
|
||||
|
||||
### Writing files
|
||||
|
||||
```js
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.generate",
|
||||
title: "Generate",
|
||||
render: ({ files }) => html`
|
||||
<button @click=${async () => {
|
||||
const result = await files.writeFile("output/result.txt", "Generated content\n");
|
||||
console.log("Wrote", result.path, result.size, "bytes");
|
||||
}}>Generate</button>
|
||||
`,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Binary writes
|
||||
|
||||
Pass a `Uint8Array` for binary content such as images:
|
||||
|
||||
```js
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||
await files.writeFile("screenshots/thumb.png", png);
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
`files.writeFile` accepts an optional third argument:
|
||||
|
||||
- `createDirs` (default `true`): create intermediate directories, like `mkdir -p`.
|
||||
- `overwrite` (default `true`): overwrite existing files. Set to `false` to throw if the file already exists.
|
||||
|
||||
```js
|
||||
// Create only — throw if the file already exists
|
||||
await files.writeFile("config/new-config.json", jsonContent, { overwrite: false });
|
||||
```
|
||||
|
||||
### Deleting files
|
||||
|
||||
`files.deleteFile` removes a workspace file. It is idempotent: deleting a file that does not exist returns `{ existed: false }` instead of throwing.
|
||||
|
||||
```js
|
||||
const result = await files.deleteFile("temp/cache.json");
|
||||
console.log(result.existed ? "File deleted" : "File did not exist");
|
||||
```
|
||||
|
||||
### Moving files
|
||||
|
||||
`files.moveFile` renames or moves a file within the workspace, like `mv`. The default is safe: it will not overwrite an existing target file.
|
||||
|
||||
```js
|
||||
// Rename a file
|
||||
await files.moveFile("old-name.txt", "new-name.txt");
|
||||
|
||||
// Move into a subdirectory (creates intermediate dirs by default)
|
||||
await files.moveFile("file.txt", "archive/file.txt");
|
||||
|
||||
// Overwrite an existing target
|
||||
await files.moveFile("incoming.txt", "current.txt", { overwrite: true });
|
||||
|
||||
// Move without creating intermediate directories
|
||||
await files.moveFile("file.txt", "deep/nested/file.txt", { createDirs: false }); // throws if dirs don't exist
|
||||
```
|
||||
|
||||
`files.moveFile` accepts an optional third argument:
|
||||
|
||||
- `createDirs` (default `true`): create intermediate directories for the target path.
|
||||
- `overwrite` (default `false`): overwrite the target file if it exists. The default is safer than `writeFile` because moving is a more destructive operation.
|
||||
|
||||
### Error handling
|
||||
|
||||
All file mutations share the same safety layer:
|
||||
|
||||
- `overwrite: false` on `writeFile` or existing target on `moveFile` (default) throws if the file already exists.
|
||||
- Path traversal (e.g., `../../etc/passwd`) is blocked by the workspace safety layer.
|
||||
- Writing to or moving to a path that is a directory returns an error.
|
||||
- Deleting a directory returns an error.
|
||||
- Intermediate directory creation with `createDirs: false` fails if the parent directory does not exist.
|
||||
|
||||
After any mutation (`writeFile`, `deleteFile`, or `moveFile`), the File Explorer updates automatically. No explicit `refreshFiles()` call is needed from plugin code. For label and badge updates, call `context.host.requestRender()` if the UI should reflect the change.
|
||||
|
||||
### Security
|
||||
|
||||
Plugins are trusted browser code. File writes go through the same path safety validation as reads — paths are resolved and checked to stay inside the workspace root.
|
||||
|
||||
## Running workspace terminal commands
|
||||
|
||||
Workspace panels can start terminal commands through the documented `terminal` helper. Commands run in the current workspace on the panel's machine.
|
||||
@@ -801,7 +921,7 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
|
||||
9. Add workspace panels for larger workspace UI.
|
||||
10. Add workspace labels for compact inline metadata.
|
||||
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
|
||||
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
|
||||
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, and `prompt`.
|
||||
13. Do not fetch PI WEB `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers.
|
||||
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
|
||||
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -167,6 +167,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 = {
|
||||
|
||||
@@ -37,6 +37,10 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||
ignoreParseFailure(workspacesApi.writeWorkspaceFile("p 1", "w 1", "README.md", "hello", { overwrite: false }, machineId)),
|
||||
ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||
ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
|
||||
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
|
||||
@@ -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, 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";
|
||||
@@ -1216,6 +1216,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1387,9 +1402,35 @@ 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 },
|
||||
selection: { anchor: sel.from + text.length },
|
||||
});
|
||||
},
|
||||
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) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createPluginRuntimeContext(): PluginRuntimeContext {
|
||||
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
|
||||
state: this.state,
|
||||
prompt: this.createPromptEditor(),
|
||||
piWebUnstable: {
|
||||
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
|
||||
openSettings: (section) => { this.openSettings(section); },
|
||||
|
||||
@@ -117,6 +117,11 @@ 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;
|
||||
}
|
||||
|
||||
private renderCompactStatus() {
|
||||
const status = this.status;
|
||||
if (status === undefined) return null;
|
||||
|
||||
@@ -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,11 @@ 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),
|
||||
},
|
||||
piWebUnstable: {
|
||||
terminalCommandRuns: {
|
||||
runCommand: vi.fn(),
|
||||
@@ -335,7 +340,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 +550,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 +567,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 +618,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,15 @@ 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;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
state: AppState;
|
||||
prompt: PluginPromptEditor;
|
||||
piWebUnstable?: PiWebUnstableRuntimeContext;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
|
||||
+28
-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,20 @@ 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;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
state: PluginRuntimeState;
|
||||
prompt: PluginPromptEditor;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
@@ -109,7 +126,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;
|
||||
|
||||
@@ -557,6 +557,235 @@ describe("buildApp", () => {
|
||||
expect(deniedResponse.statusCode).toBe(400);
|
||||
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
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 });
|
||||
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
|
||||
|
||||
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
|
||||
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
|
||||
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
|
||||
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
|
||||
|
||||
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);
|
||||
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
|
||||
|
||||
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);
|
||||
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
|
||||
|
||||
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);
|
||||
|
||||
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");
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
|
||||
|
||||
const noPathResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
expect(noPathResponse.json<{ error: string }>().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");
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
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" });
|
||||
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
|
||||
|
||||
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);
|
||||
|
||||
const noParamsResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
|
||||
});
|
||||
expect(noParamsResponse.statusCode).toBe(400);
|
||||
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
|
||||
});
|
||||
});
|
||||
|
||||
interface CapturedSessionDaemonRequest {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
|
||||
import type { PiWebConfigService } from "./configRoutes.js";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
|
||||
export interface WorkspaceExplorerRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
|
||||
registerWorkspaceFileContentParsers(app);
|
||||
|
||||
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);
|
||||
@@ -32,6 +35,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 writeOptions: WriteWorkspaceFileOptions = {
|
||||
createDirs: request.query.createDirs !== "false",
|
||||
overwrite: request.query.overwrite !== "false",
|
||||
};
|
||||
return await writeWorkspaceFile(context.root, request.query.path, request.body, writeOptions);
|
||||
} 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);
|
||||
@@ -61,3 +98,12 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerWorkspaceFileContentParsers(app: FastifyInstance): void {
|
||||
// Fastify's default parser only handles JSON; workspace file writes need to
|
||||
// accept text and arbitrary binary payloads. This route module is registered
|
||||
// for both local aliases, so parser registration must tolerate repeats.
|
||||
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_request, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/u, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
@@ -113,3 +114,262 @@ 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");
|
||||
});
|
||||
|
||||
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "subdir"), { recursive: true });
|
||||
// A real file living outside the workspace that must not be deletable.
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
|
||||
roots.push(outsideDir);
|
||||
await writeFile(join(outsideDir, "victim.txt"), "important");
|
||||
// A symlinked parent directory inside the workspace pointing outside.
|
||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||
|
||||
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
|
||||
// The outside file must survive.
|
||||
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
|
||||
expect(realContent).toBe("important");
|
||||
});
|
||||
});
|
||||
|
||||
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,9 @@
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import type { FileContentResponse, PiWebPathAccessConfig } 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, PiWebPathAccessConfig, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
|
||||
import { imageMimeTypeForPath } from "./imagePreviewService.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
@@ -39,6 +41,111 @@ 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 { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
||||
try {
|
||||
// Resolve symlinks in the parent path to prevent escape via a symlinked
|
||||
// parent directory. The final path component is intentionally NOT resolved
|
||||
// so that lstat/unlink act on the entry itself (deleting a symlink rather
|
||||
// than the file it points to).
|
||||
const realParent = await realpath(dirname(target));
|
||||
const realTarget = join(realParent, basename(target));
|
||||
ensureInside(root, realTarget);
|
||||
const s = await lstat(realTarget);
|
||||
// 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(realTarget);
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
|
||||
export async function resolveInsideWorkspace(rootPath: string, relativePath: string | undefined): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
const joined = join(root, requested);
|
||||
const target = await realpath(joined).catch((error: unknown) => {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error("Path does not exist");
|
||||
throw error;
|
||||
});
|
||||
ensureInside(root, target);
|
||||
return { root, target, relativePath: requested };
|
||||
}
|
||||
|
||||
export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
@@ -18,7 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string {
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string): void {
|
||||
export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -315,6 +315,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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FederatedHttpMethod = "GET" | "POST" | "DELETE";
|
||||
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export interface FederatedHttpRouteSpec {
|
||||
method: FederatedHttpMethod;
|
||||
@@ -15,6 +15,9 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "PUT", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/file/move" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/files" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
||||
|
||||
Reference in New Issue
Block a user