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