starmux 0.12.0 → 0.13.0

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 (93) hide show
  1. package/dist/src/cli/mac-mcp-gateway.js +4 -3
  2. package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
  3. package/dist/src/cli/starmux.js +1 -1
  4. package/dist/src/cli/starmux.js.map +1 -1
  5. package/dist/src/runtime-paths.js +3 -3
  6. package/dist/src/runtime-paths.js.map +1 -1
  7. package/dist/vendor/desktop-commander/LICENSE +21 -0
  8. package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
  9. package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
  10. package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
  11. package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
  12. package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
  13. package/dist/vendor/desktop-commander/dist/config.js +19 -0
  14. package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
  15. package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
  16. package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
  17. package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
  18. package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
  19. package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
  20. package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
  21. package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
  22. package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
  23. package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
  24. package/dist/vendor/desktop-commander/dist/index.js +88 -0
  25. package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
  26. package/dist/vendor/desktop-commander/dist/server.js +1414 -0
  27. package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
  28. package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
  29. package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
  30. package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
  31. package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
  32. package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
  33. package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
  34. package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
  35. package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
  36. package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
  37. package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
  38. package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
  39. package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
  40. package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
  41. package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
  42. package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
  43. package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
  44. package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
  45. package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
  46. package/dist/vendor/desktop-commander/dist/types.js +1 -0
  47. package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
  48. package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
  49. package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
  50. package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
  51. package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
  52. package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
  53. package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  54. package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
  55. package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
  56. package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
  57. package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
  58. package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
  59. package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
  60. package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
  61. package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
  62. package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
  63. package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
  64. package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
  65. package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
  66. package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
  67. package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
  68. package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
  69. package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
  70. package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
  71. package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
  72. package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
  73. package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
  74. package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
  75. package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
  76. package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
  77. package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
  78. package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
  79. package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
  80. package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
  81. package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
  82. package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
  83. package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
  84. package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
  85. package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
  86. package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
  87. package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
  88. package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
  89. package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
  90. package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
  91. package/dist/vendor/desktop-commander/dist/version.js +1 -0
  92. package/dist/vendor/desktop-commander/package.json +15 -0
  93. package/package.json +14 -2
@@ -0,0 +1,634 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { existsSync, watch } from 'fs';
4
+ import { mkdir } from 'fs/promises';
5
+ import lockfile from 'proper-lockfile';
6
+ import { VERSION } from './version.js';
7
+ import { CONFIG_FILE } from './config.js';
8
+ import { getDefaultShell } from './utils/shell.js';
9
+ import { writeFileAtomic } from './utils/atomic-write.js';
10
+ import { backupCorruptConfig, buildRecoveredConfig } from './config-recovery.js';
11
+ // Desktop Commander 0.2.48 and older write config.json in place, so while such
12
+ // a version runs alongside, the file can be empty or partly written for a
13
+ // moment: up to ~260ms measured on Windows (#697). A file that does not parse
14
+ // is read again until it does, for up to this long and at least this many
15
+ // reads (a start that freezes spends the time without reading); then it counts
16
+ // as corrupt. 30 reads 10 ms apart span longer than any empty moment measured.
17
+ const PARTIAL_CONFIG_WAIT_MS = 1000;
18
+ const PARTIAL_CONFIG_MIN_FAILED_READS = 30;
19
+ // A failed background save is tried again after SAVE_RETRY_MS. If it fails again
20
+ // (a read-only file system, a full disk), it is tried every HELD_SAVE_RETRY_MS.
21
+ const SAVE_RETRY_MS = 250;
22
+ const HELD_SAVE_RETRY_MS = 5000;
23
+ // How many times a config write runs again when its lock was lost, or
24
+ // config.json changed, before it committed
25
+ const MAX_CONFIG_WRITE_ATTEMPTS = 3;
26
+ /** Thrown when the lock was lost, or config.json changed, before a config write committed */
27
+ class ConfigChangedError extends Error {
28
+ constructor() {
29
+ super('config.json changed, or its lock was lost, before this write committed');
30
+ }
31
+ }
32
+ export function normalizeTelemetryEnabledValue(value) {
33
+ if (typeof value !== 'string') {
34
+ return value;
35
+ }
36
+ const normalized = value.trim().toLowerCase();
37
+ if (normalized === 'true') {
38
+ return true;
39
+ }
40
+ if (normalized === 'false') {
41
+ return false;
42
+ }
43
+ return value;
44
+ }
45
+ export function isTelemetryDisabledValue(value) {
46
+ return normalizeTelemetryEnabledValue(value) === false;
47
+ }
48
+ /**
49
+ * Parses config.json's text. Editors saving "UTF-8 with BOM" (Notepad,
50
+ * PowerShell 5's Set-Content -Encoding UTF8) put U+FEFF first, which
51
+ * JSON.parse rejects although the config is complete.
52
+ */
53
+ export function parseConfig(text) {
54
+ return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
55
+ }
56
+ /** Marks a config written before the welcome page existed as an existing install, which never gets it */
57
+ function migrateLegacyConfig(config) {
58
+ if (config['welcomeOnboardingEligible'] === undefined) {
59
+ config['welcomeOnboardingEligible'] = false;
60
+ config['pendingWelcomeOnboarding'] = false;
61
+ }
62
+ }
63
+ /**
64
+ * Shows the user a warning, as a log notification (console.warn in the MCP
65
+ * server) and on stderr, which `remote` shows in its terminal.
66
+ */
67
+ function warnUser(message) {
68
+ console.warn(message);
69
+ process.stderr.write(`[WARNING] Desktop Commander: ${message}\n`);
70
+ }
71
+ function messageOf(error) {
72
+ return error instanceof Error ? error.message : String(error);
73
+ }
74
+ /**
75
+ * Singleton config manager for the server
76
+ */
77
+ class ConfigManager {
78
+ constructor() {
79
+ this.config = {};
80
+ this.initialized = false;
81
+ this._isFirstRun = false; // Track if this is the first run (config was just created)
82
+ // Serializes all disk writes so concurrent saves can't corrupt config.json.
83
+ this.writeChain = Promise.resolve();
84
+ // True while a coalesced background write is already queued (see scheduleSave).
85
+ this.saveScheduled = false;
86
+ this.pendingMutations = [];
87
+ this.watcher = null;
88
+ this.reloadTimer = null;
89
+ // Background saves that failed in a row, and the timer that tries them again (see scheduleSave)
90
+ this.failedSaves = 0;
91
+ this.saveRetry = null;
92
+ // Recovery events from before init() finished, sent when it does (see reportRecovery)
93
+ this.heldRecoveryEvents = [];
94
+ // Get user's home directory
95
+ // Define config directory and file paths
96
+ this.configPath = CONFIG_FILE;
97
+ }
98
+ /**
99
+ * Initialize configuration - load from disk or create default.
100
+ * Creation and legacy migration use the same cross-process mutation path as
101
+ * normal writes so two processes starting together cannot clobber each other.
102
+ */
103
+ async init() {
104
+ if (this.initialized)
105
+ return;
106
+ try {
107
+ const configDir = path.dirname(this.configPath);
108
+ if (!existsSync(configDir)) {
109
+ await mkdir(configDir, { recursive: true });
110
+ }
111
+ this.config = await this.loadStartupConfig();
112
+ this.config['version'] = VERSION;
113
+ this.initialized = true;
114
+ this.startConfigWatcher();
115
+ }
116
+ catch (error) {
117
+ console.error('Failed to initialize config:', error);
118
+ this.config = this.getDefaultConfig();
119
+ this.initialized = true;
120
+ this.startConfigWatcher();
121
+ }
122
+ for (const event of this.heldRecoveryEvents.splice(0))
123
+ void this.emitCorruptConfigTelemetry(event);
124
+ }
125
+ /**
126
+ * Reads config.json at start. A missing one is created (a first run), a corrupt
127
+ * one is recovered, and one written before the welcome page existed is marked
128
+ * as an existing install. If it can't be read, recovered or saved, the session
129
+ * starts without saving it.
130
+ */
131
+ async loadStartupConfig() {
132
+ let config;
133
+ try {
134
+ config = await this.readConfigFromDisk();
135
+ }
136
+ catch (error) {
137
+ if (error?.code === 'ENOENT') {
138
+ let created = false;
139
+ config = await this.performConfigMutation((latest, existed) => {
140
+ if (!existed) {
141
+ Object.assign(latest, this.getDefaultConfig());
142
+ created = true;
143
+ }
144
+ });
145
+ this._isFirstRun = created;
146
+ }
147
+ else if (error instanceof SyntaxError) {
148
+ try {
149
+ config = await this.withConfigLock(() => this.recoverCorruptConfig('startup'));
150
+ }
151
+ catch (recoveryError) {
152
+ // Left as it is, the next start recovers it
153
+ const text = await fs.readFile(this.configPath, 'utf8').catch(() => '');
154
+ return this.startWithoutSaving(buildRecoveredConfig(this.getDefaultConfig(), null, text), `config.json could not be parsed, and replacing it failed (${messageOf(recoveryError)}). For this session Desktop Commander uses the settings still readable in it and the defaults for the rest; config.json is left as it is.`);
155
+ }
156
+ }
157
+ else {
158
+ // There, but it can't be read (e.g. no permission): an existing install,
159
+ // so the welcome page stays off, and initialize has nothing to save for it
160
+ return this.startWithoutSaving(buildRecoveredConfig(this.getDefaultConfig(), null, ''), `config.json could not be read (${messageOf(error)}). For this session Desktop Commander uses the default settings; config.json is left as it is.`);
161
+ }
162
+ }
163
+ if (!this._isFirstRun && config['welcomeOnboardingEligible'] === undefined) {
164
+ try {
165
+ config = await this.performConfigMutation(migrateLegacyConfig);
166
+ }
167
+ catch (error) {
168
+ // The settings read stay in effect, not the defaults, which allow every folder
169
+ migrateLegacyConfig(config);
170
+ this.pendingMutations.push(migrateLegacyConfig);
171
+ return this.startWithoutSaving(config, `config.json was read, but saving to it failed (${messageOf(error)}). ` +
172
+ `Desktop Commander uses the settings it read; changes to them can't be saved until config.json is writable.`);
173
+ }
174
+ }
175
+ return config;
176
+ }
177
+ /**
178
+ * Starts the session on `config` and leaves config.json as it is. `warning`
179
+ * tells the user why; saves are tried again every HELD_SAVE_RETRY_MS.
180
+ */
181
+ startWithoutSaving(config, warning) {
182
+ // As after a second failed save, whose log line the warning stands for
183
+ this.failedSaves = 2;
184
+ this.retrySaves(HELD_SAVE_RETRY_MS);
185
+ warnUser(warning);
186
+ return config;
187
+ }
188
+ /**
189
+ * Alias for init() to maintain backward compatibility
190
+ */
191
+ async loadConfig() {
192
+ return this.init();
193
+ }
194
+ /**
195
+ * Create default configuration
196
+ */
197
+ getDefaultConfig() {
198
+ return {
199
+ blockedCommands: [
200
+ // Disk and partition management
201
+ "mkfs", // Create a filesystem on a device
202
+ "format", // Format a storage device (cross-platform)
203
+ "mount", // Mount a filesystem
204
+ "umount", // Unmount a filesystem
205
+ "fdisk", // Manipulate disk partition tables
206
+ "dd", // Convert and copy files, can write directly to disks
207
+ "parted", // Disk partition manipulator
208
+ "diskpart", // Windows disk partitioning utility
209
+ // System administration and user management
210
+ "sudo", // Execute command as superuser
211
+ "su", // Substitute user identity
212
+ "passwd", // Change user password
213
+ "adduser", // Add a user to the system
214
+ "useradd", // Create a new user
215
+ "usermod", // Modify user account
216
+ "groupadd", // Create a new group
217
+ "chsh", // Change login shell
218
+ "visudo", // Edit the sudoers file
219
+ // System control
220
+ "shutdown", // Shutdown the system
221
+ "reboot", // Restart the system
222
+ "halt", // Stop the system
223
+ "poweroff", // Power off the system
224
+ "init", // Change system runlevel
225
+ // Network and security
226
+ "iptables", // Linux firewall administration
227
+ "firewall", // Generic firewall command
228
+ "netsh", // Windows network configuration
229
+ // Windows system commands
230
+ "sfc", // System File Checker
231
+ "bcdedit", // Boot Configuration Data editor
232
+ "reg", // Windows registry editor
233
+ "net", // Network/user/service management
234
+ "sc", // Service Control manager
235
+ "runas", // Execute command as another user
236
+ "cipher", // Encrypt/decrypt files or wipe data
237
+ "takeown" // Take ownership of files
238
+ ],
239
+ defaultShell: getDefaultShell(),
240
+ allowedDirectories: [],
241
+ telemetryEnabled: false, // Default to opt-out approach (telemetry on by default)
242
+ fileWriteLineLimit: 50, // Default line limit for file write operations (changed from 100)
243
+ fileReadLineLimit: 1000, // Default line limit for file read operations (changed from character-based)
244
+ pendingWelcomeOnboarding: false, // New install flag - triggers A/B test for welcome page
245
+ welcomeOnboardingEligible: false // Distinguishes new installs from migrated legacy configs
246
+ };
247
+ }
248
+ async readConfigFromDisk() {
249
+ const deadline = Date.now() + PARTIAL_CONFIG_WAIT_MS;
250
+ for (let failedReads = 1;; failedReads++) {
251
+ try {
252
+ return parseConfig(await fs.readFile(this.configPath, 'utf8'));
253
+ }
254
+ catch (error) {
255
+ if (!(error instanceof SyntaxError))
256
+ throw error;
257
+ if (Date.now() >= deadline && failedReads >= PARTIAL_CONFIG_MIN_FAILED_READS)
258
+ throw error;
259
+ await new Promise((resolve) => setTimeout(resolve, 10));
260
+ }
261
+ }
262
+ }
263
+ /**
264
+ * Recovers a corrupt config.json; the caller holds the config lock. It is backed
265
+ * up once, then rewritten as buildRecoveredConfig(). Returns the config now on disk.
266
+ */
267
+ async recoverCorruptConfig(phase) {
268
+ const bytes = await fs.readFile(this.configPath).catch((error) => {
269
+ if (error?.code === 'ENOENT')
270
+ return Buffer.alloc(0);
271
+ throw error;
272
+ });
273
+ const text = bytes.toString('utf8');
274
+ let parseError;
275
+ try {
276
+ const config = parseConfig(text);
277
+ // Another process recovered it while this one waited for the lock
278
+ this.reportRecovery({ phase, config_bytes: null, backup_created: false, recovered_by_other_process: true });
279
+ return config;
280
+ }
281
+ catch (error) {
282
+ parseError = error;
283
+ }
284
+ const backupName = await backupCorruptConfig(this.configPath, bytes);
285
+ const config = buildRecoveredConfig(this.getDefaultConfig(), this.initialized ? this.config : null, text);
286
+ await this.writeConfigAtomically(config);
287
+ this.config = { ...config, version: VERSION };
288
+ console.error(`config.json could not be parsed (${parseError.message})${backupName ? `; kept as ${backupName}` : ''}; ` +
289
+ 'replaced with the settings still readable in it and the defaults for the rest.');
290
+ this.reportRecovery({ phase, config_bytes: bytes.length, backup_created: backupName !== null, recovered_by_other_process: false });
291
+ return config;
292
+ }
293
+ /** Sends a recovery event. Telemetry reads the config, so events from before init() is done wait for it. */
294
+ reportRecovery(event) {
295
+ if (this.initialized)
296
+ void this.emitCorruptConfigTelemetry(event);
297
+ else
298
+ this.heldRecoveryEvents.push(event);
299
+ }
300
+ async emitCorruptConfigTelemetry(event) {
301
+ try {
302
+ const { capture } = await import('./utils/capture.js');
303
+ await capture('config_parse_error_recovered', event);
304
+ }
305
+ catch {
306
+ // Recovery never depends on telemetry
307
+ }
308
+ }
309
+ async writeConfigAtomically(config, beforeCommit) {
310
+ await writeFileAtomic(this.configPath, JSON.stringify(config, null, 2), { beforeCommit });
311
+ }
312
+ /** config.json's text, or null when there is none */
313
+ async readConfigText() {
314
+ try {
315
+ return await fs.readFile(this.configPath, 'utf8');
316
+ }
317
+ catch (error) {
318
+ if (error?.code === 'ENOENT')
319
+ return null;
320
+ throw error;
321
+ }
322
+ }
323
+ async acquireConfigLock(onLost) {
324
+ return lockfile.lock(this.configPath, {
325
+ realpath: false,
326
+ stale: 30000,
327
+ update: 10000,
328
+ retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 },
329
+ // This process couldn't refresh the lock for 30 s (frozen: machine sleep, a
330
+ // suspended process, a blocked event loop) and another one took it over or
331
+ // removed it. The default throws from a timer, which ends the server; here it
332
+ // is logged, and a write not yet committed runs again under a new lock
333
+ // (performConfigMutation). Its release then fails (logged).
334
+ onCompromised: (error) => {
335
+ console.error(`The config lock was lost while held: ${error.message} (${error.code})`);
336
+ onLost?.();
337
+ },
338
+ });
339
+ }
340
+ /** Runs `fn` holding the config lock; `lockLost()` tells whether the lock was lost meanwhile. */
341
+ async withConfigLock(fn) {
342
+ let lost = false;
343
+ const release = await this.acquireConfigLock(() => { lost = true; });
344
+ try {
345
+ return await fn(() => lost);
346
+ }
347
+ finally {
348
+ try {
349
+ await release();
350
+ }
351
+ catch (error) {
352
+ // The atomic rename is the commit boundary. A release failure after it
353
+ // must not make callers replay a mutation that already persisted.
354
+ console.error('Failed to release config lock:', error);
355
+ }
356
+ }
357
+ }
358
+ /**
359
+ * Read config.json, apply `mutate`, write it back, all under the cross-process
360
+ * lock. If the lock was lost, or config.json changed, before the write
361
+ * committed, nothing was written: it runs again under a new lock, so the
362
+ * change lands on what the other process saved.
363
+ */
364
+ async performConfigMutation(mutate) {
365
+ for (let attempt = 1;; attempt++) {
366
+ try {
367
+ return await this.withConfigLock((lockLost) => this.mutateLockedConfig(mutate, lockLost));
368
+ }
369
+ catch (error) {
370
+ if (!(error instanceof ConfigChangedError) || attempt >= MAX_CONFIG_WRITE_ATTEMPTS)
371
+ throw error;
372
+ }
373
+ }
374
+ }
375
+ async mutateLockedConfig(mutate, lockLost) {
376
+ let latest;
377
+ let existed = true;
378
+ try {
379
+ latest = await this.readConfigFromDisk();
380
+ }
381
+ catch (error) {
382
+ if (error instanceof SyntaxError) {
383
+ latest = await this.recoverCorruptConfig('mutation');
384
+ }
385
+ else if (error?.code === 'ENOENT') {
386
+ // Missing (a first start, or removed while running): start from the
387
+ // defaults, as `{}` would leave blockedCommands empty, blocking nothing
388
+ latest = this.getDefaultConfig();
389
+ existed = false;
390
+ if (this.initialized) {
391
+ // Removed while running: still an existing install, which never gets the welcome page
392
+ latest['welcomeOnboardingEligible'] = false;
393
+ latest['pendingWelcomeOnboarding'] = false;
394
+ }
395
+ }
396
+ else {
397
+ throw error;
398
+ }
399
+ }
400
+ // What config.json holds now. If it holds anything else when the write is
401
+ // about to commit, another process saved meanwhile.
402
+ const readText = await this.readConfigText();
403
+ // Queued changes are older than this one, so they apply first; if this
404
+ // write fails, they are queued again
405
+ const queued = this.pendingMutations.splice(0);
406
+ try {
407
+ for (const apply of queued)
408
+ apply(latest);
409
+ mutate(latest, existed);
410
+ await this.writeConfigAtomically(latest, async () => {
411
+ if (lockLost() || await this.readConfigText() !== readText)
412
+ throw new ConfigChangedError();
413
+ });
414
+ }
415
+ catch (error) {
416
+ this.pendingMutations.unshift(...queued);
417
+ throw error;
418
+ }
419
+ this.config = { ...latest, version: VERSION };
420
+ // Saves work again, so queued changes needn't wait for a retry
421
+ this.failedSaves = 0;
422
+ if (this.saveRetry)
423
+ this.retrySaves(0);
424
+ return latest;
425
+ }
426
+ queueMutation(mutate) {
427
+ this.pendingMutations.push(mutate);
428
+ this.scheduleSave();
429
+ }
430
+ /** Non-blocking, coalesced persistence for high-frequency state updates. */
431
+ scheduleSave() {
432
+ if (this.saveScheduled || this.saveRetry)
433
+ return;
434
+ this.saveScheduled = true;
435
+ const write = this.writeChain.then(async () => {
436
+ this.saveScheduled = false;
437
+ // A write in between may have saved them already
438
+ if (this.pendingMutations.length === 0)
439
+ return;
440
+ try {
441
+ // Every config write applies the queued changes first
442
+ await this.performConfigMutation(() => { });
443
+ }
444
+ catch (error) {
445
+ // The changes stay queued. A brief failure (a lock, a file scanner) is
446
+ // tried again soon, a lasting one (a read-only file system, a full disk)
447
+ // every few seconds, and logged once.
448
+ this.failedSaves++;
449
+ if (this.failedSaves === 1) {
450
+ console.error('Failed to save config (background), will retry:', error);
451
+ this.retrySaves(SAVE_RETRY_MS);
452
+ }
453
+ else {
454
+ if (this.failedSaves === 2)
455
+ console.error("config.json can't be written, so changes are kept and saved once it can:", error);
456
+ this.retrySaves(HELD_SAVE_RETRY_MS);
457
+ }
458
+ }
459
+ });
460
+ this.writeChain = write.catch(() => { });
461
+ }
462
+ /** Saves the queued changes in `ms`; until then scheduleSave() waits for it */
463
+ retrySaves(ms) {
464
+ if (this.saveRetry)
465
+ clearTimeout(this.saveRetry);
466
+ this.saveRetry = setTimeout(() => {
467
+ this.saveRetry = null;
468
+ this.scheduleSave();
469
+ }, ms);
470
+ this.saveRetry.unref?.();
471
+ }
472
+ startConfigWatcher() {
473
+ if (this.watcher)
474
+ return;
475
+ try {
476
+ const dir = path.dirname(this.configPath);
477
+ const filename = path.basename(this.configPath);
478
+ this.watcher = watch(dir, { persistent: false }, (_event, changed) => {
479
+ if (changed && changed.toString() !== filename)
480
+ return;
481
+ if (this.reloadTimer)
482
+ clearTimeout(this.reloadTimer);
483
+ this.reloadTimer = setTimeout(() => void this.reloadConfigFromDisk(), 20);
484
+ });
485
+ this.watcher.on('error', (error) => console.error('Config watcher error:', error));
486
+ }
487
+ catch (error) {
488
+ console.error('Failed to watch config:', error);
489
+ }
490
+ }
491
+ async reloadConfigFromDisk() {
492
+ try {
493
+ let latest;
494
+ try {
495
+ latest = await this.readConfigFromDisk();
496
+ }
497
+ catch (error) {
498
+ if (!(error instanceof SyntaxError))
499
+ throw error;
500
+ latest = await this.withConfigLock(() => this.recoverCorruptConfig('watcher'));
501
+ }
502
+ for (const mutate of this.pendingMutations)
503
+ mutate(latest);
504
+ this.config = { ...latest, version: VERSION };
505
+ }
506
+ catch (error) {
507
+ if (error?.code !== 'ENOENT')
508
+ console.error('Failed to reload config:', error);
509
+ }
510
+ }
511
+ /**
512
+ * Get the entire config
513
+ */
514
+ async getConfig() {
515
+ await this.init();
516
+ return { ...this.config };
517
+ }
518
+ /**
519
+ * Get a specific configuration value
520
+ */
521
+ async getValue(key) {
522
+ await this.init();
523
+ return this.config[key];
524
+ }
525
+ /**
526
+ * Set a specific configuration value and wait until it is saved: the data is
527
+ * flushed to disk before the rename; the folder flush after it is best effort.
528
+ * With holdIfNotSaved, a value whose save fails is still in effect, and is
529
+ * queued to be saved with later writes (the call still rejects).
530
+ */
531
+ async setValue(key, value, options = {}) {
532
+ await this.init();
533
+ if (key === 'telemetryEnabled')
534
+ value = normalizeTelemetryEnabledValue(value);
535
+ if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) {
536
+ const currentValue = this.config[key];
537
+ if (!isTelemetryDisabledValue(currentValue)) {
538
+ const { capture } = await import('./utils/capture.js');
539
+ await capture('server_telemetry_opt_out', { reason: 'user_disabled', prev_value: currentValue });
540
+ }
541
+ }
542
+ const nextValue = value;
543
+ const write = this.writeChain.then(() => this.performConfigMutation((latest) => {
544
+ latest[key] = nextValue;
545
+ }).catch((error) => {
546
+ // Queued before the next write in the chain starts, so a later value of the
547
+ // same key is applied after it and wins
548
+ if (options.holdIfNotSaved) {
549
+ this.config[key] = nextValue;
550
+ this.queueMutation((latest) => { latest[key] = nextValue; });
551
+ }
552
+ throw error;
553
+ }));
554
+ this.writeChain = write.then(() => { }, () => { });
555
+ await write;
556
+ }
557
+ /**
558
+ * Update one value under the cross-process lock and return it once saved: the
559
+ * data is flushed to disk before the rename; the folder flush after it is best effort.
560
+ */
561
+ async updateValue(key, updater) {
562
+ await this.init();
563
+ let updatedValue;
564
+ const write = this.writeChain.then(() => this.performConfigMutation((latest) => {
565
+ updatedValue = updater(latest[key]);
566
+ latest[key] = updatedValue;
567
+ }));
568
+ this.writeChain = write.then(() => { }, () => { });
569
+ await write;
570
+ return updatedValue;
571
+ }
572
+ /**
573
+ * Set a value without waiting on disk. The queued operation is applied to the
574
+ * latest on-disk config while holding the cross-process lock.
575
+ */
576
+ async setValueNonBlocking(key, value) {
577
+ await this.init();
578
+ this.config[key] = value;
579
+ this.queueMutation((latest) => { latest[key] = value; });
580
+ }
581
+ /**
582
+ * Atomically update one value without blocking the caller on persistence.
583
+ * The updater is replayed against the latest disk value under the lock, which
584
+ * makes counter-style updates safe across multiple Desktop Commander processes.
585
+ */
586
+ async updateValueNonBlocking(key, updater) {
587
+ await this.init();
588
+ const next = updater(this.config[key]);
589
+ this.config[key] = next;
590
+ this.queueMutation((latest) => { latest[key] = updater(latest[key]); });
591
+ return next;
592
+ }
593
+ /** Update multiple configuration values at once. */
594
+ async updateConfig(updates) {
595
+ await this.init();
596
+ const write = this.writeChain.then(() => this.performConfigMutation((latest) => {
597
+ Object.assign(latest, updates);
598
+ }));
599
+ this.writeChain = write.then(() => { }, () => { });
600
+ return { ...(await write) };
601
+ }
602
+ /** Reset configuration to defaults. This intentionally replaces all keys. */
603
+ async resetConfig() {
604
+ await this.init();
605
+ const defaults = this.getDefaultConfig();
606
+ const write = this.writeChain.then(() => this.performConfigMutation((latest) => {
607
+ for (const key of Object.keys(latest))
608
+ delete latest[key];
609
+ Object.assign(latest, defaults);
610
+ }));
611
+ this.writeChain = write.then(() => { }, () => { });
612
+ return { ...(await write) };
613
+ }
614
+ /**
615
+ * Check if this is the first run (config file was just created)
616
+ */
617
+ isFirstRun() {
618
+ return this._isFirstRun;
619
+ }
620
+ /**
621
+ * Get or create a persistent client ID for analytics and A/B tests
622
+ */
623
+ async getOrCreateClientId() {
624
+ const { randomUUID } = await import('crypto');
625
+ if (this.failedSaves > 0) {
626
+ // Saves fail until a write succeeds, so keep one for the session, saved with the queued changes
627
+ const clientId = this.config.clientId || randomUUID();
628
+ return await this.updateValueNonBlocking('clientId', (current) => current || clientId);
629
+ }
630
+ return await this.updateValue('clientId', (current) => current || randomUUID());
631
+ }
632
+ }
633
+ // Export singleton instance
634
+ export const configManager = new ConfigManager();