dsh-plugin-workbench 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -2,14 +2,27 @@
2
2
  * Host half of the workbench plugin.
3
3
  *
4
4
  * Registers one loopback-only generic RPC channel (`/dsh-plugin-files`) with
5
- * two endpoints, both implemented over `ctx.fs` (the sandboxed filesystem
6
- * service). Reads pass through untouched in every sandbox mode, so this
7
- * plugin only ever lists and reads it never mutates the workspace.
5
+ * list/read/write endpoints over `ctx.fs` (the sandboxed filesystem service),
6
+ * plus New File / New Folder / Rename / Delete for the explorer's context
7
+ * menu. Reads pass through untouched in every sandbox mode; the context-menu
8
+ * mutations and the editor write are explicit user actions over the
9
+ * loopback-only channel, run unfenced like the /api write tools — the sandbox
10
+ * service has no mkdir/rename/rm primitives, so those use node:fs/promises
11
+ * after the same `ctx.fs.resolve` → `processPath` resolution as every read.
12
+ *
13
+ * Image preview: files with a known image extension are never read over the
14
+ * RPC channel. Instead a same-origin web route (`ctx.webServer`) serves their
15
+ * raw bytes straight into the preview pane's `<img>` tag — the same pattern
16
+ * dsh-plugin-image-tools uses for inline chat images. The route resolves and
17
+ * stats the path through `ctx.fs` (so sandbox containment and file-ness apply
18
+ * exactly as for RPC reads) and only ever serves image extensions.
8
19
  */
20
+ import type { IncomingMessage, ServerResponse } from 'node:http'
21
+ import { mkdir, rename as renameFs, rm, writeFile as writeFileNode } from 'node:fs/promises'
9
22
  import type { Context } from '@deepseek-ai/cordis'
10
23
 
11
24
  export const name = 'dsh-plugin-workbench'
12
- export const inject = ['fs', 'connection']
25
+ export const inject = ['fs', 'connection', 'webServer']
13
26
 
14
27
  /** Loopback-only logical RPC channel. */
15
28
  export const CHANNEL = '/dsh-plugin-files'
@@ -17,6 +30,36 @@ export const CHANNEL = '/dsh-plugin-files'
17
30
  /** Files larger than this are never read for preview (client shows size + hint). */
18
31
  export const MAX_PREVIEW_BYTES = 512 * 1024
19
32
 
33
+ /** Same-origin route serving raw bytes for image files (see module doc). */
34
+ export const RAW_PREFIX = '/dsh-plugin-files/raw'
35
+
36
+ /** Images larger than this are never served to the preview (browser shows a hint). */
37
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
38
+
39
+ const IMAGE_MIME: Record<string, string> = {
40
+ png: 'image/png',
41
+ jpg: 'image/jpeg',
42
+ jpeg: 'image/jpeg',
43
+ gif: 'image/gif',
44
+ webp: 'image/webp',
45
+ avif: 'image/avif',
46
+ bmp: 'image/bmp',
47
+ ico: 'image/x-icon',
48
+ svg: 'image/svg+xml',
49
+ }
50
+
51
+ /** MIME type for a path's extension when it names a previewable image; undefined otherwise. */
52
+ export function imageMimeOf(path: string): string | undefined {
53
+ const idx = path.lastIndexOf('.')
54
+ if (idx < 0 || idx === path.length - 1) return undefined
55
+ return IMAGE_MIME[path.slice(idx + 1).toLowerCase()]
56
+ }
57
+
58
+ /** True when the path names a previewable image file. */
59
+ export function isImagePath(path: string): boolean {
60
+ return imageMimeOf(path) !== undefined
61
+ }
62
+
20
63
  export type FsKind = 'dir' | 'file' | 'other'
21
64
 
22
65
  export interface FsListEntry {
@@ -44,7 +87,12 @@ export interface FsWriteResult {
44
87
  size: number
45
88
  }
46
89
 
47
- export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult }
90
+ /** Result of a context-menu mutation (create/rename/delete). */
91
+ export interface FsMutationResult {
92
+ path: string
93
+ }
94
+
95
+ export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult | FsMutationResult }
48
96
  export type FilesRpcErr = { ok: false; error: { code: 'internal'; message: string; details: Record<string, never> } }
49
97
  export type FilesRpcResult = FilesRpcOk | FilesRpcErr
50
98
 
@@ -98,6 +146,15 @@ const FS_ERROR_MESSAGES: Record<string, string> = {
98
146
  FS_SANDBOX_DENIED: 'sandbox denied',
99
147
  FS_ABORTED: 'aborted',
100
148
  FS_IO_ERROR: 'io error',
149
+ // node:fs error codes surfaced by the context-menu mutations below.
150
+ EEXIST: 'file or folder already exists',
151
+ ENOENT: 'path does not exist',
152
+ ENOTEMPTY: 'folder is not empty',
153
+ EPERM: 'permission denied',
154
+ EACCES: 'permission denied',
155
+ ENOTDIR: 'not a directory',
156
+ EISDIR: 'is a directory',
157
+ EBUSY: 'file is in use',
101
158
  }
102
159
 
103
160
  /** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
@@ -135,9 +192,74 @@ export function apply(ctx: Context): void {
135
192
  if (endpoint === 'list') return listDir(ctx, payload, signal)
136
193
  if (endpoint === 'read') return readFile(ctx, payload, signal)
137
194
  if (endpoint === 'write') return writeFile(ctx, payload, signal)
195
+ if (endpoint === 'createFile') return createFile(ctx, payload, signal)
196
+ if (endpoint === 'createDir') return createDir(ctx, payload, signal)
197
+ if (endpoint === 'rename') return renameEntry(ctx, payload, signal)
198
+ if (endpoint === 'delete') return deleteEntry(ctx, payload, signal)
138
199
  return fail(`unknown endpoint: ${endpoint}`)
139
200
  }
140
- ctx.connection.rpc.handle(CHANNEL, handler, { authority: 'loopback' })
201
+ // Effect-wrapped so HMR/disable cycles dispose the channel (the connection
202
+ // service registers the HTTP carrier for the channel the same way); a plain
203
+ // call here would leak the route on reload and collide on re-apply.
204
+ ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: 'loopback' }), 'dsh-plugin-workbench: files rpc channel')
205
+
206
+ // Raw image bytes for the preview pane. The client builds the URL as
207
+ // `${RAW_PREFIX}/${encodeURIComponent(path)}`; the suffix is decoded back to
208
+ // the absolute path, then resolved/statted through the sandboxed fs service
209
+ // before any byte is read (containment parity with the RPC read endpoint).
210
+ ctx.effect(() => ctx.webServer.register({
211
+ kind: 'prefix',
212
+ path: RAW_PREFIX,
213
+ handler: (req, res) => {
214
+ void serveRaw(ctx, req, res)
215
+ },
216
+ }), 'dsh-plugin-workbench: raw image route')
217
+ }
218
+
219
+ async function serveRaw(ctx: Context, req: IncomingMessage, res: ServerResponse): Promise<void> {
220
+ const text = (code: number, body: string): void => {
221
+ res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8' })
222
+ res.end(body)
223
+ }
224
+ try {
225
+ if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
226
+ text(405, 'method not allowed')
227
+ return
228
+ }
229
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
230
+ const rest = url.pathname.slice(RAW_PREFIX.length).replace(/^\/+/, '')
231
+ if (rest.length === 0) {
232
+ text(404, 'not found')
233
+ return
234
+ }
235
+ const path = decodeURIComponent(rest)
236
+ const mime = imageMimeOf(path)
237
+ if (mime === undefined) {
238
+ text(404, 'not an image')
239
+ return
240
+ }
241
+ const target = await ctx.fs.resolve(path)
242
+ const info = await ctx.fs.stat(target)
243
+ if (info === undefined || info.type !== 'file') {
244
+ text(404, 'not found')
245
+ return
246
+ }
247
+ if ((info.size ?? 0) > MAX_IMAGE_BYTES) {
248
+ text(413, 'image too large')
249
+ return
250
+ }
251
+ const bytes = await ctx.fs.readBytes(target, undefined, MAX_IMAGE_BYTES)
252
+ res.writeHead(200, {
253
+ 'content-type': mime,
254
+ 'content-length': bytes.byteLength,
255
+ 'cache-control': 'private, max-age=300',
256
+ 'x-content-type-options': 'nosniff',
257
+ })
258
+ res.end(Buffer.from(bytes))
259
+ } catch (error) {
260
+ if (!res.headersSent) text(500, 'internal error')
261
+ else res.destroy()
262
+ }
141
263
  }
142
264
 
143
265
  async function listDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
@@ -201,3 +323,79 @@ async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): P
201
323
  return fail(mapError(error))
202
324
  }
203
325
  }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Context-menu mutations (New File / New Folder / Rename / Delete)
329
+ //
330
+ // The sandboxed `ctx.fs` service exposes no mkdir/rename/rm primitives, so
331
+ // these run node:fs/promises directly. Every path is first run through
332
+ // `ctx.fs.resolve` (which realpaths the nearest existing ancestor, so new
333
+ // targets resolve too) and the operation executes at `processPath(target)` —
334
+ // the same resolution the read/write endpoints use. Like `write`, these are
335
+ // explicit user actions over the loopback-only channel; nothing here is ever
336
+ // reached by a remote caller.
337
+ // ---------------------------------------------------------------------------
338
+
339
+ async function createFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
340
+ const path = pathOf(payload)
341
+ if (path === undefined) return fail('createFile: payload.path must be a non-empty string')
342
+ try {
343
+ const target = await ctx.fs.resolve(path, { signal })
344
+ const osPath = ctx.fs.processPath(target)
345
+ // 'wx' fails when the file already exists (VS Code New File semantics).
346
+ await writeFileNode(osPath, '', { flag: 'wx' })
347
+ return { ok: true, value: { path: osPath } }
348
+ } catch (error) {
349
+ return fail(mapError(error))
350
+ }
351
+ }
352
+
353
+ async function createDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
354
+ const path = pathOf(payload)
355
+ if (path === undefined) return fail('createDir: payload.path must be a non-empty string')
356
+ try {
357
+ const target = await ctx.fs.resolve(path, { signal })
358
+ const osPath = ctx.fs.processPath(target)
359
+ // Non-recursive: the parent must already exist; EEXIST when the folder does.
360
+ await mkdir(osPath)
361
+ return { ok: true, value: { path: osPath } }
362
+ } catch (error) {
363
+ return fail(mapError(error))
364
+ }
365
+ }
366
+
367
+ async function renameEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
368
+ const path = pathOf(payload)
369
+ const to = typeof payload === 'object' && payload !== null ? (payload as { to?: unknown }).to : undefined
370
+ if (path === undefined) return fail('rename: payload.path must be a non-empty string')
371
+ if (typeof to !== 'string' || to.trim().length === 0) return fail('rename: payload.to must be a non-empty string')
372
+ try {
373
+ const target = await ctx.fs.resolve(path, { signal })
374
+ const osPath = ctx.fs.processPath(target)
375
+ const toTarget = await ctx.fs.resolve(to, { signal })
376
+ const toOsPath = ctx.fs.processPath(toTarget)
377
+ // fs.rename overwrites silently on POSIX; refuse when the destination exists.
378
+ const existing = await ctx.fs.stat(toTarget, signal)
379
+ if (existing !== undefined) return fail('rename: destination already exists')
380
+ await renameFs(osPath, toOsPath)
381
+ return { ok: true, value: { path: toOsPath } }
382
+ } catch (error) {
383
+ return fail(mapError(error))
384
+ }
385
+ }
386
+
387
+ async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
388
+ const path = pathOf(payload)
389
+ if (path === undefined) return fail('delete: payload.path must be a non-empty string')
390
+ try {
391
+ const target = await ctx.fs.resolve(path, { signal })
392
+ const osPath = ctx.fs.processPath(target)
393
+ const info = await ctx.fs.stat(target, signal)
394
+ if (info === undefined) return fail(`path not found: ${path}`)
395
+ // Folders are removed recursively (the client confirms before calling).
396
+ await rm(osPath, { recursive: info.type === 'directory', force: true })
397
+ return { ok: true, value: { path: osPath } }
398
+ } catch (error) {
399
+ return fail(mapError(error))
400
+ }
401
+ }