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.
Files changed (59) hide show
  1. package/agents/gsd-debugger.md +5 -5
  2. package/agents/gsd-settings.md +476 -30
  3. package/bin/gsd-install.js +105 -0
  4. package/bin/gsd.js +352 -0
  5. package/{command → commands}/gsd/add-phase.md +1 -1
  6. package/{command → commands}/gsd/audit-milestone.md +1 -1
  7. package/{command → commands}/gsd/debug.md +3 -3
  8. package/{command → commands}/gsd/discuss-phase.md +1 -1
  9. package/{command → commands}/gsd/execute-phase.md +1 -1
  10. package/{command → commands}/gsd/list-phase-assumptions.md +1 -1
  11. package/{command → commands}/gsd/map-codebase.md +1 -1
  12. package/{command → commands}/gsd/new-milestone.md +1 -1
  13. package/{command → commands}/gsd/new-project.md +3 -3
  14. package/{command → commands}/gsd/plan-phase.md +2 -2
  15. package/{command → commands}/gsd/research-phase.md +1 -1
  16. package/{command → commands}/gsd/verify-work.md +1 -1
  17. package/get-shit-done/workflows/list-phase-assumptions.md +1 -1
  18. package/get-shit-done/workflows/verify-work.md +5 -5
  19. package/lib/constants.js +199 -0
  20. package/package.json +34 -20
  21. package/src/commands/check.js +329 -0
  22. package/src/commands/config.js +337 -0
  23. package/src/commands/install.js +608 -0
  24. package/src/commands/list.js +256 -0
  25. package/src/commands/repair.js +519 -0
  26. package/src/commands/uninstall.js +732 -0
  27. package/src/commands/update.js +444 -0
  28. package/src/services/backup-manager.js +585 -0
  29. package/src/services/config.js +262 -0
  30. package/src/services/file-ops.js +855 -0
  31. package/src/services/health-checker.js +475 -0
  32. package/src/services/manifest-manager.js +301 -0
  33. package/src/services/migration-service.js +831 -0
  34. package/src/services/repair-service.js +846 -0
  35. package/src/services/scope-manager.js +303 -0
  36. package/src/services/settings.js +553 -0
  37. package/src/services/structure-detector.js +240 -0
  38. package/src/services/update-service.js +863 -0
  39. package/src/utils/hash.js +71 -0
  40. package/src/utils/interactive.js +222 -0
  41. package/src/utils/logger.js +128 -0
  42. package/src/utils/npm-registry.js +255 -0
  43. package/src/utils/path-resolver.js +226 -0
  44. /package/{command → commands}/gsd/add-todo.md +0 -0
  45. /package/{command → commands}/gsd/check-todos.md +0 -0
  46. /package/{command → commands}/gsd/complete-milestone.md +0 -0
  47. /package/{command → commands}/gsd/help.md +0 -0
  48. /package/{command → commands}/gsd/insert-phase.md +0 -0
  49. /package/{command → commands}/gsd/pause-work.md +0 -0
  50. /package/{command → commands}/gsd/plan-milestone-gaps.md +0 -0
  51. /package/{command → commands}/gsd/progress.md +0 -0
  52. /package/{command → commands}/gsd/quick.md +0 -0
  53. /package/{command → commands}/gsd/remove-phase.md +0 -0
  54. /package/{command → commands}/gsd/resume-work.md +0 -0
  55. /package/{command → commands}/gsd/set-model.md +0 -0
  56. /package/{command → commands}/gsd/set-profile.md +0 -0
  57. /package/{command → commands}/gsd/settings.md +0 -0
  58. /package/{command → commands}/gsd/update.md +0 -0
  59. /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
+ };