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,855 @@
1
+ /**
2
+ * File operations service with atomic installation and path replacement.
3
+ *
4
+ * This module provides safe, atomic file operations for installing GSD-OpenCode.
5
+ * It handles:
6
+ * - Path replacement in .md files (rewriting @gsd-opencode/ references)
7
+ * - Atomic installation using temp-then-move pattern
8
+ * - Progress indication during file operations
9
+ * - Signal handling for graceful interruption and cleanup
10
+ * - Path traversal prevention
11
+ * - Permission error handling
12
+ *
13
+ * SECURITY NOTE: All target paths are validated to prevent directory traversal.
14
+ * Atomic operations ensure no partial installations remain on failure.
15
+ *
16
+ * @module file-ops
17
+ */
18
+
19
+ import fs from 'fs/promises';
20
+ import path from 'path';
21
+ import { constants as fsConstants } from 'fs';
22
+ import { createHash } from 'crypto';
23
+ import ora from 'ora';
24
+ import { validatePath, expandPath } from '../utils/path-resolver.js';
25
+ import { PATH_PATTERNS, ERROR_CODES, MANIFEST_FILENAME, DIRECTORIES_TO_COPY, COMMAND_DIR_MAPPING } from '../../lib/constants.js';
26
+ import { ManifestManager } from './manifest-manager.js';
27
+ import { StructureDetector, detectStructure, STRUCTURE_TYPES } from './structure-detector.js';
28
+
29
+ /**
30
+ * Manages file operations with atomic installation and progress indication.
31
+ *
32
+ * This class provides the core installation logic for GSD-OpenCode, handling
33
+ * safe file copying, path replacement in markdown files, and atomic moves.
34
+ * It integrates with ScopeManager for path resolution and Logger for feedback.
35
+ *
36
+ * @class FileOperations
37
+ * @example
38
+ * const fileOps = new FileOperations(scopeManager, logger);
39
+ * await fileOps.install('./get-shit-done', '/Users/name/.config/opencode');
40
+ */
41
+ export class FileOperations {
42
+ /**
43
+ * Creates a new FileOperations instance.
44
+ *
45
+ * @param {ScopeManager} scopeManager - Scope manager for path resolution
46
+ * @param {object} logger - Logger instance for output (from logger.js)
47
+ *
48
+ * @example
49
+ * const scopeManager = new ScopeManager({ scope: 'global' });
50
+ * const fileOps = new FileOperations(scopeManager, logger);
51
+ */
52
+ constructor(scopeManager, logger) {
53
+ if (!scopeManager) {
54
+ throw new Error('scopeManager is required');
55
+ }
56
+ if (!logger) {
57
+ throw new Error('logger is required');
58
+ }
59
+
60
+ this.scopeManager = scopeManager;
61
+ this.logger = logger;
62
+
63
+ /**
64
+ * Registry of temporary directories to clean up.
65
+ * @type {Set<string>}
66
+ * @private
67
+ */
68
+ this._tempDirs = new Set();
69
+
70
+ /**
71
+ * Active spinner instance for progress indication.
72
+ * @type {object|null}
73
+ * @private
74
+ */
75
+ this._spinner = null;
76
+
77
+ /**
78
+ * Flag indicating if signal handlers are registered.
79
+ * @type {boolean}
80
+ * @private
81
+ */
82
+ this._handlersRegistered = false;
83
+
84
+ // Bind methods for use as event handlers
85
+ this._handleSigint = this._handleSigint.bind(this);
86
+ this._handleSigterm = this._handleSigterm.bind(this);
87
+ }
88
+
89
+ /**
90
+ * Installs GSD-OpenCode from source to target directory.
91
+ *
92
+ * Performs atomic installation using the temp-then-move pattern:
93
+ * 1. Creates a temporary directory
94
+ * 2. Copies files with path replacement in .md files
95
+ * 3. Atomically moves temp directory to target location
96
+ *
97
+ * If interrupted or an error occurs, cleans up the temporary directory.
98
+ *
99
+ * @param {string} sourceDir - Source directory containing GSD-OpenCode files
100
+ * @param {string} targetDir - Target installation directory
101
+ * @returns {Promise<{success: boolean, filesCopied: number, directories: number}>}
102
+ * Installation result with counts
103
+ * @throws {Error} If installation fails (with cleanup performed)
104
+ *
105
+ * @example
106
+ * const result = await fileOps.install('./get-shit-done', '~/.config/opencode');
107
+ * console.log(`Copied ${result.filesCopied} files`);
108
+ */
109
+ async install(sourceDir, targetDir) {
110
+ const expandedSource = expandPath(sourceDir);
111
+ const expandedTarget = expandPath(targetDir);
112
+
113
+ // Validate source directory exists
114
+ try {
115
+ const sourceStat = await fs.stat(expandedSource);
116
+ if (!sourceStat.isDirectory()) {
117
+ throw new Error(`Source path is not a directory: ${sourceDir}`);
118
+ }
119
+ } catch (error) {
120
+ if (error.code === 'ENOENT') {
121
+ throw new Error(`Source directory not found: ${sourceDir}`);
122
+ }
123
+ throw error;
124
+ }
125
+
126
+ // Check for existing installation structure
127
+ const existingStructure = await detectStructure(expandedTarget);
128
+
129
+ if (existingStructure === STRUCTURE_TYPES.OLD) {
130
+ this.logger.warning('Existing installation with old structure detected (command/gsd/).');
131
+ this.logger.info('Run "gsd-opencode update" to migrate to the new structure (commands/gsd/).');
132
+ throw new Error(
133
+ 'Existing installation uses old directory structure. ' +
134
+ 'Use "gsd-opencode update" to migrate instead of install.'
135
+ );
136
+ }
137
+
138
+ if (existingStructure === STRUCTURE_TYPES.DUAL) {
139
+ this.logger.warning('Dual structure detected (both command/gsd/ and commands/gsd/ exist).');
140
+ this.logger.info('Run "gsd-opencode update" to complete migration.');
141
+ throw new Error(
142
+ 'Installation has dual structure state. ' +
143
+ 'Use "gsd-opencode update" to fix this issue.'
144
+ );
145
+ }
146
+
147
+ if (existingStructure === STRUCTURE_TYPES.NEW) {
148
+ this.logger.warning('Existing installation with new structure detected (commands/gsd/).');
149
+ this.logger.info('Run "gsd-opencode update" to update to the latest version.');
150
+ throw new Error(
151
+ 'Already installed with new structure. ' +
152
+ 'Use "gsd-opencode update" to update instead of install.'
153
+ );
154
+ }
155
+
156
+ // Create temporary directory with timestamp
157
+ const timestamp = Date.now();
158
+ const tempDir = `${expandedTarget}.tmp-${timestamp}`;
159
+
160
+ // Register temp dir for cleanup and setup signal handlers
161
+ this._registerTempDir(tempDir);
162
+ this._setupSignalHandlers();
163
+
164
+ // Create manifest manager to track installed files
165
+ // Use tempDir initially, will update paths after atomic move
166
+ const manifestManager = new ManifestManager(tempDir);
167
+
168
+ this.logger.info(`Installing to ${this.scopeManager.getPathPrefix()}...`);
169
+
170
+ try {
171
+ // Create temp directory
172
+ await fs.mkdir(tempDir, { recursive: true });
173
+ this.logger.debug(`Created temp directory: ${tempDir}`);
174
+
175
+ // Copy only specific directories (not everything in source)
176
+ const copyResult = await this._copySpecificDirectories(expandedSource, tempDir, manifestManager);
177
+
178
+ // Copy package.json to get-shit-done/ subdirectory
179
+ await this._copyPackageJson(expandedSource, tempDir, manifestManager);
180
+
181
+ // Save manifest to temp directory (will be moved with atomic move)
182
+ const tempManifestPath = await manifestManager.save();
183
+ this.logger.debug(`Manifest saved to temp: ${tempManifestPath}`);
184
+
185
+ // Perform atomic move
186
+ await this._atomicMove(tempDir, expandedTarget);
187
+
188
+ // Update manifest entries to use final target paths instead of temp paths
189
+ const finalManifestManager = new ManifestManager(expandedTarget);
190
+ const entries = manifestManager.getAllEntries().map(entry => ({
191
+ ...entry,
192
+ path: entry.path.replace(tempDir, expandedTarget)
193
+ }));
194
+
195
+ // Clear and re-add entries with updated paths, then save
196
+ finalManifestManager.clear();
197
+ for (const entry of entries) {
198
+ finalManifestManager.addFile(entry.path, entry.relativePath, entry.size, entry.hash);
199
+ }
200
+ await finalManifestManager.save();
201
+ this.logger.debug('Manifest updated with final paths');
202
+
203
+ // Success - clean up signal handlers and registry
204
+ this._removeTempDir(tempDir);
205
+ this._removeSignalHandlers();
206
+
207
+ this.logger.success(
208
+ `Installed ${copyResult.filesCopied} files (${copyResult.directories} directories)`
209
+ );
210
+
211
+ // After atomic move, manifest is at the target location
212
+ return {
213
+ success: true,
214
+ filesCopied: copyResult.filesCopied,
215
+ directories: copyResult.directories,
216
+ manifestPath: path.join(expandedTarget, MANIFEST_FILENAME)
217
+ };
218
+ } catch (error) {
219
+ // Ensure cleanup on any error
220
+ await this._cleanup();
221
+ this._removeSignalHandlers();
222
+
223
+ // Enhance error message with context
224
+ throw this._wrapError(error, 'installation');
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Copies files recursively with progress indication and path replacement.
230
+ *
231
+ * Copies all files from source to target directory. For .md files,
232
+ * performs path replacement to update @gsd-opencode/ references.
233
+ *
234
+ * @param {string} sourceDir - Source directory
235
+ * @param {string} targetDir - Target directory
236
+ * @returns {Promise<{filesCopied: number, directories: number}>}
237
+ * Copy statistics
238
+ * @private
239
+ */
240
+ async _copyWithProgress(sourceDir, targetDir, manifestManager) {
241
+ let filesCopied = 0;
242
+ let directories = 0;
243
+
244
+ // Get total file count for progress calculation
245
+ const totalFiles = await this._countFiles(sourceDir);
246
+
247
+ // Start progress spinner
248
+ this._spinner = ora({
249
+ text: 'Copying files...',
250
+ spinner: 'dots',
251
+ color: 'cyan'
252
+ }).start();
253
+
254
+ try {
255
+ await this._copyRecursive(sourceDir, targetDir, (filePath, relativePath, size, hash) => {
256
+ filesCopied++;
257
+ const progress = Math.round((filesCopied / totalFiles) * 100);
258
+ this._spinner.text = `Copying files... ${progress}% (${filesCopied}/${totalFiles})`;
259
+
260
+ // Add file to manifest
261
+ if (manifestManager) {
262
+ manifestManager.addFile(filePath, relativePath, size, hash);
263
+ }
264
+ }, manifestManager);
265
+
266
+ // Count directories (including target)
267
+ directories = await this._countDirectories(targetDir);
268
+
269
+ this._spinner.succeed(`Copied ${filesCopied} files`);
270
+ } catch (error) {
271
+ this._spinner.fail('Copy failed');
272
+ throw error;
273
+ } finally {
274
+ this._spinner = null;
275
+ }
276
+
277
+ return { filesCopied, directories };
278
+ }
279
+
280
+ /**
281
+ * Copies only specific directories from source to target.
282
+ *
283
+ * Only copies directories listed in DIRECTORIES_TO_COPY constant,
284
+ * ignoring other files/directories like bin/, package.json, etc.
285
+ *
286
+ * @param {string} sourceDir - Source directory
287
+ * @param {string} targetDir - Target directory
288
+ * @param {ManifestManager} manifestManager - Manifest manager for tracking
289
+ * @returns {Promise<{filesCopied: number, directories: number}>}
290
+ * @private
291
+ */
292
+ async _copySpecificDirectories(sourceDir, targetDir, manifestManager) {
293
+ let filesCopied = 0;
294
+ let directories = 0;
295
+
296
+ // Count total files in allowed directories only
297
+ // Note: We count using the source directory names (with mapping applied)
298
+ let totalFiles = 0;
299
+ for (const dirName of DIRECTORIES_TO_COPY) {
300
+ // Transform destination directory name to source directory name
301
+ const sourceDirName = COMMAND_DIR_MAPPING[dirName] || dirName;
302
+ const dirPath = path.join(sourceDir, sourceDirName);
303
+ try {
304
+ totalFiles += await this._countFiles(dirPath);
305
+ } catch (error) {
306
+ // Directory might not exist, skip
307
+ this.logger.debug(`Directory not found: ${dirPath}`);
308
+ }
309
+ }
310
+
311
+ // Start progress spinner
312
+ this._spinner = ora({
313
+ text: 'Copying files...',
314
+ spinner: 'dots',
315
+ color: 'cyan'
316
+ }).start();
317
+
318
+ try {
319
+ for (const dirName of DIRECTORIES_TO_COPY) {
320
+ // Transform destination directory name to source directory name
321
+ // e.g., 'commands' -> 'command' for source lookup
322
+ const sourceDirName = COMMAND_DIR_MAPPING[dirName] || dirName;
323
+ const sourceSubDir = path.join(sourceDir, sourceDirName);
324
+ const targetSubDir = path.join(targetDir, dirName);
325
+
326
+ try {
327
+ // Check if source subdirectory exists
328
+ const stats = await fs.stat(sourceSubDir);
329
+ if (!stats.isDirectory()) {
330
+ continue;
331
+ }
332
+
333
+ // Create target subdirectory
334
+ await fs.mkdir(targetSubDir, { recursive: true });
335
+ directories++;
336
+
337
+ // Copy contents recursively
338
+ await this._copyRecursive(sourceSubDir, targetSubDir, (filePath, relativePath, size, hash) => {
339
+ filesCopied++;
340
+ const progress = Math.round((filesCopied / totalFiles) * 100);
341
+ this._spinner.text = `Copying files... ${progress}% (${filesCopied}/${totalFiles})`;
342
+
343
+ // Add file to manifest with correct relative path (using destination dirName)
344
+ if (manifestManager) {
345
+ const fullRelativePath = path.join(dirName, relativePath).replace(/\\/g, '/');
346
+ manifestManager.addFile(filePath, fullRelativePath, size, hash);
347
+ }
348
+ }, manifestManager, sourceSubDir);
349
+ } catch (error) {
350
+ if (error.code !== 'ENOENT') {
351
+ throw error;
352
+ }
353
+ // Directory doesn't exist, skip
354
+ this.logger.debug(`Skipping missing directory: ${sourceDirName} (maps to ${dirName})`);
355
+ }
356
+ }
357
+
358
+ this._spinner.succeed(`Copied ${filesCopied} files`);
359
+ } catch (error) {
360
+ this._spinner.fail('Copy failed');
361
+ throw error;
362
+ } finally {
363
+ this._spinner = null;
364
+ }
365
+
366
+ return { filesCopied, directories };
367
+ }
368
+
369
+ /**
370
+ * Copies package.json to get-shit-done/ subdirectory.
371
+ *
372
+ * @param {string} sourceDir - Source directory
373
+ * @param {string} targetDir - Target directory
374
+ * @param {ManifestManager} manifestManager - Manifest manager for tracking
375
+ * @returns {Promise<void>}
376
+ * @private
377
+ */
378
+ async _copyPackageJson(sourceDir, targetDir, manifestManager) {
379
+ const sourcePackageJson = path.join(sourceDir, 'package.json');
380
+ const targetGetShitDoneDir = path.join(targetDir, 'get-shit-done');
381
+ const targetPackageJson = path.join(targetGetShitDoneDir, 'package.json');
382
+
383
+ try {
384
+ await fs.access(sourcePackageJson);
385
+ } catch (error) {
386
+ if (error.code === 'ENOENT') {
387
+ this.logger.debug('package.json not found in source, skipping');
388
+ return;
389
+ }
390
+ throw error;
391
+ }
392
+
393
+ // Ensure get-shit-done directory exists
394
+ await fs.mkdir(targetGetShitDoneDir, { recursive: true });
395
+
396
+ // Copy package.json
397
+ await fs.copyFile(sourcePackageJson, targetPackageJson);
398
+
399
+ // Add to manifest
400
+ if (manifestManager) {
401
+ const stats = await fs.stat(targetPackageJson);
402
+ const hash = await this._calculateFileHash(targetPackageJson);
403
+ manifestManager.addFile(targetPackageJson, 'get-shit-done/package.json', stats.size, hash);
404
+ }
405
+
406
+ this.logger.debug('Copied package.json to get-shit-done/package.json');
407
+ }
408
+
409
+ /**
410
+ * Recursively copies directory contents.
411
+ *
412
+ * @param {string} sourceDir - Source directory
413
+ * @param {string} targetDir - Target directory
414
+ * @param {Function} onFile - Callback for each file copied
415
+ * @returns {Promise<void>}
416
+ * @private
417
+ */
418
+ async _copyRecursive(sourceDir, targetDir, onFile, manifestManager, baseSourceDir = null) {
419
+ const baseDir = baseSourceDir || sourceDir;
420
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
421
+
422
+ for (const entry of entries) {
423
+ const sourcePath = path.join(sourceDir, entry.name);
424
+ const targetPath = path.join(targetDir, entry.name);
425
+
426
+ if (entry.isDirectory()) {
427
+ // Create directory
428
+ await fs.mkdir(targetPath, { recursive: true });
429
+ // Recursively copy contents
430
+ await this._copyRecursive(sourcePath, targetPath, onFile, manifestManager, baseDir);
431
+ } else {
432
+ // Copy file with potential path replacement
433
+ await this._copyFile(sourcePath, targetPath);
434
+
435
+ // Calculate file metadata for manifest
436
+ const stats = await fs.stat(targetPath);
437
+ const size = stats.size;
438
+ const hash = await this._calculateFileHash(targetPath);
439
+ const relativePath = path.relative(baseDir, sourcePath).replace(/\\/g, '/');
440
+
441
+ onFile(targetPath, relativePath, size, hash);
442
+ }
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Calculates SHA256 hash of a file.
448
+ *
449
+ * @param {string} filePath - Path to file
450
+ * @returns {Promise<string>} SHA256 hash with 'sha256:' prefix
451
+ * @private
452
+ */
453
+ async _calculateFileHash(filePath) {
454
+ const content = await fs.readFile(filePath);
455
+ const hash = createHash('sha256').update(content).digest('hex');
456
+ return `sha256:${hash}`;
457
+ }
458
+
459
+ /**
460
+ * Copies a single file, performing path replacement for .md files.
461
+ *
462
+ * For markdown files, replaces @gsd-opencode/ references with the
463
+ * actual installation path.
464
+ *
465
+ * @param {string} sourcePath - Source file path
466
+ * @param {string} targetPath - Target file path
467
+ * @returns {Promise<void>}
468
+ * @private
469
+ */
470
+ async _copyFile(sourcePath, targetPath) {
471
+ const isMarkdown = sourcePath.endsWith('.md');
472
+
473
+ if (isMarkdown) {
474
+ // Read, replace, and write markdown content
475
+ let content = await fs.readFile(sourcePath, 'utf-8');
476
+
477
+ // Optimization: Skip files that don't contain any patterns needing replacement
478
+ const hasGsdRef = PATH_PATTERNS.gsdReference.test(content);
479
+ PATH_PATTERNS.gsdReference.lastIndex = 0; // Reset regex
480
+ const hasAbsRef = PATH_PATTERNS.absoluteReference && PATH_PATTERNS.absoluteReference.test(content);
481
+ if (PATH_PATTERNS.absoluteReference) {
482
+ PATH_PATTERNS.absoluteReference.lastIndex = 0; // Reset regex
483
+ }
484
+
485
+ if (!hasGsdRef && !hasAbsRef) {
486
+ await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE);
487
+ return;
488
+ }
489
+
490
+ // Replace @gsd-opencode/ references with actual path
491
+ // Use function-based replacement to avoid issues with special characters
492
+ // like '$' in the target directory path
493
+ // Use getPathPrefix() to get the correct prefix (./.opencode for local, ~/.config/opencode for global)
494
+ const targetDir = this.scopeManager.getPathPrefix();
495
+ content = content.replace(
496
+ PATH_PATTERNS.gsdReference,
497
+ () => targetDir + '/'
498
+ );
499
+
500
+ // For local installs, also replace @~/.config/opencode/ with local path
501
+ // This handles files that have hardcoded global references
502
+ if (this.scopeManager.scope === 'local' && PATH_PATTERNS.absoluteReference) {
503
+ content = content.replace(
504
+ PATH_PATTERNS.absoluteReference,
505
+ () => targetDir + '/'
506
+ );
507
+ }
508
+
509
+ await fs.writeFile(targetPath, content, 'utf-8');
510
+ } else {
511
+ // Copy binary or other files directly
512
+ await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Counts total files in a directory recursively.
518
+ *
519
+ * @param {string} dir - Directory to count
520
+ * @returns {Promise<number>} Total file count
521
+ * @private
522
+ */
523
+ async _countFiles(dir) {
524
+ let count = 0;
525
+
526
+ const entries = await fs.readdir(dir, { withFileTypes: true });
527
+
528
+ for (const entry of entries) {
529
+ const fullPath = path.join(dir, entry.name);
530
+ if (entry.isDirectory()) {
531
+ count += await this._countFiles(fullPath);
532
+ } else {
533
+ count++;
534
+ }
535
+ }
536
+
537
+ return count;
538
+ }
539
+
540
+ /**
541
+ * Counts directories recursively.
542
+ *
543
+ * @param {string} dir - Directory to count
544
+ * @returns {Promise<number>} Total directory count
545
+ * @private
546
+ */
547
+ async _countDirectories(dir) {
548
+ let count = 1; // Count the root directory
549
+
550
+ try {
551
+ const entries = await fs.readdir(dir, { withFileTypes: true });
552
+
553
+ for (const entry of entries) {
554
+ if (entry.isDirectory()) {
555
+ const fullPath = path.join(dir, entry.name);
556
+ count += await this._countDirectories(fullPath);
557
+ }
558
+ }
559
+ } catch (error) {
560
+ // Directory might not exist yet
561
+ return 0;
562
+ }
563
+
564
+ return count;
565
+ }
566
+
567
+ /**
568
+ * Performs atomic move from temp directory to target.
569
+ *
570
+ * Uses fs.rename for atomic move when possible. Falls back to
571
+ * copy-and-delete for cross-device moves.
572
+ *
573
+ * @param {string} tempDir - Temporary directory
574
+ * @param {string} targetDir - Target directory
575
+ * @returns {Promise<void>}
576
+ * @throws {Error} If move fails
577
+ * @private
578
+ */
579
+ async _atomicMove(tempDir, targetDir) {
580
+ this.logger.debug(`Performing atomic move: ${tempDir} -> ${targetDir}`);
581
+
582
+ try {
583
+ // Try atomic rename first
584
+ await fs.rename(tempDir, targetDir);
585
+ this.logger.debug('Atomic move completed successfully');
586
+ } catch (error) {
587
+ if (error.code === 'EXDEV') {
588
+ // Cross-device move needed
589
+ this.logger.debug('Cross-device move detected, using copy+delete');
590
+ await this._crossDeviceMove(tempDir, targetDir);
591
+ } else if (error.code === 'ENOTEMPTY' || error.code === 'EEXIST') {
592
+ // Target exists with other files - MERGE instead of replace
593
+ // This preserves existing opencode configuration
594
+ this.logger.debug('Target exists with existing files, merging contents');
595
+ await this._mergeDirectories(tempDir, targetDir);
596
+ // Clean up temp directory after merge
597
+ await fs.rm(tempDir, { recursive: true, force: true });
598
+ } else {
599
+ throw error;
600
+ }
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Merges temp directory contents into target directory.
606
+ * Preserves existing files and only overwrites gsd-opencode files.
607
+ *
608
+ * @param {string} sourceDir - Source (temp) directory
609
+ * @param {string} targetDir - Target directory
610
+ * @returns {Promise<void>}
611
+ * @private
612
+ */
613
+ async _mergeDirectories(sourceDir, targetDir) {
614
+ this.logger.debug(`Merging ${sourceDir} into ${targetDir}`);
615
+
616
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
617
+
618
+ for (const entry of entries) {
619
+ const sourcePath = path.join(sourceDir, entry.name);
620
+ const targetPath = path.join(targetDir, entry.name);
621
+
622
+ if (entry.isDirectory()) {
623
+ // Create target directory if it doesn't exist
624
+ await fs.mkdir(targetPath, { recursive: true });
625
+ // Recursively merge
626
+ await this._mergeDirectories(sourcePath, targetPath);
627
+ } else {
628
+ // Copy file (overwrites if exists)
629
+ await fs.copyFile(sourcePath, targetPath);
630
+ this.logger.debug(`Merged file: ${entry.name}`);
631
+ }
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Performs cross-device move using copy and delete.
637
+ *
638
+ * @param {string} sourceDir - Source directory
639
+ * @param {string} targetDir - Target directory
640
+ * @returns {Promise<void>}
641
+ * @private
642
+ */
643
+ async _crossDeviceMove(sourceDir, targetDir) {
644
+ // Copy all files
645
+ await this._copyRecursiveNoProgress(sourceDir, targetDir);
646
+ // Delete source
647
+ await fs.rm(sourceDir, { recursive: true, force: true });
648
+ }
649
+
650
+ /**
651
+ * Recursively copies directory contents without progress tracking.
652
+ *
653
+ * @param {string} sourceDir - Source directory
654
+ * @param {string} targetDir - Target directory
655
+ * @returns {Promise<void>}
656
+ * @private
657
+ */
658
+ async _copyRecursiveNoProgress(sourceDir, targetDir) {
659
+ await fs.mkdir(targetDir, { recursive: true });
660
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
661
+
662
+ for (const entry of entries) {
663
+ const sourcePath = path.join(sourceDir, entry.name);
664
+ const targetPath = path.join(targetDir, entry.name);
665
+
666
+ if (entry.isDirectory()) {
667
+ await this._copyRecursiveNoProgress(sourcePath, targetPath);
668
+ } else {
669
+ await fs.copyFile(sourcePath, targetPath);
670
+ }
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Registers a temporary directory for cleanup.
676
+ *
677
+ * @param {string} dirPath - Temporary directory path
678
+ * @private
679
+ */
680
+ _registerTempDir(dirPath) {
681
+ this._tempDirs.add(dirPath);
682
+ this.logger.debug(`Registered temp directory: ${dirPath}`);
683
+ }
684
+
685
+ /**
686
+ * Removes a directory from the cleanup registry.
687
+ *
688
+ * Called after successful atomic move to prevent cleanup
689
+ * of the final installation.
690
+ *
691
+ * @param {string} dirPath - Directory path to remove from registry
692
+ * @private
693
+ */
694
+ _removeTempDir(dirPath) {
695
+ this._tempDirs.delete(dirPath);
696
+ this.logger.debug(`Unregistered temp directory: ${dirPath}`);
697
+ }
698
+
699
+ /**
700
+ * Cleans up all registered temporary directories.
701
+ *
702
+ * @returns {Promise<void>}
703
+ * @private
704
+ */
705
+ async _cleanup() {
706
+ if (this._tempDirs.size === 0) {
707
+ return;
708
+ }
709
+
710
+ this.logger.debug(`Cleaning up ${this._tempDirs.size} temporary directories`);
711
+
712
+ for (const dirPath of this._tempDirs) {
713
+ try {
714
+ await fs.rm(dirPath, { recursive: true, force: true });
715
+ this.logger.debug(`Cleaned up: ${dirPath}`);
716
+ } catch (error) {
717
+ // Log but don't throw during cleanup
718
+ this.logger.debug(`Failed to cleanup ${dirPath}: ${error.message}`);
719
+ }
720
+ }
721
+
722
+ this._tempDirs.clear();
723
+ }
724
+
725
+ /**
726
+ * Sets up signal handlers for graceful interruption.
727
+ *
728
+ * Registers SIGINT and SIGTERM handlers that perform cleanup
729
+ * before exiting.
730
+ *
731
+ * @private
732
+ */
733
+ _setupSignalHandlers() {
734
+ if (this._handlersRegistered) {
735
+ return;
736
+ }
737
+
738
+ process.on('SIGINT', this._handleSigint);
739
+ process.on('SIGTERM', this._handleSigterm);
740
+
741
+ this._handlersRegistered = true;
742
+ this.logger.debug('Signal handlers registered');
743
+ }
744
+
745
+ /**
746
+ * Removes signal handlers.
747
+ *
748
+ * Called after successful installation to restore normal
749
+ * signal handling.
750
+ *
751
+ * @private
752
+ */
753
+ _removeSignalHandlers() {
754
+ if (!this._handlersRegistered) {
755
+ return;
756
+ }
757
+
758
+ process.off('SIGINT', this._handleSigint);
759
+ process.off('SIGTERM', this._handleSigterm);
760
+
761
+ this._handlersRegistered = false;
762
+ this.logger.debug('Signal handlers removed');
763
+ }
764
+
765
+ /**
766
+ * Handles SIGINT signal (Ctrl+C).
767
+ *
768
+ * @private
769
+ */
770
+ _handleSigint() {
771
+ this.logger.warning('\nInstallation interrupted by user');
772
+ this._handleSignal('SIGINT');
773
+ }
774
+
775
+ /**
776
+ * Handles SIGTERM signal.
777
+ *
778
+ * @private
779
+ */
780
+ _handleSigterm() {
781
+ this.logger.warning('\nInstallation terminated');
782
+ this._handleSignal('SIGTERM');
783
+ }
784
+
785
+ /**
786
+ * Common signal handling logic.
787
+ *
788
+ * Performs cleanup and exits with appropriate code.
789
+ *
790
+ * @param {string} signalName - Name of the signal
791
+ * @private
792
+ */
793
+ _handleSignal(signalName) {
794
+ // Stop spinner if active
795
+ if (this._spinner) {
796
+ this._spinner.fail('Installation cancelled');
797
+ this._spinner = null;
798
+ }
799
+
800
+ // Perform cleanup
801
+ this._cleanup().then(() => {
802
+ this.logger.info('Cleanup completed');
803
+ process.exit(ERROR_CODES.INTERRUPTED);
804
+ }).catch((error) => {
805
+ this.logger.error('Cleanup failed', error);
806
+ process.exit(ERROR_CODES.INTERRUPTED);
807
+ });
808
+ }
809
+
810
+ /**
811
+ * Wraps an error with additional context.
812
+ *
813
+ * @param {Error} error - Original error
814
+ * @param {string} operation - Operation name for context
815
+ * @returns {Error} Enhanced error
816
+ * @private
817
+ */
818
+ _wrapError(error, operation) {
819
+ // Handle specific error codes with helpful messages
820
+ if (error.code === 'EACCES') {
821
+ return new Error(
822
+ `Permission denied during ${operation}. ` +
823
+ `Try running with appropriate permissions or check directory ownership.`
824
+ );
825
+ }
826
+
827
+ if (error.code === 'ENOSPC') {
828
+ return new Error(
829
+ `Disk full during ${operation}. ` +
830
+ `Free up disk space and try again.`
831
+ );
832
+ }
833
+
834
+ if (error.code === 'ENOENT') {
835
+ return new Error(
836
+ `File or directory not found during ${operation}: ${error.message}`
837
+ );
838
+ }
839
+
840
+ // Return original error with operation context
841
+ error.message = `Failed during ${operation}: ${error.message}`;
842
+ return error;
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Default export for the file-ops module.
848
+ *
849
+ * @example
850
+ * import { FileOperations } from './services/file-ops.js';
851
+ * const fileOps = new FileOperations(scopeManager, logger);
852
+ */
853
+ export default {
854
+ FileOperations
855
+ };