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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add file mutation, prompt editor, and attachment APIs to the plugin system, completing the stable workspace interaction surface.
|
||||
@@ -10,3 +10,7 @@ dist/
|
||||
|
||||
# Local runtime attachment uploads (created by the chat composer "save to folder" mode).
|
||||
.pi-web/
|
||||
|
||||
# Local paste upload directory and temporary working docs.
|
||||
.pi-paste/
|
||||
docs/tmp/
|
||||
|
||||
+170
-5
@@ -439,6 +439,7 @@ interface PluginRuntimeContext {
|
||||
selectedSession?: unknown;
|
||||
piWebStatus?: PiWebStatusResponse;
|
||||
};
|
||||
prompt: PluginPromptEditor;
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
@@ -464,6 +465,74 @@ 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`. |
|
||||
| `onPaste(handler)` | Register a paste handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. |
|
||||
| `onKeyDown(handler)` | Register a keydown handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. |
|
||||
| `focus()` | Focus the prompt editor. |
|
||||
|
||||
Usage:
|
||||
|
||||
```js
|
||||
// Insert text at cursor
|
||||
context.prompt.insertText("@file.txt");
|
||||
|
||||
// Intercept paste events
|
||||
const unsub = context.prompt.onPaste((event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (items?.[0]?.type.startsWith("image/")) {
|
||||
// Handle image paste
|
||||
return true; // consume the event
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// Later, when the plugin no longer needs the handler:
|
||||
unsub();
|
||||
```
|
||||
|
||||
Handlers registered via `onPaste` and `onKeyDown` are scoped to the prompt editor using CodeMirror's extension system. They run in registration order: if a handler returns `true` to consume the event, subsequent plugin handlers for the same event will not run (first-to-consume-wins). Register handlers early if your plugin needs to intercept events before others. Handlers are automatically cleaned up when the editor is destroyed. Call the returned unsubscribe function when your plugin no longer needs the handler. Do not use `document.addEventListener` for prompt interactions — raw DOM listeners are not scoped to the editor, can leak memory, and may break across PI WEB upgrades.
|
||||
|
||||
`focusPrompt()` on `PluginRuntimeContext` is kept for backward compatibility. `prompt.focus()` is the preferred path.
|
||||
|
||||
### Attachment API
|
||||
|
||||
The `attachments` helper on `PluginRuntimeContext` manages file references in the chat prompt:
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `insertFileReference(path)` | Validate a workspace file exists and insert `@path` at the cursor. Returns the reference string. Throws if no workspace is selected or the file does not exist. |
|
||||
| `getAttachedFiles()` | Returns an array of file paths currently referenced in the prompt (without the `@` prefix). |
|
||||
| `removeFileReference(path)` | Remove the first occurrence of `@path` from the prompt. |
|
||||
|
||||
Usage:
|
||||
|
||||
```js
|
||||
// Save a file, then attach it
|
||||
const result = await context.files.writeFile(".pi-paste/screenshot.png", imageBytes);
|
||||
const ref = await context.attachments.insertFileReference(result.path);
|
||||
// ref is "@.pi-paste/screenshot.png"
|
||||
|
||||
// Check what's attached
|
||||
const files = context.attachments.getAttachedFiles();
|
||||
// files is [".pi-paste/screenshot.png"]
|
||||
|
||||
// Remove it
|
||||
context.attachments.removeFileReference(".pi-paste/screenshot.png");
|
||||
```
|
||||
|
||||
`insertFileReference` validates the file exists using `files.readFile()` before inserting the `@path` reference. Use `files.writeFile()` to create the file first, then `attachments.insertFileReference()` to attach it.
|
||||
|
||||
`getAttachedFiles()` uses a pattern that matches `@path/to/file.ext` — it requires a file extension (`.something`) to avoid matching email addresses like `user@example.com`. Paths are returned without the `@` prefix.
|
||||
|
||||
`removeFileReference(path)` removes the first occurrence of `@path` in the prompt text. If the path is not found, it does nothing.
|
||||
|
||||
#### 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 +590,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 +611,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 +679,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 +689,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 +828,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 +966,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`, `prompt`, and `attachments`.
|
||||
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.
|
||||
@@ -811,13 +976,13 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
|
||||
Check discovery:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
|
||||
curl http://localhost:8504/pi-web-plugins/manifest.json
|
||||
```
|
||||
|
||||
Check a plugin module:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js
|
||||
curl http://localhost:8504/pi-web-plugins/my-plugin/pi-web-plugin.js
|
||||
```
|
||||
|
||||
Common issues:
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"prepublishOnly": "npm run verify",
|
||||
"publish:npm": "npm publish --access public",
|
||||
"prepare": "node scripts/install-git-hooks.mjs",
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"changeset": "changeset",
|
||||
"release:version": "changeset version",
|
||||
"changelog:status": "changeset status"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { chmodSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* node-pty 1.1.0 ships macOS prebuilds with `spawn-helper` files at 644
|
||||
* instead of 755, causing `posix_spawnp failed` at runtime. This script
|
||||
* fixes permissions after install on Darwin platforms.
|
||||
*/
|
||||
function fixNodePtyPermissions() {
|
||||
if (process.platform === "win32") return;
|
||||
const prebuildsDir = join("node_modules", "node-pty", "prebuilds");
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(prebuildsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const helper = join(prebuildsDir, entry.name, "spawn-helper");
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(helper);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stats.isFile()) continue;
|
||||
// 0o100 = regular file, 0o111 = owner/group/other execute
|
||||
if ((stats.mode & 0o111) === 0) {
|
||||
chmodSync(helper, 0o755);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixNodePtyPermissions();
|
||||
@@ -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