gsd-opencode 1.9.2 → 1.10.2
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/agents/gsd-settings.md +476 -30
- 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 +199 -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 +855 -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,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration service for atomic migration from old to new directory structure.
|
|
3
|
+
*
|
|
4
|
+
* This module provides safe migration from the legacy `command/gsd/` structure
|
|
5
|
+
* to the new `commands/gsd/` structure with full rollback capability.
|
|
6
|
+
*
|
|
7
|
+
* Key features:
|
|
8
|
+
* - Pre-migration backup creation before any changes
|
|
9
|
+
* - Atomic file operations using temp-then-move pattern
|
|
10
|
+
* - Automatic rollback on any failure
|
|
11
|
+
* - Manifest path transformation
|
|
12
|
+
* - Verification after migration
|
|
13
|
+
*
|
|
14
|
+
* @module migration-service
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs/promises';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { StructureDetector, STRUCTURE_TYPES } from './structure-detector.js';
|
|
20
|
+
import { ManifestManager } from './manifest-manager.js';
|
|
21
|
+
import { BackupManager } from './backup-manager.js';
|
|
22
|
+
import { OLD_COMMAND_DIR, NEW_COMMAND_DIR } from '../../lib/constants.js';
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Performs atomic migration from old to new directory structure.
|
|
27
|
+
*
|
|
28
|
+
* This class handles the migration of GSD-OpenCode installations from the legacy
|
|
29
|
+
* `command/gsd/` structure to the new `commands/gsd/` structure. It provides
|
|
30
|
+
* full rollback capability and ensures data integrity throughout the migration.
|
|
31
|
+
*
|
|
32
|
+
* @class MigrationService
|
|
33
|
+
* @example
|
|
34
|
+
* const scopeManager = new ScopeManager({ scope: 'global' });
|
|
35
|
+
* const logger = createLogger();
|
|
36
|
+
* const migrationService = new MigrationService(scopeManager, logger);
|
|
37
|
+
*
|
|
38
|
+
* // Perform migration
|
|
39
|
+
* const result = await migrationService.migrate();
|
|
40
|
+
* if (result.migrated) {
|
|
41
|
+
* console.log('Migration completed successfully');
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* // Or perform dry run first
|
|
45
|
+
* const dryRun = await migrationService.dryRun();
|
|
46
|
+
* console.log(`Would migrate ${dryRun.filesToMigrate.length} files`);
|
|
47
|
+
*/
|
|
48
|
+
export class MigrationService {
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new MigrationService instance.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} scopeManager - ScopeManager instance for path resolution
|
|
53
|
+
* @param {Object} logger - Logger instance for output
|
|
54
|
+
* @throws {Error} If scopeManager or logger is not provided
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const scopeManager = new ScopeManager({ scope: 'global' });
|
|
58
|
+
* const logger = createLogger();
|
|
59
|
+
* const migrationService = new MigrationService(scopeManager, logger);
|
|
60
|
+
*/
|
|
61
|
+
constructor(scopeManager, logger) {
|
|
62
|
+
if (!scopeManager) {
|
|
63
|
+
throw new Error('scopeManager is required');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!logger) {
|
|
67
|
+
throw new Error('logger is required');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.scopeManager = scopeManager;
|
|
71
|
+
this.logger = logger;
|
|
72
|
+
this.targetDir = scopeManager.getTargetDir();
|
|
73
|
+
this.structureDetector = new StructureDetector(this.targetDir);
|
|
74
|
+
this.manifestManager = new ManifestManager(this.targetDir);
|
|
75
|
+
this.backupManager = new BackupManager(scopeManager, logger);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Migration state for potential rollback.
|
|
79
|
+
* @type {Object|null}
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
this._migrationState = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Performs the migration from old to new structure.
|
|
87
|
+
*
|
|
88
|
+
* The migration process:
|
|
89
|
+
* 1. Detects current structure type
|
|
90
|
+
* 2. Creates backup before any changes
|
|
91
|
+
* 3. Performs migration based on current state
|
|
92
|
+
* 4. Verifies migration succeeded
|
|
93
|
+
* 5. Cleans up backup on success
|
|
94
|
+
*
|
|
95
|
+
* On any error, automatically rolls back to the original state.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Promise<Object>} Migration result
|
|
98
|
+
* @property {boolean} migrated - True if migration was performed
|
|
99
|
+
* @property {string} [reason] - Reason if migration was skipped
|
|
100
|
+
* @property {string} [backup] - Path to backup if migration performed
|
|
101
|
+
*
|
|
102
|
+
* @throws {Error} If migration fails (rollback is attempted automatically)
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* const result = await migrationService.migrate();
|
|
106
|
+
* if (result.migrated) {
|
|
107
|
+
* console.log(`Migration completed. Backup at: ${result.backup}`);
|
|
108
|
+
* } else {
|
|
109
|
+
* console.log(`Migration skipped: ${result.reason}`);
|
|
110
|
+
* }
|
|
111
|
+
*/
|
|
112
|
+
async migrate() {
|
|
113
|
+
// Step 1: Detect current structure
|
|
114
|
+
const currentStructure = await this.structureDetector.detect();
|
|
115
|
+
|
|
116
|
+
if (currentStructure === STRUCTURE_TYPES.NEW) {
|
|
117
|
+
this.logger.info('Already using new structure, no migration needed');
|
|
118
|
+
return { migrated: false, reason: 'already_new' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (currentStructure === STRUCTURE_TYPES.NONE) {
|
|
122
|
+
this.logger.info('No existing structure found, fresh install needed');
|
|
123
|
+
return { migrated: false, reason: 'none_found' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.logger.info(`Starting migration from ${currentStructure} structure...`);
|
|
127
|
+
|
|
128
|
+
// Step 2: Create backup before any changes
|
|
129
|
+
const backup = await this._createBackup(currentStructure);
|
|
130
|
+
this._migrationState = { backup, originalStructure: currentStructure };
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// Step 3: Perform migration based on current state
|
|
134
|
+
if (currentStructure === STRUCTURE_TYPES.OLD) {
|
|
135
|
+
await this._migrateFromOld();
|
|
136
|
+
} else if (currentStructure === STRUCTURE_TYPES.DUAL) {
|
|
137
|
+
await this._migrateFromDual();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Step 4: Verify migration succeeded
|
|
141
|
+
const newStructure = await this.structureDetector.detect();
|
|
142
|
+
if (newStructure !== STRUCTURE_TYPES.NEW) {
|
|
143
|
+
throw new Error(`Migration verification failed: expected 'new', got '${newStructure}'`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Step 5: Clean up backup on success
|
|
147
|
+
await this._cleanupBackup(backup);
|
|
148
|
+
|
|
149
|
+
this.logger.success('Migration completed successfully');
|
|
150
|
+
return { migrated: true, backup: backup.path };
|
|
151
|
+
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Rollback on any error
|
|
154
|
+
this.logger.error(`Migration failed: ${error.message}`);
|
|
155
|
+
await this.rollback();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Migrates from old structure (command/gsd/) to new structure (commands/gsd/).
|
|
162
|
+
*
|
|
163
|
+
* @returns {Promise<void>}
|
|
164
|
+
* @private
|
|
165
|
+
*/
|
|
166
|
+
async _migrateFromOld() {
|
|
167
|
+
const oldPath = path.join(this.targetDir, OLD_COMMAND_DIR, 'gsd');
|
|
168
|
+
const newPath = path.join(this.targetDir, NEW_COMMAND_DIR, 'gsd');
|
|
169
|
+
|
|
170
|
+
this.logger.info('Migrating from old structure...');
|
|
171
|
+
|
|
172
|
+
// Step 1: Create new directory structure
|
|
173
|
+
await fs.mkdir(path.dirname(newPath), { recursive: true });
|
|
174
|
+
|
|
175
|
+
// Step 2: Copy files from old to new location using atomic move
|
|
176
|
+
await this._atomicCopy(oldPath, newPath);
|
|
177
|
+
|
|
178
|
+
// Step 3: Update manifest paths
|
|
179
|
+
await this._updateManifestPaths();
|
|
180
|
+
|
|
181
|
+
// Step 4: Remove old directory (after successful copy)
|
|
182
|
+
await fs.rm(oldPath, { recursive: true, force: true });
|
|
183
|
+
|
|
184
|
+
// Step 5: Clean up empty parent directory if applicable
|
|
185
|
+
await this._cleanupEmptyParent(OLD_COMMAND_DIR);
|
|
186
|
+
|
|
187
|
+
this.logger.info('Files migrated successfully');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Migrates from dual structure (both exist) to new structure.
|
|
192
|
+
*
|
|
193
|
+
* When both structures exist, prefers new structure and removes old.
|
|
194
|
+
*
|
|
195
|
+
* @returns {Promise<void>}
|
|
196
|
+
* @private
|
|
197
|
+
*/
|
|
198
|
+
async _migrateFromDual() {
|
|
199
|
+
const oldPath = path.join(this.targetDir, OLD_COMMAND_DIR, 'gsd');
|
|
200
|
+
|
|
201
|
+
this.logger.info('Dual structure detected, consolidating to new structure...');
|
|
202
|
+
|
|
203
|
+
// Update manifest to point to new paths
|
|
204
|
+
await this._updateManifestPaths();
|
|
205
|
+
|
|
206
|
+
// Remove old structure
|
|
207
|
+
await fs.rm(oldPath, { recursive: true, force: true });
|
|
208
|
+
|
|
209
|
+
// Clean up empty parent directory if applicable
|
|
210
|
+
await this._cleanupEmptyParent(OLD_COMMAND_DIR);
|
|
211
|
+
|
|
212
|
+
this.logger.info('Old structure removed, migration complete');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Performs atomic copy of directory using temp-then-move pattern.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} source - Source directory path
|
|
219
|
+
* @param {string} target - Target directory path
|
|
220
|
+
* @returns {Promise<void>}
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
async _atomicCopy(source, target) {
|
|
224
|
+
const tempTarget = `${target}.tmp-${Date.now()}`;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
this.logger.debug(`Copying files to temp location: ${tempTarget}`);
|
|
228
|
+
await fs.cp(source, tempTarget, { recursive: true, force: true });
|
|
229
|
+
|
|
230
|
+
this.logger.debug(`Performing atomic move: ${tempTarget} -> ${target}`);
|
|
231
|
+
await fs.rename(tempTarget, target);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// Clean up temp on failure
|
|
234
|
+
await fs.rm(tempTarget, { recursive: true, force: true }).catch(() => {});
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Updates manifest paths from old to new structure.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Promise<void>}
|
|
243
|
+
* @private
|
|
244
|
+
*/
|
|
245
|
+
async _updateManifestPaths() {
|
|
246
|
+
this.logger.debug('Updating manifest paths...');
|
|
247
|
+
|
|
248
|
+
const entries = await this.manifestManager.load();
|
|
249
|
+
if (!entries || entries.length === 0) {
|
|
250
|
+
this.logger.debug('No manifest entries to update');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Transform paths from command/gsd/ to commands/gsd/
|
|
255
|
+
const updatedEntries = entries.map(entry => ({
|
|
256
|
+
...entry,
|
|
257
|
+
path: entry.path.replace(
|
|
258
|
+
new RegExp(`/${OLD_COMMAND_DIR}/gsd/`, 'g'),
|
|
259
|
+
`/${NEW_COMMAND_DIR}/gsd/`
|
|
260
|
+
),
|
|
261
|
+
relativePath: entry.relativePath.replace(
|
|
262
|
+
new RegExp(`^${OLD_COMMAND_DIR}/gsd/`, 'g'),
|
|
263
|
+
`${NEW_COMMAND_DIR}/gsd/`
|
|
264
|
+
)
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
// Clear and re-add updated entries
|
|
268
|
+
this.manifestManager.clear();
|
|
269
|
+
for (const entry of updatedEntries) {
|
|
270
|
+
this.manifestManager.addFile(entry.path, entry.relativePath, entry.size, entry.hash);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await this.manifestManager.save();
|
|
274
|
+
this.logger.debug(`Updated ${updatedEntries.length} manifest entries`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Cleans up empty parent directory after migration.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} dirName - Directory name to check
|
|
281
|
+
* @returns {Promise<void>}
|
|
282
|
+
* @private
|
|
283
|
+
*/
|
|
284
|
+
async _cleanupEmptyParent(dirName) {
|
|
285
|
+
const dirPath = path.join(this.targetDir, dirName);
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const entries = await fs.readdir(dirPath);
|
|
289
|
+
if (entries.length === 0) {
|
|
290
|
+
await fs.rmdir(dirPath);
|
|
291
|
+
this.logger.debug(`Removed empty directory: ${dirName}`);
|
|
292
|
+
} else {
|
|
293
|
+
this.logger.debug(`Directory not empty, preserving: ${dirName}`);
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (error.code !== 'ENOENT') {
|
|
297
|
+
this.logger.debug(`Could not check directory ${dirName}: ${error.message}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Rolls back migration to original state.
|
|
304
|
+
*
|
|
305
|
+
* Restores files from backup and cleans up any partial migration artifacts.
|
|
306
|
+
*
|
|
307
|
+
* @returns {Promise<boolean>} True if rollback succeeded
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* const success = await migrationService.rollback();
|
|
311
|
+
* if (!success) {
|
|
312
|
+
* console.error('Rollback failed, manual intervention may be required');
|
|
313
|
+
* }
|
|
314
|
+
*/
|
|
315
|
+
async rollback() {
|
|
316
|
+
if (!this._migrationState) {
|
|
317
|
+
this.logger.warning('No migration state to rollback');
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.logger.info('Rolling back migration...');
|
|
322
|
+
|
|
323
|
+
const { backup, originalStructure } = this._migrationState;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
// Restore from backup
|
|
327
|
+
await this.backupManager.restoreFromMigrationBackup(backup);
|
|
328
|
+
|
|
329
|
+
// Clean up any partial migration artifacts
|
|
330
|
+
const newPath = path.join(this.targetDir, NEW_COMMAND_DIR, 'gsd');
|
|
331
|
+
await fs.rm(newPath, { recursive: true, force: true }).catch(() => {});
|
|
332
|
+
|
|
333
|
+
// Clean up temp files that might exist
|
|
334
|
+
const tempPattern = path.join(this.targetDir, `${NEW_COMMAND_DIR}.tmp-*`);
|
|
335
|
+
await this._cleanupTempFiles(tempPattern);
|
|
336
|
+
|
|
337
|
+
this.logger.success('Rollback completed');
|
|
338
|
+
return true;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.logger.error(`Rollback failed: ${error.message}`);
|
|
341
|
+
this.logger.error('Manual intervention may be required');
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Cleans up temporary files matching a pattern.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} pattern - Pattern to match (directory prefix)
|
|
350
|
+
* @returns {Promise<void>}
|
|
351
|
+
* @private
|
|
352
|
+
*/
|
|
353
|
+
async _cleanupTempFiles(pattern) {
|
|
354
|
+
const baseDir = path.dirname(pattern);
|
|
355
|
+
const prefix = path.basename(pattern).replace('*', '');
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (entry.isDirectory() && entry.name.startsWith(prefix)) {
|
|
361
|
+
const tempPath = path.join(baseDir, entry.name);
|
|
362
|
+
await fs.rm(tempPath, { recursive: true, force: true });
|
|
363
|
+
this.logger.debug(`Cleaned up temp directory: ${entry.name}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch (error) {
|
|
367
|
+
// Ignore cleanup errors
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Creates backup before migration.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} structureType - Current structure type
|
|
375
|
+
* @returns {Promise<Object>} Backup metadata
|
|
376
|
+
* @private
|
|
377
|
+
*/
|
|
378
|
+
async _createBackup(structureType) {
|
|
379
|
+
return await this.backupManager.createMigrationBackup({
|
|
380
|
+
targetDir: this.targetDir,
|
|
381
|
+
originalStructure: structureType,
|
|
382
|
+
timestamp: Date.now()
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Cleans up backup after successful migration.
|
|
388
|
+
*
|
|
389
|
+
* For now, preserves the backup for safety. Could be extended to
|
|
390
|
+
* remove backups after a retention period.
|
|
391
|
+
*
|
|
392
|
+
* @param {Object} backup - Backup metadata
|
|
393
|
+
* @returns {Promise<void>}
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
async _cleanupBackup(backup) {
|
|
397
|
+
this.logger.debug(`Backup preserved at: ${backup.path}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Verifies integrity of migrated files by comparing hashes.
|
|
402
|
+
*
|
|
403
|
+
* Checks that all expected files exist in new location and have
|
|
404
|
+
* matching hashes to verify no data loss occurred.
|
|
405
|
+
*
|
|
406
|
+
* @returns {Promise<Object>} Verification report
|
|
407
|
+
* @property {boolean} success - True if verification passed
|
|
408
|
+
* @property {number} totalFiles - Total files checked
|
|
409
|
+
* @property {number} passed - Files that passed verification
|
|
410
|
+
* @property {number} failed - Files that failed verification
|
|
411
|
+
* @property {Array<Object>} details - Per-file verification details
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* const report = await migrationService.verify();
|
|
415
|
+
* console.log(`Verification: ${report.passed}/${report.totalFiles} files OK`);
|
|
416
|
+
* if (!report.success) {
|
|
417
|
+
* console.log('Failed files:', report.details.filter(d => !d.passed));
|
|
418
|
+
* }
|
|
419
|
+
*/
|
|
420
|
+
async verify() {
|
|
421
|
+
this.logger.info('Verifying migration integrity...');
|
|
422
|
+
|
|
423
|
+
const report = {
|
|
424
|
+
success: true,
|
|
425
|
+
totalFiles: 0,
|
|
426
|
+
passed: 0,
|
|
427
|
+
failed: 0,
|
|
428
|
+
details: []
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const newPath = path.join(this.targetDir, NEW_COMMAND_DIR, 'gsd');
|
|
433
|
+
|
|
434
|
+
// Check if new structure exists
|
|
435
|
+
try {
|
|
436
|
+
await fs.access(newPath);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
return {
|
|
439
|
+
...report,
|
|
440
|
+
success: false,
|
|
441
|
+
error: 'New structure not found'
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Load manifest
|
|
446
|
+
const entries = await this.manifestManager.load();
|
|
447
|
+
if (!entries) {
|
|
448
|
+
// No manifest, just verify directory structure exists
|
|
449
|
+
const files = await this._collectFilesRecursively(newPath);
|
|
450
|
+
report.totalFiles = files.length;
|
|
451
|
+
report.passed = files.length;
|
|
452
|
+
return report;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Verify each manifest entry in new structure
|
|
456
|
+
for (const entry of entries) {
|
|
457
|
+
// Only check entries in command directories
|
|
458
|
+
if (!entry.relativePath.includes('/gsd/')) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
report.totalFiles++;
|
|
463
|
+
|
|
464
|
+
const checkPath = path.join(this.targetDir, entry.relativePath);
|
|
465
|
+
const detail = {
|
|
466
|
+
relativePath: entry.relativePath,
|
|
467
|
+
passed: false,
|
|
468
|
+
error: null
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
await fs.access(checkPath);
|
|
473
|
+
|
|
474
|
+
// If we have a hash, verify it
|
|
475
|
+
if (entry.hash && entry.hash.startsWith('sha256:')) {
|
|
476
|
+
const currentHash = await this._calculateFileHash(checkPath);
|
|
477
|
+
if (currentHash === entry.hash) {
|
|
478
|
+
detail.passed = true;
|
|
479
|
+
report.passed++;
|
|
480
|
+
} else {
|
|
481
|
+
detail.error = 'Hash mismatch';
|
|
482
|
+
detail.expectedHash = entry.hash;
|
|
483
|
+
detail.actualHash = currentHash;
|
|
484
|
+
report.failed++;
|
|
485
|
+
report.success = false;
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
detail.passed = true;
|
|
489
|
+
report.passed++;
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
detail.error = error.message;
|
|
493
|
+
report.failed++;
|
|
494
|
+
report.success = false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
report.details.push(detail);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (report.success) {
|
|
501
|
+
this.logger.success(`Verification passed: ${report.passed}/${report.totalFiles} files OK`);
|
|
502
|
+
} else {
|
|
503
|
+
this.logger.error(`Verification failed: ${report.failed}/${report.totalFiles} files failed`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return report;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return {
|
|
509
|
+
...report,
|
|
510
|
+
success: false,
|
|
511
|
+
error: error.message
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Simulates migration without making changes.
|
|
518
|
+
*
|
|
519
|
+
* Returns a preview of actions that would be performed during migration,
|
|
520
|
+
* allowing users to see what will happen before committing.
|
|
521
|
+
*
|
|
522
|
+
* @returns {Promise<Object>} Dry run report
|
|
523
|
+
* @property {boolean} wouldMigrate - True if migration would be performed
|
|
524
|
+
* @property {string} currentStructure - Current structure type
|
|
525
|
+
* @property {Array<string>} actions - List of actions that would be taken
|
|
526
|
+
* @property {number} filesToMigrate - Number of files that would be moved
|
|
527
|
+
* @property {number} estimatedBytes - Estimated bytes to be moved
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* const dryRun = await migrationService.dryRun();
|
|
531
|
+
* console.log(`Would perform ${dryRun.actions.length} actions`);
|
|
532
|
+
* console.log('Actions:', dryRun.actions);
|
|
533
|
+
*/
|
|
534
|
+
async dryRun() {
|
|
535
|
+
const currentStructure = await this.structureDetector.detect();
|
|
536
|
+
|
|
537
|
+
const result = {
|
|
538
|
+
wouldMigrate: false,
|
|
539
|
+
currentStructure,
|
|
540
|
+
actions: [],
|
|
541
|
+
filesToMigrate: 0,
|
|
542
|
+
estimatedBytes: 0
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
if (currentStructure === STRUCTURE_TYPES.NEW) {
|
|
546
|
+
result.actions.push('No migration needed - already using new structure');
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (currentStructure === STRUCTURE_TYPES.NONE) {
|
|
551
|
+
result.actions.push('No migration possible - no existing structure found');
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
result.wouldMigrate = true;
|
|
556
|
+
|
|
557
|
+
if (currentStructure === STRUCTURE_TYPES.OLD) {
|
|
558
|
+
result.actions.push('1. Create backup of current structure');
|
|
559
|
+
result.actions.push(`2. Create new directory: ${NEW_COMMAND_DIR}/gsd/`);
|
|
560
|
+
result.actions.push(`3. Copy files from ${OLD_COMMAND_DIR}/gsd/ to ${NEW_COMMAND_DIR}/gsd/`);
|
|
561
|
+
result.actions.push('4. Update manifest paths');
|
|
562
|
+
result.actions.push(`5. Remove old directory: ${OLD_COMMAND_DIR}/gsd/`);
|
|
563
|
+
result.actions.push('6. Verify migration integrity');
|
|
564
|
+
|
|
565
|
+
// Count files and estimate size
|
|
566
|
+
const oldPath = path.join(this.targetDir, OLD_COMMAND_DIR, 'gsd');
|
|
567
|
+
const stats = await this._calculateDirectoryStats(oldPath);
|
|
568
|
+
result.filesToMigrate = stats.fileCount;
|
|
569
|
+
result.estimatedBytes = stats.totalBytes;
|
|
570
|
+
} else if (currentStructure === STRUCTURE_TYPES.DUAL) {
|
|
571
|
+
result.actions.push('1. Update manifest to use new structure paths');
|
|
572
|
+
result.actions.push(`2. Remove old directory: ${OLD_COMMAND_DIR}/gsd/`);
|
|
573
|
+
result.actions.push('3. Verify migration integrity');
|
|
574
|
+
|
|
575
|
+
const oldPath = path.join(this.targetDir, OLD_COMMAND_DIR, 'gsd');
|
|
576
|
+
const stats = await this._calculateDirectoryStats(oldPath);
|
|
577
|
+
result.filesToMigrate = stats.fileCount;
|
|
578
|
+
result.estimatedBytes = stats.totalBytes;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Gets current migration status.
|
|
586
|
+
*
|
|
587
|
+
* Provides information about whether migration is needed and
|
|
588
|
+
* what the current state is.
|
|
589
|
+
*
|
|
590
|
+
* @returns {Promise<Object>} Migration status
|
|
591
|
+
* @property {string} structureType - Current structure type
|
|
592
|
+
* @property {boolean} migrationNeeded - Whether migration is needed
|
|
593
|
+
* @property {Array<Object>} backups - Available migration backups
|
|
594
|
+
* @property {number} estimatedFileCount - Estimated number of files to migrate
|
|
595
|
+
* @property {number} estimatedSize - Estimated total size to migrate
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* const status = await migrationService.getMigrationStatus();
|
|
599
|
+
* console.log(`Current structure: ${status.structureType}`);
|
|
600
|
+
* console.log(`Migration needed: ${status.migrationNeeded}`);
|
|
601
|
+
*/
|
|
602
|
+
async getMigrationStatus() {
|
|
603
|
+
const structureType = await this.structureDetector.detect();
|
|
604
|
+
const migrationNeeded = structureType === STRUCTURE_TYPES.OLD ||
|
|
605
|
+
structureType === STRUCTURE_TYPES.DUAL;
|
|
606
|
+
|
|
607
|
+
// Get available backups
|
|
608
|
+
const backups = await this._listMigrationBackups();
|
|
609
|
+
|
|
610
|
+
// Calculate estimated migration size
|
|
611
|
+
let estimatedFileCount = 0;
|
|
612
|
+
let estimatedSize = 0;
|
|
613
|
+
|
|
614
|
+
if (structureType === STRUCTURE_TYPES.OLD) {
|
|
615
|
+
const oldPath = path.join(this.targetDir, OLD_COMMAND_DIR, 'gsd');
|
|
616
|
+
const stats = await this._calculateDirectoryStats(oldPath);
|
|
617
|
+
estimatedFileCount = stats.fileCount;
|
|
618
|
+
estimatedSize = stats.totalBytes;
|
|
619
|
+
} else if (structureType === STRUCTURE_TYPES.DUAL) {
|
|
620
|
+
const oldPath = path.join(this.targetDir, OLD_COMMAND_DIR, 'gsd');
|
|
621
|
+
const stats = await this._calculateDirectoryStats(oldPath);
|
|
622
|
+
estimatedFileCount = stats.fileCount;
|
|
623
|
+
estimatedSize = stats.totalBytes;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
structureType,
|
|
628
|
+
migrationNeeded,
|
|
629
|
+
backups,
|
|
630
|
+
estimatedFileCount,
|
|
631
|
+
estimatedSize
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Lists available migration backups.
|
|
637
|
+
*
|
|
638
|
+
* @returns {Promise<Array<Object>>} Array of backup metadata
|
|
639
|
+
* @private
|
|
640
|
+
*/
|
|
641
|
+
async _listMigrationBackups() {
|
|
642
|
+
const backups = [];
|
|
643
|
+
const migrationBackupDir = path.join(this.targetDir, '.backups');
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
await fs.access(migrationBackupDir);
|
|
647
|
+
const entries = await fs.readdir(migrationBackupDir, { withFileTypes: true });
|
|
648
|
+
|
|
649
|
+
for (const entry of entries) {
|
|
650
|
+
if (entry.isDirectory() && entry.name.startsWith('backup-')) {
|
|
651
|
+
try {
|
|
652
|
+
const metadataPath = path.join(migrationBackupDir, entry.name, 'metadata.json');
|
|
653
|
+
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
|
|
654
|
+
const metadata = JSON.parse(metadataContent);
|
|
655
|
+
backups.push(metadata);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
// Skip invalid backup directories
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (error.code !== 'ENOENT') {
|
|
663
|
+
this.logger.debug(`Could not list migration backups: ${error.message}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Sort by timestamp descending
|
|
668
|
+
return backups.sort((a, b) => b.timestamp - a.timestamp);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Calculates directory statistics.
|
|
673
|
+
*
|
|
674
|
+
* @param {string} dirPath - Directory to analyze
|
|
675
|
+
* @returns {Promise<Object>} Directory statistics
|
|
676
|
+
* @property {number} fileCount - Number of files
|
|
677
|
+
* @property {number} totalBytes - Total bytes
|
|
678
|
+
* @private
|
|
679
|
+
*/
|
|
680
|
+
async _calculateDirectoryStats(dirPath) {
|
|
681
|
+
const stats = { fileCount: 0, totalBytes: 0 };
|
|
682
|
+
|
|
683
|
+
try {
|
|
684
|
+
const files = await this._collectFilesRecursively(dirPath);
|
|
685
|
+
for (const file of files) {
|
|
686
|
+
try {
|
|
687
|
+
const fileStat = await fs.stat(file);
|
|
688
|
+
stats.fileCount++;
|
|
689
|
+
stats.totalBytes += fileStat.size;
|
|
690
|
+
} catch {
|
|
691
|
+
// Skip files we can't stat
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch {
|
|
695
|
+
// Directory might not exist
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return stats;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Collects all files recursively in a directory.
|
|
703
|
+
*
|
|
704
|
+
* @param {string} dirPath - Directory to scan
|
|
705
|
+
* @returns {Promise<string[]>} Array of file paths
|
|
706
|
+
* @private
|
|
707
|
+
*/
|
|
708
|
+
async _collectFilesRecursively(dirPath) {
|
|
709
|
+
const files = [];
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
713
|
+
|
|
714
|
+
for (const entry of entries) {
|
|
715
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
716
|
+
if (entry.isDirectory()) {
|
|
717
|
+
const subFiles = await this._collectFilesRecursively(fullPath);
|
|
718
|
+
files.push(...subFiles);
|
|
719
|
+
} else {
|
|
720
|
+
files.push(fullPath);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (error.code !== 'ENOENT') {
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return files;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Calculates SHA256 hash of a file.
|
|
734
|
+
*
|
|
735
|
+
* @param {string} filePath - Path to file
|
|
736
|
+
* @returns {Promise<string>} SHA256 hash with 'sha256:' prefix
|
|
737
|
+
* @private
|
|
738
|
+
*/
|
|
739
|
+
async _calculateFileHash(filePath) {
|
|
740
|
+
const content = await fs.readFile(filePath);
|
|
741
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
742
|
+
return `sha256:${hash}`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Cleans up old migration backups.
|
|
747
|
+
*
|
|
748
|
+
* Removes migration backups older than the specified retention period.
|
|
749
|
+
*
|
|
750
|
+
* @param {Object} [options={}] - Cleanup options
|
|
751
|
+
* @param {number} [options.retentionDays=30] - Number of days to retain backups
|
|
752
|
+
* @returns {Promise<Object>} Cleanup result
|
|
753
|
+
* @property {number} cleaned - Number of backups removed
|
|
754
|
+
* @property {number} kept - Number of backups retained
|
|
755
|
+
* @property {Array<string>} errors - Errors encountered during cleanup
|
|
756
|
+
*
|
|
757
|
+
* @example
|
|
758
|
+
* const result = await migrationService.cleanup({ retentionDays: 7 });
|
|
759
|
+
* console.log(`Cleaned ${result.cleaned} old backups`);
|
|
760
|
+
*/
|
|
761
|
+
async cleanup(options = {}) {
|
|
762
|
+
const retentionDays = options.retentionDays ?? 30;
|
|
763
|
+
const cutoffTime = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
|
|
764
|
+
|
|
765
|
+
this.logger.info(`Cleaning up migration backups older than ${retentionDays} days...`);
|
|
766
|
+
|
|
767
|
+
const result = {
|
|
768
|
+
cleaned: 0,
|
|
769
|
+
kept: 0,
|
|
770
|
+
errors: []
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const migrationBackupDir = path.join(this.targetDir, '.backups');
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
await fs.access(migrationBackupDir);
|
|
777
|
+
const entries = await fs.readdir(migrationBackupDir, { withFileTypes: true });
|
|
778
|
+
|
|
779
|
+
for (const entry of entries) {
|
|
780
|
+
if (!entry.isDirectory() || !entry.name.startsWith('backup-')) {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const backupPath = path.join(migrationBackupDir, entry.name);
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
// Extract timestamp from directory name
|
|
788
|
+
const timestamp = parseInt(entry.name.replace('backup-', ''), 10);
|
|
789
|
+
|
|
790
|
+
if (isNaN(timestamp)) {
|
|
791
|
+
result.errors.push(`Invalid backup name: ${entry.name}`);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (timestamp < cutoffTime) {
|
|
796
|
+
await fs.rm(backupPath, { recursive: true, force: true });
|
|
797
|
+
result.cleaned++;
|
|
798
|
+
this.logger.debug(`Removed old backup: ${entry.name}`);
|
|
799
|
+
} else {
|
|
800
|
+
result.kept++;
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
result.errors.push(`Failed to process ${entry.name}: ${error.message}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (result.cleaned > 0) {
|
|
808
|
+
this.logger.info(`Cleaned up ${result.cleaned} old backups, kept ${result.kept}`);
|
|
809
|
+
} else {
|
|
810
|
+
this.logger.info('No old backups to clean up');
|
|
811
|
+
}
|
|
812
|
+
} catch (error) {
|
|
813
|
+
if (error.code !== 'ENOENT') {
|
|
814
|
+
result.errors.push(`Failed to access backup directory: ${error.message}`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return result;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Default export for the migration-service module.
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* import { MigrationService } from './services/migration-service.js';
|
|
827
|
+
* const migrationService = new MigrationService(scopeManager, logger);
|
|
828
|
+
*/
|
|
829
|
+
export default {
|
|
830
|
+
MigrationService
|
|
831
|
+
};
|