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,863 @@
1
+ /**
2
+ * Update service for orchestrating GSD-OpenCode version updates.
3
+ *
4
+ * This module provides the core update logic that coordinates all aspects of updating
5
+ * GSD-OpenCode: detecting current version, checking for updates, performing health checks,
6
+ * creating backups, installing new versions, and verifying results. It is the core business
7
+ * logic for the update command.
8
+ *
9
+ * Works in conjunction with NpmRegistry for version queries, ScopeManager for path resolution,
10
+ * BackupManager for safe backups, FileOperations for installation, and HealthChecker for
11
+ * pre/post validation.
12
+ *
13
+ * @module update-service
14
+ */
15
+
16
+ import { exec } from 'child_process';
17
+ import { promisify } from 'util';
18
+ import fs from 'fs/promises';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+ import { ScopeManager } from './scope-manager.js';
22
+ import { BackupManager } from './backup-manager.js';
23
+ import { FileOperations } from './file-ops.js';
24
+ import { NpmRegistry } from '../utils/npm-registry.js';
25
+ import { StructureDetector, STRUCTURE_TYPES } from './structure-detector.js';
26
+
27
+ const execAsync = promisify(exec);
28
+
29
+ // Get the directory of the current module
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+
33
+ /**
34
+ * Manages update operations for GSD-OpenCode installations.
35
+ *
36
+ * This class provides methods to check for available updates and perform updates
37
+ * safely with health checks, backup creation, and progress reporting. It uses a
38
+ * phased approach: pre-update validation, backup, install, and post-update verification.
39
+ *
40
+ * @class UpdateService
41
+ * @example
42
+ * const scope = new ScopeManager({ scope: 'global' });
43
+ * const backupManager = new BackupManager(scope, logger);
44
+ * const fileOps = new FileOperations(scope, logger);
45
+ * const npmRegistry = new NpmRegistry(logger);
46
+ * const updateService = new UpdateService({
47
+ * scopeManager: scope,
48
+ * backupManager,
49
+ * fileOps,
50
+ * npmRegistry,
51
+ * logger,
52
+ * packageName: 'gsd-opencode'
53
+ * });
54
+ *
55
+ * // Check for updates
56
+ * const updateInfo = await updateService.checkForUpdate();
57
+ * if (updateInfo.updateAvailable) {
58
+ * console.log(`Update available: ${updateInfo.currentVersion} -> ${updateInfo.latestVersion}`);
59
+ *
60
+ * // Perform update with progress tracking
61
+ * const result = await updateService.performUpdate(null, {
62
+ * onProgress: ({ phase, current, total, message }) => {
63
+ * console.log(`${phase}: ${current}/${total} - ${message}`);
64
+ * }
65
+ * });
66
+ *
67
+ * console.log(`Update ${result.success ? 'successful' : 'failed'}`);
68
+ * }
69
+ */
70
+ export class UpdateService {
71
+ /**
72
+ * Creates a new UpdateService instance.
73
+ *
74
+ * @param {Object} dependencies - Required dependencies
75
+ * @param {ScopeManager} dependencies.scopeManager - ScopeManager instance for path resolution
76
+ * @param {BackupManager} dependencies.backupManager - BackupManager instance for creating backups
77
+ * @param {FileOperations} dependencies.fileOps - FileOperations instance for file installation
78
+ * @param {NpmRegistry} dependencies.npmRegistry - NpmRegistry instance for version queries
79
+ * @param {Object} dependencies.logger - Logger instance for output
80
+ * @param {string} [dependencies.packageName='gsd-opencode'] - Package name to update (can be '@rokicool/gsd-opencode' for beta)
81
+ * @throws {Error} If any required dependency is missing or invalid
82
+ *
83
+ * @example
84
+ * const updateService = new UpdateService({
85
+ * scopeManager: scope,
86
+ * backupManager,
87
+ * fileOps,
88
+ * npmRegistry,
89
+ * logger,
90
+ * packageName: 'gsd-opencode'
91
+ * });
92
+ */
93
+ constructor(dependencies) {
94
+ // Validate all required dependencies
95
+ if (!dependencies) {
96
+ throw new Error('Dependencies object is required');
97
+ }
98
+
99
+ const { scopeManager, backupManager, fileOps, npmRegistry, logger, packageName } = dependencies;
100
+
101
+ // Validate scopeManager
102
+ if (!scopeManager) {
103
+ throw new Error('ScopeManager instance is required');
104
+ }
105
+ if (typeof scopeManager.getTargetDir !== 'function') {
106
+ throw new Error('Invalid ScopeManager: missing getTargetDir method');
107
+ }
108
+ if (typeof scopeManager.isGlobal !== 'function') {
109
+ throw new Error('Invalid ScopeManager: missing isGlobal method');
110
+ }
111
+ if (typeof scopeManager.getInstalledVersion !== 'function') {
112
+ throw new Error('Invalid ScopeManager: missing getInstalledVersion method');
113
+ }
114
+
115
+ // Validate backupManager
116
+ if (!backupManager) {
117
+ throw new Error('BackupManager instance is required');
118
+ }
119
+ if (typeof backupManager.backupFile !== 'function') {
120
+ throw new Error('Invalid BackupManager: missing backupFile method');
121
+ }
122
+
123
+ // Validate fileOps
124
+ if (!fileOps) {
125
+ throw new Error('FileOperations instance is required');
126
+ }
127
+ if (typeof fileOps._copyFile !== 'function') {
128
+ throw new Error('Invalid FileOperations: missing _copyFile method');
129
+ }
130
+
131
+ // Validate npmRegistry
132
+ if (!npmRegistry) {
133
+ throw new Error('NpmRegistry instance is required');
134
+ }
135
+ if (typeof npmRegistry.getLatestVersion !== 'function') {
136
+ throw new Error('Invalid NpmRegistry: missing getLatestVersion method');
137
+ }
138
+ if (typeof npmRegistry.compareVersions !== 'function') {
139
+ throw new Error('Invalid NpmRegistry: missing compareVersions method');
140
+ }
141
+
142
+ // Validate logger
143
+ if (!logger) {
144
+ throw new Error('Logger instance is required');
145
+ }
146
+ if (typeof logger.info !== 'function' || typeof logger.error !== 'function') {
147
+ throw new Error('Invalid Logger: missing required methods (info, error)');
148
+ }
149
+
150
+ // Store dependencies
151
+ this.scopeManager = scopeManager;
152
+ this.backupManager = backupManager;
153
+ this.fileOps = fileOps;
154
+ this.npmRegistry = npmRegistry;
155
+ this.logger = logger;
156
+ this.packageName = packageName || 'gsd-opencode';
157
+
158
+ // Lazy-load HealthChecker to avoid circular dependencies
159
+ this._healthChecker = null;
160
+
161
+ // Structure detection for migration support
162
+ this.structureDetector = new StructureDetector(this.scopeManager.getTargetDir());
163
+
164
+ this.logger.debug('UpdateService initialized');
165
+ }
166
+
167
+ /**
168
+ * Gets or creates the HealthChecker instance.
169
+ *
170
+ * @returns {Promise<Object>} HealthChecker instance
171
+ * @private
172
+ */
173
+ async _getHealthChecker() {
174
+ if (!this._healthChecker) {
175
+ const { HealthChecker } = await import('./health-checker.js');
176
+ this._healthChecker = new HealthChecker(this.scopeManager);
177
+ }
178
+ return this._healthChecker;
179
+ }
180
+
181
+ /**
182
+ * Gets the current installed version.
183
+ *
184
+ * Reads the VERSION file via ScopeManager to determine the installed version.
185
+ *
186
+ * @returns {Promise<string|null>} The installed version string, or null if not installed
187
+ * @private
188
+ */
189
+ async _getCurrentVersion() {
190
+ return this.scopeManager.getInstalledVersion();
191
+ }
192
+
193
+ /**
194
+ * Performs pre-update health check.
195
+ *
196
+ * Runs health checks on the current installation to ensure it's safe to update.
197
+ * Only runs checks if the installation exists.
198
+ *
199
+ * @returns {Promise<Object>} Health check result
200
+ * @property {boolean} passed - True if health check passed
201
+ * @property {Object} details - Detailed check results
202
+ * @private
203
+ */
204
+ async _performPreUpdateCheck() {
205
+ const isInstalled = await this.scopeManager.isInstalled();
206
+ if (!isInstalled) {
207
+ this.logger.debug('No existing installation, skipping pre-update health check');
208
+ return { passed: true, details: null };
209
+ }
210
+
211
+ this.logger.info('Performing pre-update health check...');
212
+
213
+ const healthChecker = await this._getHealthChecker();
214
+ const currentVersion = await this._getCurrentVersion();
215
+
216
+ const checkResult = await healthChecker.checkAll({
217
+ expectedVersion: currentVersion
218
+ });
219
+
220
+ if (!checkResult.passed) {
221
+ this.logger.warning('Pre-update health check found issues');
222
+ } else {
223
+ this.logger.success('Pre-update health check passed');
224
+ }
225
+
226
+ return {
227
+ passed: checkResult.passed,
228
+ details: checkResult
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Performs post-update verification.
234
+ *
235
+ * Runs health checks on the new installation to verify it was installed correctly.
236
+ *
237
+ * @param {string} expectedVersion - The expected version after update
238
+ * @returns {Promise<Object>} Verification result
239
+ * @property {boolean} passed - True if verification passed
240
+ * @property {Object} details - Detailed check results
241
+ * @private
242
+ */
243
+ async _performPostUpdateCheck(expectedVersion) {
244
+ this.logger.info('Performing post-update verification...');
245
+
246
+ const healthChecker = await this._getHealthChecker();
247
+
248
+ const checkResult = await healthChecker.checkAll({
249
+ expectedVersion
250
+ });
251
+
252
+ if (!checkResult.passed) {
253
+ this.logger.error('Post-update verification failed');
254
+ } else {
255
+ this.logger.success('Post-update verification passed');
256
+ }
257
+
258
+ return {
259
+ passed: checkResult.passed,
260
+ details: checkResult
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Installs a specific version using npm.
266
+ *
267
+ * Uses npm install to download and install the package. Handles both global
268
+ * and local installations based on scope.
269
+ *
270
+ * @param {string} version - Version to install (e.g., '1.9.2')
271
+ * @returns {Promise<Object>} Installation result
272
+ * @property {boolean} success - True if installation succeeded
273
+ * @property {string|null} error - Error message if installation failed
274
+ * @private
275
+ */
276
+ async _installVersion(version) {
277
+ const isGlobal = this.scopeManager.isGlobal();
278
+ const escapedPackage = this._escapePackageName(this.packageName);
279
+ const escapedVersion = version.replace(/[^0-9.a-zA-Z-]/g, '');
280
+
281
+ this.logger.info(`Installing ${this.packageName}@${escapedVersion}...`);
282
+
283
+ try {
284
+ if (isGlobal) {
285
+ // Global installation with --force to overwrite existing binaries
286
+ await execAsync(`npm install -g --force ${escapedPackage}@${escapedVersion}`);
287
+ } else {
288
+ // Local installation in target directory
289
+ const targetDir = this.scopeManager.getTargetDir();
290
+ await fs.mkdir(targetDir, { recursive: true });
291
+ await execAsync(`npm install ${escapedPackage}@${escapedVersion}`, {
292
+ cwd: targetDir
293
+ });
294
+ }
295
+
296
+ return { success: true, error: null };
297
+ } catch (error) {
298
+ return {
299
+ success: false,
300
+ error: error.message || 'Installation failed'
301
+ };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Escapes a package name for safe use in shell commands.
307
+ *
308
+ * @param {string} packageName - The package name to escape
309
+ * @returns {string} Escaped package name
310
+ * @private
311
+ */
312
+ _escapePackageName(packageName) {
313
+ // Replace any potentially dangerous characters
314
+ return packageName.replace(/[^a-zA-Z0-9@._/-]/g, '');
315
+ }
316
+
317
+ /**
318
+ * Checks if an update is available.
319
+ *
320
+ * Compares the current installed version with the latest version from npm registry.
321
+ *
322
+ * @returns {Promise<Object>} Update check result
323
+ * @property {string|null} currentVersion - The currently installed version
324
+ * @property {string|null} latestVersion - The latest version available on npm
325
+ * @property {boolean} updateAvailable - True if an update is available
326
+ * @property {boolean} isBeta - True if using a beta/scoped package
327
+ * @property {string|null} error - Error message if check failed
328
+ *
329
+ * @example
330
+ * const result = await updateService.checkForUpdate();
331
+ * console.log(result.currentVersion); // '1.9.1'
332
+ * console.log(result.latestVersion); // '1.9.2'
333
+ * console.log(result.updateAvailable); // true
334
+ */
335
+ async checkForUpdate() {
336
+ this.logger.info('Checking for updates...');
337
+
338
+ try {
339
+ // Get current installed version
340
+ const currentVersion = await this._getCurrentVersion();
341
+
342
+ // Get latest version from npm
343
+ const latestVersion = await this.npmRegistry.getLatestVersion(this.packageName);
344
+
345
+ if (!latestVersion) {
346
+ return {
347
+ currentVersion,
348
+ latestVersion: null,
349
+ updateAvailable: false,
350
+ isBeta: this._isBetaPackage(),
351
+ error: 'Failed to fetch latest version from npm'
352
+ };
353
+ }
354
+
355
+ // Compare versions
356
+ let updateAvailable = false;
357
+ if (currentVersion) {
358
+ const comparison = this.npmRegistry.compareVersions(latestVersion, currentVersion);
359
+ updateAvailable = comparison > 0;
360
+ } else {
361
+ // Not currently installed, treat as update available
362
+ updateAvailable = true;
363
+ }
364
+
365
+ this.logger.info(
366
+ updateAvailable
367
+ ? `Update available: ${currentVersion || 'none'} -> ${latestVersion}`
368
+ : `Up to date (${currentVersion})`
369
+ );
370
+
371
+ return {
372
+ currentVersion,
373
+ latestVersion,
374
+ updateAvailable,
375
+ isBeta: this._isBetaPackage(),
376
+ error: null
377
+ };
378
+ } catch (error) {
379
+ this.logger.error('Failed to check for updates', error);
380
+
381
+ return {
382
+ currentVersion: null,
383
+ latestVersion: null,
384
+ updateAvailable: false,
385
+ isBeta: this._isBetaPackage(),
386
+ error: error.message || 'Unknown error checking for updates'
387
+ };
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Checks if the current package is a beta/scoped package.
393
+ *
394
+ * @returns {boolean} True if using a scoped package name
395
+ * @private
396
+ */
397
+ _isBetaPackage() {
398
+ return this.packageName.startsWith('@');
399
+ }
400
+
401
+ /**
402
+ * Performs pre-flight validation before update.
403
+ *
404
+ * Validates that the update can proceed by checking:
405
+ * - Installation exists (if required)
406
+ * - Target version exists
407
+ * - Write permissions are available
408
+ *
409
+ * @param {string|null} targetVersion - Specific version to validate (null for latest)
410
+ * @returns {Promise<Object>} Validation result
411
+ * @property {boolean} valid - True if validation passed
412
+ * @property {string[]} errors - Array of error messages if validation failed
413
+ *
414
+ * @example
415
+ * const validation = await updateService.validateUpdate('1.9.2');
416
+ * if (!validation.valid) {
417
+ * console.log('Cannot update:', validation.errors.join(', '));
418
+ * }
419
+ */
420
+ async validateUpdate(targetVersion = null) {
421
+ const errors = [];
422
+
423
+ this.logger.debug('Performing pre-flight validation...');
424
+
425
+ // Check if target version exists (if specified)
426
+ if (targetVersion) {
427
+ const versionExists = await this.npmRegistry.versionExists(this.packageName, targetVersion);
428
+ if (!versionExists) {
429
+ errors.push(`Version ${targetVersion} does not exist in npm registry`);
430
+ }
431
+ }
432
+
433
+ // Check write permissions
434
+ try {
435
+ const targetDir = this.scopeManager.getTargetDir();
436
+ const testPath = path.join(targetDir, '.write-test');
437
+ await fs.writeFile(testPath, '', 'utf-8');
438
+ await fs.unlink(testPath).catch(() => {});
439
+ } catch (error) {
440
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
441
+ errors.push('Permission denied: Cannot write to installation directory');
442
+ } else if (error.code === 'ENOENT') {
443
+ // Directory doesn't exist, which is fine for new installs
444
+ } else {
445
+ errors.push(`Write permission check failed: ${error.message}`);
446
+ }
447
+ }
448
+
449
+ const valid = errors.length === 0;
450
+
451
+ if (valid) {
452
+ this.logger.debug('Pre-flight validation passed');
453
+ } else {
454
+ this.logger.warning(`Pre-flight validation failed: ${errors.join(', ')}`);
455
+ }
456
+
457
+ return { valid, errors };
458
+ }
459
+
460
+ /**
461
+ * Performs the full update workflow.
462
+ *
463
+ * Orchestrates the complete update process:
464
+ * 1. Pre-update health check
465
+ * 2. Create backup of current installation
466
+ * 3. Download and install new version
467
+ * 4. Run FileOperations for path replacement
468
+ * 5. Post-update verification
469
+ *
470
+ * @param {string|null} targetVersion - Specific version to install (null for latest)
471
+ * @param {Object} [options={}] - Update options
472
+ * @param {Function} [options.onProgress] - Progress callback ({ phase, current, total, message })
473
+ * @param {boolean} [options.force] - Skip confirmation (not used here, handled by CLI)
474
+ * @returns {Promise<Object>} Update result
475
+ * @property {boolean} success - True if update succeeded
476
+ * @property {Object} stats - Statistics about the update
477
+ * @property {Array} errors - Array of error messages if update failed
478
+ *
479
+ * @example
480
+ * const result = await updateService.performUpdate(null, {
481
+ * onProgress: ({ phase, current, total, message }) => {
482
+ * console.log(`${phase}: ${message} (${current}/${total})`);
483
+ * }
484
+ * });
485
+ *
486
+ * if (result.success) {
487
+ * console.log('Update complete!');
488
+ * } else {
489
+ * console.log('Update failed:', result.errors);
490
+ * }
491
+ */
492
+ async performUpdate(targetVersion = null, options = {}) {
493
+ const { onProgress, dryRun, skipMigration } = options;
494
+ const errors = [];
495
+ const stats = {
496
+ preUpdateChecksPassed: false,
497
+ backupCreated: false,
498
+ structureMigrated: false,
499
+ migrationSkipped: false,
500
+ migrationBackup: null,
501
+ installSucceeded: false,
502
+ postUpdateChecksPassed: false,
503
+ startTime: Date.now(),
504
+ endTime: null
505
+ };
506
+
507
+ this.logger.info('Starting update process...');
508
+
509
+ // Define progress phases (includes structure check and migration)
510
+ const phases = [
511
+ { id: 'structure-check', name: 'Checking structure', weight: 5 },
512
+ { id: 'pre-check', name: 'Pre-update health check', weight: 10 },
513
+ { id: 'migration', name: 'Migrating structure', weight: 15 },
514
+ { id: 'backup', name: 'Creating backup', weight: 15 },
515
+ { id: 'install', name: 'Installing new version', weight: 40 },
516
+ { id: 'post-check', name: 'Post-update verification', weight: 15 }
517
+ ];
518
+
519
+ const totalWeight = phases.reduce((sum, p) => sum + p.weight, 0);
520
+ let currentWeight = 0;
521
+
522
+ const reportProgress = (phaseId, current, total, message) => {
523
+ if (onProgress) {
524
+ const phase = phases.find(p => p.id === phaseId);
525
+ const phaseProgress = total > 0 ? (current / total) * phase.weight : 0;
526
+ const overallProgress = Math.round(((currentWeight + phaseProgress) / totalWeight) * 100);
527
+
528
+ onProgress({
529
+ phase: phase.name,
530
+ current,
531
+ total,
532
+ message: message || phase.name,
533
+ overallProgress
534
+ });
535
+ }
536
+ };
537
+
538
+ try {
539
+ // Phase 1: Check current structure
540
+ reportProgress('structure-check', 0, 1, 'Detecting directory structure');
541
+ const structure = await this.structureDetector.detect();
542
+ reportProgress('structure-check', 1, 1, 'Structure detection complete');
543
+ currentWeight += phases[0].weight;
544
+
545
+ // Phase 2: Pre-update health check
546
+ reportProgress('pre-check', 0, 1, 'Running pre-update health check');
547
+ const preCheckResult = await this._performPreUpdateCheck();
548
+ stats.preUpdateChecksPassed = preCheckResult.passed;
549
+ reportProgress('pre-check', 1, 1, 'Pre-update health check complete');
550
+ currentWeight += phases[1].weight;
551
+
552
+ // Phase 3: Migrate if needed (before downloading new version)
553
+ if (!skipMigration && (structure === STRUCTURE_TYPES.OLD || structure === STRUCTURE_TYPES.DUAL)) {
554
+ if (dryRun) {
555
+ this.logger.info('Would migrate from old structure to new structure');
556
+ stats.structureMigrated = false;
557
+ } else {
558
+ reportProgress('migration', 0, 1, 'Converting to new directory structure');
559
+
560
+ // Lazy-load MigrationService to avoid circular dependencies
561
+ const { MigrationService } = await import('./migration-service.js');
562
+ const migrationService = new MigrationService(this.scopeManager, this.logger);
563
+
564
+ try {
565
+ const migrationResult = await migrationService.migrate();
566
+
567
+ if (migrationResult.migrated) {
568
+ this.logger.success('Structure migration completed successfully');
569
+ stats.structureMigrated = true;
570
+ stats.migrationBackup = migrationResult.backup;
571
+ } else {
572
+ this.logger.info(`Migration skipped: ${migrationResult.reason}`);
573
+ stats.structureMigrated = false;
574
+ }
575
+ } catch (error) {
576
+ this.logger.error(`Migration failed: ${error.message}`);
577
+ stats.structureMigrated = false;
578
+
579
+ // Enhanced error handling for specific failure scenarios
580
+ const errorMessage = this._formatMigrationError(error, structure);
581
+
582
+ return {
583
+ success: false,
584
+ version: targetVersion,
585
+ stats,
586
+ errors: [errorMessage]
587
+ };
588
+ }
589
+
590
+ reportProgress('migration', 1, 1, 'Structure migration complete');
591
+ }
592
+ } else {
593
+ // Check if migration was skipped due to flag
594
+ if (skipMigration && (structure === STRUCTURE_TYPES.OLD || structure === STRUCTURE_TYPES.DUAL)) {
595
+ this.logger.warning('Skipping structure migration (--skip-migration flag)');
596
+ stats.migrationSkipped = true;
597
+ }
598
+ reportProgress('migration', 1, 1, 'No migration needed');
599
+ }
600
+ currentWeight += phases[2].weight;
601
+
602
+ // Phase 4: Create backup (if installed)
603
+ reportProgress('backup', 0, 1, 'Creating backup');
604
+ const isInstalled = await this.scopeManager.isInstalled();
605
+ if (isInstalled) {
606
+ const targetDir = this.scopeManager.getTargetDir();
607
+ const versionFile = path.join(targetDir, 'VERSION');
608
+
609
+ try {
610
+ await this.backupManager.backupFile(versionFile, 'VERSION');
611
+ stats.backupCreated = true;
612
+ this.logger.success('Backup created');
613
+ } catch (error) {
614
+ this.logger.warning('Failed to create backup, continuing anyway');
615
+ // Non-fatal: continue without backup
616
+ }
617
+ }
618
+ reportProgress('backup', 1, 1, 'Backup complete');
619
+ currentWeight += phases[3].weight;
620
+
621
+ // Determine target version
622
+ let versionToInstall = targetVersion;
623
+ if (!versionToInstall) {
624
+ const checkResult = await this.checkForUpdate();
625
+ versionToInstall = checkResult.latestVersion;
626
+ }
627
+
628
+ if (!versionToInstall) {
629
+ throw new Error('Could not determine version to install');
630
+ }
631
+
632
+ // Phase 5: Install new version
633
+ reportProgress('install', 0, 3, 'Downloading package');
634
+ const installResult = await this._installVersion(versionToInstall);
635
+
636
+ if (!installResult.success) {
637
+ throw new Error(`Installation failed: ${installResult.error}`);
638
+ }
639
+
640
+ reportProgress('install', 1, 3, 'Package downloaded');
641
+
642
+ // Run FileOperations for path replacement
643
+ reportProgress('install', 2, 3, 'Performing path replacement');
644
+ const packageRoot = this._getPackageRoot();
645
+ const targetDir = this.scopeManager.getTargetDir();
646
+
647
+ // Copy files from package to target directory with path replacement
648
+ const sourceDir = packageRoot;
649
+ try {
650
+ await this._copyWithPathReplacement(sourceDir, targetDir);
651
+ } catch (error) {
652
+ this.logger.warning(`Path replacement had issues: ${error.message}`);
653
+ // Continue anyway - npm install may have already placed files
654
+ }
655
+
656
+ reportProgress('install', 3, 3, 'Installation complete');
657
+ stats.installSucceeded = true;
658
+ currentWeight += phases[4].weight;
659
+
660
+ // Phase 6: Post-update verification
661
+ reportProgress('post-check', 0, 1, 'Running post-update verification');
662
+ const postCheckResult = await this._performPostUpdateCheck(versionToInstall);
663
+ stats.postUpdateChecksPassed = postCheckResult.passed;
664
+
665
+ if (!postCheckResult.passed) {
666
+ errors.push('Post-update verification failed - installation may be incomplete');
667
+ }
668
+
669
+ reportProgress('post-check', 1, 1, 'Post-update verification complete');
670
+
671
+ // Verify structure is correct after update
672
+ if (!dryRun) {
673
+ const structureOk = await this._verifyPostUpdateStructure();
674
+ if (!structureOk) {
675
+ errors.push('Post-update structure verification failed');
676
+ }
677
+ }
678
+
679
+ currentWeight += phases[5].weight;
680
+
681
+ stats.endTime = Date.now();
682
+
683
+ const success = errors.length === 0;
684
+
685
+ if (success) {
686
+ this.logger.success(`Update to ${versionToInstall} completed successfully`);
687
+ } else {
688
+ this.logger.error('Update completed with errors');
689
+ }
690
+
691
+ return {
692
+ success,
693
+ version: versionToInstall,
694
+ stats,
695
+ errors
696
+ };
697
+ } catch (error) {
698
+ stats.endTime = Date.now();
699
+ errors.push(error.message || 'Unknown error during update');
700
+
701
+ this.logger.error('Update failed', error);
702
+
703
+ return {
704
+ success: false,
705
+ version: targetVersion,
706
+ stats,
707
+ errors
708
+ };
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Copies files with path replacement for .md files.
714
+ *
715
+ * Recursively copies files from source to target, performing path
716
+ * replacement in markdown files.
717
+ *
718
+ * @param {string} sourceDir - Source directory
719
+ * @param {string} targetDir - Target directory
720
+ * @returns {Promise<void>}
721
+ * @private
722
+ */
723
+ async _copyWithPathReplacement(sourceDir, targetDir) {
724
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
725
+
726
+ for (const entry of entries) {
727
+ const sourcePath = path.join(sourceDir, entry.name);
728
+ const targetPath = path.join(targetDir, entry.name);
729
+
730
+ if (entry.isDirectory()) {
731
+ await fs.mkdir(targetPath, { recursive: true });
732
+ await this._copyWithPathReplacement(sourcePath, targetPath);
733
+ } else {
734
+ await this.fileOps._copyFile(sourcePath, targetPath);
735
+ }
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Verifies that the directory structure is correct after update.
741
+ *
742
+ * Checks that the installation uses the new structure after a successful
743
+ * update. If not, shows a warning suggesting repair.
744
+ *
745
+ * @returns {Promise<boolean>} True if structure is correct
746
+ * @private
747
+ */
748
+ async _verifyPostUpdateStructure() {
749
+ try {
750
+ const structure = await this.structureDetector.detect();
751
+
752
+ if (structure === STRUCTURE_TYPES.OLD) {
753
+ this.logger.warning('Post-update check: Installation still uses old structure');
754
+ this.logger.dim(" Run 'gsd-opencode update' again to complete migration");
755
+ return false;
756
+ }
757
+
758
+ if (structure === STRUCTURE_TYPES.DUAL) {
759
+ this.logger.warning('Post-update check: Both old and new structures detected');
760
+ this.logger.dim(" Run 'gsd-opencode repair' to fix the installation");
761
+ return false;
762
+ }
763
+
764
+ return true;
765
+ } catch (error) {
766
+ this.logger.debug(`Could not verify post-update structure: ${error.message}`);
767
+ return true; // Don't fail update for verification errors
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Formats migration error messages with helpful suggestions.
773
+ *
774
+ * Provides specific error messages and recovery suggestions based on
775
+ * the type of error encountered during migration.
776
+ *
777
+ * @param {Error} error - The error that occurred
778
+ * @param {string} structureType - The structure type being migrated
779
+ * @returns {string} Formatted error message with suggestions
780
+ * @private
781
+ */
782
+ _formatMigrationError(error, structureType) {
783
+ const baseMessage = `Migration failed: ${error.message}`;
784
+
785
+ // Disk space errors
786
+ if (error.code === 'ENOSPC' || error.message.includes('no space left')) {
787
+ return `${baseMessage}\n\n` +
788
+ 'Insufficient disk space for migration.\n' +
789
+ 'Migration requires approximately 2x the current installation size.\n' +
790
+ 'Suggestions:\n' +
791
+ ' - Free up disk space and try again\n' +
792
+ ' - Run with --skip-migration to update without migrating (not recommended)';
793
+ }
794
+
795
+ // Permission errors
796
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
797
+ return `${baseMessage}\n\n` +
798
+ 'Permission denied during migration.\n' +
799
+ 'Suggestions:\n' +
800
+ ' - Check that you have write access to the installation directory\n' +
801
+ ' - On Unix systems, you may need to use sudo for global installations\n' +
802
+ ' - Ensure no other processes are using the files';
803
+ }
804
+
805
+ // File busy errors (open file handles)
806
+ if (error.code === 'EBUSY' || error.message.includes('resource busy')) {
807
+ return `${baseMessage}\n\n` +
808
+ 'Some files are currently in use and cannot be moved.\n' +
809
+ 'Suggestions:\n' +
810
+ ' - Close any editors or terminals with files open in the installation\n' +
811
+ ' - Close any running GSD-OpenCode commands\n' +
812
+ ' - Try again after closing conflicting applications';
813
+ }
814
+
815
+ // Interrupted migration (dual structure)
816
+ if (structureType === STRUCTURE_TYPES.DUAL) {
817
+ return `${baseMessage}\n\n` +
818
+ 'Previous migration may have been interrupted.\n' +
819
+ 'Suggestions:\n' +
820
+ ' - Run "gsd-opencode repair" to fix the installation\n' +
821
+ ' - Or manually remove the old structure and run update again\n' +
822
+ ' - Migration backup may be available for rollback';
823
+ }
824
+
825
+ // Default error with rollback info
826
+ return `${baseMessage}\n\n` +
827
+ 'The migration was automatically rolled back to prevent data loss.\n' +
828
+ 'You can try again or use --skip-migration (not recommended).';
829
+ }
830
+
831
+ /**
832
+ * Gets the package root directory.
833
+ *
834
+ * Resolves the path to the installed npm package.
835
+ *
836
+ * @returns {string} Path to package root
837
+ * @private
838
+ */
839
+ _getPackageRoot() {
840
+ // For global installs, find the global npm root
841
+ // For local installs, use the target directory's node_modules
842
+ if (this.scopeManager.isGlobal()) {
843
+ // Global packages are typically in npm's global root
844
+ // We'll need to find where npm installed our package
845
+ return path.resolve(__dirname, '../..');
846
+ } else {
847
+ // Local installs go in node_modules
848
+ const targetDir = this.scopeManager.getTargetDir();
849
+ return path.join(targetDir, 'node_modules', this.packageName);
850
+ }
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Default export for the update-service module.
856
+ *
857
+ * @example
858
+ * import { UpdateService } from './services/update-service.js';
859
+ * const updateService = new UpdateService({ scopeManager, backupManager, fileOps, npmRegistry, logger });
860
+ */
861
+ export default {
862
+ UpdateService
863
+ };