jettypod 4.2.11 → 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/hooks/post-checkout +78 -3
- package/hooks/pre-push +33 -0
- package/jettypod.js +215 -25
- package/lib/claudemd.js +12 -4
- package/lib/current-work.js +97 -84
- package/lib/jettypod-backup.js +124 -8
- package/lib/merge-lock.js +30 -0
- package/lib/safe-delete.js +794 -0
- package/lib/session-writer.js +20 -0
- package/lib/worktree-facade.js +43 -0
- package/lib/worktree-manager.js +54 -41
- package/package.json +1 -1
- package/lib/migrations/016-worktree-sessions-table.js +0 -84
- package/lib/worktree-sessions.js +0 -186
|
@@ -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/hooks/post-checkout
CHANGED
|
@@ -1,13 +1,88 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Post-Checkout Hook
|
|
5
|
+
*
|
|
6
|
+
* 1. Prevents checking out default branch in JettyPod worktrees
|
|
7
|
+
* 2. Imports database snapshots after checkout
|
|
8
|
+
*/
|
|
9
|
+
|
|
3
10
|
const { importAll } = require('../lib/db-import');
|
|
11
|
+
const { execSync } = require('child_process');
|
|
4
12
|
|
|
5
13
|
(async () => {
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
|
|
16
|
+
// FIRST: Check if we're in a JettyPod worktree and prevent default branch checkout
|
|
17
|
+
if (cwd.includes('.jettypod-work')) {
|
|
18
|
+
try {
|
|
19
|
+
// Get the branch we just checked out
|
|
20
|
+
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
23
|
+
}).trim();
|
|
24
|
+
|
|
25
|
+
// Detect default branch
|
|
26
|
+
let defaultBranch;
|
|
27
|
+
try {
|
|
28
|
+
// Try to get the default branch from git config
|
|
29
|
+
defaultBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
32
|
+
}).trim().replace('refs/remotes/origin/', '');
|
|
33
|
+
} catch {
|
|
34
|
+
// Fallback: check which common branch names exist
|
|
35
|
+
try {
|
|
36
|
+
execSync('git rev-parse --verify main', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
37
|
+
defaultBranch = 'main';
|
|
38
|
+
} catch {
|
|
39
|
+
try {
|
|
40
|
+
execSync('git rev-parse --verify master', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
41
|
+
defaultBranch = 'master';
|
|
42
|
+
} catch {
|
|
43
|
+
// Can't determine default branch - skip check
|
|
44
|
+
defaultBranch = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if we checked out the default branch
|
|
50
|
+
if (defaultBranch && currentBranch === defaultBranch) {
|
|
51
|
+
console.error('');
|
|
52
|
+
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
53
|
+
console.error('❌ ERROR: Cannot checkout default branch in worktree');
|
|
54
|
+
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
55
|
+
console.error('');
|
|
56
|
+
console.error(`You are in a JettyPod worktree for isolated work.`);
|
|
57
|
+
console.error(`Checking out ${defaultBranch} in a worktree bypasses the merge workflow.`);
|
|
58
|
+
console.error('');
|
|
59
|
+
console.error('To merge your changes:');
|
|
60
|
+
console.error(' 1. Commit your changes: git add . && git commit -m "..."');
|
|
61
|
+
console.error(' 2. Use JettyPod merge: jettypod work merge');
|
|
62
|
+
console.error('');
|
|
63
|
+
console.error('This ensures proper worktree cleanup and database updates.');
|
|
64
|
+
console.error('');
|
|
65
|
+
console.error('Reverting checkout...');
|
|
66
|
+
console.error('');
|
|
67
|
+
|
|
68
|
+
// Revert to previous branch
|
|
69
|
+
try {
|
|
70
|
+
execSync('git checkout -', { stdio: 'inherit' });
|
|
71
|
+
} catch (revertErr) {
|
|
72
|
+
console.error('Failed to revert checkout. You may need to manually switch branches.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// If we can't determine the branch, allow the checkout
|
|
79
|
+
// (better to be permissive than block legitimate operations)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// SECOND: Import database snapshots
|
|
6
84
|
try {
|
|
7
|
-
// Import JSON snapshots into databases after checkout
|
|
8
85
|
await importAll();
|
|
9
|
-
|
|
10
|
-
// Exit successfully - checkout should not be blocked
|
|
11
86
|
process.exit(0);
|
|
12
87
|
} catch (err) {
|
|
13
88
|
// Log error but don't block checkout
|
package/hooks/pre-push
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pre-Push Hook
|
|
5
|
+
*
|
|
6
|
+
* Prevents pushing directly from JettyPod worktrees.
|
|
7
|
+
* Forces use of `jettypod work merge` to ensure proper cleanup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
|
|
12
|
+
// Check if we're in a JettyPod worktree
|
|
13
|
+
if (cwd.includes('.jettypod-work')) {
|
|
14
|
+
console.error('');
|
|
15
|
+
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
16
|
+
console.error('❌ ERROR: Cannot push directly from worktree');
|
|
17
|
+
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
18
|
+
console.error('');
|
|
19
|
+
console.error('You are in a JettyPod worktree for isolated work.');
|
|
20
|
+
console.error('Pushing directly bypasses the merge workflow.');
|
|
21
|
+
console.error('');
|
|
22
|
+
console.error('To merge and push your changes:');
|
|
23
|
+
console.error(' 1. Commit your changes: git add . && git commit -m "..."');
|
|
24
|
+
console.error(' 2. Use JettyPod merge: jettypod work merge');
|
|
25
|
+
console.error('');
|
|
26
|
+
console.error('This ensures proper worktree cleanup and database updates.');
|
|
27
|
+
console.error('');
|
|
28
|
+
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Allow pushes from main repo
|
|
33
|
+
process.exit(0);
|
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');
|
|
@@ -1153,7 +1233,11 @@ switch (command) {
|
|
|
1153
1233
|
} else if (subcommand === 'merge') {
|
|
1154
1234
|
const workCommands = require('./features/work-commands/index.js');
|
|
1155
1235
|
try {
|
|
1156
|
-
|
|
1236
|
+
// Parse merge flags from args
|
|
1237
|
+
const withTransition = args.includes('--with-transition');
|
|
1238
|
+
const releaseLock = args.includes('--release-lock');
|
|
1239
|
+
|
|
1240
|
+
await workCommands.mergeWork({ withTransition, releaseLock });
|
|
1157
1241
|
} catch (err) {
|
|
1158
1242
|
console.error(`Error: ${err.message}`);
|
|
1159
1243
|
process.exit(1);
|
|
@@ -1872,6 +1956,112 @@ Quick commands:
|
|
|
1872
1956
|
}
|
|
1873
1957
|
break;
|
|
1874
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
|
+
|
|
1875
2065
|
default:
|
|
1876
2066
|
// Smart mode: auto-initialize if needed, otherwise show guidance
|
|
1877
2067
|
if (!fs.existsSync('.jettypod')) {
|
package/lib/claudemd.js
CHANGED
|
@@ -65,11 +65,19 @@ function updateCurrentWork(currentWork, mode) {
|
|
|
65
65
|
|
|
66
66
|
// Write to session file (gitignored) instead of CLAUDE.md
|
|
67
67
|
// This prevents merge conflicts and stale context on main branch
|
|
68
|
+
// Only write session file if we're in a worktree (not root directory)
|
|
68
69
|
if (currentMode !== null) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
try {
|
|
71
|
+
writeSessionFile(currentWork, currentMode, {
|
|
72
|
+
epicId: currentWork.epic_id,
|
|
73
|
+
epicTitle: currentWork.epic_title
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Skip if not in worktree - validation will throw error
|
|
77
|
+
if (!err.message.includes('Cannot create session.md in root directory')) {
|
|
78
|
+
throw err; // Re-throw unexpected errors
|
|
79
|
+
}
|
|
80
|
+
}
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
|