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,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
|
+
};
|