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.
- package/SECURITY-AUDIT-CATASTROPHIC-DELETE.md +196 -0
- package/TEST_HOOK.md +1 -0
- package/docs/DECISIONS.md +4 -52
- package/jettypod.js +210 -24
- package/lib/chore-classifier.js +232 -0
- package/lib/chore-taxonomy.js +172 -0
- package/lib/jettypod-backup.js +124 -8
- package/lib/safe-delete.js +794 -0
- package/lib/worktree-manager.js +54 -41
- package/package.json +1 -1
- package/skills-templates/chore-mode/SKILL.md +396 -0
- package/skills-templates/chore-mode/verification.js +255 -0
- package/skills-templates/chore-planning/SKILL.md +229 -0
- package/skills-templates/epic-planning/SKILL.md +13 -5
- package/skills-templates/feature-planning/SKILL.md +113 -158
- package/skills-templates/production-mode/SKILL.md +7 -4
- package/skills-templates/speed-mode/SKILL.md +463 -471
- package/skills-templates/stable-mode/SKILL.md +371 -319
|
@@ -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
|
+
};
|