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.
- package/dist/src/cli/mac-mcp-gateway.js +4 -3
- package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
- package/dist/src/cli/starmux.js +1 -1
- package/dist/src/cli/starmux.js.map +1 -1
- package/dist/src/runtime-paths.js +3 -3
- package/dist/src/runtime-paths.js.map +1 -1
- package/dist/vendor/desktop-commander/LICENSE +21 -0
- package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
- package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
- package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
- package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
- package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
- package/dist/vendor/desktop-commander/dist/config.js +19 -0
- package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
- package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
- package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
- package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
- package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
- package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
- package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
- package/dist/vendor/desktop-commander/dist/index.js +88 -0
- package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
- package/dist/vendor/desktop-commander/dist/server.js +1414 -0
- package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
- package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
- package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
- package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
- package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
- package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
- package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
- package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
- package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
- package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
- package/dist/vendor/desktop-commander/dist/types.js +1 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
- package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
- package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
- package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
- package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
- package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
- package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
- package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
- package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
- package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
- package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
- package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
- package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
- package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
- package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
- package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
- package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
- package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
- package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
- package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
- package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
- package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
- package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
- package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
- package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
- package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
- package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
- package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
- package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
- package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
- package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
- package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
- package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
- package/dist/vendor/desktop-commander/dist/version.js +1 -0
- package/dist/vendor/desktop-commander/package.json +15 -0
- 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();
|