gsd-opencode 1.9.2 → 1.10.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/agents/gsd-debugger.md +5 -5
- package/bin/gsd-install.js +105 -0
- package/bin/gsd.js +352 -0
- package/{command → commands}/gsd/add-phase.md +1 -1
- package/{command → commands}/gsd/audit-milestone.md +1 -1
- package/{command → commands}/gsd/debug.md +3 -3
- package/{command → commands}/gsd/discuss-phase.md +1 -1
- package/{command → commands}/gsd/execute-phase.md +1 -1
- package/{command → commands}/gsd/list-phase-assumptions.md +1 -1
- package/{command → commands}/gsd/map-codebase.md +1 -1
- package/{command → commands}/gsd/new-milestone.md +1 -1
- package/{command → commands}/gsd/new-project.md +3 -3
- package/{command → commands}/gsd/plan-phase.md +2 -2
- package/{command → commands}/gsd/research-phase.md +1 -1
- package/{command → commands}/gsd/verify-work.md +1 -1
- package/get-shit-done/workflows/list-phase-assumptions.md +1 -1
- package/get-shit-done/workflows/verify-work.md +5 -5
- package/lib/constants.js +193 -0
- package/package.json +34 -20
- package/src/commands/check.js +329 -0
- package/src/commands/config.js +337 -0
- package/src/commands/install.js +608 -0
- package/src/commands/list.js +256 -0
- package/src/commands/repair.js +519 -0
- package/src/commands/uninstall.js +732 -0
- package/src/commands/update.js +444 -0
- package/src/services/backup-manager.js +585 -0
- package/src/services/config.js +262 -0
- package/src/services/file-ops.js +830 -0
- package/src/services/health-checker.js +475 -0
- package/src/services/manifest-manager.js +301 -0
- package/src/services/migration-service.js +831 -0
- package/src/services/repair-service.js +846 -0
- package/src/services/scope-manager.js +303 -0
- package/src/services/settings.js +553 -0
- package/src/services/structure-detector.js +240 -0
- package/src/services/update-service.js +863 -0
- package/src/utils/hash.js +71 -0
- package/src/utils/interactive.js +222 -0
- package/src/utils/logger.js +128 -0
- package/src/utils/npm-registry.js +255 -0
- package/src/utils/path-resolver.js +226 -0
- /package/{command → commands}/gsd/add-todo.md +0 -0
- /package/{command → commands}/gsd/check-todos.md +0 -0
- /package/{command → commands}/gsd/complete-milestone.md +0 -0
- /package/{command → commands}/gsd/help.md +0 -0
- /package/{command → commands}/gsd/insert-phase.md +0 -0
- /package/{command → commands}/gsd/pause-work.md +0 -0
- /package/{command → commands}/gsd/plan-milestone-gaps.md +0 -0
- /package/{command → commands}/gsd/progress.md +0 -0
- /package/{command → commands}/gsd/quick.md +0 -0
- /package/{command → commands}/gsd/remove-phase.md +0 -0
- /package/{command → commands}/gsd/resume-work.md +0 -0
- /package/{command → commands}/gsd/set-model.md +0 -0
- /package/{command → commands}/gsd/set-profile.md +0 -0
- /package/{command → commands}/gsd/settings.md +0 -0
- /package/{command → commands}/gsd/update.md +0 -0
- /package/{command → commands}/gsd/whats-new.md +0 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup manager service for safe backup and retention during repair operations.
|
|
3
|
+
*
|
|
4
|
+
* This module provides backup creation and retention management for GSD-OpenCode
|
|
5
|
+
* installations. It handles:
|
|
6
|
+
* - Creating date-stamped backups of files before they are overwritten
|
|
7
|
+
* - Maintaining a backup directory with proper structure
|
|
8
|
+
* - Enforcing retention policies (keeping only N most recent backups)
|
|
9
|
+
* - Graceful error handling that doesn't fail the entire operation
|
|
10
|
+
*
|
|
11
|
+
* Used by the RepairService to ensure users can recover if repairs go wrong.
|
|
12
|
+
*
|
|
13
|
+
* @module backup-manager
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs/promises';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Manages backups for GSD-OpenCode installation repairs.
|
|
21
|
+
*
|
|
22
|
+
* This class provides safe backup creation with date-stamped filenames and
|
|
23
|
+
* automatic retention management. It ensures that files are backed up before
|
|
24
|
+
* being overwritten during repair operations, with configurable retention
|
|
25
|
+
* policies to prevent disk space issues.
|
|
26
|
+
*
|
|
27
|
+
* @class BackupManager
|
|
28
|
+
* @example
|
|
29
|
+
* const scope = new ScopeManager({ scope: 'global' });
|
|
30
|
+
* const logger = createLogger();
|
|
31
|
+
* const backupManager = new BackupManager(scope, logger, { maxBackups: 5 });
|
|
32
|
+
*
|
|
33
|
+
* // Create a backup before repair
|
|
34
|
+
* const result = await backupManager.backupFile(
|
|
35
|
+
* '/home/user/.config/opencode/agents/ro-commit.md',
|
|
36
|
+
* 'agents/ro-commit.md'
|
|
37
|
+
* );
|
|
38
|
+
* if (result.success) {
|
|
39
|
+
* console.log(`Backed up to: ${result.backupPath}`);
|
|
40
|
+
* }
|
|
41
|
+
*
|
|
42
|
+
* // Clean up old backups (keep only 5 newest)
|
|
43
|
+
* const cleanup = await backupManager.cleanupOldBackups();
|
|
44
|
+
* console.log(`Removed ${cleanup.cleaned} old backups`);
|
|
45
|
+
*/
|
|
46
|
+
export class BackupManager {
|
|
47
|
+
/**
|
|
48
|
+
* Creates a new BackupManager instance.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} scopeManager - ScopeManager instance for path resolution
|
|
51
|
+
* @param {Object} logger - Logger instance for output (from logger.js)
|
|
52
|
+
* @param {Object} [options={}] - Configuration options
|
|
53
|
+
* @param {number} [options.maxBackups=5] - Maximum number of backups to retain
|
|
54
|
+
* @throws {Error} If scopeManager is not provided or missing getTargetDir method
|
|
55
|
+
* @throws {Error} If logger is not provided or missing required methods
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const scope = new ScopeManager({ scope: 'global' });
|
|
59
|
+
* const logger = createLogger();
|
|
60
|
+
* const backupManager = new BackupManager(scope, logger);
|
|
61
|
+
*
|
|
62
|
+
* // With custom retention
|
|
63
|
+
* const backupManager = new BackupManager(scope, logger, { maxBackups: 10 });
|
|
64
|
+
*/
|
|
65
|
+
constructor(scopeManager, logger, options = {}) {
|
|
66
|
+
// Validate scopeManager
|
|
67
|
+
if (!scopeManager) {
|
|
68
|
+
throw new Error('ScopeManager instance is required');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof scopeManager.getTargetDir !== 'function') {
|
|
72
|
+
throw new Error('Invalid ScopeManager: missing getTargetDir method');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate logger
|
|
76
|
+
if (!logger) {
|
|
77
|
+
throw new Error('Logger instance is required');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof logger.debug !== 'function') {
|
|
81
|
+
throw new Error('Invalid Logger: missing debug method');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof logger.info !== 'function') {
|
|
85
|
+
throw new Error('Invalid Logger: missing info method');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof logger.error !== 'function') {
|
|
89
|
+
throw new Error('Invalid Logger: missing error method');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.scopeManager = scopeManager;
|
|
93
|
+
this.logger = logger;
|
|
94
|
+
|
|
95
|
+
// Set backup directory (within target installation directory)
|
|
96
|
+
const targetDir = scopeManager.getTargetDir();
|
|
97
|
+
this._backupDir = path.join(targetDir, '.backups');
|
|
98
|
+
|
|
99
|
+
// Set retention count (default to 5)
|
|
100
|
+
this._retentionCount = options.maxBackups ?? 5;
|
|
101
|
+
|
|
102
|
+
this.logger.debug(`BackupManager initialized with retention: ${this._retentionCount}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Creates a date-stamped backup of a file.
|
|
107
|
+
*
|
|
108
|
+
* Copies the source file to the backup directory with a date prefix.
|
|
109
|
+
* The backup filename format is: YYYY-MM-DD_original-filename.ext
|
|
110
|
+
*
|
|
111
|
+
* @param {string} sourcePath - Absolute path to the file being backed up
|
|
112
|
+
* @param {string} relativePath - Path relative to installation root (for reference)
|
|
113
|
+
* @returns {Promise<Object>} Backup result
|
|
114
|
+
* @property {boolean} success - True if backup was successful
|
|
115
|
+
* @property {string|null} backupPath - Path to the backup file, or null if failed
|
|
116
|
+
* @property {string} originalPath - Original file path
|
|
117
|
+
* @property {string|null} error - Error message if backup failed
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* const result = await backupManager.backupFile(
|
|
121
|
+
* '/home/user/.config/opencode/agents/ro-commit.md',
|
|
122
|
+
* 'agents/ro-commit.md'
|
|
123
|
+
* );
|
|
124
|
+
* // Result: { success: true, backupPath: '/.../.backups/2026-02-10_ro-commit.md', ... }
|
|
125
|
+
*/
|
|
126
|
+
async backupFile(sourcePath, relativePath) {
|
|
127
|
+
try {
|
|
128
|
+
// Check if source file exists
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(sourcePath);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.code === 'ENOENT') {
|
|
133
|
+
this.logger.debug(`No backup needed for ${relativePath}: file doesn't exist`);
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
backupPath: null,
|
|
137
|
+
originalPath: sourcePath,
|
|
138
|
+
note: 'File did not exist, no backup needed'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ensure backup directory exists
|
|
145
|
+
await fs.mkdir(this._backupDir, { recursive: true });
|
|
146
|
+
|
|
147
|
+
// Generate date-stamped backup filename
|
|
148
|
+
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
149
|
+
const originalName = path.basename(relativePath);
|
|
150
|
+
const backupFileName = `${timestamp}_${originalName}`;
|
|
151
|
+
const backupPath = path.join(this._backupDir, backupFileName);
|
|
152
|
+
|
|
153
|
+
this.logger.debug(`Creating backup: ${relativePath} -> ${backupFileName}`);
|
|
154
|
+
|
|
155
|
+
// Copy file to backup location
|
|
156
|
+
await fs.copyFile(sourcePath, backupPath);
|
|
157
|
+
|
|
158
|
+
this.logger.info(`Backed up ${relativePath}`);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
success: true,
|
|
162
|
+
backupPath,
|
|
163
|
+
originalPath: sourcePath
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const errorMessage = `Failed to backup ${relativePath}: ${error.message}`;
|
|
167
|
+
this.logger.error(errorMessage);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
backupPath: null,
|
|
172
|
+
originalPath: sourcePath,
|
|
173
|
+
error: errorMessage
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Cleans up old backups according to retention policy.
|
|
180
|
+
*
|
|
181
|
+
* Reads all backup files, sorts by date (newest first), and removes
|
|
182
|
+
* files beyond the retention count. Files without valid date prefixes
|
|
183
|
+
* are ignored and not deleted.
|
|
184
|
+
*
|
|
185
|
+
* @returns {Promise<Object>} Cleanup result
|
|
186
|
+
* @property {number} cleaned - Number of backups removed
|
|
187
|
+
* @property {number} kept - Number of backups retained
|
|
188
|
+
* @property {Array<string>} errors - Array of error messages for failed deletions
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* const result = await backupManager.cleanupOldBackups();
|
|
192
|
+
* console.log(`Cleaned ${result.cleaned} backups, kept ${result.kept}`);
|
|
193
|
+
* if (result.errors.length > 0) {
|
|
194
|
+
* console.warn('Some backups could not be removed:', result.errors);
|
|
195
|
+
* }
|
|
196
|
+
*/
|
|
197
|
+
async cleanupOldBackups() {
|
|
198
|
+
const errors = [];
|
|
199
|
+
let cleaned = 0;
|
|
200
|
+
let kept = 0;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// Check if backup directory exists
|
|
204
|
+
try {
|
|
205
|
+
await fs.access(this._backupDir);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error.code === 'ENOENT') {
|
|
208
|
+
this.logger.debug('Backup directory does not exist, nothing to clean up');
|
|
209
|
+
return { cleaned: 0, kept: 0, errors: [] };
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Read backup directory contents
|
|
215
|
+
const entries = await fs.readdir(this._backupDir, { withFileTypes: true });
|
|
216
|
+
|
|
217
|
+
// Filter for files (not directories) with date prefix
|
|
218
|
+
const backupFiles = entries
|
|
219
|
+
.filter(entry => entry.isFile())
|
|
220
|
+
.map(entry => entry.name)
|
|
221
|
+
.filter(name => this._isDatePrefixed(name));
|
|
222
|
+
|
|
223
|
+
if (backupFiles.length === 0) {
|
|
224
|
+
this.logger.debug('No backup files found in backup directory');
|
|
225
|
+
return { cleaned: 0, kept: 0, errors: [] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.logger.debug(`Found ${backupFiles.length} backup files, retention: ${this._retentionCount}`);
|
|
229
|
+
|
|
230
|
+
// Sort by date descending (newest first)
|
|
231
|
+
const sortedFiles = backupFiles.sort((a, b) => {
|
|
232
|
+
const dateA = this._extractDate(a);
|
|
233
|
+
const dateB = this._extractDate(b);
|
|
234
|
+
return dateB - dateA; // Descending order
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Remove files beyond retention count
|
|
238
|
+
const filesToRemove = sortedFiles.slice(this._retentionCount);
|
|
239
|
+
const filesToKeep = sortedFiles.slice(0, this._retentionCount);
|
|
240
|
+
|
|
241
|
+
kept = filesToKeep.length;
|
|
242
|
+
|
|
243
|
+
for (const fileName of filesToRemove) {
|
|
244
|
+
const filePath = path.join(this._backupDir, fileName);
|
|
245
|
+
try {
|
|
246
|
+
await fs.unlink(filePath);
|
|
247
|
+
cleaned++;
|
|
248
|
+
this.logger.debug(`Removed old backup: ${fileName}`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const errorMsg = `Failed to remove ${fileName}: ${error.message}`;
|
|
251
|
+
errors.push(errorMsg);
|
|
252
|
+
this.logger.error(errorMsg);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (cleaned > 0) {
|
|
257
|
+
this.logger.info(`Cleaned up ${cleaned} old backups, kept ${kept}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { cleaned, kept, errors };
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const errorMessage = `Failed to cleanup old backups: ${error.message}`;
|
|
263
|
+
this.logger.error(errorMessage);
|
|
264
|
+
throw new Error(errorMessage);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Checks if a filename has a date prefix (YYYY-MM-DD format).
|
|
270
|
+
*
|
|
271
|
+
* @param {string} fileName - Filename to check
|
|
272
|
+
* @returns {boolean} True if filename starts with date pattern
|
|
273
|
+
* @private
|
|
274
|
+
*/
|
|
275
|
+
_isDatePrefixed(fileName) {
|
|
276
|
+
// Check if filename starts with YYYY-MM-DD pattern
|
|
277
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}_/;
|
|
278
|
+
return datePattern.test(fileName);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extracts the date from a backup filename.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} fileName - Backup filename with date prefix
|
|
285
|
+
* @returns {Date} Date object parsed from filename
|
|
286
|
+
* @private
|
|
287
|
+
*/
|
|
288
|
+
_extractDate(fileName) {
|
|
289
|
+
// Extract YYYY-MM-DD from beginning of filename
|
|
290
|
+
const dateString = fileName.substring(0, 10);
|
|
291
|
+
return new Date(dateString);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Returns the backup directory path.
|
|
296
|
+
*
|
|
297
|
+
* @returns {string} Path to backup directory
|
|
298
|
+
*/
|
|
299
|
+
getBackupDir() {
|
|
300
|
+
return this._backupDir;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Returns the current retention count.
|
|
305
|
+
*
|
|
306
|
+
* @returns {number} Number of backups to retain
|
|
307
|
+
*/
|
|
308
|
+
getRetentionCount() {
|
|
309
|
+
return this._retentionCount;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Creates a migration backup of the entire installation.
|
|
314
|
+
*
|
|
315
|
+
* Creates a timestamped backup of the installation before migration,
|
|
316
|
+
* including manifest, command/gsd/ directory, and all tracked files.
|
|
317
|
+
* Stores backup in .backups/ subdirectory.
|
|
318
|
+
*
|
|
319
|
+
* @param {Object} options - Backup options
|
|
320
|
+
* @param {string} options.targetDir - Path to the installation directory
|
|
321
|
+
* @param {string} options.originalStructure - Structure type ('old', 'new', 'dual', 'none')
|
|
322
|
+
* @param {number} options.timestamp - Timestamp for the backup
|
|
323
|
+
* @returns {Promise<Object>} Backup metadata
|
|
324
|
+
* @property {string} path - Path to the backup directory
|
|
325
|
+
* @property {number} timestamp - Backup timestamp
|
|
326
|
+
* @property {string} originalStructure - Original structure type
|
|
327
|
+
* @property {string[]} affectedFiles - List of files that will be affected
|
|
328
|
+
* @property {Object|null} originalManifest - Original manifest content (if exists)
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* const backup = await backupManager.createMigrationBackup({
|
|
332
|
+
* targetDir: '/home/user/.config/opencode',
|
|
333
|
+
* originalStructure: 'old',
|
|
334
|
+
* timestamp: Date.now()
|
|
335
|
+
* });
|
|
336
|
+
* // Returns: { path: '/.../.backups/backup-1234567890', ... }
|
|
337
|
+
*/
|
|
338
|
+
async createMigrationBackup({ targetDir, originalStructure, timestamp }) {
|
|
339
|
+
const backupDirName = `backup-${timestamp}`;
|
|
340
|
+
const migrationBackupDir = path.join(targetDir, '.backups', backupDirName);
|
|
341
|
+
|
|
342
|
+
this.logger.info('Creating migration backup...');
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
// Ensure migration backup directory exists
|
|
346
|
+
await fs.mkdir(migrationBackupDir, { recursive: true });
|
|
347
|
+
|
|
348
|
+
// Determine files to backup based on structure type
|
|
349
|
+
const affectedFiles = [];
|
|
350
|
+
const oldPath = path.join(targetDir, 'command', 'gsd');
|
|
351
|
+
const newPath = path.join(targetDir, 'commands', 'gsd');
|
|
352
|
+
const manifestPath = path.join(targetDir, 'get-shit-done', 'INSTALLED_FILES.json');
|
|
353
|
+
|
|
354
|
+
// Check and add old structure files
|
|
355
|
+
try {
|
|
356
|
+
await fs.access(oldPath);
|
|
357
|
+
const oldFiles = await this._collectFilesRecursively(oldPath);
|
|
358
|
+
for (const file of oldFiles) {
|
|
359
|
+
const relativePath = path.relative(targetDir, file);
|
|
360
|
+
affectedFiles.push(relativePath);
|
|
361
|
+
await this._copyToBackup(file, migrationBackupDir, relativePath);
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error.code !== 'ENOENT') {
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Check and add new structure files
|
|
370
|
+
try {
|
|
371
|
+
await fs.access(newPath);
|
|
372
|
+
const newFiles = await this._collectFilesRecursively(newPath);
|
|
373
|
+
for (const file of newFiles) {
|
|
374
|
+
const relativePath = path.relative(targetDir, file);
|
|
375
|
+
if (!affectedFiles.includes(relativePath)) {
|
|
376
|
+
affectedFiles.push(relativePath);
|
|
377
|
+
await this._copyToBackup(file, migrationBackupDir, relativePath);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (error.code !== 'ENOENT') {
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Backup manifest if it exists
|
|
387
|
+
let originalManifest = null;
|
|
388
|
+
try {
|
|
389
|
+
await fs.access(manifestPath);
|
|
390
|
+
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
|
|
391
|
+
originalManifest = JSON.parse(manifestContent);
|
|
392
|
+
await fs.writeFile(
|
|
393
|
+
path.join(migrationBackupDir, 'manifest.json'),
|
|
394
|
+
manifestContent,
|
|
395
|
+
'utf-8'
|
|
396
|
+
);
|
|
397
|
+
affectedFiles.push('get-shit-done/INSTALLED_FILES.json');
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error.code !== 'ENOENT') {
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
this.logger.debug('No manifest found, skipping manifest backup');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Create backup metadata file
|
|
406
|
+
const metadata = {
|
|
407
|
+
timestamp,
|
|
408
|
+
originalStructure,
|
|
409
|
+
affectedFiles,
|
|
410
|
+
backupPath: migrationBackupDir,
|
|
411
|
+
created: new Date(timestamp).toISOString()
|
|
412
|
+
};
|
|
413
|
+
await fs.writeFile(
|
|
414
|
+
path.join(migrationBackupDir, 'metadata.json'),
|
|
415
|
+
JSON.stringify(metadata, null, 2),
|
|
416
|
+
'utf-8'
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
this.logger.info(`Migration backup created at ${migrationBackupDir}`);
|
|
420
|
+
this.logger.info(`Backed up ${affectedFiles.length} files`);
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
path: migrationBackupDir,
|
|
424
|
+
timestamp,
|
|
425
|
+
originalStructure,
|
|
426
|
+
affectedFiles,
|
|
427
|
+
originalManifest
|
|
428
|
+
};
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// Clean up partial backup on failure
|
|
431
|
+
try {
|
|
432
|
+
await fs.rm(migrationBackupDir, { recursive: true, force: true });
|
|
433
|
+
} catch {
|
|
434
|
+
// Ignore cleanup errors
|
|
435
|
+
}
|
|
436
|
+
throw new Error(`Failed to create migration backup: ${error.message}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Restores original structure from migration backup.
|
|
442
|
+
*
|
|
443
|
+
* Handles rollback of failed migrations by restoring files from backup
|
|
444
|
+
* and cleaning up any partial migration artifacts.
|
|
445
|
+
*
|
|
446
|
+
* @param {Object} backup - Backup metadata object from createMigrationBackup
|
|
447
|
+
* @returns {Promise<boolean>} True if restore succeeded
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* const success = await backupManager.restoreFromMigrationBackup(backup);
|
|
451
|
+
* if (success) {
|
|
452
|
+
* console.log('Rollback completed successfully');
|
|
453
|
+
* }
|
|
454
|
+
*/
|
|
455
|
+
async restoreFromMigrationBackup(backup) {
|
|
456
|
+
if (!backup || !backup.path) {
|
|
457
|
+
throw new Error('Invalid backup metadata: path is required');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
this.logger.info('Restoring from migration backup...');
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const backupDir = backup.path;
|
|
464
|
+
const targetDir = this.scopeManager.getTargetDir();
|
|
465
|
+
|
|
466
|
+
// Verify backup directory exists
|
|
467
|
+
try {
|
|
468
|
+
await fs.access(backupDir);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
throw new Error(`Backup directory not found: ${backupDir}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Load metadata
|
|
474
|
+
let metadata;
|
|
475
|
+
try {
|
|
476
|
+
const metadataContent = await fs.readFile(
|
|
477
|
+
path.join(backupDir, 'metadata.json'),
|
|
478
|
+
'utf-8'
|
|
479
|
+
);
|
|
480
|
+
metadata = JSON.parse(metadataContent);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
this.logger.warning('Could not load backup metadata, proceeding with file restoration');
|
|
483
|
+
metadata = { affectedFiles: [] };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Restore manifest if backed up
|
|
487
|
+
const manifestBackupPath = path.join(backupDir, 'manifest.json');
|
|
488
|
+
const manifestTargetPath = path.join(targetDir, 'get-shit-done', 'INSTALLED_FILES.json');
|
|
489
|
+
try {
|
|
490
|
+
await fs.access(manifestBackupPath);
|
|
491
|
+
await fs.mkdir(path.dirname(manifestTargetPath), { recursive: true });
|
|
492
|
+
await fs.copyFile(manifestBackupPath, manifestTargetPath);
|
|
493
|
+
this.logger.debug('Restored manifest');
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (error.code !== 'ENOENT') {
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Restore files from backup
|
|
501
|
+
for (const relativePath of metadata.affectedFiles || []) {
|
|
502
|
+
if (relativePath === 'get-shit-done/INSTALLED_FILES.json') {
|
|
503
|
+
continue; // Already handled above
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const backupFilePath = path.join(backupDir, relativePath);
|
|
507
|
+
const targetFilePath = path.join(targetDir, relativePath);
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
await fs.access(backupFilePath);
|
|
511
|
+
await fs.mkdir(path.dirname(targetFilePath), { recursive: true });
|
|
512
|
+
await fs.copyFile(backupFilePath, targetFilePath);
|
|
513
|
+
this.logger.debug(`Restored: ${relativePath}`);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code !== 'ENOENT') {
|
|
516
|
+
this.logger.error(`Failed to restore ${relativePath}: ${error.message}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
this.logger.success('Migration rollback completed');
|
|
522
|
+
return true;
|
|
523
|
+
} catch (error) {
|
|
524
|
+
this.logger.error(`Migration rollback failed: ${error.message}`);
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Collects all files recursively in a directory.
|
|
531
|
+
*
|
|
532
|
+
* @param {string} dirPath - Directory to scan
|
|
533
|
+
* @returns {Promise<string[]>} Array of file paths
|
|
534
|
+
* @private
|
|
535
|
+
*/
|
|
536
|
+
async _collectFilesRecursively(dirPath) {
|
|
537
|
+
const files = [];
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
541
|
+
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
544
|
+
if (entry.isDirectory()) {
|
|
545
|
+
const subFiles = await this._collectFilesRecursively(fullPath);
|
|
546
|
+
files.push(...subFiles);
|
|
547
|
+
} else {
|
|
548
|
+
files.push(fullPath);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error.code !== 'ENOENT') {
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return files;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Copies a file to the backup directory maintaining relative path structure.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} sourcePath - Source file path
|
|
564
|
+
* @param {string} backupDir - Backup directory root
|
|
565
|
+
* @param {string} relativePath - Relative path from target dir
|
|
566
|
+
* @returns {Promise<void>}
|
|
567
|
+
* @private
|
|
568
|
+
*/
|
|
569
|
+
async _copyToBackup(sourcePath, backupDir, relativePath) {
|
|
570
|
+
const targetPath = path.join(backupDir, relativePath);
|
|
571
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
572
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Default export for the backup-manager module.
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* import { BackupManager } from './services/backup-manager.js';
|
|
581
|
+
* const backupManager = new BackupManager(scopeManager, logger);
|
|
582
|
+
*/
|
|
583
|
+
export default {
|
|
584
|
+
BackupManager
|
|
585
|
+
};
|