jettypod 4.3.0 → 4.4.1

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.
@@ -0,0 +1,794 @@
1
+ /**
2
+ * Safe Delete Module
3
+ *
4
+ * Provides safe wrappers around fs deletion operations with:
5
+ * - Path validation to prevent catastrophic deletions
6
+ * - Audit logging for forensic analysis
7
+ * - Boundary enforcement (only delete within allowed paths)
8
+ *
9
+ * NEVER bypass these functions with direct fs.rmSync() calls.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+
16
+ /**
17
+ * Forbidden paths that should NEVER be deleted
18
+ */
19
+ const FORBIDDEN_PATHS = [
20
+ '/',
21
+ '/bin',
22
+ '/sbin',
23
+ '/usr',
24
+ '/etc',
25
+ '/var',
26
+ '/tmp',
27
+ '/home',
28
+ '/Users',
29
+ '/System',
30
+ '/Library',
31
+ '/Applications',
32
+ os.homedir(),
33
+ ];
34
+
35
+ /**
36
+ * Allowed path patterns for deletion
37
+ * Paths must match one of these patterns to be deletable
38
+ */
39
+ const ALLOWED_PATTERNS = [
40
+ /^\/tmp\/jettypod-test-/, // Test directories
41
+ /^\/var\/folders\/.*\/jettypod-test-/, // macOS temp directories
42
+ /\.jettypod-work\//, // Worktree directories
43
+ /\.jettypod-trash\//, // Trash directories
44
+ /\.jettypod\/.*\.backup/, // Backup directories
45
+ /\.git\/jettypod-backups\//, // Git-stored backups
46
+ ];
47
+
48
+ /**
49
+ * Deletion log entry
50
+ */
51
+ function logDeletion(targetPath, operation, success, error = null, metadata = {}) {
52
+ const logEntry = {
53
+ timestamp: new Date().toISOString(),
54
+ operation,
55
+ path: targetPath,
56
+ success,
57
+ error: error ? error.message : null,
58
+ pid: process.pid,
59
+ cwd: process.cwd(),
60
+ ...metadata
61
+ };
62
+
63
+ // Try to write to .jettypod/deletion-log.json
64
+ try {
65
+ const logDir = path.join(process.cwd(), '.jettypod');
66
+ const logPath = path.join(logDir, 'deletion-log.json');
67
+
68
+ let logs = [];
69
+ if (fs.existsSync(logPath)) {
70
+ try {
71
+ logs = JSON.parse(fs.readFileSync(logPath, 'utf8'));
72
+ } catch {
73
+ logs = [];
74
+ }
75
+ }
76
+
77
+ logs.push(logEntry);
78
+
79
+ // Keep only last 1000 entries
80
+ if (logs.length > 1000) {
81
+ logs = logs.slice(-1000);
82
+ }
83
+
84
+ if (fs.existsSync(logDir)) {
85
+ fs.writeFileSync(logPath, JSON.stringify(logs, null, 2));
86
+ }
87
+ } catch {
88
+ // Logging failure should not block operation
89
+ console.warn('Warning: Could not write to deletion log');
90
+ }
91
+
92
+ return logEntry;
93
+ }
94
+
95
+ /**
96
+ * Validate that a path is safe to delete
97
+ *
98
+ * @param {string} targetPath - Path to validate
99
+ * @param {string} gitRoot - Git repository root (optional, for additional validation)
100
+ * @returns {Object} { safe: boolean, reason: string }
101
+ */
102
+ function validatePath(targetPath, gitRoot = null) {
103
+ if (!targetPath || typeof targetPath !== 'string') {
104
+ return { safe: false, reason: 'Invalid path: must be a non-empty string' };
105
+ }
106
+
107
+ // Resolve to absolute path
108
+ const resolved = path.resolve(targetPath);
109
+
110
+ // Check against forbidden paths
111
+ for (const forbidden of FORBIDDEN_PATHS) {
112
+ if (resolved === forbidden || resolved === path.resolve(forbidden)) {
113
+ return { safe: false, reason: `Forbidden path: ${forbidden}` };
114
+ }
115
+ }
116
+
117
+ // Check if path is the home directory or a direct child
118
+ const home = os.homedir();
119
+ if (resolved === home) {
120
+ return { safe: false, reason: 'Cannot delete home directory' };
121
+ }
122
+
123
+ // Prevent deleting direct children of home (e.g., ~/Documents, ~/Desktop)
124
+ const homeChildren = ['Documents', 'Desktop', 'Downloads', 'Pictures', 'Music', 'Movies', 'Library', 'Applications'];
125
+ for (const child of homeChildren) {
126
+ if (resolved === path.join(home, child)) {
127
+ return { safe: false, reason: `Cannot delete home subdirectory: ${child}` };
128
+ }
129
+ }
130
+
131
+ // If gitRoot is provided, ensure we're not deleting it or its parents
132
+ if (gitRoot) {
133
+ const resolvedGitRoot = path.resolve(gitRoot);
134
+
135
+ // Cannot delete the git root itself
136
+ if (resolved === resolvedGitRoot) {
137
+ return { safe: false, reason: 'Cannot delete repository root' };
138
+ }
139
+
140
+ // Cannot delete parent of git root
141
+ if (resolvedGitRoot.startsWith(resolved + path.sep)) {
142
+ return { safe: false, reason: 'Cannot delete parent of repository root' };
143
+ }
144
+ }
145
+
146
+ // Check if path matches allowed patterns
147
+ let matchesAllowed = false;
148
+ for (const pattern of ALLOWED_PATTERNS) {
149
+ if (pattern.test(resolved)) {
150
+ matchesAllowed = true;
151
+ break;
152
+ }
153
+ }
154
+
155
+ // If in test environment, allow /tmp paths
156
+ if (process.env.NODE_ENV === 'test' && resolved.startsWith('/tmp/')) {
157
+ matchesAllowed = true;
158
+ }
159
+
160
+ // If path doesn't match allowed patterns, require it to be within gitRoot/.jettypod-work
161
+ if (!matchesAllowed && gitRoot) {
162
+ const worktreeBase = path.join(path.resolve(gitRoot), '.jettypod-work');
163
+ if (!resolved.startsWith(worktreeBase)) {
164
+ return {
165
+ safe: false,
166
+ reason: `Path not in allowed location. Must be in .jettypod-work/ or match allowed patterns`
167
+ };
168
+ }
169
+ matchesAllowed = true;
170
+ }
171
+
172
+ if (!matchesAllowed) {
173
+ return { safe: false, reason: 'Path does not match any allowed patterns' };
174
+ }
175
+
176
+ return { safe: true, reason: 'Path validated successfully' };
177
+ }
178
+
179
+ /**
180
+ * Count files and directories that would be deleted
181
+ *
182
+ * @param {string} targetPath - Path to count
183
+ * @returns {Object} { files: number, directories: number, total: number }
184
+ */
185
+ function countContents(targetPath) {
186
+ let files = 0;
187
+ let directories = 0;
188
+
189
+ function traverse(currentPath) {
190
+ try {
191
+ const stats = fs.lstatSync(currentPath);
192
+
193
+ if (stats.isDirectory()) {
194
+ directories++;
195
+ const entries = fs.readdirSync(currentPath);
196
+ for (const entry of entries) {
197
+ traverse(path.join(currentPath, entry));
198
+ }
199
+ } else {
200
+ files++;
201
+ }
202
+ } catch {
203
+ // Ignore errors during counting
204
+ }
205
+ }
206
+
207
+ if (fs.existsSync(targetPath)) {
208
+ traverse(targetPath);
209
+ }
210
+
211
+ return { files, directories, total: files + directories };
212
+ }
213
+
214
+ /**
215
+ * Safely remove a file
216
+ *
217
+ * @param {string} targetPath - Path to file to delete
218
+ * @param {Object} options - Options
219
+ * @param {string} options.gitRoot - Git repository root for validation
220
+ * @returns {Object} { success: boolean, error?: string }
221
+ */
222
+ function safeUnlink(targetPath, options = {}) {
223
+ const validation = validatePath(targetPath, options.gitRoot);
224
+
225
+ if (!validation.safe) {
226
+ const error = new Error(`SAFETY: Cannot delete file - ${validation.reason}`);
227
+ logDeletion(targetPath, 'unlink', false, error);
228
+ return { success: false, error: validation.reason };
229
+ }
230
+
231
+ try {
232
+ fs.unlinkSync(targetPath);
233
+ logDeletion(targetPath, 'unlink', true);
234
+ return { success: true };
235
+ } catch (err) {
236
+ logDeletion(targetPath, 'unlink', false, err);
237
+ return { success: false, error: err.message };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Safely remove a directory (non-recursive)
243
+ *
244
+ * @param {string} targetPath - Path to directory to delete
245
+ * @param {Object} options - Options
246
+ * @param {string} options.gitRoot - Git repository root for validation
247
+ * @returns {Object} { success: boolean, error?: string }
248
+ */
249
+ function safeRmdir(targetPath, options = {}) {
250
+ const validation = validatePath(targetPath, options.gitRoot);
251
+
252
+ if (!validation.safe) {
253
+ const error = new Error(`SAFETY: Cannot delete directory - ${validation.reason}`);
254
+ logDeletion(targetPath, 'rmdir', false, error);
255
+ return { success: false, error: validation.reason };
256
+ }
257
+
258
+ try {
259
+ fs.rmdirSync(targetPath);
260
+ logDeletion(targetPath, 'rmdir', true);
261
+ return { success: true };
262
+ } catch (err) {
263
+ logDeletion(targetPath, 'rmdir', false, err);
264
+ return { success: false, error: err.message };
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Safely remove a directory recursively
270
+ *
271
+ * This is the ONLY safe way to recursively delete directories.
272
+ * NEVER use fs.rmSync() directly with { recursive: true }.
273
+ *
274
+ * @param {string} targetPath - Path to directory to delete
275
+ * @param {Object} options - Options
276
+ * @param {string} options.gitRoot - Git repository root for validation (REQUIRED)
277
+ * @param {number} options.maxCount - Maximum files/dirs to delete without confirmation (default: 100)
278
+ * @param {boolean} options.force - Force deletion even if count exceeds threshold
279
+ * @returns {Object} { success: boolean, error?: string, count?: Object }
280
+ */
281
+ function safeRmRecursive(targetPath, options = {}) {
282
+ const { gitRoot, maxCount = 100, force = false } = options;
283
+
284
+ // Require gitRoot for recursive deletes
285
+ if (!gitRoot) {
286
+ const error = new Error('SAFETY: gitRoot is required for recursive deletes');
287
+ logDeletion(targetPath, 'rmRecursive', false, error);
288
+ return { success: false, error: error.message };
289
+ }
290
+
291
+ const validation = validatePath(targetPath, gitRoot);
292
+
293
+ if (!validation.safe) {
294
+ const error = new Error(`SAFETY: Cannot delete directory - ${validation.reason}`);
295
+ logDeletion(targetPath, 'rmRecursive', false, error);
296
+ return { success: false, error: validation.reason };
297
+ }
298
+
299
+ // Count contents before deletion
300
+ const count = countContents(targetPath);
301
+
302
+ // Check threshold
303
+ if (count.total > maxCount && !force) {
304
+ const error = new Error(
305
+ `SAFETY: Refusing to delete ${count.total} items (${count.files} files, ${count.directories} dirs). ` +
306
+ `Use force: true to override.`
307
+ );
308
+ logDeletion(targetPath, 'rmRecursive', false, error, { count });
309
+ return { success: false, error: error.message, count };
310
+ }
311
+
312
+ try {
313
+ // Do NOT use force: true - we want errors to surface
314
+ fs.rmSync(targetPath, { recursive: true });
315
+ logDeletion(targetPath, 'rmRecursive', true, null, { count });
316
+ return { success: true, count };
317
+ } catch (err) {
318
+ logDeletion(targetPath, 'rmRecursive', false, err, { count });
319
+ return { success: false, error: err.message, count };
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Check if we're running from within a worktree
325
+ *
326
+ * @returns {boolean} True if current directory is inside a worktree
327
+ */
328
+ function isInWorktree() {
329
+ try {
330
+ const { execSync } = require('child_process');
331
+ const gitDir = execSync('git rev-parse --git-dir', {
332
+ encoding: 'utf8',
333
+ stdio: ['pipe', 'pipe', 'pipe']
334
+ }).trim();
335
+
336
+ return gitDir.includes('.jettypod-work') || (gitDir !== '.git' && !gitDir.endsWith('/.git'));
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Ensure we're not running from within a worktree
344
+ * Throws an error if we are.
345
+ *
346
+ * @throws {Error} If running from within a worktree
347
+ */
348
+ function ensureNotInWorktree() {
349
+ if (isInWorktree()) {
350
+ throw new Error('SAFETY: Cannot run destructive operations from within a worktree. ' +
351
+ 'Please change to the main repository directory.');
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Get the main repository root, even if we're in a worktree
357
+ *
358
+ * @returns {string|null} Path to main repository root, or null if not in a git repo
359
+ */
360
+ function getMainRepoRoot() {
361
+ try {
362
+ const { execSync } = require('child_process');
363
+
364
+ // Get the git common dir (points to main repo even in worktrees)
365
+ const commonDir = execSync('git rev-parse --git-common-dir', {
366
+ encoding: 'utf8',
367
+ stdio: ['pipe', 'pipe', 'pipe']
368
+ }).trim();
369
+
370
+ // Common dir is the .git directory, so get its parent
371
+ if (commonDir === '.git') {
372
+ return process.cwd();
373
+ }
374
+
375
+ return path.dirname(path.resolve(commonDir));
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * SAFETY: Validate that a path is safe to delete in test context
383
+ * Only allows deletion of paths in temp directories with test-related names
384
+ *
385
+ * @param {string} dirPath - Path to validate
386
+ * @returns {boolean} True if path is safe to delete in test context
387
+ */
388
+ function isSafeTestPath(dirPath) {
389
+ const resolved = path.resolve(dirPath);
390
+ const tempDir = os.tmpdir();
391
+
392
+ // Must be in temp directory
393
+ const isInTemp = resolved.startsWith(path.resolve(tempDir)) ||
394
+ resolved.startsWith('/var/folders') ||
395
+ resolved.startsWith('/tmp');
396
+
397
+ // Must have test-related name
398
+ const basename = path.basename(resolved);
399
+ const hasTestName = basename.includes('test') ||
400
+ basename.includes('jettypod-test') ||
401
+ basename.startsWith('tmp');
402
+
403
+ return isInTemp && hasTestName;
404
+ }
405
+
406
+ /**
407
+ * Safely cleanup a test directory
408
+ * Only deletes directories that are in temp and have test-related names
409
+ *
410
+ * @param {string} testDir - Path to test directory to cleanup
411
+ * @returns {Object} { success: boolean, error?: string }
412
+ */
413
+ function safeTestCleanup(testDir) {
414
+ if (!testDir || typeof testDir !== 'string') {
415
+ return { success: false, error: 'Invalid test directory path' };
416
+ }
417
+
418
+ if (!fs.existsSync(testDir)) {
419
+ return { success: true }; // Already gone
420
+ }
421
+
422
+ if (!isSafeTestPath(testDir)) {
423
+ const error = `SAFETY: Refusing to delete ${testDir} - not a valid test directory`;
424
+ console.warn(error);
425
+ return { success: false, error };
426
+ }
427
+
428
+ try {
429
+ fs.rmSync(testDir, { recursive: true });
430
+ logDeletion(testDir, 'safeTestCleanup', true);
431
+ return { success: true };
432
+ } catch (err) {
433
+ logDeletion(testDir, 'safeTestCleanup', false, err);
434
+ return { success: false, error: err.message };
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Get the trash directory path for a given git root
440
+ *
441
+ * @param {string} gitRoot - Git repository root
442
+ * @returns {string} Path to trash directory
443
+ */
444
+ function getTrashDir(gitRoot) {
445
+ return path.join(gitRoot, '.jettypod-trash');
446
+ }
447
+
448
+ /**
449
+ * Move a file or directory to trash instead of deleting
450
+ *
451
+ * @param {string} targetPath - Path to move to trash
452
+ * @param {Object} options - Options
453
+ * @param {string} options.gitRoot - Git repository root (REQUIRED)
454
+ * @returns {Object} { success: boolean, trashPath?: string, error?: string }
455
+ */
456
+ function moveToTrash(targetPath, options = {}) {
457
+ const { gitRoot } = options;
458
+
459
+ if (!gitRoot) {
460
+ return { success: false, error: 'SAFETY: gitRoot is required for trash operations' };
461
+ }
462
+
463
+ if (!fs.existsSync(targetPath)) {
464
+ return { success: false, error: 'Path does not exist' };
465
+ }
466
+
467
+ const validation = validatePath(targetPath, gitRoot);
468
+ if (!validation.safe) {
469
+ logDeletion(targetPath, 'moveToTrash', false, new Error(validation.reason));
470
+ return { success: false, error: validation.reason };
471
+ }
472
+
473
+ const trashDir = getTrashDir(gitRoot);
474
+
475
+ // Create trash directory if it doesn't exist
476
+ if (!fs.existsSync(trashDir)) {
477
+ fs.mkdirSync(trashDir, { recursive: true });
478
+ }
479
+
480
+ // Generate unique trash item name with timestamp and original path
481
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
482
+ const originalName = path.basename(targetPath);
483
+ const relativePath = path.relative(gitRoot, targetPath);
484
+ const sanitizedRelativePath = relativePath.replace(/\//g, '__');
485
+ const trashItemName = `${timestamp}__${sanitizedRelativePath}`;
486
+ const trashPath = path.join(trashDir, trashItemName);
487
+
488
+ // Create metadata file
489
+ const metadataPath = trashPath + '.meta.json';
490
+ const metadata = {
491
+ originalPath: targetPath,
492
+ relativePath: relativePath,
493
+ trashedAt: new Date().toISOString(),
494
+ isDirectory: fs.statSync(targetPath).isDirectory()
495
+ };
496
+
497
+ try {
498
+ // Move to trash
499
+ fs.renameSync(targetPath, trashPath);
500
+
501
+ // Write metadata
502
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
503
+
504
+ logDeletion(targetPath, 'moveToTrash', true, null, {
505
+ trashPath,
506
+ metadata
507
+ });
508
+
509
+ return { success: true, trashPath, metadata };
510
+ } catch (err) {
511
+ // If rename fails (cross-device), try copy + delete
512
+ try {
513
+ const { execSync } = require('child_process');
514
+ execSync(`cp -R "${targetPath}" "${trashPath}"`, { stdio: 'pipe' });
515
+ fs.rmSync(targetPath, { recursive: true });
516
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
517
+
518
+ logDeletion(targetPath, 'moveToTrash', true, null, {
519
+ trashPath,
520
+ metadata,
521
+ crossDevice: true
522
+ });
523
+
524
+ return { success: true, trashPath, metadata };
525
+ } catch (copyErr) {
526
+ logDeletion(targetPath, 'moveToTrash', false, copyErr);
527
+ return { success: false, error: copyErr.message };
528
+ }
529
+ }
530
+ }
531
+
532
+ /**
533
+ * List items in trash
534
+ *
535
+ * @param {string} gitRoot - Git repository root
536
+ * @returns {Array} List of trash items with metadata
537
+ */
538
+ function listTrash(gitRoot) {
539
+ const trashDir = getTrashDir(gitRoot);
540
+
541
+ if (!fs.existsSync(trashDir)) {
542
+ return [];
543
+ }
544
+
545
+ const items = [];
546
+ const entries = fs.readdirSync(trashDir);
547
+
548
+ for (const entry of entries) {
549
+ // Skip metadata files
550
+ if (entry.endsWith('.meta.json')) continue;
551
+
552
+ const trashPath = path.join(trashDir, entry);
553
+ const metadataPath = trashPath + '.meta.json';
554
+
555
+ let metadata = {};
556
+ if (fs.existsSync(metadataPath)) {
557
+ try {
558
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
559
+ } catch {
560
+ // Corrupted metadata, construct from filename
561
+ const parts = entry.split('__');
562
+ metadata = {
563
+ trashedAt: parts[0] ? parts[0].replace(/-/g, ':').replace(/T(\d+):(\d+):(\d+)/, 'T$1:$2:$3') : 'unknown',
564
+ relativePath: parts.slice(1).join('/'),
565
+ originalPath: 'unknown'
566
+ };
567
+ }
568
+ }
569
+
570
+ const stat = fs.statSync(trashPath);
571
+
572
+ items.push({
573
+ name: entry,
574
+ trashPath,
575
+ originalPath: metadata.originalPath || 'unknown',
576
+ relativePath: metadata.relativePath || entry,
577
+ trashedAt: metadata.trashedAt ? new Date(metadata.trashedAt) : stat.mtime,
578
+ isDirectory: stat.isDirectory(),
579
+ size: stat.isDirectory() ? countContents(trashPath).total : stat.size
580
+ });
581
+ }
582
+
583
+ // Sort by trashed date, newest first
584
+ return items.sort((a, b) => b.trashedAt - a.trashedAt);
585
+ }
586
+
587
+ /**
588
+ * Restore an item from trash
589
+ *
590
+ * @param {string} gitRoot - Git repository root
591
+ * @param {string} trashItemName - Name of trash item to restore (or 'latest')
592
+ * @param {Object} options - Options
593
+ * @param {string} options.restoreTo - Custom restore path (optional)
594
+ * @returns {Object} { success: boolean, restoredTo?: string, error?: string }
595
+ */
596
+ function restoreFromTrash(gitRoot, trashItemName = 'latest', options = {}) {
597
+ const trashDir = getTrashDir(gitRoot);
598
+ const trashItems = listTrash(gitRoot);
599
+
600
+ if (trashItems.length === 0) {
601
+ return { success: false, error: 'Trash is empty' };
602
+ }
603
+
604
+ let item;
605
+ if (trashItemName === 'latest') {
606
+ item = trashItems[0];
607
+ } else {
608
+ item = trashItems.find(i => i.name === trashItemName || i.relativePath === trashItemName);
609
+ }
610
+
611
+ if (!item) {
612
+ return { success: false, error: `Trash item not found: ${trashItemName}` };
613
+ }
614
+
615
+ // Determine restore path
616
+ const restorePath = options.restoreTo || item.originalPath;
617
+
618
+ // Check if restore path already exists
619
+ if (fs.existsSync(restorePath)) {
620
+ return {
621
+ success: false,
622
+ error: `Cannot restore - path already exists: ${restorePath}`
623
+ };
624
+ }
625
+
626
+ // Ensure parent directory exists
627
+ const parentDir = path.dirname(restorePath);
628
+ if (!fs.existsSync(parentDir)) {
629
+ fs.mkdirSync(parentDir, { recursive: true });
630
+ }
631
+
632
+ try {
633
+ // Move from trash back to original location
634
+ fs.renameSync(item.trashPath, restorePath);
635
+
636
+ // Remove metadata file
637
+ const metadataPath = item.trashPath + '.meta.json';
638
+ if (fs.existsSync(metadataPath)) {
639
+ fs.unlinkSync(metadataPath);
640
+ }
641
+
642
+ logDeletion(restorePath, 'restoreFromTrash', true, null, {
643
+ trashPath: item.trashPath,
644
+ originalPath: item.originalPath
645
+ });
646
+
647
+ return { success: true, restoredTo: restorePath };
648
+ } catch (err) {
649
+ // If rename fails (cross-device), try copy + delete
650
+ try {
651
+ const { execSync } = require('child_process');
652
+ execSync(`cp -R "${item.trashPath}" "${restorePath}"`, { stdio: 'pipe' });
653
+ fs.rmSync(item.trashPath, { recursive: true });
654
+
655
+ const metadataPath = item.trashPath + '.meta.json';
656
+ if (fs.existsSync(metadataPath)) {
657
+ fs.unlinkSync(metadataPath);
658
+ }
659
+
660
+ logDeletion(restorePath, 'restoreFromTrash', true, null, {
661
+ trashPath: item.trashPath,
662
+ crossDevice: true
663
+ });
664
+
665
+ return { success: true, restoredTo: restorePath };
666
+ } catch (copyErr) {
667
+ return { success: false, error: copyErr.message };
668
+ }
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Purge old items from trash
674
+ *
675
+ * @param {string} gitRoot - Git repository root
676
+ * @param {Object} options - Options
677
+ * @param {number} options.maxAgeHours - Maximum age in hours (default: 24)
678
+ * @param {boolean} options.dryRun - If true, only list what would be purged
679
+ * @returns {Object} { success: boolean, purged: Array, errors: Array }
680
+ */
681
+ function purgeOldTrash(gitRoot, options = {}) {
682
+ const { maxAgeHours = 24, dryRun = false } = options;
683
+ const trashItems = listTrash(gitRoot);
684
+ const cutoffTime = new Date(Date.now() - (maxAgeHours * 60 * 60 * 1000));
685
+
686
+ const toPurge = trashItems.filter(item => item.trashedAt < cutoffTime);
687
+ const purged = [];
688
+ const errors = [];
689
+
690
+ for (const item of toPurge) {
691
+ if (dryRun) {
692
+ purged.push({
693
+ name: item.name,
694
+ originalPath: item.originalPath,
695
+ trashedAt: item.trashedAt,
696
+ dryRun: true
697
+ });
698
+ continue;
699
+ }
700
+
701
+ try {
702
+ fs.rmSync(item.trashPath, { recursive: true });
703
+
704
+ const metadataPath = item.trashPath + '.meta.json';
705
+ if (fs.existsSync(metadataPath)) {
706
+ fs.unlinkSync(metadataPath);
707
+ }
708
+
709
+ purged.push({
710
+ name: item.name,
711
+ originalPath: item.originalPath,
712
+ trashedAt: item.trashedAt
713
+ });
714
+
715
+ logDeletion(item.trashPath, 'purgeTrash', true, null, {
716
+ originalPath: item.originalPath,
717
+ age: Math.round((Date.now() - item.trashedAt.getTime()) / (60 * 60 * 1000)) + ' hours'
718
+ });
719
+ } catch (err) {
720
+ errors.push({
721
+ name: item.name,
722
+ error: err.message
723
+ });
724
+ }
725
+ }
726
+
727
+ return {
728
+ success: errors.length === 0,
729
+ purged,
730
+ errors,
731
+ remaining: trashItems.length - toPurge.length
732
+ };
733
+ }
734
+
735
+ /**
736
+ * Empty the entire trash
737
+ *
738
+ * @param {string} gitRoot - Git repository root
739
+ * @returns {Object} { success: boolean, count: number, error?: string }
740
+ */
741
+ function emptyTrash(gitRoot) {
742
+ const trashDir = getTrashDir(gitRoot);
743
+
744
+ if (!fs.existsSync(trashDir)) {
745
+ return { success: true, count: 0 };
746
+ }
747
+
748
+ const trashItems = listTrash(gitRoot);
749
+ let count = 0;
750
+
751
+ for (const item of trashItems) {
752
+ try {
753
+ fs.rmSync(item.trashPath, { recursive: true });
754
+
755
+ const metadataPath = item.trashPath + '.meta.json';
756
+ if (fs.existsSync(metadataPath)) {
757
+ fs.unlinkSync(metadataPath);
758
+ }
759
+
760
+ count++;
761
+ } catch (err) {
762
+ // Log but continue
763
+ console.warn(`Warning: Could not remove ${item.name}: ${err.message}`);
764
+ }
765
+ }
766
+
767
+ logDeletion(trashDir, 'emptyTrash', true, null, { itemsRemoved: count });
768
+
769
+ return { success: true, count };
770
+ }
771
+
772
+ module.exports = {
773
+ validatePath,
774
+ countContents,
775
+ safeUnlink,
776
+ safeRmdir,
777
+ safeRmRecursive,
778
+ isInWorktree,
779
+ ensureNotInWorktree,
780
+ getMainRepoRoot,
781
+ logDeletion,
782
+ isSafeTestPath,
783
+ safeTestCleanup,
784
+ // Trash functionality
785
+ getTrashDir,
786
+ moveToTrash,
787
+ listTrash,
788
+ restoreFromTrash,
789
+ purgeOldTrash,
790
+ emptyTrash,
791
+ // Export constants for testing
792
+ FORBIDDEN_PATHS,
793
+ ALLOWED_PATTERNS
794
+ };