jettypod 4.3.0 → 4.4.0
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/jettypod.js +210 -24
- 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/feature-planning/SKILL.md +105 -155
- 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,196 @@
|
|
|
1
|
+
# Security Audit: Catastrophic Codebase Deletion
|
|
2
|
+
|
|
3
|
+
**Date**: 2025-11-25
|
|
4
|
+
**Context**: Codebase was deleted while working on "chores not requiring a parent" feature
|
|
5
|
+
**Symptom**: Shell became stuck, entire codebase was deleted
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Executive Summary
|
|
10
|
+
|
|
11
|
+
The JettyPod codebase contains multiple unsafe deletion patterns that, when combined with incorrect path resolution, can cause catastrophic data loss. The root cause is **79 instances of `fs.rmSync()` with `{ recursive: true, force: true }` that lack path validation**.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Phase 1: Immediate Fixes (Critical)
|
|
16
|
+
|
|
17
|
+
### 1.1 Add Safe Delete Wrapper
|
|
18
|
+
|
|
19
|
+
- [x] Create `lib/safe-delete.js` with path validation
|
|
20
|
+
- [x] Validate target path is within expected boundaries
|
|
21
|
+
- [x] Reject deletion of repository root, home directory, or system paths
|
|
22
|
+
- [x] Log all deletion operations for forensic analysis
|
|
23
|
+
- [x] Never use `force: true` - let errors surface
|
|
24
|
+
|
|
25
|
+
### 1.2 Fix `removeDirectoryResilient()`
|
|
26
|
+
|
|
27
|
+
Location: `lib/worktree-manager.js:477-536`
|
|
28
|
+
|
|
29
|
+
- [x] Add path validation before any deletion stage
|
|
30
|
+
- [x] Ensure `dirPath` starts with `.jettypod-work/`
|
|
31
|
+
- [x] Add explicit check that `dirPath !== gitRoot`
|
|
32
|
+
- [x] Replace direct `fs.rmSync()` with safe wrapper
|
|
33
|
+
|
|
34
|
+
### 1.3 Fix Test Cleanup Script
|
|
35
|
+
|
|
36
|
+
Location: `scripts/test-cleanup.js`
|
|
37
|
+
|
|
38
|
+
- [x] Only delete worktrees/branches that match test naming patterns
|
|
39
|
+
- [x] Add dry-run mode that logs what would be deleted
|
|
40
|
+
- [x] Add protected branches list (main, master, develop, staging, production)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Phase 2: Path Resolution Hardening (High Priority)
|
|
45
|
+
|
|
46
|
+
### 2.1 Eliminate Dangerous `process.cwd()` Usage
|
|
47
|
+
|
|
48
|
+
- [ ] Audit all 94 instances of `process.cwd()` for deletion contexts
|
|
49
|
+
- [ ] Pass explicit `repoPath` to all functions
|
|
50
|
+
- [ ] Add safety checks that reject operations if `cwd` is inside `.jettypod-work/`
|
|
51
|
+
- [ ] Cache and validate the main repo path at startup
|
|
52
|
+
|
|
53
|
+
### 2.2 Add Worktree Detection Guard
|
|
54
|
+
|
|
55
|
+
- [x] Create `ensureNotInWorktree()` helper function (in safe-delete.js)
|
|
56
|
+
- [x] Add guard to createWorktree() in `worktree-manager.js`
|
|
57
|
+
- [x] cleanupWorktree() intentionally DOES NOT have this guard - the merge workflow runs from within worktrees. Safety comes from explicit repoPath requirement + path validation in removeDirectoryResilient()
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Phase 3: Deletion Audit Trail (Medium Priority)
|
|
62
|
+
|
|
63
|
+
### 3.1 Create Deletion Logger
|
|
64
|
+
|
|
65
|
+
- [x] Add logging to `lib/safe-delete.js`
|
|
66
|
+
- [x] Log timestamp, path, caller stack trace, success/failure
|
|
67
|
+
- [x] Write to `.jettypod/deletion-log.json`
|
|
68
|
+
|
|
69
|
+
### 3.2 Add Pre-Delete Snapshot
|
|
70
|
+
|
|
71
|
+
- [x] Count files/directories before recursive delete (countContents in safe-delete.js)
|
|
72
|
+
- [x] If count exceeds threshold (default: 100), require force flag
|
|
73
|
+
- [x] Log manifest of what will be deleted (count logged in deletion log)
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Phase 4: Test Infrastructure Safety (Medium Priority)
|
|
78
|
+
|
|
79
|
+
### 4.1 Isolate Test Deletions to `/tmp/`
|
|
80
|
+
|
|
81
|
+
- [ ] Validate all test cleanup paths match `/tmp/jettypod-test-*`
|
|
82
|
+
- [ ] Update `lib/test-helpers.js` to enforce boundary
|
|
83
|
+
- [ ] Update all step definition files to use safe patterns
|
|
84
|
+
|
|
85
|
+
### 4.2 Add Test Environment Validation
|
|
86
|
+
|
|
87
|
+
- [ ] Verify `NODE_ENV === 'test'` before test cleanup
|
|
88
|
+
- [ ] Verify current directory is a test directory
|
|
89
|
+
- [ ] Verify no production database exists in path
|
|
90
|
+
|
|
91
|
+
### 4.3 Fix Cucumber After Hooks
|
|
92
|
+
|
|
93
|
+
Location: `test-templates.hidden/hooks.js`
|
|
94
|
+
|
|
95
|
+
- [ ] Validate all tracked paths are within `/tmp/`
|
|
96
|
+
- [ ] Clear tracking on test failure to prevent partial cleanup
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Phase 5: Backup & Recovery (Low Priority)
|
|
101
|
+
|
|
102
|
+
### 5.1 Enhance Automatic Backups
|
|
103
|
+
|
|
104
|
+
- [x] Create backup before ANY destructive operation (existing behavior preserved)
|
|
105
|
+
- [x] Store backups outside the repository (new `options.global` flag stores in `~/.jettypod-backups/`)
|
|
106
|
+
- [x] Enhanced listBackups to show both local and global backups
|
|
107
|
+
|
|
108
|
+
### 5.2 Add Deletion Undo
|
|
109
|
+
|
|
110
|
+
- [x] Implement `.jettypod-trash/` directory
|
|
111
|
+
- [x] Move deleted files to trash instead of immediate delete (`moveToTrash()` in safe-delete.js)
|
|
112
|
+
- [x] Auto-purge after 24 hours (`purgeOldTrash()` with configurable hours)
|
|
113
|
+
- [x] Add `jettypod undelete` command
|
|
114
|
+
- [x] Add `jettypod trash` command (list, empty, purge subcommands)
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Files Requiring Changes
|
|
119
|
+
|
|
120
|
+
| Status | File | Risk Level | Changes Needed |
|
|
121
|
+
|--------|------|------------|----------------|
|
|
122
|
+
| [x] | `lib/safe-delete.js` | NEW | Create safe deletion wrapper |
|
|
123
|
+
| [x] | `lib/worktree-manager.js` | CRITICAL | Add path validation to all delete operations |
|
|
124
|
+
| [x] | `scripts/test-cleanup.js` | CRITICAL | Restrict to test-pattern paths only |
|
|
125
|
+
| [x] | `lib/jettypod-backup.js` | HIGH | Validate paths before delete |
|
|
126
|
+
| [x] | `lib/test-helpers.js` | HIGH | Ensure cleanup only targets `/tmp/` |
|
|
127
|
+
| [x] | `jettypod.js` (line 908) | HIGH | Validate `skillsDestDir` before delete |
|
|
128
|
+
| [x] | `test-templates.hidden/hooks.js` | MEDIUM | Validate tracked paths |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Dangerous Code Patterns Found
|
|
133
|
+
|
|
134
|
+
### Pattern 1: Unvalidated Recursive Delete
|
|
135
|
+
```javascript
|
|
136
|
+
// DANGEROUS - no path validation
|
|
137
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Pattern 2: Force Flag Suppresses Errors
|
|
141
|
+
```javascript
|
|
142
|
+
// DANGEROUS - errors are silently ignored
|
|
143
|
+
fs.rmSync(path, { force: true });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Pattern 3: Path from Untrusted Source
|
|
147
|
+
```javascript
|
|
148
|
+
// DANGEROUS - process.cwd() can be manipulated
|
|
149
|
+
const targetPath = path.join(process.cwd(), 'some-dir');
|
|
150
|
+
fs.rmSync(targetPath, { recursive: true });
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Pattern 4: Cleanup Without Boundaries
|
|
154
|
+
```javascript
|
|
155
|
+
// DANGEROUS - deletes ALL worktrees, not just test ones
|
|
156
|
+
for (const worktree of worktrees) {
|
|
157
|
+
execSync(`git worktree remove --force "${worktree}"`);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Root Cause Analysis
|
|
164
|
+
|
|
165
|
+
The catastrophic deletion likely occurred through this sequence:
|
|
166
|
+
|
|
167
|
+
1. User was modifying chore validation in `features/work-tracking/index.js`
|
|
168
|
+
2. A test run or command was executed
|
|
169
|
+
3. Path resolution returned unexpected value (possibly due to worktree context)
|
|
170
|
+
4. `fs.rmSync()` with `{ recursive: true, force: true }` executed on wrong path
|
|
171
|
+
5. `force: true` suppressed all warnings
|
|
172
|
+
6. Shell appeared "stuck" while deleting thousands of files
|
|
173
|
+
7. By the time the operation completed, codebase was gone
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Implementation Order
|
|
178
|
+
|
|
179
|
+
- [x] **Step 1**: Create `lib/safe-delete.js` wrapper
|
|
180
|
+
- [x] **Step 2**: Patch `removeDirectoryResilient()` with path validation
|
|
181
|
+
- [x] **Step 3**: Fix `scripts/test-cleanup.js`
|
|
182
|
+
- [x] **Step 4**: Add worktree detection guard to destructive operations
|
|
183
|
+
- [x] **Step 5**: Implement deletion audit trail
|
|
184
|
+
- [x] **Step 6**: Fix remaining unsafe `fs.rmSync()` instances (production code fixed; safeTestCleanup helper created for test files)
|
|
185
|
+
- [x] **Step 7**: Enhanced backup system (global backups in ~/.jettypod-backups/)
|
|
186
|
+
- [x] **Step 8**: Deletion undo/trash functionality (`.jettypod-trash/`, `jettypod trash`, `jettypod undelete`)
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Testing the Fixes
|
|
191
|
+
|
|
192
|
+
- [ ] Attempt to delete repository root - should be blocked
|
|
193
|
+
- [ ] Attempt to delete from within worktree - should be blocked
|
|
194
|
+
- [ ] Attempt to delete path outside repo - should be blocked
|
|
195
|
+
- [ ] Run test suite - should only clean up `/tmp/` paths
|
|
196
|
+
- [ ] Check deletion log after operations - should show audit trail
|
package/TEST_HOOK.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Test commit to verify post-merge hook
|
package/jettypod.js
CHANGED
|
@@ -8,6 +8,7 @@ const config = require('./lib/config');
|
|
|
8
8
|
// CRITICAL: Calculate and cache the REAL git root BEFORE any worktree operations
|
|
9
9
|
// This prevents catastrophic bugs where process.cwd() returns a worktree path
|
|
10
10
|
const { getGitRoot } = require('./lib/git-root');
|
|
11
|
+
const safeDelete = require('./lib/safe-delete');
|
|
11
12
|
let GIT_ROOT;
|
|
12
13
|
try {
|
|
13
14
|
GIT_ROOT = getGitRoot();
|
|
@@ -16,6 +17,66 @@ try {
|
|
|
16
17
|
GIT_ROOT = process.cwd();
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Format bytes to human-readable string
|
|
22
|
+
*/
|
|
23
|
+
function formatBytes(bytes) {
|
|
24
|
+
if (bytes === 0) return '0 B';
|
|
25
|
+
const k = 1024;
|
|
26
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
27
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
28
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect if project has existing code (mid-project adoption)
|
|
33
|
+
* Checks for common code directories and package.json dependencies
|
|
34
|
+
*/
|
|
35
|
+
function hasExistingCode() {
|
|
36
|
+
// Check for common code directories with actual code files
|
|
37
|
+
const codeDirectories = ['src', 'lib', 'app'];
|
|
38
|
+
const hasCodeDir = codeDirectories.some(dir => {
|
|
39
|
+
if (!fs.existsSync(dir)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if directory contains any code files (not just .gitkeep)
|
|
44
|
+
try {
|
|
45
|
+
const files = fs.readdirSync(dir, { recursive: true, withFileTypes: true });
|
|
46
|
+
const codeFiles = files.filter(file => {
|
|
47
|
+
if (file.isDirectory()) return false;
|
|
48
|
+
// Exclude .gitkeep and other dotfiles
|
|
49
|
+
if (file.name.startsWith('.')) return false;
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
return codeFiles.length > 0;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Permission denied or other error - treat as no code
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (hasCodeDir) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check for package.json with dependencies
|
|
64
|
+
if (fs.existsSync('package.json')) {
|
|
65
|
+
try {
|
|
66
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
67
|
+
const hasDeps = (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) ||
|
|
68
|
+
(pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0);
|
|
69
|
+
if (hasDeps) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Invalid package.json - treat as no code
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
19
80
|
/**
|
|
20
81
|
* Ensure jettypod-specific paths are gitignored and untracked
|
|
21
82
|
* Called during init and update to fix existing projects
|
|
@@ -24,7 +85,8 @@ function ensureJettypodGitignores() {
|
|
|
24
85
|
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
25
86
|
const entriesToIgnore = [
|
|
26
87
|
'.claude/session.md',
|
|
27
|
-
'.jettypod-work/'
|
|
88
|
+
'.jettypod-work/',
|
|
89
|
+
'.jettypod-trash/'
|
|
28
90
|
];
|
|
29
91
|
|
|
30
92
|
// Add entries to .gitignore if not present
|
|
@@ -525,29 +587,33 @@ ${communicationStyle}`;
|
|
|
525
587
|
|
|
526
588
|
detectTechStack() {
|
|
527
589
|
const stacks = [];
|
|
528
|
-
|
|
590
|
+
|
|
529
591
|
if (fs.existsSync('package.json')) {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
592
|
+
try {
|
|
593
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
594
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
595
|
+
|
|
596
|
+
// Detect major frameworks
|
|
597
|
+
if (deps.react) stacks.push('React');
|
|
598
|
+
if (deps.vue) stacks.push('Vue');
|
|
599
|
+
if (deps.angular) stacks.push('Angular');
|
|
600
|
+
if (deps.svelte) stacks.push('Svelte');
|
|
601
|
+
if (deps.next) stacks.push('Next.js');
|
|
602
|
+
if (deps.express) stacks.push('Express');
|
|
603
|
+
if (deps.fastify) stacks.push('Fastify');
|
|
604
|
+
if (deps.nestjs) stacks.push('NestJS');
|
|
605
|
+
|
|
606
|
+
// Testing frameworks
|
|
607
|
+
if (deps.jest) stacks.push('Jest');
|
|
608
|
+
if (deps.mocha) stacks.push('Mocha');
|
|
609
|
+
if (deps.vitest) stacks.push('Vitest');
|
|
610
|
+
if (deps.cucumber) stacks.push('Cucumber');
|
|
611
|
+
|
|
612
|
+
// Node.js is implied
|
|
613
|
+
stacks.unshift('Node.js');
|
|
614
|
+
} catch (e) {
|
|
615
|
+
// Invalid or malformed package.json - ignore and continue with other detection
|
|
616
|
+
}
|
|
551
617
|
}
|
|
552
618
|
|
|
553
619
|
if (fs.existsSync('Cargo.toml')) stacks.push('Rust');
|
|
@@ -639,6 +705,14 @@ async function initializeProject() {
|
|
|
639
705
|
name: projectName,
|
|
640
706
|
project_state: 'internal'
|
|
641
707
|
};
|
|
708
|
+
|
|
709
|
+
// Auto-detect mid-project adoption and skip discovery
|
|
710
|
+
if (hasExistingCode()) {
|
|
711
|
+
initialConfig.project_discovery = {
|
|
712
|
+
status: 'completed'
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
642
716
|
config.write(initialConfig);
|
|
643
717
|
}
|
|
644
718
|
|
|
@@ -844,7 +918,13 @@ async function initializeProject() {
|
|
|
844
918
|
try {
|
|
845
919
|
// Remove partial copy if it exists
|
|
846
920
|
if (fs.existsSync(skillsDestDir)) {
|
|
847
|
-
|
|
921
|
+
// SAFETY: Validate path before deletion
|
|
922
|
+
const resolvedSkillsDir = path.resolve(skillsDestDir);
|
|
923
|
+
const resolvedCwd = path.resolve(process.cwd());
|
|
924
|
+
if (!resolvedSkillsDir.startsWith(resolvedCwd) || !resolvedSkillsDir.includes('.claude')) {
|
|
925
|
+
throw new Error(`SAFETY: Refusing to delete ${skillsDestDir} - not within .claude directory`);
|
|
926
|
+
}
|
|
927
|
+
fs.rmSync(skillsDestDir, { recursive: true });
|
|
848
928
|
}
|
|
849
929
|
fs.renameSync(backupDir, skillsDestDir);
|
|
850
930
|
console.log('✅ Previous skills restored successfully');
|
|
@@ -1876,6 +1956,112 @@ Quick commands:
|
|
|
1876
1956
|
}
|
|
1877
1957
|
break;
|
|
1878
1958
|
|
|
1959
|
+
case 'trash': {
|
|
1960
|
+
// Trash management commands
|
|
1961
|
+
const trashSubcommand = args[0];
|
|
1962
|
+
const gitRoot = process.cwd();
|
|
1963
|
+
|
|
1964
|
+
if (trashSubcommand === 'list' || !trashSubcommand) {
|
|
1965
|
+
// List items in trash
|
|
1966
|
+
const trashItems = safeDelete.listTrash(gitRoot);
|
|
1967
|
+
|
|
1968
|
+
if (trashItems.length === 0) {
|
|
1969
|
+
console.log('🗑️ Trash is empty');
|
|
1970
|
+
} else {
|
|
1971
|
+
console.log('🗑️ Trash contents:');
|
|
1972
|
+
console.log('');
|
|
1973
|
+
trashItems.forEach((item, index) => {
|
|
1974
|
+
const age = Math.round((Date.now() - item.trashedAt.getTime()) / (60 * 60 * 1000));
|
|
1975
|
+
const ageStr = age < 1 ? 'just now' : age < 24 ? `${age}h ago` : `${Math.round(age / 24)}d ago`;
|
|
1976
|
+
const typeIcon = item.isDirectory ? '📁' : '📄';
|
|
1977
|
+
console.log(` ${index + 1}. ${typeIcon} ${item.relativePath}`);
|
|
1978
|
+
console.log(` Deleted: ${ageStr} | Size: ${item.isDirectory ? item.size + ' items' : formatBytes(item.size)}`);
|
|
1979
|
+
});
|
|
1980
|
+
console.log('');
|
|
1981
|
+
console.log(`Total: ${trashItems.length} item(s)`);
|
|
1982
|
+
console.log('');
|
|
1983
|
+
console.log('Use "jettypod undelete" to restore the most recent item');
|
|
1984
|
+
console.log('Use "jettypod undelete <path>" to restore a specific item');
|
|
1985
|
+
}
|
|
1986
|
+
} else if (trashSubcommand === 'empty') {
|
|
1987
|
+
// Empty the trash
|
|
1988
|
+
const result = safeDelete.emptyTrash(gitRoot);
|
|
1989
|
+
if (result.success) {
|
|
1990
|
+
console.log(`🗑️ Trash emptied (${result.count} items removed)`);
|
|
1991
|
+
} else {
|
|
1992
|
+
console.error(`❌ Failed to empty trash: ${result.error}`);
|
|
1993
|
+
process.exit(1);
|
|
1994
|
+
}
|
|
1995
|
+
} else if (trashSubcommand === 'purge') {
|
|
1996
|
+
// Purge old items (older than 24 hours by default)
|
|
1997
|
+
const hoursArg = args[1];
|
|
1998
|
+
const maxAgeHours = hoursArg ? parseInt(hoursArg, 10) : 24;
|
|
1999
|
+
const dryRun = args.includes('--dry-run');
|
|
2000
|
+
|
|
2001
|
+
if (dryRun) {
|
|
2002
|
+
console.log(`🔍 Dry run: Would purge items older than ${maxAgeHours} hours`);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const result = safeDelete.purgeOldTrash(gitRoot, { maxAgeHours, dryRun });
|
|
2006
|
+
|
|
2007
|
+
if (result.purged.length === 0) {
|
|
2008
|
+
console.log(`✅ No items older than ${maxAgeHours} hours to purge`);
|
|
2009
|
+
} else {
|
|
2010
|
+
console.log(`🗑️ ${dryRun ? 'Would purge' : 'Purged'} ${result.purged.length} item(s):`);
|
|
2011
|
+
result.purged.forEach(item => {
|
|
2012
|
+
console.log(` - ${item.originalPath}`);
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
if (result.errors.length > 0) {
|
|
2017
|
+
console.error('');
|
|
2018
|
+
console.error('Errors:');
|
|
2019
|
+
result.errors.forEach(err => {
|
|
2020
|
+
console.error(` - ${err.name}: ${err.error}`);
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
console.log(`\nRemaining in trash: ${result.remaining} item(s)`);
|
|
2025
|
+
} else {
|
|
2026
|
+
console.log('Usage: jettypod trash [list|empty|purge]');
|
|
2027
|
+
console.log('');
|
|
2028
|
+
console.log('Commands:');
|
|
2029
|
+
console.log(' list List items in trash (default)');
|
|
2030
|
+
console.log(' empty Permanently delete all items in trash');
|
|
2031
|
+
console.log(' purge [hours] Delete items older than N hours (default: 24)');
|
|
2032
|
+
console.log(' Add --dry-run to preview what would be deleted');
|
|
2033
|
+
}
|
|
2034
|
+
break;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
case 'undelete': {
|
|
2038
|
+
// Restore from trash
|
|
2039
|
+
const gitRoot = process.cwd();
|
|
2040
|
+
const itemName = args[0] || 'latest';
|
|
2041
|
+
|
|
2042
|
+
const trashItems = safeDelete.listTrash(gitRoot);
|
|
2043
|
+
if (trashItems.length === 0) {
|
|
2044
|
+
console.log('🗑️ Trash is empty - nothing to restore');
|
|
2045
|
+
process.exit(0);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const result = safeDelete.restoreFromTrash(gitRoot, itemName);
|
|
2049
|
+
|
|
2050
|
+
if (result.success) {
|
|
2051
|
+
console.log(`✅ Restored: ${result.restoredTo}`);
|
|
2052
|
+
} else {
|
|
2053
|
+
console.error(`❌ Failed to restore: ${result.error}`);
|
|
2054
|
+
|
|
2055
|
+
if (result.error.includes('path already exists')) {
|
|
2056
|
+
console.log('');
|
|
2057
|
+
console.log('The original location already has a file/directory.');
|
|
2058
|
+
console.log('Delete it first or use a different restore location.');
|
|
2059
|
+
}
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
}
|
|
2062
|
+
break;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
1879
2065
|
default:
|
|
1880
2066
|
// Smart mode: auto-initialize if needed, otherwise show guidance
|
|
1881
2067
|
if (!fs.existsSync('.jettypod')) {
|
package/lib/jettypod-backup.js
CHANGED
|
@@ -8,16 +8,41 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const { execSync } = require('child_process');
|
|
11
|
+
const safeDelete = require('./safe-delete');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the global backup directory path (in home directory)
|
|
15
|
+
* @returns {string} Path to global backup directory
|
|
16
|
+
*/
|
|
17
|
+
function getGlobalBackupDir() {
|
|
18
|
+
const os = require('os');
|
|
19
|
+
return path.join(os.homedir(), '.jettypod-backups');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get project-specific identifier for global backups
|
|
24
|
+
* @param {string} gitRoot - Path to git repository root
|
|
25
|
+
* @returns {string} Project identifier
|
|
26
|
+
*/
|
|
27
|
+
function getProjectId(gitRoot) {
|
|
28
|
+
// Use the directory name and a hash of the full path for uniqueness
|
|
29
|
+
const dirname = path.basename(gitRoot);
|
|
30
|
+
const hash = require('crypto').createHash('md5').update(gitRoot).digest('hex').slice(0, 8);
|
|
31
|
+
return `${dirname}-${hash}`;
|
|
32
|
+
}
|
|
11
33
|
|
|
12
34
|
/**
|
|
13
35
|
* Create a backup of the .jettypod directory
|
|
14
36
|
*
|
|
15
37
|
* @param {string} gitRoot - Absolute path to git repository root
|
|
16
38
|
* @param {string} reason - Human-readable reason for backup (e.g., "cleanup-worktree-1234")
|
|
39
|
+
* @param {Object} options - Backup options
|
|
40
|
+
* @param {boolean} options.global - Store backup in home directory (default: false)
|
|
17
41
|
* @returns {Promise<Object>} Result with success status and backup path
|
|
18
42
|
*/
|
|
19
|
-
async function createBackup(gitRoot, reason = 'unknown') {
|
|
43
|
+
async function createBackup(gitRoot, reason = 'unknown', options = {}) {
|
|
20
44
|
const jettypodPath = path.join(gitRoot, '.jettypod');
|
|
45
|
+
const useGlobal = options.global || false;
|
|
21
46
|
|
|
22
47
|
// Verify .jettypod exists
|
|
23
48
|
if (!fs.existsSync(jettypodPath)) {
|
|
@@ -28,8 +53,17 @@ async function createBackup(gitRoot, reason = 'unknown') {
|
|
|
28
53
|
};
|
|
29
54
|
}
|
|
30
55
|
|
|
56
|
+
// Determine backup location
|
|
57
|
+
let backupBaseDir;
|
|
58
|
+
if (useGlobal) {
|
|
59
|
+
const globalDir = getGlobalBackupDir();
|
|
60
|
+
const projectId = getProjectId(gitRoot);
|
|
61
|
+
backupBaseDir = path.join(globalDir, projectId);
|
|
62
|
+
} else {
|
|
63
|
+
backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
// Create backup directory if it doesn't exist
|
|
32
|
-
const backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
|
|
33
67
|
if (!fs.existsSync(backupBaseDir)) {
|
|
34
68
|
fs.mkdirSync(backupBaseDir, { recursive: true });
|
|
35
69
|
}
|
|
@@ -89,7 +123,16 @@ async function cleanupOldBackups(backupDir, keepCount = 10) {
|
|
|
89
123
|
// Delete old backups
|
|
90
124
|
const toDelete = backups.slice(keepCount);
|
|
91
125
|
for (const backup of toDelete) {
|
|
92
|
-
|
|
126
|
+
// SAFETY: Validate backup path before deletion
|
|
127
|
+
const resolvedPath = path.resolve(backup.path);
|
|
128
|
+
const isValidBackupPath = (resolvedPath.includes('.git/jettypod-backups') ||
|
|
129
|
+
resolvedPath.includes('.jettypod-backups')) &&
|
|
130
|
+
backup.name.startsWith('jettypod-');
|
|
131
|
+
if (!isValidBackupPath) {
|
|
132
|
+
console.warn(`Warning: Skipping suspicious backup path: ${backup.path}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
fs.rmSync(backup.path, { recursive: true });
|
|
93
136
|
}
|
|
94
137
|
} catch (err) {
|
|
95
138
|
// Non-fatal - just log warning
|
|
@@ -98,12 +141,77 @@ async function cleanupOldBackups(backupDir, keepCount = 10) {
|
|
|
98
141
|
}
|
|
99
142
|
|
|
100
143
|
/**
|
|
101
|
-
* List available backups
|
|
144
|
+
* List available backups from both local and global locations
|
|
102
145
|
*
|
|
103
146
|
* @param {string} gitRoot - Absolute path to git repository root
|
|
104
|
-
* @
|
|
147
|
+
* @param {Object} options - Options
|
|
148
|
+
* @param {boolean} options.includeGlobal - Include global backups (default: true)
|
|
149
|
+
* @returns {Array} List of backup objects with name, path, timestamp, location
|
|
105
150
|
*/
|
|
106
|
-
function listBackups(gitRoot) {
|
|
151
|
+
function listBackups(gitRoot, options = {}) {
|
|
152
|
+
const includeGlobal = options.includeGlobal !== false;
|
|
153
|
+
const allBackups = [];
|
|
154
|
+
|
|
155
|
+
// Local backups
|
|
156
|
+
const localDir = path.join(gitRoot, '.git', 'jettypod-backups');
|
|
157
|
+
if (fs.existsSync(localDir)) {
|
|
158
|
+
try {
|
|
159
|
+
const localBackups = fs.readdirSync(localDir)
|
|
160
|
+
.filter(name => name.startsWith('jettypod-'))
|
|
161
|
+
.map(name => {
|
|
162
|
+
const backupPath = path.join(localDir, name);
|
|
163
|
+
const stat = fs.statSync(backupPath);
|
|
164
|
+
return {
|
|
165
|
+
name: name,
|
|
166
|
+
path: backupPath,
|
|
167
|
+
created: stat.mtime,
|
|
168
|
+
size: getDirectorySize(backupPath),
|
|
169
|
+
location: 'local'
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
allBackups.push(...localBackups);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error(`Error reading local backups: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Global backups
|
|
179
|
+
if (includeGlobal) {
|
|
180
|
+
const globalDir = getGlobalBackupDir();
|
|
181
|
+
const projectId = getProjectId(gitRoot);
|
|
182
|
+
const projectBackupDir = path.join(globalDir, projectId);
|
|
183
|
+
|
|
184
|
+
if (fs.existsSync(projectBackupDir)) {
|
|
185
|
+
try {
|
|
186
|
+
const globalBackups = fs.readdirSync(projectBackupDir)
|
|
187
|
+
.filter(name => name.startsWith('jettypod-'))
|
|
188
|
+
.map(name => {
|
|
189
|
+
const backupPath = path.join(projectBackupDir, name);
|
|
190
|
+
const stat = fs.statSync(backupPath);
|
|
191
|
+
return {
|
|
192
|
+
name: name,
|
|
193
|
+
path: backupPath,
|
|
194
|
+
created: stat.mtime,
|
|
195
|
+
size: getDirectorySize(backupPath),
|
|
196
|
+
location: 'global'
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
allBackups.push(...globalBackups);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.error(`Error reading global backups: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Sort all backups by date, newest first
|
|
207
|
+
return allBackups.sort((a, b) => b.created - a.created);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* List available backups (legacy function for backwards compatibility)
|
|
212
|
+
* @deprecated Use listBackups with options instead
|
|
213
|
+
*/
|
|
214
|
+
function listBackupsLegacy(gitRoot) {
|
|
107
215
|
const backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
|
|
108
216
|
|
|
109
217
|
if (!fs.existsSync(backupBaseDir)) {
|
|
@@ -182,7 +290,13 @@ async function restoreBackup(gitRoot, backupName = 'latest') {
|
|
|
182
290
|
|
|
183
291
|
// Remove current .jettypod
|
|
184
292
|
if (fs.existsSync(jettypodPath)) {
|
|
185
|
-
|
|
293
|
+
// SAFETY: Validate jettypod path before deletion
|
|
294
|
+
const resolvedPath = path.resolve(jettypodPath);
|
|
295
|
+
const resolvedGitRoot = path.resolve(gitRoot);
|
|
296
|
+
if (!resolvedPath.startsWith(resolvedGitRoot) || !resolvedPath.endsWith('.jettypod')) {
|
|
297
|
+
throw new Error(`SAFETY: Refusing to delete ${jettypodPath} - not a valid .jettypod directory`);
|
|
298
|
+
}
|
|
299
|
+
fs.rmSync(jettypodPath, { recursive: true });
|
|
186
300
|
}
|
|
187
301
|
|
|
188
302
|
// Copy backup to .jettypod
|
|
@@ -234,5 +348,7 @@ function getDirectorySize(dirPath) {
|
|
|
234
348
|
module.exports = {
|
|
235
349
|
createBackup,
|
|
236
350
|
listBackups,
|
|
237
|
-
restoreBackup
|
|
351
|
+
restoreBackup,
|
|
352
|
+
getGlobalBackupDir,
|
|
353
|
+
getProjectId
|
|
238
354
|
};
|