gsd-opencode 1.9.2 → 1.10.1

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 (58) hide show
  1. package/agents/gsd-debugger.md +5 -5
  2. package/bin/gsd-install.js +105 -0
  3. package/bin/gsd.js +352 -0
  4. package/{command → commands}/gsd/add-phase.md +1 -1
  5. package/{command → commands}/gsd/audit-milestone.md +1 -1
  6. package/{command → commands}/gsd/debug.md +3 -3
  7. package/{command → commands}/gsd/discuss-phase.md +1 -1
  8. package/{command → commands}/gsd/execute-phase.md +1 -1
  9. package/{command → commands}/gsd/list-phase-assumptions.md +1 -1
  10. package/{command → commands}/gsd/map-codebase.md +1 -1
  11. package/{command → commands}/gsd/new-milestone.md +1 -1
  12. package/{command → commands}/gsd/new-project.md +3 -3
  13. package/{command → commands}/gsd/plan-phase.md +2 -2
  14. package/{command → commands}/gsd/research-phase.md +1 -1
  15. package/{command → commands}/gsd/verify-work.md +1 -1
  16. package/get-shit-done/workflows/list-phase-assumptions.md +1 -1
  17. package/get-shit-done/workflows/verify-work.md +5 -5
  18. package/lib/constants.js +193 -0
  19. package/package.json +34 -20
  20. package/src/commands/check.js +329 -0
  21. package/src/commands/config.js +337 -0
  22. package/src/commands/install.js +608 -0
  23. package/src/commands/list.js +256 -0
  24. package/src/commands/repair.js +519 -0
  25. package/src/commands/uninstall.js +732 -0
  26. package/src/commands/update.js +444 -0
  27. package/src/services/backup-manager.js +585 -0
  28. package/src/services/config.js +262 -0
  29. package/src/services/file-ops.js +830 -0
  30. package/src/services/health-checker.js +475 -0
  31. package/src/services/manifest-manager.js +301 -0
  32. package/src/services/migration-service.js +831 -0
  33. package/src/services/repair-service.js +846 -0
  34. package/src/services/scope-manager.js +303 -0
  35. package/src/services/settings.js +553 -0
  36. package/src/services/structure-detector.js +240 -0
  37. package/src/services/update-service.js +863 -0
  38. package/src/utils/hash.js +71 -0
  39. package/src/utils/interactive.js +222 -0
  40. package/src/utils/logger.js +128 -0
  41. package/src/utils/npm-registry.js +255 -0
  42. package/src/utils/path-resolver.js +226 -0
  43. /package/{command → commands}/gsd/add-todo.md +0 -0
  44. /package/{command → commands}/gsd/check-todos.md +0 -0
  45. /package/{command → commands}/gsd/complete-milestone.md +0 -0
  46. /package/{command → commands}/gsd/help.md +0 -0
  47. /package/{command → commands}/gsd/insert-phase.md +0 -0
  48. /package/{command → commands}/gsd/pause-work.md +0 -0
  49. /package/{command → commands}/gsd/plan-milestone-gaps.md +0 -0
  50. /package/{command → commands}/gsd/progress.md +0 -0
  51. /package/{command → commands}/gsd/quick.md +0 -0
  52. /package/{command → commands}/gsd/remove-phase.md +0 -0
  53. /package/{command → commands}/gsd/resume-work.md +0 -0
  54. /package/{command → commands}/gsd/set-model.md +0 -0
  55. /package/{command → commands}/gsd/set-profile.md +0 -0
  56. /package/{command → commands}/gsd/settings.md +0 -0
  57. /package/{command → commands}/gsd/update.md +0 -0
  58. /package/{command → commands}/gsd/whats-new.md +0 -0
@@ -0,0 +1,830 @@
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
+ // Replace @gsd-opencode/ references with actual path
478
+ const targetDir = this.scopeManager.getTargetDir();
479
+ content = content.replace(
480
+ PATH_PATTERNS.gsdReference,
481
+ targetDir + '/'
482
+ );
483
+
484
+ await fs.writeFile(targetPath, content, 'utf-8');
485
+ } else {
486
+ // Copy binary or other files directly
487
+ await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Counts total files in a directory recursively.
493
+ *
494
+ * @param {string} dir - Directory to count
495
+ * @returns {Promise<number>} Total file count
496
+ * @private
497
+ */
498
+ async _countFiles(dir) {
499
+ let count = 0;
500
+
501
+ const entries = await fs.readdir(dir, { withFileTypes: true });
502
+
503
+ for (const entry of entries) {
504
+ const fullPath = path.join(dir, entry.name);
505
+ if (entry.isDirectory()) {
506
+ count += await this._countFiles(fullPath);
507
+ } else {
508
+ count++;
509
+ }
510
+ }
511
+
512
+ return count;
513
+ }
514
+
515
+ /**
516
+ * Counts directories recursively.
517
+ *
518
+ * @param {string} dir - Directory to count
519
+ * @returns {Promise<number>} Total directory count
520
+ * @private
521
+ */
522
+ async _countDirectories(dir) {
523
+ let count = 1; // Count the root directory
524
+
525
+ try {
526
+ const entries = await fs.readdir(dir, { withFileTypes: true });
527
+
528
+ for (const entry of entries) {
529
+ if (entry.isDirectory()) {
530
+ const fullPath = path.join(dir, entry.name);
531
+ count += await this._countDirectories(fullPath);
532
+ }
533
+ }
534
+ } catch (error) {
535
+ // Directory might not exist yet
536
+ return 0;
537
+ }
538
+
539
+ return count;
540
+ }
541
+
542
+ /**
543
+ * Performs atomic move from temp directory to target.
544
+ *
545
+ * Uses fs.rename for atomic move when possible. Falls back to
546
+ * copy-and-delete for cross-device moves.
547
+ *
548
+ * @param {string} tempDir - Temporary directory
549
+ * @param {string} targetDir - Target directory
550
+ * @returns {Promise<void>}
551
+ * @throws {Error} If move fails
552
+ * @private
553
+ */
554
+ async _atomicMove(tempDir, targetDir) {
555
+ this.logger.debug(`Performing atomic move: ${tempDir} -> ${targetDir}`);
556
+
557
+ try {
558
+ // Try atomic rename first
559
+ await fs.rename(tempDir, targetDir);
560
+ this.logger.debug('Atomic move completed successfully');
561
+ } catch (error) {
562
+ if (error.code === 'EXDEV') {
563
+ // Cross-device move needed
564
+ this.logger.debug('Cross-device move detected, using copy+delete');
565
+ await this._crossDeviceMove(tempDir, targetDir);
566
+ } else if (error.code === 'ENOTEMPTY' || error.code === 'EEXIST') {
567
+ // Target exists with other files - MERGE instead of replace
568
+ // This preserves existing opencode configuration
569
+ this.logger.debug('Target exists with existing files, merging contents');
570
+ await this._mergeDirectories(tempDir, targetDir);
571
+ // Clean up temp directory after merge
572
+ await fs.rm(tempDir, { recursive: true, force: true });
573
+ } else {
574
+ throw error;
575
+ }
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Merges temp directory contents into target directory.
581
+ * Preserves existing files and only overwrites gsd-opencode files.
582
+ *
583
+ * @param {string} sourceDir - Source (temp) directory
584
+ * @param {string} targetDir - Target directory
585
+ * @returns {Promise<void>}
586
+ * @private
587
+ */
588
+ async _mergeDirectories(sourceDir, targetDir) {
589
+ this.logger.debug(`Merging ${sourceDir} into ${targetDir}`);
590
+
591
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
592
+
593
+ for (const entry of entries) {
594
+ const sourcePath = path.join(sourceDir, entry.name);
595
+ const targetPath = path.join(targetDir, entry.name);
596
+
597
+ if (entry.isDirectory()) {
598
+ // Create target directory if it doesn't exist
599
+ await fs.mkdir(targetPath, { recursive: true });
600
+ // Recursively merge
601
+ await this._mergeDirectories(sourcePath, targetPath);
602
+ } else {
603
+ // Copy file (overwrites if exists)
604
+ await fs.copyFile(sourcePath, targetPath);
605
+ this.logger.debug(`Merged file: ${entry.name}`);
606
+ }
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Performs cross-device move using copy and delete.
612
+ *
613
+ * @param {string} sourceDir - Source directory
614
+ * @param {string} targetDir - Target directory
615
+ * @returns {Promise<void>}
616
+ * @private
617
+ */
618
+ async _crossDeviceMove(sourceDir, targetDir) {
619
+ // Copy all files
620
+ await this._copyRecursiveNoProgress(sourceDir, targetDir);
621
+ // Delete source
622
+ await fs.rm(sourceDir, { recursive: true, force: true });
623
+ }
624
+
625
+ /**
626
+ * Recursively copies directory contents without progress tracking.
627
+ *
628
+ * @param {string} sourceDir - Source directory
629
+ * @param {string} targetDir - Target directory
630
+ * @returns {Promise<void>}
631
+ * @private
632
+ */
633
+ async _copyRecursiveNoProgress(sourceDir, targetDir) {
634
+ await fs.mkdir(targetDir, { recursive: true });
635
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
636
+
637
+ for (const entry of entries) {
638
+ const sourcePath = path.join(sourceDir, entry.name);
639
+ const targetPath = path.join(targetDir, entry.name);
640
+
641
+ if (entry.isDirectory()) {
642
+ await this._copyRecursiveNoProgress(sourcePath, targetPath);
643
+ } else {
644
+ await fs.copyFile(sourcePath, targetPath);
645
+ }
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Registers a temporary directory for cleanup.
651
+ *
652
+ * @param {string} dirPath - Temporary directory path
653
+ * @private
654
+ */
655
+ _registerTempDir(dirPath) {
656
+ this._tempDirs.add(dirPath);
657
+ this.logger.debug(`Registered temp directory: ${dirPath}`);
658
+ }
659
+
660
+ /**
661
+ * Removes a directory from the cleanup registry.
662
+ *
663
+ * Called after successful atomic move to prevent cleanup
664
+ * of the final installation.
665
+ *
666
+ * @param {string} dirPath - Directory path to remove from registry
667
+ * @private
668
+ */
669
+ _removeTempDir(dirPath) {
670
+ this._tempDirs.delete(dirPath);
671
+ this.logger.debug(`Unregistered temp directory: ${dirPath}`);
672
+ }
673
+
674
+ /**
675
+ * Cleans up all registered temporary directories.
676
+ *
677
+ * @returns {Promise<void>}
678
+ * @private
679
+ */
680
+ async _cleanup() {
681
+ if (this._tempDirs.size === 0) {
682
+ return;
683
+ }
684
+
685
+ this.logger.debug(`Cleaning up ${this._tempDirs.size} temporary directories`);
686
+
687
+ for (const dirPath of this._tempDirs) {
688
+ try {
689
+ await fs.rm(dirPath, { recursive: true, force: true });
690
+ this.logger.debug(`Cleaned up: ${dirPath}`);
691
+ } catch (error) {
692
+ // Log but don't throw during cleanup
693
+ this.logger.debug(`Failed to cleanup ${dirPath}: ${error.message}`);
694
+ }
695
+ }
696
+
697
+ this._tempDirs.clear();
698
+ }
699
+
700
+ /**
701
+ * Sets up signal handlers for graceful interruption.
702
+ *
703
+ * Registers SIGINT and SIGTERM handlers that perform cleanup
704
+ * before exiting.
705
+ *
706
+ * @private
707
+ */
708
+ _setupSignalHandlers() {
709
+ if (this._handlersRegistered) {
710
+ return;
711
+ }
712
+
713
+ process.on('SIGINT', this._handleSigint);
714
+ process.on('SIGTERM', this._handleSigterm);
715
+
716
+ this._handlersRegistered = true;
717
+ this.logger.debug('Signal handlers registered');
718
+ }
719
+
720
+ /**
721
+ * Removes signal handlers.
722
+ *
723
+ * Called after successful installation to restore normal
724
+ * signal handling.
725
+ *
726
+ * @private
727
+ */
728
+ _removeSignalHandlers() {
729
+ if (!this._handlersRegistered) {
730
+ return;
731
+ }
732
+
733
+ process.off('SIGINT', this._handleSigint);
734
+ process.off('SIGTERM', this._handleSigterm);
735
+
736
+ this._handlersRegistered = false;
737
+ this.logger.debug('Signal handlers removed');
738
+ }
739
+
740
+ /**
741
+ * Handles SIGINT signal (Ctrl+C).
742
+ *
743
+ * @private
744
+ */
745
+ _handleSigint() {
746
+ this.logger.warning('\nInstallation interrupted by user');
747
+ this._handleSignal('SIGINT');
748
+ }
749
+
750
+ /**
751
+ * Handles SIGTERM signal.
752
+ *
753
+ * @private
754
+ */
755
+ _handleSigterm() {
756
+ this.logger.warning('\nInstallation terminated');
757
+ this._handleSignal('SIGTERM');
758
+ }
759
+
760
+ /**
761
+ * Common signal handling logic.
762
+ *
763
+ * Performs cleanup and exits with appropriate code.
764
+ *
765
+ * @param {string} signalName - Name of the signal
766
+ * @private
767
+ */
768
+ _handleSignal(signalName) {
769
+ // Stop spinner if active
770
+ if (this._spinner) {
771
+ this._spinner.fail('Installation cancelled');
772
+ this._spinner = null;
773
+ }
774
+
775
+ // Perform cleanup
776
+ this._cleanup().then(() => {
777
+ this.logger.info('Cleanup completed');
778
+ process.exit(ERROR_CODES.INTERRUPTED);
779
+ }).catch((error) => {
780
+ this.logger.error('Cleanup failed', error);
781
+ process.exit(ERROR_CODES.INTERRUPTED);
782
+ });
783
+ }
784
+
785
+ /**
786
+ * Wraps an error with additional context.
787
+ *
788
+ * @param {Error} error - Original error
789
+ * @param {string} operation - Operation name for context
790
+ * @returns {Error} Enhanced error
791
+ * @private
792
+ */
793
+ _wrapError(error, operation) {
794
+ // Handle specific error codes with helpful messages
795
+ if (error.code === 'EACCES') {
796
+ return new Error(
797
+ `Permission denied during ${operation}. ` +
798
+ `Try running with appropriate permissions or check directory ownership.`
799
+ );
800
+ }
801
+
802
+ if (error.code === 'ENOSPC') {
803
+ return new Error(
804
+ `Disk full during ${operation}. ` +
805
+ `Free up disk space and try again.`
806
+ );
807
+ }
808
+
809
+ if (error.code === 'ENOENT') {
810
+ return new Error(
811
+ `File or directory not found during ${operation}: ${error.message}`
812
+ );
813
+ }
814
+
815
+ // Return original error with operation context
816
+ error.message = `Failed during ${operation}: ${error.message}`;
817
+ return error;
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Default export for the file-ops module.
823
+ *
824
+ * @example
825
+ * import { FileOperations } from './services/file-ops.js';
826
+ * const fileOps = new FileOperations(scopeManager, logger);
827
+ */
828
+ export default {
829
+ FileOperations
830
+ };