@restregular/dsh-filekit 0.0.0-stage → 0.2.2

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/lib/filekit.js ADDED
@@ -0,0 +1,586 @@
1
+ /**
2
+ * dsh-filekit — shared workspace file operations.
3
+ *
4
+ * Both callers use this module: the loopback HTTP route serving the browser UI
5
+ * and the `filekit` model tool. Every path is confined to one workspace root,
6
+ * deletes are recoverable moves into `<root>/.dsh-trash`, and every mutation
7
+ * returns enough information for the caller to describe the change to the
8
+ * Agent.
9
+ *
10
+ * @module dsh-filekit/lib/filekit
11
+ */
12
+
13
+ import { randomBytes, randomUUID } from 'node:crypto';
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ /** Errors carry a stable code the client can localize. */
18
+ export class FileKitError extends Error {
19
+ /**
20
+ * @param {string} code - stable machine code (for example `outside-workspace`).
21
+ * @param {string} message - human-readable English fallback.
22
+ */
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.name = 'FileKitError';
26
+ this.code = code;
27
+ }
28
+ }
29
+
30
+ /** @param {string} code @param {string} message @returns {never} */
31
+ function fail(code, message) {
32
+ throw new FileKitError(code, message);
33
+ }
34
+
35
+ /** Default trash directory name inside a workspace root. */
36
+ export const DEFAULT_TRASH_DIR = '.dsh-trash';
37
+
38
+ /** Default maximum text file size the editor will load. */
39
+ export const DEFAULT_MAX_TEXT_BYTES = 2 * 1024 * 1024;
40
+
41
+ const ENTRY_ID = /^[a-z0-9]+-[a-z0-9]+$/u;
42
+
43
+ /**
44
+ * Normalize a workspace-relative path supplied over the wire.
45
+ *
46
+ * Accepts `/` or `\` separators, rejects absolute paths and `..` segments.
47
+ * @param {unknown} value - raw input.
48
+ * @param {{ allowRoot?: boolean }} [options] - whether the empty path is legal.
49
+ * @returns {string} normalized relative path with `/` separators.
50
+ */
51
+ export function normalizeRel(value, options = {}) {
52
+ const allowRoot = options.allowRoot === true;
53
+ let raw = typeof value === 'string' ? value : '';
54
+ raw = raw.replace(/\\/gu, '/');
55
+ if (raw.startsWith('/') || /^[a-zA-Z]:/u.test(raw)) fail('bad-path', 'Path must be workspace-relative.');
56
+ const rel = raw.replace(/^\/+|\/+$/gu, '');
57
+ if (rel === '') {
58
+ if (allowRoot) return '';
59
+ fail('bad-path', 'A path inside the workspace is required.');
60
+ }
61
+ const segments = rel.split('/');
62
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
63
+ fail('bad-path', 'Path contains an invalid segment.');
64
+ }
65
+ if (rel.includes('\0')) fail('bad-path', 'Path contains a NUL character.');
66
+ return rel;
67
+ }
68
+
69
+ /**
70
+ * Validate a single new entry name (rename / create).
71
+ * @param {unknown} value - proposed name.
72
+ * @returns {string} the unchanged name.
73
+ */
74
+ export function normalizeName(value) {
75
+ const name = typeof value === 'string' ? value.trim() : '';
76
+ if (name === '' || name === '.' || name === '..') fail('bad-name', 'Name is empty or reserved.');
77
+ if (name.includes('/') || name.includes('\\') || name.includes('\0')) {
78
+ fail('bad-name', 'Name cannot contain a path separator.');
79
+ }
80
+ // eslint-disable-next-line no-control-regex
81
+ if (/[<>:"|?*\u0000-\u001f]/u.test(name)) fail('bad-name', 'Name contains characters the filesystem rejects.');
82
+ if (name.length > 200) fail('bad-name', 'Name is too long.');
83
+ if (/[. ]$/u.test(name)) fail('bad-name', 'Name cannot end with a dot or space.');
84
+ const stem = name.split('.')[0]?.toUpperCase() ?? '';
85
+ if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/u.test(stem)) fail('bad-name', 'Name is reserved on Windows.');
86
+ return name;
87
+ }
88
+
89
+ /** Case-insensitive containment check on already-resolved paths. */
90
+ function within(parent, child) {
91
+ const rel = path.relative(parent, child);
92
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
93
+ }
94
+
95
+ /** Join a normalized relative path onto a root with native separators. */
96
+ function absOf(root, rel) {
97
+ return rel === '' ? root : path.join(root, rel.split('/').join(path.sep));
98
+ }
99
+
100
+ /** Relative path with `/` separators. */
101
+ function relOf(root, abs) {
102
+ const rel = path.relative(root, abs);
103
+ return rel === '' ? '' : rel.split(path.sep).join('/');
104
+ }
105
+
106
+ /** @param {string} abs @returns {Promise<import('node:fs').Stats | undefined>} */
107
+ async function statOrNull(abs) {
108
+ try {
109
+ return await fs.stat(abs);
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /** @param {string} abs @returns {Promise<import('node:fs').Stats | undefined>} */
116
+ async function lstatOrNull(abs) {
117
+ try {
118
+ return await fs.lstat(abs);
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Resolve and validate a workspace root.
126
+ * @param {unknown} value - absolute directory path.
127
+ * @returns {Promise<string>} canonical absolute root.
128
+ */
129
+ export async function resolveRoot(value) {
130
+ if (typeof value !== 'string' || value.trim() === '' || !path.isAbsolute(value)) {
131
+ fail('bad-root', 'A workspace root absolute path is required.');
132
+ }
133
+ const root = path.resolve(value);
134
+ const stat = await statOrNull(root);
135
+ if (stat === undefined) fail('no-root', 'Workspace root does not exist.');
136
+ if (!stat.isDirectory()) fail('bad-root', 'Workspace root is not a directory.');
137
+ return root;
138
+ }
139
+
140
+ /**
141
+ * Ensure an existing target really lives inside the root (symlinks included).
142
+ * @param {string} root - canonical root.
143
+ * @param {string} abs - target path.
144
+ * @returns {Promise<void>} resolves when safe.
145
+ */
146
+ async function assertExistingInside(root, abs) {
147
+ let realRoot;
148
+ let realTarget;
149
+ try {
150
+ realRoot = await fs.realpath(root);
151
+ realTarget = await fs.realpath(abs);
152
+ } catch {
153
+ fail('not-found', 'Path does not exist.');
154
+ }
155
+ if (!within(realRoot, realTarget)) fail('outside-workspace', 'Path escapes the workspace.');
156
+ }
157
+
158
+ /**
159
+ * Ensure a not-yet-existing target's parent really lives inside the root.
160
+ * @param {string} root - canonical root.
161
+ * @param {string} abs - target path (parent must exist).
162
+ * @returns {Promise<void>} resolves when safe.
163
+ */
164
+ async function assertParentInside(root, abs) {
165
+ let realRoot;
166
+ let realParent;
167
+ try {
168
+ realRoot = await fs.realpath(root);
169
+ realParent = await fs.realpath(path.dirname(abs));
170
+ } catch {
171
+ fail('parent-missing', 'Parent directory does not exist.');
172
+ }
173
+ if (!within(realRoot, realParent)) fail('outside-workspace', 'Path escapes the workspace.');
174
+ }
175
+
176
+ /** Refuse to write through an existing symbolic link. */
177
+ async function assertNotSymlink(abs) {
178
+ const stat = await lstatOrNull(abs);
179
+ if (stat?.isSymbolicLink() === true) fail('symlink', 'Refusing to write through a symbolic link.');
180
+ }
181
+
182
+ /** Trash directory path for a root. */
183
+ export function trashPathOf(root, trashDir = DEFAULT_TRASH_DIR) {
184
+ return path.join(root, trashDir);
185
+ }
186
+
187
+ /**
188
+ * List one directory.
189
+ * @param {string} root - workspace root.
190
+ * @param {string} rel - directory path relative to root ('' for root).
191
+ * @param {{ trashDir?: string }} [options] - conventions.
192
+ */
193
+ export async function listDirectory(root, rel, options = {}) {
194
+ const trashDir = options.trashDir ?? DEFAULT_TRASH_DIR;
195
+ const safeRel = normalizeRel(rel, { allowRoot: true });
196
+ const abs = absOf(root, safeRel);
197
+ const stat = await statOrNull(abs);
198
+ if (stat === undefined) fail('not-found', 'Directory does not exist.');
199
+ if (!stat.isDirectory()) fail('not-directory', 'Not a directory.');
200
+ await assertExistingInside(root, abs);
201
+ const dirents = await fs.readdir(abs, { withFileTypes: true });
202
+ /** @type {Array<Record<string, unknown>>} */
203
+ const entries = [];
204
+ for (const dirent of dirents) {
205
+ if (safeRel === '' && dirent.name === trashDir) continue;
206
+ const childAbs = path.join(abs, dirent.name);
207
+ const childRel = relOf(root, childAbs);
208
+ const symlink = dirent.isSymbolicLink();
209
+ const childStat = symlink ? await lstatOrNull(childAbs) : await statOrNull(childAbs);
210
+ const kind = dirent.isDirectory() ? 'directory' : dirent.isFile() ? 'file' : symlink ? 'symlink' : 'other';
211
+ entries.push({
212
+ name: dirent.name,
213
+ path: childRel,
214
+ kind,
215
+ symlink,
216
+ size: childStat !== undefined && childStat.isFile() ? childStat.size : undefined,
217
+ mtimeMs: childStat !== undefined ? childStat.mtimeMs : undefined,
218
+ });
219
+ }
220
+ entries.sort((a, b) => {
221
+ const rank = (entry) => (entry.kind === 'directory' ? 0 : entry.kind === 'file' ? 1 : 2);
222
+ const byKind = rank(a) - rank(b);
223
+ if (byKind !== 0) return byKind;
224
+ return String(a.name).toLowerCase().localeCompare(String(b.name).toLowerCase());
225
+ });
226
+ return { root, path: safeRel, entries };
227
+ }
228
+
229
+ /**
230
+ * Read a text file for the editor.
231
+ * @param {string} root - workspace root.
232
+ * @param {string} rel - file path relative to root.
233
+ * @param {{ maxTextBytes?: number }} [options] - size cap.
234
+ */
235
+ export async function readTextFile(root, rel, options = {}) {
236
+ const maxTextBytes = options.maxTextBytes ?? DEFAULT_MAX_TEXT_BYTES;
237
+ const safeRel = normalizeRel(rel);
238
+ const abs = absOf(root, safeRel);
239
+ const stat = await statOrNull(abs);
240
+ if (stat === undefined) fail('not-found', 'File does not exist.');
241
+ if (!stat.isFile()) fail('not-file', 'Not a regular file.');
242
+ await assertExistingInside(root, abs);
243
+ const readBytes = Math.min(stat.size, maxTextBytes);
244
+ const handle = await fs.open(abs, 'r');
245
+ let bytes;
246
+ try {
247
+ bytes = Buffer.alloc(readBytes);
248
+ const { bytesRead } = await handle.read(bytes, 0, readBytes, 0);
249
+ bytes = bytes.subarray(0, bytesRead);
250
+ } finally {
251
+ await handle.close();
252
+ }
253
+ const probe = bytes.subarray(0, Math.min(bytes.length, 8192));
254
+ const binary = probe.includes(0);
255
+ return {
256
+ root,
257
+ path: safeRel,
258
+ size: stat.size,
259
+ mtimeMs: stat.mtimeMs,
260
+ truncated: stat.size > maxTextBytes,
261
+ binary,
262
+ text: binary ? '' : bytes.toString('utf8'),
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Write UTF-8 text atomically.
268
+ * @param {string} root - workspace root.
269
+ * @param {string} rel - file path relative to root.
270
+ * @param {string} text - new contents.
271
+ * @param {{ expectedMtimeMs?: number, maxTextBytes?: number }} [options] - conflict guard and cap.
272
+ */
273
+ export async function writeTextFile(root, rel, text, options = {}) {
274
+ if (typeof text !== 'string') fail('bad-input', 'Text content is required.');
275
+ if (Buffer.byteLength(text, 'utf8') > (options.maxTextBytes ?? DEFAULT_MAX_TEXT_BYTES) * 4) {
276
+ fail('too-large', 'File is too large for the editor.');
277
+ }
278
+ const safeRel = normalizeRel(rel);
279
+ const abs = absOf(root, safeRel);
280
+ const stat = await statOrNull(abs);
281
+ if (stat !== undefined) {
282
+ if (!stat.isFile()) fail('not-file', 'Not a regular file.');
283
+ await assertExistingInside(root, abs);
284
+ await assertNotSymlink(abs);
285
+ if (
286
+ typeof options.expectedMtimeMs === 'number' &&
287
+ Number.isFinite(options.expectedMtimeMs) &&
288
+ Math.abs(stat.mtimeMs - options.expectedMtimeMs) > 2
289
+ ) {
290
+ fail('conflict', 'File changed on disk since it was opened.');
291
+ }
292
+ } else {
293
+ await assertParentInside(root, abs);
294
+ }
295
+ const dir = path.dirname(abs);
296
+ const tmp = path.join(dir, `.dsh-filekit-${randomUUID()}.tmp`);
297
+ await fs.writeFile(tmp, text, 'utf8');
298
+ try {
299
+ await fs.rename(tmp, abs);
300
+ } catch (error) {
301
+ await fs.rm(tmp, { force: true }).catch(() => {});
302
+ throw error;
303
+ }
304
+ const next = await fs.stat(abs);
305
+ return {
306
+ root,
307
+ path: safeRel,
308
+ size: next.size,
309
+ mtimeMs: next.mtimeMs,
310
+ lines: text === '' ? 0 : text.split(/\r\n|\r|\n/u).length,
311
+ created: stat === undefined,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Create one file or directory (non-recursive).
317
+ * @param {string} root - workspace root.
318
+ * @param {string} rel - new entry path.
319
+ * @param {'file' | 'directory'} kind - entry kind.
320
+ */
321
+ export async function createEntry(root, rel, kind) {
322
+ if (kind !== 'file' && kind !== 'directory') fail('bad-input', 'kind must be file or directory.');
323
+ const safeRel = normalizeRel(rel);
324
+ const abs = absOf(root, safeRel);
325
+ if ((await lstatOrNull(abs)) !== undefined) fail('exists', 'An entry with that name already exists.');
326
+ await assertParentInside(root, abs);
327
+ if (kind === 'directory') await fs.mkdir(abs);
328
+ else await fs.writeFile(abs, '', { flag: 'wx' });
329
+ return { root, path: safeRel, kind };
330
+ }
331
+
332
+ /**
333
+ * Rename one entry in place.
334
+ * @param {string} root - workspace root.
335
+ * @param {string} rel - existing entry path.
336
+ * @param {string} name - new entry name.
337
+ */
338
+ export async function renameEntry(root, rel, name) {
339
+ const safeRel = normalizeRel(rel);
340
+ const newName = normalizeName(name);
341
+ const abs = absOf(root, safeRel);
342
+ if (safeRel === '') fail('protected', 'The workspace root cannot be renamed.');
343
+ const stat = await lstatOrNull(abs);
344
+ if (stat === undefined) fail('not-found', 'Entry does not exist.');
345
+ await assertExistingInside(root, abs);
346
+ const target = path.join(path.dirname(abs), newName);
347
+ if (path.resolve(target) === path.resolve(abs)) return { root, path: safeRel, from: safeRel, unchanged: true };
348
+ if ((await lstatOrNull(target)) !== undefined) fail('exists', 'An entry with that name already exists.');
349
+ await assertParentInside(root, target);
350
+ await fs.rename(abs, target);
351
+ return { root, path: relOf(root, target), from: safeRel };
352
+ }
353
+
354
+ /**
355
+ * Move one entry into another directory.
356
+ * @param {string} root - workspace root.
357
+ * @param {string} rel - existing entry path.
358
+ * @param {string} toDirRel - destination directory.
359
+ * @param {{ unique?: boolean }} [options] - `unique` picks a free "name (n)" when the name is taken.
360
+ */
361
+ export async function moveEntry(root, rel, toDirRel, options = {}) {
362
+ const safeRel = normalizeRel(rel);
363
+ const toDir = normalizeRel(toDirRel, { allowRoot: true });
364
+ if (safeRel === '') fail('protected', 'The workspace root cannot be moved.');
365
+ const abs = absOf(root, safeRel);
366
+ if ((await lstatOrNull(abs)) === undefined) fail('not-found', 'Entry does not exist.');
367
+ await assertExistingInside(root, abs);
368
+ const dirAbs = absOf(root, toDir);
369
+ const dirStat = await statOrNull(dirAbs);
370
+ if (dirStat === undefined) fail('not-found', 'Destination directory does not exist.');
371
+ if (!dirStat.isDirectory()) fail('not-directory', 'Destination is not a directory.');
372
+ await assertExistingInside(root, dirAbs);
373
+ if (path.dirname(abs) === dirAbs) return { root, path: safeRel, from: safeRel, unchanged: true };
374
+ if (within(abs, dirAbs)) fail('bad-move', 'Cannot move a directory into itself.');
375
+ const name = path.basename(abs);
376
+ const target =
377
+ (await lstatOrNull(path.join(dirAbs, name))) === undefined || options.unique !== true
378
+ ? path.join(dirAbs, name)
379
+ : path.join(dirAbs, await uniqueTargetName(dirAbs, name));
380
+ if ((await lstatOrNull(target)) !== undefined) fail('exists', 'Destination already has an entry with that name.');
381
+ await fs.rename(abs, target);
382
+ return { root, path: relOf(root, target), from: safeRel };
383
+ }
384
+
385
+ /**
386
+ * Copy one entry into another directory.
387
+ * @param {string} root - workspace root.
388
+ * @param {string} rel - source entry path.
389
+ * @param {string} toDirRel - destination directory.
390
+ * @param {{ unique?: boolean }} [options] - `unique` picks a free "name (n)" when the name is taken.
391
+ */
392
+ export async function copyEntry(root, rel, toDirRel, options = {}) {
393
+ const safeRel = normalizeRel(rel);
394
+ const toDir = normalizeRel(toDirRel, { allowRoot: true });
395
+ const abs = absOf(root, safeRel);
396
+ const source = await lstatOrNull(abs);
397
+ if (source === undefined) fail('not-found', 'Entry does not exist.');
398
+ await assertExistingInside(root, abs);
399
+ if (source.isSymbolicLink()) fail('symlink', 'Copying symbolic links is not supported.');
400
+ const dirAbs = absOf(root, toDir);
401
+ const dirStat = await statOrNull(dirAbs);
402
+ if (dirStat === undefined) fail('not-found', 'Destination directory does not exist.');
403
+ if (!dirStat.isDirectory()) fail('not-directory', 'Destination is not a directory.');
404
+ await assertExistingInside(root, dirAbs);
405
+ if (within(abs, dirAbs)) fail('bad-copy', 'Cannot copy a directory into itself.');
406
+ const name = path.basename(abs);
407
+ const target =
408
+ (await lstatOrNull(path.join(dirAbs, name))) === undefined || options.unique !== true
409
+ ? path.join(dirAbs, name)
410
+ : path.join(dirAbs, await uniqueTargetName(dirAbs, name));
411
+ if ((await lstatOrNull(target)) !== undefined) fail('exists', 'Destination already has an entry with that name.');
412
+ if (source.isDirectory()) {
413
+ await fs.cp(abs, target, { recursive: true, force: false, errorOnExist: true, preserveTimestamps: true });
414
+ } else {
415
+ await fs.copyFile(abs, target, fs.constants.COPYFILE_EXCL);
416
+ }
417
+ return { root, path: relOf(root, target), from: safeRel };
418
+ }
419
+
420
+ /**
421
+ * First free `stem (n).ext` (or `name (n)` for directories) in one directory.
422
+ * @param {string} dirAbs - destination directory (absolute).
423
+ * @param {string} name - original entry name.
424
+ * @returns {Promise<string>} a name no entry currently uses.
425
+ */
426
+ async function uniqueTargetName(dirAbs, name) {
427
+ const ext = path.extname(name);
428
+ const stem = ext === '' ? name : name.slice(0, -ext.length);
429
+ for (let index = 2; index < 1000; index += 1) {
430
+ const candidate = `${stem} (${index})${ext}`;
431
+ if ((await lstatOrNull(path.join(dirAbs, candidate))) === undefined) return candidate;
432
+ }
433
+ fail('exists', 'Could not find a free name for the pasted entry.');
434
+ }
435
+
436
+ /**
437
+ * Ensure the trash directory exists.
438
+ * @param {string} root - workspace root.
439
+ * @param {string} trashDir - trash directory name.
440
+ */
441
+ async function ensureTrash(root, trashDir) {
442
+ const dir = trashPathOf(root, trashDir);
443
+ await fs.mkdir(dir, { recursive: true });
444
+ return dir;
445
+ }
446
+
447
+ /**
448
+ * Read one trash entry's metadata.
449
+ * @param {string} entryDir - entry directory.
450
+ * @returns {Promise<Record<string, unknown> | undefined>}
451
+ */
452
+ async function readTrashMeta(entryDir) {
453
+ try {
454
+ const raw = await fs.readFile(path.join(entryDir, 'meta.json'), 'utf8');
455
+ const meta = JSON.parse(raw);
456
+ return typeof meta === 'object' && meta !== null ? meta : undefined;
457
+ } catch {
458
+ return undefined;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Move one entry into the workspace trash.
464
+ * @param {string} root - workspace root.
465
+ * @param {string} rel - entry path.
466
+ * @param {{ trashDir?: string }} [options] - conventions.
467
+ */
468
+ export async function trashEntry(root, rel, options = {}) {
469
+ const trashDir = options.trashDir ?? DEFAULT_TRASH_DIR;
470
+ const safeRel = normalizeRel(rel);
471
+ if (safeRel === '') fail('protected', 'The workspace root cannot be deleted.');
472
+ if (safeRel === trashDir || safeRel.startsWith(trashDir + '/')) {
473
+ fail('protected', 'The trash directory is managed from the recycle bin view.');
474
+ }
475
+ const abs = absOf(root, safeRel);
476
+ const stat = await lstatOrNull(abs);
477
+ if (stat === undefined) fail('not-found', 'Entry does not exist.');
478
+ await assertExistingInside(root, abs);
479
+ const dir = await ensureTrash(root, trashDir);
480
+ const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
481
+ const entryDir = path.join(dir, id);
482
+ await fs.mkdir(entryDir);
483
+ const meta = {
484
+ id,
485
+ name: path.basename(abs),
486
+ originalPath: safeRel,
487
+ kind: stat.isDirectory() ? 'directory' : 'file',
488
+ deletedAt: new Date().toISOString(),
489
+ size: stat.isFile() ? stat.size : undefined,
490
+ mtimeMs: stat.mtimeMs,
491
+ };
492
+ await fs.writeFile(path.join(entryDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
493
+ try {
494
+ await fs.rename(abs, path.join(entryDir, 'item'));
495
+ } catch (error) {
496
+ await fs.rm(entryDir, { recursive: true, force: true }).catch(() => {});
497
+ throw error;
498
+ }
499
+ return { root, entry: meta };
500
+ }
501
+
502
+ /**
503
+ * List trash entries, newest first.
504
+ * @param {string} root - workspace root.
505
+ * @param {{ trashDir?: string }} [options] - conventions.
506
+ */
507
+ export async function listTrash(root, options = {}) {
508
+ const trashDir = options.trashDir ?? DEFAULT_TRASH_DIR;
509
+ const dir = trashPathOf(root, trashDir);
510
+ const stat = await statOrNull(dir);
511
+ if (stat === undefined || !stat.isDirectory()) return { root, entries: [] };
512
+ const dirents = await fs.readdir(dir, { withFileTypes: true });
513
+ /** @type {Array<Record<string, unknown>>} */
514
+ const entries = [];
515
+ for (const dirent of dirents) {
516
+ if (!dirent.isDirectory()) continue;
517
+ const entryDir = path.join(dir, dirent.name);
518
+ const meta = await readTrashMeta(entryDir);
519
+ if (meta === undefined) continue;
520
+ entries.push({ ...meta, id: dirent.name });
521
+ }
522
+ entries.sort((a, b) => String(b.deletedAt ?? '').localeCompare(String(a.deletedAt ?? '')));
523
+ return { root, entries };
524
+ }
525
+
526
+ /**
527
+ * Restore one trash entry to its original path.
528
+ * @param {string} root - workspace root.
529
+ * @param {string} id - trash entry id.
530
+ * @param {{ trashDir?: string }} [options] - conventions.
531
+ */
532
+ export async function restoreTrashEntry(root, id, options = {}) {
533
+ const trashDir = options.trashDir ?? DEFAULT_TRASH_DIR;
534
+ const safeId = String(id ?? '');
535
+ if (!ENTRY_ID.test(safeId)) fail('bad-entry', 'Invalid trash entry id.');
536
+ const entryDir = path.join(trashPathOf(root, trashDir), safeId);
537
+ const meta = await readTrashMeta(entryDir);
538
+ if (meta === undefined) fail('not-found', 'Trash entry does not exist.');
539
+ const item = path.join(entryDir, 'item');
540
+ if ((await lstatOrNull(item)) === undefined) fail('not-found', 'Trash entry payload is missing.');
541
+ const original = typeof meta.originalPath === 'string' ? meta.originalPath : '';
542
+ const safeRel = normalizeRel(original);
543
+ if (safeRel === trashDir || safeRel.startsWith(trashDir + '/')) fail('protected', 'Cannot restore into the trash directory.');
544
+ const target = absOf(root, safeRel);
545
+ await assertParentInside(root, target);
546
+ if ((await lstatOrNull(target)) !== undefined) fail('exists', 'The original path is occupied; move that entry first.');
547
+ await fs.rename(item, target);
548
+ await fs.rm(entryDir, { recursive: true, force: true });
549
+ return { root, path: safeRel, id: safeId };
550
+ }
551
+
552
+ /**
553
+ * Permanently remove one trash entry.
554
+ * @param {string} root - workspace root.
555
+ * @param {string} id - trash entry id.
556
+ * @param {{ trashDir?: string }} [options] - conventions.
557
+ */
558
+ export async function purgeTrashEntry(root, id, options = {}) {
559
+ const trashDir = options.trashDir ?? DEFAULT_TRASH_DIR;
560
+ const safeId = String(id ?? '');
561
+ if (!ENTRY_ID.test(safeId)) fail('bad-entry', 'Invalid trash entry id.');
562
+ const entryDir = path.join(trashPathOf(root, trashDir), safeId);
563
+ if ((await lstatOrNull(entryDir)) === undefined) fail('not-found', 'Trash entry does not exist.');
564
+ await fs.rm(entryDir, { recursive: true, force: true });
565
+ return { root, id: safeId };
566
+ }
567
+
568
+ /**
569
+ * Permanently remove every trash entry.
570
+ * @param {string} root - workspace root.
571
+ * @param {{ trashDir?: string }} [options] - conventions.
572
+ */
573
+ export async function emptyTrash(root, options = {}) {
574
+ const trashDir = options.trashDir ?? DEFAULT_TRASH_DIR;
575
+ const dir = trashPathOf(root, trashDir);
576
+ const stat = await statOrNull(dir);
577
+ if (stat === undefined || !stat.isDirectory()) return { root, count: 0 };
578
+ const dirents = await fs.readdir(dir, { withFileTypes: true });
579
+ let count = 0;
580
+ for (const dirent of dirents) {
581
+ if (!dirent.isDirectory()) continue;
582
+ await fs.rm(path.join(dir, dirent.name), { recursive: true, force: true });
583
+ count += 1;
584
+ }
585
+ return { root, count };
586
+ }
package/lib/notice.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * dsh-filekit — Agent change notices.
3
+ *
4
+ * The Agent inbox accepts complete Harness messages only. `agent.inject()` does
5
+ * not fill in defaults: whatever object it receives is committed to the session
6
+ * log as-is and is later serialised verbatim into the provider request. A
7
+ * hand-rolled `{ content, source }` object therefore has no `role`, and every
8
+ * later request in that session fails with
9
+ * `messages[N]: missing field `role`` (422, INVALID_REQUEST) — permanently,
10
+ * because the malformed record is already part of the session history.
11
+ *
12
+ * Notices are user-role messages with a stable identity: `id` keeps the inbox
13
+ * projection's pending-message identities unique, `role` lets the history
14
+ * serialiser emit a valid provider message.
15
+ *
16
+ * @module dsh-filekit/lib/notice
17
+ */
18
+
19
+ import { randomUUID } from 'node:crypto';
20
+
21
+ /**
22
+ * Build one immutable-looking user-role notice message for the Agent inbox.
23
+ * @param {string} text - the short, human-readable notice.
24
+ * @returns {{ id: string, role: 'user', content: { type: 'text', text: string }[], source: { kind: string } }}
25
+ */
26
+ export function buildChangeNotice(text) {
27
+ return {
28
+ id: randomUUID(),
29
+ role: 'user',
30
+ content: [{ type: 'text', text: String(text) }],
31
+ source: { kind: 'dsh-filekit' },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Structural check against the Harness user-message contract. Publishing a
37
+ * malformed message permanently breaks the session, so callers verify first.
38
+ * @param {unknown} message
39
+ * @returns {boolean}
40
+ */
41
+ export function isWellFormedUserMessage(message) {
42
+ if (message === null || typeof message !== 'object') return false;
43
+ if (typeof message.id !== 'string' || message.id === '') return false;
44
+ if (message.role !== 'user') return false;
45
+ if (!Array.isArray(message.content) || message.content.length === 0) return false;
46
+ if (!message.content.every((block) => block?.type === 'text' && typeof block.text === 'string')) return false;
47
+ return message.source?.kind === 'dsh-filekit';
48
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "FileKit file tools",
4
+ "description": "Right-click, drag, edit, and recoverably delete workspace files in the right sidebar, with changes reported to the Agent."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "FileKit 文件工具",
4
+ "description": "在右侧「工作区文件」面板中右键操作、拖拽移动、编辑与可恢复删除,并把变更自动告知 Agent。"
5
+ }
6
+ }