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,732 @@
1
+ /**
2
+ * Uninstall command for GSD-OpenCode CLI with manifest-based safety.
3
+ *
4
+ * This module provides safe removal of GSD-OpenCode installations with:
5
+ * - Manifest-based tracking (INSTALLED_FILES.json)
6
+ * - Namespace protection (only removes files in gsd-* namespaces)
7
+ * --dry-run mode for previewing what will be removed
8
+ * - Typed confirmation requiring user to type 'uninstall'
9
+ * - Backup creation before deletion
10
+ * - Directory preservation when containing non-gsd-opencode files
11
+ *
12
+ * Safety Principles:
13
+ * - Only delete files in allowed namespaces (agents/gsd-*, command/gsd/*, skills/gsd-*, get-shit-done/*)
14
+ * - Never delete files outside these namespaces, even if tracked in manifest
15
+ * - Preserve directories that would become non-empty after file removal
16
+ * - Create backup before any destructive operation
17
+ * - Require typed confirmation for extra safety
18
+ *
19
+ * @module commands/uninstall
20
+ * @description Safe uninstall command with namespace protection
21
+ */
22
+
23
+ import { ScopeManager } from "../services/scope-manager.js";
24
+ import { BackupManager } from "../services/backup-manager.js";
25
+ import { ManifestManager } from "../services/manifest-manager.js";
26
+ import {
27
+ detectStructure,
28
+ STRUCTURE_TYPES,
29
+ } from "../services/structure-detector.js";
30
+ import { logger, setVerbose } from "../utils/logger.js";
31
+ import { promptTypedConfirmation } from "../utils/interactive.js";
32
+ import {
33
+ ERROR_CODES,
34
+ ALLOWED_NAMESPACES,
35
+ UNINSTALL_BACKUP_DIR,
36
+ } from "../../lib/constants.js";
37
+ import fs from "fs/promises";
38
+ import path from "path";
39
+
40
+ /**
41
+ * Main uninstall command function with safety-first design.
42
+ *
43
+ * Orchestrates safe uninstallation with manifest loading, namespace filtering,
44
+ * backup creation, typed confirmation, and directory preservation.
45
+ *
46
+ * @param {Object} options - Command options
47
+ * @param {boolean} [options.global] - Remove global installation
48
+ * @param {boolean} [options.local] - Remove local installation
49
+ * @param {boolean} [options.force] - Skip typed confirmation (still shows summary)
50
+ * @param {boolean} [options.dryRun] - Show preview without removing files
51
+ * @param {boolean} [options.backup=true] - Create backup before removal (use --no-backup to skip)
52
+ * @param {boolean} [options.verbose] - Enable verbose logging
53
+ * @returns {Promise<number>} Exit code (0=success, 1=error, 2=permission, 130=interrupted)
54
+ * @async
55
+ *
56
+ * @example
57
+ * // Remove global installation with typed confirmation
58
+ * const exitCode = await uninstallCommand({ global: true });
59
+ *
60
+ * // Preview what would be removed (dry run)
61
+ * const exitCode = await uninstallCommand({ local: true, dryRun: true });
62
+ *
63
+ * // Remove without backup (user takes responsibility)
64
+ * const exitCode = await uninstallCommand({ global: true, backup: false, force: true });
65
+ */
66
+ export async function uninstallCommand(options = {}) {
67
+ // Set verbose mode early for consistent logging
68
+ setVerbose(options.verbose);
69
+
70
+ logger.debug("Starting uninstall command");
71
+ logger.debug(
72
+ `Options: global=${options.global}, local=${options.local}, force=${options.force}, dryRun=${options.dryRun}, backup=${options.backup}, verbose=${options.verbose}`,
73
+ );
74
+
75
+ try {
76
+ // Step 1: Determine scope
77
+ const scope = await determineScope(options);
78
+ if (scope === null) {
79
+ return ERROR_CODES.GENERAL_ERROR;
80
+ }
81
+
82
+ // Step 2: Create ScopeManager and verify installation exists
83
+ const scopeManager = new ScopeManager({ scope });
84
+ const targetDir = scopeManager.getTargetDir();
85
+
86
+ const isInstalled = await scopeManager.isInstalled();
87
+ if (!isInstalled) {
88
+ logger.warning(
89
+ `No ${scope} installation found at ${scopeManager.getPathPrefix()}`,
90
+ );
91
+ return ERROR_CODES.GENERAL_ERROR;
92
+ }
93
+
94
+ // Detect and log structure type
95
+ const structureType = await detectStructure(targetDir);
96
+ logger.debug(`Detected structure: ${structureType}`);
97
+
98
+ // Step 3: Load manifest or use fallback mode
99
+ const manifestManager = new ManifestManager(targetDir);
100
+ let manifestEntries = await manifestManager.load();
101
+ const usingFallback = manifestEntries === null;
102
+
103
+ if (usingFallback) {
104
+ logger.warning(
105
+ "Manifest not found - using fallback mode (namespace-based detection)",
106
+ );
107
+ manifestEntries = await buildFallbackManifest(targetDir);
108
+ } else {
109
+ logger.debug(
110
+ `Loaded manifest with ${manifestEntries.length} tracked files`,
111
+ );
112
+ }
113
+
114
+ // Step 4: Filter files by allowed namespaces
115
+ const filesToRemove = manifestEntries.filter((entry) =>
116
+ isInAllowedNamespace(entry.relativePath),
117
+ );
118
+
119
+ logger.debug(
120
+ `${filesToRemove.length} files in allowed namespaces (out of ${manifestEntries.length} total)`,
121
+ );
122
+
123
+ // Step 5: Categorize files and directories
124
+ const categorized = await categorizeItems(filesToRemove, targetDir);
125
+
126
+ if (categorized.toRemove.length === 0) {
127
+ logger.warning(
128
+ "No GSD-OpenCode files found to remove (all files outside allowed namespaces)",
129
+ );
130
+ return ERROR_CODES.SUCCESS;
131
+ }
132
+
133
+ // Step 6: Display warning and summary
134
+ displayWarningHeader(scope, scopeManager.getPathPrefix());
135
+ displayCategorizedItems(categorized, targetDir);
136
+ displaySafetySummary(categorized, options.backup !== false);
137
+
138
+ // Step 7: Dry run mode - exit here without removing
139
+ if (options.dryRun) {
140
+ logger.info("\n📋 Dry run complete - no files were removed");
141
+ return ERROR_CODES.SUCCESS;
142
+ }
143
+
144
+ // Step 8: Typed confirmation (unless --force)
145
+ if (!options.force) {
146
+ logger.debug("Requesting typed confirmation...");
147
+
148
+ const confirmed = await promptTypedConfirmation(
149
+ "\n⚠️ This will permanently remove the files listed above",
150
+ "yes",
151
+ );
152
+
153
+ if (confirmed === null) {
154
+ logger.info("Uninstall cancelled");
155
+ return ERROR_CODES.INTERRUPTED;
156
+ }
157
+
158
+ if (!confirmed) {
159
+ logger.info("Uninstall cancelled - confirmation word did not match");
160
+ return ERROR_CODES.SUCCESS;
161
+ }
162
+
163
+ logger.debug("User confirmed uninstallation");
164
+ } else {
165
+ logger.debug("--force flag provided, skipping typed confirmation");
166
+ }
167
+
168
+ // Step 9: Create backup (unless --no-backup)
169
+ let backupResult = null;
170
+ if (options.backup !== false) {
171
+ backupResult = await createBackup(
172
+ categorized.toRemove,
173
+ targetDir,
174
+ scopeManager,
175
+ );
176
+ }
177
+
178
+ // Step 10: Remove files
179
+ logger.info("\n🗑️ Removing files...");
180
+ const removalResult = await removeFiles(categorized.toRemove, targetDir);
181
+
182
+ // Step 11: Clean up empty directories
183
+ const dirResult = await cleanupDirectories(categorized, targetDir);
184
+
185
+ // Step 12: Success message with recovery instructions
186
+ displaySuccessMessage(removalResult, dirResult, backupResult, targetDir);
187
+
188
+ return ERROR_CODES.SUCCESS;
189
+ } catch (error) {
190
+ // Handle Ctrl+C during async operations
191
+ if (error.name === "AbortPromptError") {
192
+ logger.info("Uninstall cancelled");
193
+ return ERROR_CODES.INTERRUPTED;
194
+ }
195
+
196
+ // Handle permission errors (EACCES)
197
+ if (error.code === "EACCES") {
198
+ logger.error("Permission denied: Cannot remove installation directory");
199
+ logger.dim("");
200
+ logger.dim(
201
+ "Suggestion: Check directory permissions or run with appropriate privileges",
202
+ );
203
+ return ERROR_CODES.PERMISSION_ERROR;
204
+ }
205
+
206
+ // Handle all other errors
207
+ logger.error(`Uninstall failed: ${error.message}`);
208
+
209
+ if (options.verbose && error.stack) {
210
+ logger.dim(error.stack);
211
+ }
212
+
213
+ return ERROR_CODES.GENERAL_ERROR;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Determines installation scope based on options.
219
+ *
220
+ * @param {Object} options - Command options
221
+ * @returns {Promise<string|null>} 'global', 'local', or null if error
222
+ * @private
223
+ */
224
+ async function determineScope(options) {
225
+ if (options.global) {
226
+ logger.debug("Scope determined by --global flag");
227
+ return "global";
228
+ }
229
+
230
+ if (options.local) {
231
+ logger.debug("Scope determined by --local flag");
232
+ return "local";
233
+ }
234
+
235
+ // Auto-detect: check both global and local installations
236
+ logger.debug("No scope flags provided, auto-detecting...");
237
+
238
+ const globalScope = new ScopeManager({ scope: "global" });
239
+ const localScope = new ScopeManager({ scope: "local" });
240
+
241
+ const [globalInstalled, localInstalled] = await Promise.all([
242
+ globalScope.isInstalled(),
243
+ localScope.isInstalled(),
244
+ ]);
245
+
246
+ logger.debug(
247
+ `Global installed: ${globalInstalled}, Local installed: ${localInstalled}`,
248
+ );
249
+
250
+ // If both exist, user must specify which to remove
251
+ if (globalInstalled && localInstalled) {
252
+ logger.warning("Both global and local installations found");
253
+ logger.info("Use --global or --local to specify which to remove");
254
+ return null;
255
+ }
256
+
257
+ // If neither exists, nothing to uninstall
258
+ if (!globalInstalled && !localInstalled) {
259
+ logger.warning("No GSD-OpenCode installation found");
260
+ return null;
261
+ }
262
+
263
+ // Use whichever one exists
264
+ const scope = globalInstalled ? "global" : "local";
265
+ logger.debug(`Auto-detected scope: ${scope}`);
266
+ return scope;
267
+ }
268
+
269
+ /**
270
+ * Builds a fallback manifest by scanning allowed namespace directories.
271
+ *
272
+ * Used when INSTALLED_FILES.json is missing.
273
+ * Scans both old (command/gsd) and new (commands/gsd) structures.
274
+ *
275
+ * @param {string} targetDir - Installation directory
276
+ * @returns {Promise<Array>} Array of manifest entry objects
277
+ * @private
278
+ */
279
+ async function buildFallbackManifest(targetDir) {
280
+ const entries = [];
281
+
282
+ // Scan allowed namespace directories - include both old and new command structures
283
+ const dirsToScan = [
284
+ "agents",
285
+ "command/gsd", // Old structure (singular)
286
+ "commands/gsd", // New structure (plural)
287
+ "skills",
288
+ "get-shit-done",
289
+ ];
290
+
291
+ for (const dir of dirsToScan) {
292
+ const fullPath = path.join(targetDir, dir);
293
+ try {
294
+ await scanDirectory(fullPath, targetDir, entries, dir);
295
+ } catch (error) {
296
+ if (error.code !== "ENOENT") {
297
+ logger.debug(`Error scanning ${dir}: ${error.message}`);
298
+ }
299
+ }
300
+ }
301
+
302
+ return entries;
303
+ }
304
+
305
+ /**
306
+ * Recursively scans a directory and adds files to entries array.
307
+ *
308
+ * @param {string} dirPath - Directory to scan
309
+ * @param {string} baseDir - Base installation directory
310
+ * @param {Array} entries - Array to populate with entries
311
+ * @param {string} relativePrefix - Relative path prefix
312
+ * @private
313
+ */
314
+ async function scanDirectory(dirPath, baseDir, entries, relativePrefix) {
315
+ try {
316
+ const stats = await fs.stat(dirPath);
317
+ if (!stats.isDirectory()) {
318
+ return;
319
+ }
320
+ } catch {
321
+ return;
322
+ }
323
+
324
+ const items = await fs.readdir(dirPath, { withFileTypes: true });
325
+
326
+ for (const item of items) {
327
+ const itemPath = path.join(dirPath, item.name);
328
+ const relativePath = path.relative(baseDir, itemPath).replace(/\\/g, "/");
329
+
330
+ if (item.isDirectory()) {
331
+ // Only recurse into gsd-* directories (except get-shit-done which is fully owned)
332
+ if (relativePrefix === "get-shit-done" || item.name.startsWith("gsd-")) {
333
+ await scanDirectory(itemPath, baseDir, entries, relativePath);
334
+ }
335
+ } else {
336
+ // Add file entry
337
+ const fileStats = await fs.stat(itemPath);
338
+ entries.push({
339
+ path: itemPath,
340
+ relativePath: relativePath,
341
+ size: fileStats.size,
342
+ hash: null, // Cannot calculate without reading file
343
+ });
344
+ }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Checks if a relative path is in an allowed namespace.
350
+ *
351
+ * @param {string} relativePath - Path relative to installation root
352
+ * @returns {boolean} True if in allowed namespace
353
+ * @private
354
+ */
355
+ function isInAllowedNamespace(relativePath) {
356
+ const normalizedPath = relativePath.replace(/\\/g, "/");
357
+ return ALLOWED_NAMESPACES.some((pattern) => pattern.test(normalizedPath));
358
+ }
359
+
360
+ /**
361
+ * Categorizes files into toRemove, missing, and preserved.
362
+ *
363
+ * @param {Array} files - Files from manifest
364
+ * @param {string} targetDir - Installation directory
365
+ * @returns {Object} Categorized items
366
+ * @private
367
+ */
368
+ async function categorizeItems(files, targetDir) {
369
+ const toRemove = [];
370
+ const missing = [];
371
+ const directories = new Set();
372
+
373
+ for (const file of files) {
374
+ // Use the stored path or construct from relativePath
375
+ const filePath = file.path || path.join(targetDir, file.relativePath);
376
+
377
+ try {
378
+ await fs.access(filePath);
379
+ toRemove.push({
380
+ ...file,
381
+ path: filePath,
382
+ });
383
+
384
+ // Track parent directory
385
+ const parentDir = path.dirname(file.relativePath);
386
+ if (parentDir && parentDir !== ".") {
387
+ directories.add(parentDir);
388
+ }
389
+ } catch {
390
+ missing.push(file);
391
+ }
392
+ }
393
+
394
+ // Ensure top-level directories in allowed namespaces are tracked for cleanup
395
+ // This includes get-shit-done, agents, command, commands, skills
396
+ // Include both old (command) and new (commands) structures
397
+ const topLevelDirs = [
398
+ "agents",
399
+ "command",
400
+ "commands",
401
+ "skills",
402
+ "get-shit-done",
403
+ ];
404
+ for (const dir of topLevelDirs) {
405
+ try {
406
+ const fullPath = path.join(targetDir, dir);
407
+ await fs.access(fullPath);
408
+ directories.add(dir);
409
+ } catch {
410
+ // Directory doesn't exist, skip
411
+ }
412
+ }
413
+
414
+ return { toRemove, missing, directories: Array.from(directories) };
415
+ }
416
+
417
+ /**
418
+ * Displays the warning header.
419
+ *
420
+ * @param {string} scope - Installation scope
421
+ * @param {string} location - Installation location
422
+ * @private
423
+ */
424
+ function displayWarningHeader(scope, location) {
425
+ logger.error(
426
+ "╔══════════════════════════════════════════════════════════════╗",
427
+ );
428
+ logger.error(
429
+ "║ ⚠️ WARNING: DESTRUCTIVE OPERATION ║",
430
+ );
431
+ logger.error(
432
+ "╚══════════════════════════════════════════════════════════════╝",
433
+ );
434
+ logger.dim("");
435
+ logger.info(`Scope: ${scope}`);
436
+ logger.info(`Location: ${location}`);
437
+ logger.dim("");
438
+ logger.warning("Only removing files in gsd-opencode namespaces (gsd-*)");
439
+ logger.dim("User files in other directories will be preserved");
440
+ logger.dim("");
441
+ }
442
+
443
+ /**
444
+ * Displays categorized items (to remove, missing, preserved directories).
445
+ *
446
+ * @param {Object} categorized - Categorized items
447
+ * @param {string} targetDir - Installation directory
448
+ * @private
449
+ */
450
+ function displayCategorizedItems(categorized, targetDir) {
451
+ // Files to remove
452
+ if (categorized.toRemove.length > 0) {
453
+ logger.info(
454
+ `📋 Files that will be removed (${categorized.toRemove.length}):`,
455
+ );
456
+
457
+ const displayCount = Math.min(categorized.toRemove.length, 10);
458
+ for (let i = 0; i < displayCount; i++) {
459
+ const file = categorized.toRemove[i];
460
+ logger.dim(` ✓ ${file.relativePath}`);
461
+ }
462
+
463
+ if (categorized.toRemove.length > 10) {
464
+ logger.dim(` ... and ${categorized.toRemove.length - 10} more files`);
465
+ }
466
+
467
+ logger.dim("");
468
+ }
469
+
470
+ // Files already missing
471
+ if (categorized.missing.length > 0) {
472
+ logger.info(`⚠️ Files already missing (${categorized.missing.length}):`);
473
+ const displayCount = Math.min(categorized.missing.length, 5);
474
+ for (let i = 0; i < displayCount; i++) {
475
+ logger.dim(` - ${categorized.missing[i].relativePath}`);
476
+ }
477
+ if (categorized.missing.length > 5) {
478
+ logger.dim(` ... and ${categorized.missing.length - 5} more`);
479
+ }
480
+ logger.dim("");
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Displays the safety summary before confirmation.
486
+ *
487
+ * @param {Object} categorized - Categorized items
488
+ * @param {boolean} willCreateBackup - Whether backup will be created
489
+ * @private
490
+ */
491
+ function displaySafetySummary(categorized, willCreateBackup) {
492
+ const totalSize = categorized.toRemove.reduce(
493
+ (sum, file) => sum + (file.size || 0),
494
+ 0,
495
+ );
496
+ const sizeInKB = (totalSize / 1024).toFixed(1);
497
+
498
+ logger.info("📊 Safety Summary:");
499
+ logger.info(
500
+ ` • ${categorized.toRemove.length} files will be removed (${sizeInKB} KB)`,
501
+ );
502
+ logger.info(
503
+ ` • ${categorized.directories.length} directories will be checked for cleanup`,
504
+ );
505
+
506
+ if (categorized.missing.length > 0) {
507
+ logger.info(
508
+ ` • ${categorized.missing.length} files already missing (will be skipped)`,
509
+ );
510
+ }
511
+
512
+ if (willCreateBackup) {
513
+ logger.info(` • Backup will be created in: ${UNINSTALL_BACKUP_DIR}/`);
514
+ } else {
515
+ logger.warning(` • ⚠️ No backup will be created (--no-backup specified)`);
516
+ }
517
+
518
+ logger.dim("");
519
+ }
520
+
521
+ /**
522
+ * Creates a backup of files before removal.
523
+ *
524
+ * Creates a timestamped backup directory and replicates the folder structure
525
+ * for all files being backed up.
526
+ *
527
+ * @param {Array} files - Files to backup
528
+ * @param {string} targetDir - Installation directory
529
+ * @param {ScopeManager} scopeManager - ScopeManager instance
530
+ * @returns {Promise<Object>} Backup result
531
+ * @private
532
+ */
533
+ async function createBackup(files, targetDir, scopeManager) {
534
+ logger.info("\n📦 Creating backup...");
535
+
536
+ try {
537
+ // Create backup directory with timestamp
538
+ const timestamp = new Date()
539
+ .toISOString()
540
+ .replace(/[:.]/g, "-")
541
+ .slice(0, 19);
542
+ const backupDir = path.join(targetDir, UNINSTALL_BACKUP_DIR, timestamp);
543
+ await fs.mkdir(backupDir, { recursive: true });
544
+
545
+ const backedUpFiles = [];
546
+ let totalSize = 0;
547
+
548
+ for (const file of files) {
549
+ try {
550
+ // Determine backup path with replicated structure
551
+ const relativePath = file.relativePath;
552
+ const backupFilePath = path.join(backupDir, relativePath);
553
+ const backupFileDir = path.dirname(backupFilePath);
554
+
555
+ // Ensure the directory structure exists in backup
556
+ await fs.mkdir(backupFileDir, { recursive: true });
557
+
558
+ // Copy file to backup location
559
+ await fs.copyFile(file.path, backupFilePath);
560
+ backedUpFiles.push({
561
+ original: file.relativePath,
562
+ backup: path.join(timestamp, relativePath),
563
+ });
564
+ totalSize += file.size || 0;
565
+ } catch (error) {
566
+ logger.debug(`Failed to backup ${file.relativePath}: ${error.message}`);
567
+ }
568
+ }
569
+
570
+ logger.info(
571
+ `✓ Backed up ${backedUpFiles.length} files (${(totalSize / 1024).toFixed(1)} KB)`,
572
+ );
573
+ logger.debug(`Backup location: ${backupDir}`);
574
+
575
+ return {
576
+ success: true,
577
+ backupDir,
578
+ timestamp,
579
+ fileCount: backedUpFiles.length,
580
+ totalSize,
581
+ };
582
+ } catch (error) {
583
+ logger.warning(
584
+ `⚠️ Backup creation failed: ${error.message} - continuing without backup`,
585
+ );
586
+ return {
587
+ success: false,
588
+ error: error.message,
589
+ };
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Removes files one by one.
595
+ *
596
+ * @param {Array} files - Files to remove
597
+ * @param {string} targetDir - Installation directory
598
+ * @returns {Promise<Object>} Removal result
599
+ * @private
600
+ */
601
+ async function removeFiles(files, targetDir) {
602
+ let removed = 0;
603
+ let failed = 0;
604
+
605
+ for (const file of files) {
606
+ try {
607
+ await fs.unlink(file.path);
608
+ removed++;
609
+ logger.debug(`Removed: ${file.relativePath}`);
610
+ } catch (error) {
611
+ failed++;
612
+ logger.debug(`Failed to remove ${file.relativePath}: ${error.message}`);
613
+ }
614
+ }
615
+
616
+ return { removed, failed };
617
+ }
618
+
619
+ /**
620
+ * Cleans up empty directories while preserving non-empty ones.
621
+ * get-shit-done directory is always removed (forcefully).
622
+ *
623
+ * @param {Object} categorized - Categorized items with directories
624
+ * @param {string} targetDir - Installation directory
625
+ * @returns {Promise<Object>} Directory cleanup result
626
+ * @private
627
+ */
628
+ async function cleanupDirectories(categorized, targetDir) {
629
+ const removed = [];
630
+ const preserved = [];
631
+
632
+ // Sort directories by depth (deepest first) so we remove children before parents
633
+ const sortedDirs = [...categorized.directories].sort((a, b) => {
634
+ const depthA = a.split("/").length;
635
+ const depthB = b.split("/").length;
636
+ return depthB - depthA;
637
+ });
638
+
639
+ for (const dir of sortedDirs) {
640
+ const fullPath = path.join(targetDir, dir);
641
+
642
+ try {
643
+ // get-shit-done directory is always forcefully removed
644
+ if (dir === "get-shit-done" || dir.startsWith("get-shit-done/")) {
645
+ await fs.rm(fullPath, { recursive: true, force: true });
646
+ removed.push(dir);
647
+ logger.debug(`Forcefully removed get-shit-done directory: ${dir}`);
648
+ continue;
649
+ }
650
+
651
+ // Check if directory exists and is empty
652
+ const entries = await fs.readdir(fullPath);
653
+
654
+ if (entries.length === 0) {
655
+ // Directory is empty, safe to remove
656
+ await fs.rmdir(fullPath);
657
+ removed.push(dir);
658
+ logger.debug(`Removed empty directory: ${dir}`);
659
+ } else {
660
+ // Directory has contents, preserve it
661
+ preserved.push({ dir, entryCount: entries.length });
662
+ logger.dim(
663
+ `📁 Preserved: ${dir} (contains ${entries.length} non-gsd-opencode files)`,
664
+ );
665
+ }
666
+ } catch (error) {
667
+ if (error.code === "ENOENT") {
668
+ // Directory already gone
669
+ removed.push(dir);
670
+ } else {
671
+ logger.debug(`Could not process directory ${dir}: ${error.message}`);
672
+ preserved.push({ dir, error: error.message });
673
+ }
674
+ }
675
+ }
676
+
677
+ return { removed, preserved };
678
+ }
679
+
680
+ /**
681
+ * Displays success message with recovery instructions.
682
+ *
683
+ * @param {Object} removalResult - File removal result
684
+ * @param {Object} dirResult - Directory cleanup result
685
+ * @param {Object} backupResult - Backup creation result
686
+ * @param {string} targetDir - Target directory where files were installed
687
+ * @private
688
+ */
689
+ function displaySuccessMessage(
690
+ removalResult,
691
+ dirResult,
692
+ backupResult,
693
+ targetDir,
694
+ ) {
695
+ logger.dim("");
696
+ logger.success("✓ GSD-OpenCode has been successfully uninstalled");
697
+ logger.dim("");
698
+
699
+ // Summary
700
+ logger.info("Summary:");
701
+ logger.info(` • ${removalResult.removed} files removed`);
702
+ if (removalResult.failed > 0) {
703
+ logger.warning(` • ${removalResult.failed} files could not be removed`);
704
+ }
705
+ logger.info(` • ${dirResult.removed.length} directories removed`);
706
+ logger.info(` • ${dirResult.preserved.length} directories preserved`);
707
+
708
+ logger.dim("");
709
+
710
+ // Backup info
711
+ if (backupResult && backupResult.success) {
712
+ logger.info("📦 Backup Information:");
713
+ logger.info(` • Location: ${backupResult.backupDir}`);
714
+ logger.info(` • Timestamp: ${backupResult.timestamp}`);
715
+ logger.info(
716
+ ` • Files: ${backupResult.fileCount} (${(backupResult.totalSize / 1024).toFixed(1)} KB)`,
717
+ );
718
+ logger.dim("");
719
+ logger.dim("Recovery:");
720
+ logger.dim(` cp -r "${backupResult.backupDir}/." ${targetDir}/`);
721
+ logger.dim("");
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Default export for the uninstall command.
727
+ *
728
+ * @example
729
+ * import uninstallCommand from './commands/uninstall.js';
730
+ * const exitCode = await uninstallCommand({ global: true, force: true });
731
+ */
732
+ export default uninstallCommand;