gsd-opencode 1.9.2 → 1.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/gsd-debugger.md +5 -5
- package/agents/gsd-settings.md +476 -30
- package/bin/gsd-install.js +105 -0
- package/bin/gsd.js +352 -0
- package/{command → commands}/gsd/add-phase.md +1 -1
- package/{command → commands}/gsd/audit-milestone.md +1 -1
- package/{command → commands}/gsd/debug.md +3 -3
- package/{command → commands}/gsd/discuss-phase.md +1 -1
- package/{command → commands}/gsd/execute-phase.md +1 -1
- package/{command → commands}/gsd/list-phase-assumptions.md +1 -1
- package/{command → commands}/gsd/map-codebase.md +1 -1
- package/{command → commands}/gsd/new-milestone.md +1 -1
- package/{command → commands}/gsd/new-project.md +3 -3
- package/{command → commands}/gsd/plan-phase.md +2 -2
- package/{command → commands}/gsd/research-phase.md +1 -1
- package/{command → commands}/gsd/verify-work.md +1 -1
- package/get-shit-done/workflows/list-phase-assumptions.md +1 -1
- package/get-shit-done/workflows/verify-work.md +5 -5
- package/lib/constants.js +199 -0
- package/package.json +34 -20
- package/src/commands/check.js +329 -0
- package/src/commands/config.js +337 -0
- package/src/commands/install.js +608 -0
- package/src/commands/list.js +256 -0
- package/src/commands/repair.js +519 -0
- package/src/commands/uninstall.js +732 -0
- package/src/commands/update.js +444 -0
- package/src/services/backup-manager.js +585 -0
- package/src/services/config.js +262 -0
- package/src/services/file-ops.js +855 -0
- package/src/services/health-checker.js +475 -0
- package/src/services/manifest-manager.js +301 -0
- package/src/services/migration-service.js +831 -0
- package/src/services/repair-service.js +846 -0
- package/src/services/scope-manager.js +303 -0
- package/src/services/settings.js +553 -0
- package/src/services/structure-detector.js +240 -0
- package/src/services/update-service.js +863 -0
- package/src/utils/hash.js +71 -0
- package/src/utils/interactive.js +222 -0
- package/src/utils/logger.js +128 -0
- package/src/utils/npm-registry.js +255 -0
- package/src/utils/path-resolver.js +226 -0
- /package/{command → commands}/gsd/add-todo.md +0 -0
- /package/{command → commands}/gsd/check-todos.md +0 -0
- /package/{command → commands}/gsd/complete-milestone.md +0 -0
- /package/{command → commands}/gsd/help.md +0 -0
- /package/{command → commands}/gsd/insert-phase.md +0 -0
- /package/{command → commands}/gsd/pause-work.md +0 -0
- /package/{command → commands}/gsd/plan-milestone-gaps.md +0 -0
- /package/{command → commands}/gsd/progress.md +0 -0
- /package/{command → commands}/gsd/quick.md +0 -0
- /package/{command → commands}/gsd/remove-phase.md +0 -0
- /package/{command → commands}/gsd/resume-work.md +0 -0
- /package/{command → commands}/gsd/set-model.md +0 -0
- /package/{command → commands}/gsd/set-profile.md +0 -0
- /package/{command → commands}/gsd/settings.md +0 -0
- /package/{command → commands}/gsd/update.md +0 -0
- /package/{command → commands}/gsd/whats-new.md +0 -0
|
@@ -0,0 +1,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
|
+
};
|