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.
- package/agents/gsd-debugger.md +5 -5
- package/bin/gsd-install.js +105 -0
- package/bin/gsd.js +352 -0
- package/{command → commands}/gsd/add-phase.md +1 -1
- package/{command → commands}/gsd/audit-milestone.md +1 -1
- package/{command → commands}/gsd/debug.md +3 -3
- package/{command → commands}/gsd/discuss-phase.md +1 -1
- package/{command → commands}/gsd/execute-phase.md +1 -1
- package/{command → commands}/gsd/list-phase-assumptions.md +1 -1
- package/{command → commands}/gsd/map-codebase.md +1 -1
- package/{command → commands}/gsd/new-milestone.md +1 -1
- package/{command → commands}/gsd/new-project.md +3 -3
- package/{command → commands}/gsd/plan-phase.md +2 -2
- package/{command → commands}/gsd/research-phase.md +1 -1
- package/{command → commands}/gsd/verify-work.md +1 -1
- package/get-shit-done/workflows/list-phase-assumptions.md +1 -1
- package/get-shit-done/workflows/verify-work.md +5 -5
- package/lib/constants.js +193 -0
- package/package.json +34 -20
- package/src/commands/check.js +329 -0
- package/src/commands/config.js +337 -0
- package/src/commands/install.js +608 -0
- package/src/commands/list.js +256 -0
- package/src/commands/repair.js +519 -0
- package/src/commands/uninstall.js +732 -0
- package/src/commands/update.js +444 -0
- package/src/services/backup-manager.js +585 -0
- package/src/services/config.js +262 -0
- package/src/services/file-ops.js +830 -0
- package/src/services/health-checker.js +475 -0
- package/src/services/manifest-manager.js +301 -0
- package/src/services/migration-service.js +831 -0
- package/src/services/repair-service.js +846 -0
- package/src/services/scope-manager.js +303 -0
- package/src/services/settings.js +553 -0
- package/src/services/structure-detector.js +240 -0
- package/src/services/update-service.js +863 -0
- package/src/utils/hash.js +71 -0
- package/src/utils/interactive.js +222 -0
- package/src/utils/logger.js +128 -0
- package/src/utils/npm-registry.js +255 -0
- package/src/utils/path-resolver.js +226 -0
- /package/{command → commands}/gsd/add-todo.md +0 -0
- /package/{command → commands}/gsd/check-todos.md +0 -0
- /package/{command → commands}/gsd/complete-milestone.md +0 -0
- /package/{command → commands}/gsd/help.md +0 -0
- /package/{command → commands}/gsd/insert-phase.md +0 -0
- /package/{command → commands}/gsd/pause-work.md +0 -0
- /package/{command → commands}/gsd/plan-milestone-gaps.md +0 -0
- /package/{command → commands}/gsd/progress.md +0 -0
- /package/{command → commands}/gsd/quick.md +0 -0
- /package/{command → commands}/gsd/remove-phase.md +0 -0
- /package/{command → commands}/gsd/resume-work.md +0 -0
- /package/{command → commands}/gsd/set-model.md +0 -0
- /package/{command → commands}/gsd/set-profile.md +0 -0
- /package/{command → commands}/gsd/settings.md +0 -0
- /package/{command → commands}/gsd/update.md +0 -0
- /package/{command → commands}/gsd/whats-new.md +0 -0
|
@@ -0,0 +1,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;
|