gsd-opencode 1.9.2 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/agents/gsd-debugger.md +5 -5
  2. package/bin/gsd-install.js +105 -0
  3. package/bin/gsd.js +352 -0
  4. package/{command → commands}/gsd/add-phase.md +1 -1
  5. package/{command → commands}/gsd/audit-milestone.md +1 -1
  6. package/{command → commands}/gsd/debug.md +3 -3
  7. package/{command → commands}/gsd/discuss-phase.md +1 -1
  8. package/{command → commands}/gsd/execute-phase.md +1 -1
  9. package/{command → commands}/gsd/list-phase-assumptions.md +1 -1
  10. package/{command → commands}/gsd/map-codebase.md +1 -1
  11. package/{command → commands}/gsd/new-milestone.md +1 -1
  12. package/{command → commands}/gsd/new-project.md +3 -3
  13. package/{command → commands}/gsd/plan-phase.md +2 -2
  14. package/{command → commands}/gsd/research-phase.md +1 -1
  15. package/{command → commands}/gsd/verify-work.md +1 -1
  16. package/get-shit-done/workflows/list-phase-assumptions.md +1 -1
  17. package/get-shit-done/workflows/verify-work.md +5 -5
  18. package/lib/constants.js +193 -0
  19. package/package.json +34 -20
  20. package/src/commands/check.js +329 -0
  21. package/src/commands/config.js +337 -0
  22. package/src/commands/install.js +608 -0
  23. package/src/commands/list.js +256 -0
  24. package/src/commands/repair.js +519 -0
  25. package/src/commands/uninstall.js +732 -0
  26. package/src/commands/update.js +444 -0
  27. package/src/services/backup-manager.js +585 -0
  28. package/src/services/config.js +262 -0
  29. package/src/services/file-ops.js +830 -0
  30. package/src/services/health-checker.js +475 -0
  31. package/src/services/manifest-manager.js +301 -0
  32. package/src/services/migration-service.js +831 -0
  33. package/src/services/repair-service.js +846 -0
  34. package/src/services/scope-manager.js +303 -0
  35. package/src/services/settings.js +553 -0
  36. package/src/services/structure-detector.js +240 -0
  37. package/src/services/update-service.js +863 -0
  38. package/src/utils/hash.js +71 -0
  39. package/src/utils/interactive.js +222 -0
  40. package/src/utils/logger.js +128 -0
  41. package/src/utils/npm-registry.js +255 -0
  42. package/src/utils/path-resolver.js +226 -0
  43. /package/{command → commands}/gsd/add-todo.md +0 -0
  44. /package/{command → commands}/gsd/check-todos.md +0 -0
  45. /package/{command → commands}/gsd/complete-milestone.md +0 -0
  46. /package/{command → commands}/gsd/help.md +0 -0
  47. /package/{command → commands}/gsd/insert-phase.md +0 -0
  48. /package/{command → commands}/gsd/pause-work.md +0 -0
  49. /package/{command → commands}/gsd/plan-milestone-gaps.md +0 -0
  50. /package/{command → commands}/gsd/progress.md +0 -0
  51. /package/{command → commands}/gsd/quick.md +0 -0
  52. /package/{command → commands}/gsd/remove-phase.md +0 -0
  53. /package/{command → commands}/gsd/resume-work.md +0 -0
  54. /package/{command → commands}/gsd/set-model.md +0 -0
  55. /package/{command → commands}/gsd/set-profile.md +0 -0
  56. /package/{command → commands}/gsd/settings.md +0 -0
  57. /package/{command → commands}/gsd/update.md +0 -0
  58. /package/{command → commands}/gsd/whats-new.md +0 -0
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Settings manager for persistent user configuration.
3
+ *
4
+ * This module provides user-level configuration management with XDG Base Directory
5
+ * compliance, atomic file writes, dot-notation key access, and default value support.
6
+ * Unlike ConfigManager which tracks installation state, SettingsManager handles
7
+ * user preferences like default installation scope, UI settings, and behavior options.
8
+ *
9
+ * Features:
10
+ * - XDG Base Directory specification compliance (~/.config/gsd-opencode/settings.json)
11
+ * - Atomic writes using temp-then-rename pattern to prevent corruption
12
+ * - Dot-notation key access (e.g., 'ui.colors' → config.ui.colors)
13
+ * - Default values merged with user overrides
14
+ * - In-memory caching for performance
15
+ *
16
+ * @module settings
17
+ */
18
+
19
+ import fs from 'fs/promises';
20
+ import path from 'path';
21
+ import os from 'os';
22
+
23
+ /**
24
+ * Configuration value type.
25
+ *
26
+ * @typedef {string|number|boolean|Object|null} ConfigValue
27
+ */
28
+
29
+ /**
30
+ * Manages user configuration with XDG-compliant storage and atomic writes.
31
+ *
32
+ * This class provides a complete settings management solution with persistent
33
+ * storage, dot-notation key access, and safe atomic file operations. Settings
34
+ * are stored in ~/.config/gsd-opencode/settings.json following the XDG Base
35
+ * Directory specification.
36
+ *
37
+ * @class SettingsManager
38
+ * @example
39
+ * const settings = new SettingsManager();
40
+ *
41
+ * // Get a value (returns default if not set)
42
+ * const colors = await settings.get('ui.colors'); // true
43
+ *
44
+ * // Set a value using dot-notation
45
+ * await settings.set('ui.colors', false);
46
+ *
47
+ * // Reset to default
48
+ * await settings.reset('ui.colors');
49
+ *
50
+ * // List all settings (merged with defaults)
51
+ * const all = await settings.list();
52
+ *
53
+ * // Get config file path
54
+ * console.log(settings.getConfigPath()); // ~/.config/gsd-opencode/settings.json
55
+ */
56
+ export class SettingsManager {
57
+ /**
58
+ * Creates a new SettingsManager instance.
59
+ *
60
+ * Initializes the configuration directory path following the XDG Base Directory
61
+ * specification: uses XDG_CONFIG_HOME environment variable if set, otherwise
62
+ * defaults to ~/.config. Creates the config directory on first write.
63
+ *
64
+ * Default values are applied when a key is not present in user configuration:
65
+ * - 'install.defaultScope': 'global' - Default installation scope
66
+ * - 'ui.colors': true - Enable colored output
67
+ * - 'ui.progressBars': true - Show progress indicators
68
+ * - 'behavior.confirmDestructive': true - Confirm before destructive operations
69
+ * - 'logging.verbose': false - Verbose logging disabled by default
70
+ *
71
+ * @example
72
+ * const settings = new SettingsManager();
73
+ *
74
+ * // With custom config directory (for testing)
75
+ * process.env.XDG_CONFIG_HOME = '/tmp/test-config';
76
+ * const testSettings = new SettingsManager();
77
+ */
78
+ constructor() {
79
+ // Follow XDG Base Directory Specification
80
+ // Priority: XDG_CONFIG_HOME env var > ~/.config
81
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
82
+ const baseDir = xdgConfig || path.join(os.homedir(), '.config');
83
+
84
+ this.configDir = path.join(baseDir, 'opencode', 'gsd-opencode');
85
+ this.configPath = path.join(this.configDir, 'settings.json');
86
+
87
+ // Default configuration values
88
+ // These are used when a key is not present in user configuration
89
+ // Stored as nested object to match user config structure
90
+ this.defaults = {
91
+ install: {
92
+ defaultScope: 'global'
93
+ },
94
+ ui: {
95
+ colors: true,
96
+ progressBars: true
97
+ },
98
+ behavior: {
99
+ confirmDestructive: true
100
+ },
101
+ logging: {
102
+ verbose: false
103
+ }
104
+ };
105
+
106
+ // In-memory cache for performance
107
+ // Cache is invalidated on any write operation
108
+ this._cache = null;
109
+ this._cacheValid = false;
110
+ }
111
+
112
+ /**
113
+ * Gets a configuration value by key.
114
+ *
115
+ * Retrieves the value for the specified key using dot-notation (e.g., 'ui.colors').
116
+ * If the key is not found in user configuration, returns the default value.
117
+ * Returns undefined if the key has no user value and no default.
118
+ *
119
+ * @param {string} key - Configuration key using dot-notation (e.g., 'ui.colors')
120
+ * @returns {Promise<ConfigValue>} Configuration value, default value, or undefined
121
+ *
122
+ * @example
123
+ * const settings = new SettingsManager();
124
+ *
125
+ * // Get with default fallback
126
+ * const colors = await settings.get('ui.colors'); // true (default)
127
+ *
128
+ * await settings.set('ui.colors', false);
129
+ * const updated = await settings.get('ui.colors'); // false (user set)
130
+ *
131
+ * // Get nested value
132
+ * await settings.set('user.name', 'John');
133
+ * const name = await settings.get('user.name'); // 'John'
134
+ */
135
+ async get(key) {
136
+ if (!key || typeof key !== 'string') {
137
+ throw new Error('Key must be a non-empty string');
138
+ }
139
+
140
+ const config = await this._load();
141
+ const value = this._getNested(config, key);
142
+
143
+ // Return user value if set, otherwise return default
144
+ return value !== undefined ? value : this._getNested(this.defaults, key);
145
+ }
146
+
147
+ /**
148
+ * Sets a configuration value by key.
149
+ *
150
+ * Stores the value for the specified key using dot-notation. Creates nested
151
+ * objects as needed. Performs an atomic write to prevent config corruption
152
+ * if the process crashes during the write operation.
153
+ *
154
+ * @param {string} key - Configuration key using dot-notation (e.g., 'ui.colors')
155
+ * @param {ConfigValue} value - Value to store
156
+ * @returns {Promise<void>}
157
+ * @throws {Error} If the config file cannot be written
158
+ *
159
+ * @example
160
+ * const settings = new SettingsManager();
161
+ *
162
+ * // Set simple value
163
+ * await settings.set('ui.colors', false);
164
+ *
165
+ * // Set nested value (creates intermediate objects)
166
+ * await settings.set('user.preferences.theme', 'dark');
167
+ *
168
+ * // Set boolean
169
+ * await settings.set('behavior.confirmDestructive', false);
170
+ *
171
+ * // Set number
172
+ * await settings.set('output.timeout', 30);
173
+ */
174
+ async set(key, value) {
175
+ if (!key || typeof key !== 'string') {
176
+ throw new Error('Key must be a non-empty string');
177
+ }
178
+
179
+ const config = await this._load();
180
+ this._setNested(config, key, value);
181
+ await this._save(config);
182
+
183
+ // Update cache with new config
184
+ this._cache = config;
185
+ this._cacheValid = true;
186
+ }
187
+
188
+ /**
189
+ * Resets configuration to defaults.
190
+ *
191
+ * If a specific key is provided, removes that key from user configuration
192
+ * so the default value will be used on next access. If no key is provided,
193
+ * deletes the entire configuration file, effectively resetting all settings
194
+ * to their defaults.
195
+ *
196
+ * @param {string} [key] - Specific key to reset, or omit to reset all
197
+ * @returns {Promise<void>}
198
+ * @throws {Error} If the config file cannot be modified
199
+ *
200
+ * @example
201
+ * const settings = new SettingsManager();
202
+ *
203
+ * // Reset specific key to default
204
+ * await settings.set('ui.colors', false);
205
+ * await settings.reset('ui.colors');
206
+ * const colors = await settings.get('ui.colors'); // true (default)
207
+ *
208
+ * // Reset all settings to defaults
209
+ * await settings.reset();
210
+ * const all = await settings.list(); // All defaults
211
+ */
212
+ async reset(key) {
213
+ if (key) {
214
+ // Reset specific key
215
+ const config = await this._load();
216
+ this._deleteNested(config, key);
217
+
218
+ // If config is now empty, delete the file entirely
219
+ if (Object.keys(config).length === 0) {
220
+ await this._deleteConfigFile();
221
+ } else {
222
+ await this._save(config);
223
+ }
224
+ } else {
225
+ // Reset all - delete config file
226
+ await this._deleteConfigFile();
227
+ }
228
+
229
+ // Invalidate cache
230
+ this._cacheValid = false;
231
+ this._cache = null;
232
+ }
233
+
234
+ /**
235
+ * Lists all configuration values.
236
+ *
237
+ * Returns all configuration settings merged with their default values.
238
+ * User-set values override defaults. The returned object contains the
239
+ * fully resolved configuration.
240
+ *
241
+ * @returns {Promise<Object>} All configuration values merged with defaults
242
+ *
243
+ * @example
244
+ * const settings = new SettingsManager();
245
+ *
246
+ * const all = await settings.list();
247
+ * // {
248
+ * // install: { defaultScope: 'global' },
249
+ * // ui: { colors: true, progressBars: true },
250
+ * // behavior: { confirmDestructive: true },
251
+ * // logging: { verbose: false }
252
+ * // }
253
+ */
254
+ async list() {
255
+ const config = await this._load();
256
+ return this._deepMerge({}, this._flattenDefaults(), config);
257
+ }
258
+
259
+ /**
260
+ * Gets the raw user configuration without defaults.
261
+ *
262
+ * Returns only the user-set configuration values, without merging defaults.
263
+ * Useful for debugging or when you need to see exactly what the user has
264
+ * configured versus what defaults would apply.
265
+ *
266
+ * @returns {Promise<Object>} User configuration only (no defaults)
267
+ *
268
+ * @example
269
+ * const settings = new SettingsManager();
270
+ *
271
+ * // Before setting any values
272
+ * const raw = await settings.getRaw(); // {}
273
+ *
274
+ * await settings.set('ui.colors', false);
275
+ * const updated = await settings.getRaw(); // { ui: { colors: false } }
276
+ */
277
+ async getRaw() {
278
+ return this._load();
279
+ }
280
+
281
+ /**
282
+ * Gets the absolute path to the configuration file.
283
+ *
284
+ * Returns the full filesystem path where settings are stored. This follows
285
+ * the XDG Base Directory specification and respects the XDG_CONFIG_HOME
286
+ * environment variable.
287
+ *
288
+ * @returns {string} Absolute path to settings.json
289
+ *
290
+ * @example
291
+ * const settings = new SettingsManager();
292
+ *
293
+ * console.log(settings.getConfigPath());
294
+ * // /home/user/.config/gsd-opencode/settings.json
295
+ * // or if XDG_CONFIG_HOME is set:
296
+ * // /custom/path/gsd-opencode/settings.json
297
+ */
298
+ getConfigPath() {
299
+ return this.configPath;
300
+ }
301
+
302
+ /**
303
+ * Loads configuration from disk.
304
+ *
305
+ * Private method that reads and parses the configuration file. Uses an
306
+ * in-memory cache to avoid repeated disk reads. Returns an empty object
307
+ * if the file doesn't exist yet.
308
+ *
309
+ * @returns {Promise<Object>} Parsed configuration object
310
+ * @private
311
+ * @throws {Error} If the config file contains invalid JSON
312
+ */
313
+ async _load() {
314
+ // Return cached config if valid
315
+ if (this._cacheValid && this._cache) {
316
+ return this._cache;
317
+ }
318
+
319
+ try {
320
+ const content = await fs.readFile(this.configPath, 'utf-8');
321
+ const config = JSON.parse(content);
322
+
323
+ // Update cache
324
+ this._cache = config;
325
+ this._cacheValid = true;
326
+
327
+ return config;
328
+ } catch (error) {
329
+ if (error.code === 'ENOENT') {
330
+ // Config file doesn't exist yet - return empty object
331
+ this._cache = {};
332
+ this._cacheValid = true;
333
+ return {};
334
+ }
335
+
336
+ if (error instanceof SyntaxError) {
337
+ throw new Error(
338
+ `Invalid configuration file at ${this.configPath}: ${error.message}. ` +
339
+ 'The file may be corrupted. You can reset it with: gsd-opencode config reset --all'
340
+ );
341
+ }
342
+
343
+ if (error.code === 'EACCES') {
344
+ throw new Error(
345
+ `Permission denied: Cannot read configuration file at ${this.configPath}. ` +
346
+ 'Check file permissions or run with appropriate privileges.'
347
+ );
348
+ }
349
+
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Saves configuration to disk atomically.
356
+ *
357
+ * Private method that writes configuration using the atomic write pattern:
358
+ * 1. Write to a temporary file
359
+ * 2. Rename temp file to final location
360
+ *
361
+ * This ensures that the configuration file is never in a partially written
362
+ * state, even if the process crashes during the write operation.
363
+ *
364
+ * @param {Object} config - Configuration object to save
365
+ * @returns {Promise<void>}
366
+ * @private
367
+ * @throws {Error} If the config file cannot be written
368
+ */
369
+ async _save(config) {
370
+ // Ensure config directory exists
371
+ await fs.mkdir(this.configDir, { recursive: true });
372
+
373
+ // Atomic write: write to temp file, then rename
374
+ // This prevents corruption if process crashes during write
375
+ const tempPath = `${this.configPath}.tmp.${Date.now()}`;
376
+ const content = JSON.stringify(config, null, 2);
377
+
378
+ try {
379
+ await fs.writeFile(tempPath, content, 'utf-8');
380
+ await fs.rename(tempPath, this.configPath);
381
+ } catch (error) {
382
+ // Clean up temp file on error
383
+ try {
384
+ await fs.unlink(tempPath);
385
+ } catch {
386
+ // Ignore cleanup errors
387
+ }
388
+
389
+ if (error.code === 'EACCES') {
390
+ throw new Error(
391
+ `Permission denied: Cannot write configuration to ${this.configDir}. ` +
392
+ 'Check directory permissions or run with appropriate privileges.'
393
+ );
394
+ }
395
+
396
+ if (error.code === 'ENOSPC') {
397
+ throw new Error(
398
+ `No space left on device: Cannot write configuration to ${this.configDir}. ` +
399
+ 'Free up disk space and try again.'
400
+ );
401
+ }
402
+
403
+ throw new Error(`Failed to save configuration: ${error.message}`);
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Deletes the configuration file.
409
+ *
410
+ * Private helper that removes the configuration file entirely.
411
+ * Silently succeeds if the file doesn't exist.
412
+ *
413
+ * @returns {Promise<void>}
414
+ * @private
415
+ */
416
+ async _deleteConfigFile() {
417
+ try {
418
+ await fs.unlink(this.configPath);
419
+ } catch (error) {
420
+ if (error.code !== 'ENOENT') {
421
+ throw error;
422
+ }
423
+ // File doesn't exist - that's fine
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Gets a nested value using dot-notation key.
429
+ *
430
+ * Private helper that traverses an object using a dot-separated key path.
431
+ * Returns undefined if any part of the path doesn't exist.
432
+ *
433
+ * @param {Object} obj - Object to traverse
434
+ * @param {string} key - Dot-notation key (e.g., 'ui.colors')
435
+ * @returns {ConfigValue} Value at the key path, or undefined
436
+ * @private
437
+ */
438
+ _getNested(obj, key) {
439
+ const keys = key.split('.');
440
+ let result = obj;
441
+
442
+ for (const k of keys) {
443
+ if (result === null || result === undefined) {
444
+ return undefined;
445
+ }
446
+ result = result[k];
447
+ }
448
+
449
+ return result;
450
+ }
451
+
452
+ /**
453
+ * Sets a nested value using dot-notation key.
454
+ *
455
+ * Private helper that creates intermediate objects as needed and sets
456
+ * the value at the specified key path. Modifies the object in place.
457
+ *
458
+ * @param {Object} obj - Object to modify
459
+ * @param {string} key - Dot-notation key (e.g., 'ui.colors')
460
+ * @param {ConfigValue} value - Value to set
461
+ * @private
462
+ */
463
+ _setNested(obj, key, value) {
464
+ const keys = key.split('.');
465
+ const last = keys.pop();
466
+ let target = obj;
467
+
468
+ // Create intermediate objects as needed
469
+ for (const k of keys) {
470
+ if (!(k in target) || typeof target[k] !== 'object' || target[k] === null) {
471
+ target[k] = {};
472
+ }
473
+ target = target[k];
474
+ }
475
+
476
+ target[last] = value;
477
+ }
478
+
479
+ /**
480
+ * Deletes a nested value using dot-notation key.
481
+ *
482
+ * Private helper that removes a key from a nested object structure.
483
+ * Silently returns if any part of the path doesn't exist.
484
+ *
485
+ * @param {Object} obj - Object to modify
486
+ * @param {string} key - Dot-notation key (e.g., 'ui.colors')
487
+ * @private
488
+ */
489
+ _deleteNested(obj, key) {
490
+ const keys = key.split('.');
491
+ const last = keys.pop();
492
+ let target = obj;
493
+
494
+ // Traverse to parent of target key
495
+ for (const k of keys) {
496
+ if (!(k in target)) {
497
+ return;
498
+ }
499
+ target = target[k];
500
+ }
501
+
502
+ delete target[last];
503
+ }
504
+
505
+ /**
506
+ * Deep merges multiple objects.
507
+ *
508
+ * Private helper that recursively merges source objects into the target.
509
+ * Later sources overwrite earlier ones. Creates new objects for nested
510
+ * merges to avoid mutating sources.
511
+ *
512
+ * @param {Object} target - Target object to merge into
513
+ * @param {...Object} sources - Source objects to merge from
514
+ * @returns {Object} Merged object (same reference as target)
515
+ * @private
516
+ */
517
+ _deepMerge(target, ...sources) {
518
+ for (const source of sources) {
519
+ for (const key in source) {
520
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
521
+ target[key] = this._deepMerge(target[key] || {}, source[key]);
522
+ } else {
523
+ target[key] = source[key];
524
+ }
525
+ }
526
+ }
527
+ return target;
528
+ }
529
+
530
+ /**
531
+ * Returns a copy of default configuration.
532
+ *
533
+ * Private helper that returns a deep copy of the default configuration
534
+ * for merging with user config.
535
+ *
536
+ * @returns {Object} Deep copy of defaults
537
+ * @private
538
+ */
539
+ _flattenDefaults() {
540
+ return this._deepMerge({}, this.defaults);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Default export for the settings module.
546
+ *
547
+ * @example
548
+ * import { SettingsManager } from './services/settings.js';
549
+ * const settings = new SettingsManager();
550
+ */
551
+ export default {
552
+ SettingsManager
553
+ };