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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chore Classifier
|
|
3
|
+
*
|
|
4
|
+
* Classifies chore type from title/description using keyword matching.
|
|
5
|
+
* Maps to the 4 chore types defined in chore-taxonomy.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { CHORE_TYPES, isValidChoreType } = require('./chore-taxonomy');
|
|
9
|
+
|
|
10
|
+
// Keywords that indicate each chore type
|
|
11
|
+
const TYPE_KEYWORDS = {
|
|
12
|
+
[CHORE_TYPES.REFACTOR]: [
|
|
13
|
+
'refactor',
|
|
14
|
+
'restructure',
|
|
15
|
+
'reorganize',
|
|
16
|
+
'extract',
|
|
17
|
+
'inline',
|
|
18
|
+
'rename',
|
|
19
|
+
'move',
|
|
20
|
+
'split',
|
|
21
|
+
'consolidate',
|
|
22
|
+
'simplify',
|
|
23
|
+
'decouple',
|
|
24
|
+
'modularize'
|
|
25
|
+
],
|
|
26
|
+
[CHORE_TYPES.DEPENDENCY]: [
|
|
27
|
+
'update',
|
|
28
|
+
'upgrade',
|
|
29
|
+
'bump',
|
|
30
|
+
'migrate',
|
|
31
|
+
'dependency',
|
|
32
|
+
'dependencies',
|
|
33
|
+
'package',
|
|
34
|
+
'library',
|
|
35
|
+
'framework',
|
|
36
|
+
'version',
|
|
37
|
+
'security patch',
|
|
38
|
+
'vulnerability',
|
|
39
|
+
'npm',
|
|
40
|
+
'yarn',
|
|
41
|
+
'lodash',
|
|
42
|
+
'react',
|
|
43
|
+
'node'
|
|
44
|
+
],
|
|
45
|
+
[CHORE_TYPES.CLEANUP]: [
|
|
46
|
+
'cleanup',
|
|
47
|
+
'clean up',
|
|
48
|
+
'remove',
|
|
49
|
+
'delete',
|
|
50
|
+
'deprecate',
|
|
51
|
+
'dead code',
|
|
52
|
+
'unused',
|
|
53
|
+
'legacy',
|
|
54
|
+
'obsolete',
|
|
55
|
+
'prune',
|
|
56
|
+
'trim'
|
|
57
|
+
],
|
|
58
|
+
[CHORE_TYPES.TOOLING]: [
|
|
59
|
+
'tooling',
|
|
60
|
+
'ci',
|
|
61
|
+
'cd',
|
|
62
|
+
'pipeline',
|
|
63
|
+
'build',
|
|
64
|
+
'lint',
|
|
65
|
+
'eslint',
|
|
66
|
+
'prettier',
|
|
67
|
+
'config',
|
|
68
|
+
'configuration',
|
|
69
|
+
'script',
|
|
70
|
+
'automation',
|
|
71
|
+
'github action',
|
|
72
|
+
'workflow',
|
|
73
|
+
'jest',
|
|
74
|
+
'test setup'
|
|
75
|
+
]
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize and validate input text
|
|
80
|
+
* @param {string} title - The chore title
|
|
81
|
+
* @param {string} [description] - Optional description
|
|
82
|
+
* @returns {string} - Normalized text for classification
|
|
83
|
+
* @throws {Error} - If title is invalid
|
|
84
|
+
*/
|
|
85
|
+
function normalizeInput(title, description = '') {
|
|
86
|
+
// Validate title
|
|
87
|
+
if (title === null || title === undefined) {
|
|
88
|
+
throw new Error('Chore title is required for classification');
|
|
89
|
+
}
|
|
90
|
+
if (typeof title !== 'string') {
|
|
91
|
+
throw new Error(`Chore title must be a string, got ${typeof title}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const trimmedTitle = title.trim();
|
|
95
|
+
if (trimmedTitle.length === 0) {
|
|
96
|
+
throw new Error('Chore title cannot be empty or whitespace only');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Normalize description (coerce to string if not)
|
|
100
|
+
const normalizedDesc = description != null ? String(description).trim() : '';
|
|
101
|
+
|
|
102
|
+
return `${trimmedTitle} ${normalizedDesc}`.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Escape special regex characters in a string
|
|
107
|
+
* @param {string} str - String to escape
|
|
108
|
+
* @returns {string} - Escaped string safe for regex
|
|
109
|
+
*/
|
|
110
|
+
function escapeRegex(str) {
|
|
111
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Calculate scores for all chore types
|
|
116
|
+
* @param {string} text - Normalized text to analyze
|
|
117
|
+
* @returns {Object} - Scores for each type
|
|
118
|
+
*/
|
|
119
|
+
function calculateScores(text) {
|
|
120
|
+
const scores = {};
|
|
121
|
+
for (const [type, keywords] of Object.entries(TYPE_KEYWORDS)) {
|
|
122
|
+
scores[type] = 0;
|
|
123
|
+
for (const keyword of keywords) {
|
|
124
|
+
if (text.includes(keyword.toLowerCase())) {
|
|
125
|
+
// Exact word match scores higher (escape special chars for safety)
|
|
126
|
+
const escapedKeyword = escapeRegex(keyword);
|
|
127
|
+
const wordBoundaryRegex = new RegExp(`\\b${escapedKeyword}\\b`, 'i');
|
|
128
|
+
if (wordBoundaryRegex.test(text)) {
|
|
129
|
+
scores[type] += 2;
|
|
130
|
+
} else {
|
|
131
|
+
scores[type] += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return scores;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find the winning type from scores
|
|
141
|
+
* @param {Object} scores - Scores for each type
|
|
142
|
+
* @returns {Object} - { type: string, score: number }
|
|
143
|
+
*/
|
|
144
|
+
function findWinningType(scores) {
|
|
145
|
+
let maxScore = 0;
|
|
146
|
+
let classifiedType = CHORE_TYPES.REFACTOR; // Default fallback
|
|
147
|
+
|
|
148
|
+
for (const [type, score] of Object.entries(scores)) {
|
|
149
|
+
if (score > maxScore) {
|
|
150
|
+
maxScore = score;
|
|
151
|
+
classifiedType = type;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { type: classifiedType, score: maxScore };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Classify a chore based on its title (and optionally description)
|
|
160
|
+
* @param {string} title - The chore title
|
|
161
|
+
* @param {string} [description] - Optional description for additional context
|
|
162
|
+
* @returns {string} - One of the 4 chore types
|
|
163
|
+
* @throws {Error} - If title is invalid
|
|
164
|
+
*/
|
|
165
|
+
function classifyChoreType(title, description = '') {
|
|
166
|
+
const text = normalizeInput(title, description);
|
|
167
|
+
const scores = calculateScores(text);
|
|
168
|
+
const { type } = findWinningType(scores);
|
|
169
|
+
return type;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Determine confidence level based on score
|
|
174
|
+
* @param {number} score - The classification score
|
|
175
|
+
* @returns {string} - 'high', 'medium', or 'low'
|
|
176
|
+
*/
|
|
177
|
+
function getConfidenceLevel(score) {
|
|
178
|
+
if (score >= 4) return 'high';
|
|
179
|
+
if (score >= 2) return 'medium';
|
|
180
|
+
return 'low';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get confidence level for a classification
|
|
185
|
+
* @param {string} title - The chore title
|
|
186
|
+
* @param {string} [description] - Optional description
|
|
187
|
+
* @returns {object} - { type: string, confidence: 'high'|'medium'|'low', score: number }
|
|
188
|
+
* @throws {Error} - If title is invalid
|
|
189
|
+
*/
|
|
190
|
+
function classifyWithConfidence(title, description = '') {
|
|
191
|
+
const text = normalizeInput(title, description);
|
|
192
|
+
const scores = calculateScores(text);
|
|
193
|
+
const { type, score } = findWinningType(scores);
|
|
194
|
+
const confidence = getConfidenceLevel(score);
|
|
195
|
+
|
|
196
|
+
return { type, confidence, score };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get detailed classification with all type scores
|
|
201
|
+
* Useful for debugging or when needing transparency in classification
|
|
202
|
+
* @param {string} title - The chore title
|
|
203
|
+
* @param {string} [description] - Optional description
|
|
204
|
+
* @returns {object} - { type, confidence, score, allScores }
|
|
205
|
+
* @throws {Error} - If title is invalid
|
|
206
|
+
*/
|
|
207
|
+
function classifyWithDetails(title, description = '') {
|
|
208
|
+
const text = normalizeInput(title, description);
|
|
209
|
+
const scores = calculateScores(text);
|
|
210
|
+
const { type, score } = findWinningType(scores);
|
|
211
|
+
const confidence = getConfidenceLevel(score);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
type,
|
|
215
|
+
confidence,
|
|
216
|
+
score,
|
|
217
|
+
allScores: scores
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
classifyChoreType,
|
|
223
|
+
classifyWithConfidence,
|
|
224
|
+
classifyWithDetails,
|
|
225
|
+
TYPE_KEYWORDS,
|
|
226
|
+
// Expose helpers for testing
|
|
227
|
+
normalizeInput,
|
|
228
|
+
escapeRegex,
|
|
229
|
+
calculateScores,
|
|
230
|
+
findWinningType,
|
|
231
|
+
getConfidenceLevel
|
|
232
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chore Type Taxonomy
|
|
3
|
+
* Defines the 4 chore types and their workflow guidance for the chore-planning and chore-mode skills.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Chore types
|
|
8
|
+
* @constant {Object}
|
|
9
|
+
*/
|
|
10
|
+
const CHORE_TYPES = Object.freeze({
|
|
11
|
+
REFACTOR: 'refactor',
|
|
12
|
+
DEPENDENCY: 'dependency',
|
|
13
|
+
CLEANUP: 'cleanup',
|
|
14
|
+
TOOLING: 'tooling'
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Valid chore type values as array
|
|
19
|
+
* @constant {Array<string>}
|
|
20
|
+
*/
|
|
21
|
+
const VALID_CHORE_TYPES = Object.freeze(Object.values(CHORE_TYPES));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Guidance for each chore type
|
|
25
|
+
* @constant {Object}
|
|
26
|
+
*/
|
|
27
|
+
const CHORE_TYPE_GUIDANCE = Object.freeze({
|
|
28
|
+
[CHORE_TYPES.REFACTOR]: {
|
|
29
|
+
scope: [
|
|
30
|
+
'Define clear boundaries - what code is being restructured',
|
|
31
|
+
'Identify all callers/dependents of code being changed',
|
|
32
|
+
'Ensure behavior remains unchanged (refactor, not rewrite)',
|
|
33
|
+
'Consider breaking into smaller refactors if scope is large'
|
|
34
|
+
],
|
|
35
|
+
verification: [
|
|
36
|
+
'All existing tests pass without modification',
|
|
37
|
+
'No new functionality added (that requires new tests)',
|
|
38
|
+
'Code review confirms behavior preservation',
|
|
39
|
+
'Performance is not degraded'
|
|
40
|
+
],
|
|
41
|
+
testHandling: {
|
|
42
|
+
required: true,
|
|
43
|
+
approach: 'Run all tests for affected modules before and after. Update test file paths/imports if moved. Do NOT change test assertions - if tests fail, the refactor broke behavior.'
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[CHORE_TYPES.DEPENDENCY]: {
|
|
47
|
+
scope: [
|
|
48
|
+
'Identify which packages are being updated',
|
|
49
|
+
'Check changelogs for breaking changes',
|
|
50
|
+
'Note any deprecated APIs that need migration',
|
|
51
|
+
'Consider update strategy: one at a time vs batch'
|
|
52
|
+
],
|
|
53
|
+
verification: [
|
|
54
|
+
'All tests pass after update',
|
|
55
|
+
'Application builds successfully',
|
|
56
|
+
'No new deprecation warnings (or documented)',
|
|
57
|
+
'Security vulnerabilities addressed (if security update)'
|
|
58
|
+
],
|
|
59
|
+
testHandling: {
|
|
60
|
+
required: false,
|
|
61
|
+
approach: 'Run full test suite to catch regressions. No new tests needed unless migrating to new API patterns. Document any test changes needed due to library API changes.'
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[CHORE_TYPES.CLEANUP]: {
|
|
65
|
+
scope: [
|
|
66
|
+
'Define what is being cleaned (dead code, unused files, etc.)',
|
|
67
|
+
'Verify code is actually unused (grep for references)',
|
|
68
|
+
'Set clear boundaries to avoid scope creep',
|
|
69
|
+
'Consider impact on git history/blame'
|
|
70
|
+
],
|
|
71
|
+
verification: [
|
|
72
|
+
'All tests still pass',
|
|
73
|
+
'No broken imports or references',
|
|
74
|
+
'Application runs correctly',
|
|
75
|
+
'Removed code was actually unused'
|
|
76
|
+
],
|
|
77
|
+
testHandling: {
|
|
78
|
+
required: false,
|
|
79
|
+
approach: 'Run existing tests to ensure nothing breaks. Remove tests only if they test deleted code. No new tests needed for cleanup work.'
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[CHORE_TYPES.TOOLING]: {
|
|
83
|
+
scope: [
|
|
84
|
+
'Define what tooling is being changed (CI, build, dev environment)',
|
|
85
|
+
'Document current behavior before changes',
|
|
86
|
+
'Consider impact on team workflows',
|
|
87
|
+
'Plan rollback strategy if changes cause issues'
|
|
88
|
+
],
|
|
89
|
+
verification: [
|
|
90
|
+
'CI pipeline passes',
|
|
91
|
+
'Build completes successfully',
|
|
92
|
+
'Dev environment works for all team members',
|
|
93
|
+
'No regression in build times or developer experience'
|
|
94
|
+
],
|
|
95
|
+
testHandling: {
|
|
96
|
+
required: false,
|
|
97
|
+
approach: 'Verify tooling changes work via manual testing or CI runs. Add integration tests only if tooling is complex. Focus on verification over unit testing for infrastructure.'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalize a chore type string (lowercase, trimmed)
|
|
104
|
+
* @param {string} type - Type to normalize
|
|
105
|
+
* @returns {string|null} Normalized type or null if input is invalid
|
|
106
|
+
*/
|
|
107
|
+
function normalizeChoreType(type) {
|
|
108
|
+
if (type === null || type === undefined) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (typeof type !== 'string') {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return type.toLowerCase().trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a chore type is valid (case-insensitive)
|
|
119
|
+
* @param {string} type - Type to validate
|
|
120
|
+
* @returns {boolean} True if type is valid
|
|
121
|
+
*/
|
|
122
|
+
function isValidChoreType(type) {
|
|
123
|
+
const normalized = normalizeChoreType(type);
|
|
124
|
+
if (normalized === null) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return VALID_CHORE_TYPES.includes(normalized);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get guidance for a chore type
|
|
132
|
+
* @param {string} type - Chore type (case-insensitive)
|
|
133
|
+
* @returns {Object} Guidance object with scope, verification, and testHandling
|
|
134
|
+
* @throws {Error} If type is null, undefined, or invalid
|
|
135
|
+
*/
|
|
136
|
+
function getGuidance(type) {
|
|
137
|
+
if (type === null || type === undefined) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
'Chore type is required. Valid types: refactor, dependency, cleanup, tooling'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const normalized = normalizeChoreType(type);
|
|
144
|
+
|
|
145
|
+
if (!normalized || !VALID_CHORE_TYPES.includes(normalized)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Invalid chore type: "${type}". Valid types: refactor, dependency, cleanup, tooling`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return CHORE_TYPE_GUIDANCE[normalized];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get all chore types
|
|
156
|
+
* @returns {Array<string>} Array of valid chore types
|
|
157
|
+
*/
|
|
158
|
+
function getChoreTypes() {
|
|
159
|
+
return [...VALID_CHORE_TYPES];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
CHORE_TYPES,
|
|
164
|
+
VALID_CHORE_TYPES,
|
|
165
|
+
CHORE_TYPE_GUIDANCE,
|
|
166
|
+
normalizeChoreType,
|
|
167
|
+
isValidChoreType,
|
|
168
|
+
getGuidance,
|
|
169
|
+
getChoreTypes,
|
|
170
|
+
// Alias for step definitions
|
|
171
|
+
types: CHORE_TYPES
|
|
172
|
+
};
|
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
|
};
|