ultra-lean-mcp-proxy 0.3.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/bin/cli.mjs +311 -0
- package/package.json +23 -0
- package/src/compress.mjs +170 -0
- package/src/config.mjs +496 -0
- package/src/delta.mjs +188 -0
- package/src/installer.mjs +1756 -0
- package/src/proxy.mjs +1122 -0
- package/src/result-compression.mjs +332 -0
- package/src/state.mjs +293 -0
- package/src/tools-hash-sync.mjs +52 -0
- package/src/watcher.mjs +530 -0
package/src/watcher.mjs
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultra Lean MCP Proxy - config file watcher.
|
|
3
|
+
*
|
|
4
|
+
* Watches discovered MCP client config files for changes and automatically
|
|
5
|
+
* wraps new unwrapped stdio server entries to route through the proxy.
|
|
6
|
+
*
|
|
7
|
+
* Uses fs.watchFile (polling) for cross-platform reliability.
|
|
8
|
+
* Includes file locking with PID-based stale lock recovery.
|
|
9
|
+
*
|
|
10
|
+
* Zero npm dependencies - uses only Node.js built-ins.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import {
|
|
19
|
+
acquireConfigLock,
|
|
20
|
+
backupConfig,
|
|
21
|
+
getConfigLocations,
|
|
22
|
+
isProcessAlive,
|
|
23
|
+
readConfig,
|
|
24
|
+
releaseConfigLock,
|
|
25
|
+
isWrapped,
|
|
26
|
+
isStdioServer,
|
|
27
|
+
isUrlServer,
|
|
28
|
+
wrapEntry,
|
|
29
|
+
wrapUrlEntry,
|
|
30
|
+
writeConfigAtomic,
|
|
31
|
+
resolveProxyPath,
|
|
32
|
+
isUrlBridgeAvailable,
|
|
33
|
+
parseClaudeMcpListCloudConnectors,
|
|
34
|
+
parseClaudeMcpListNames,
|
|
35
|
+
parseClaudeMcpGetDetails,
|
|
36
|
+
isClaudeCloudScope,
|
|
37
|
+
isClaudeLocalScope,
|
|
38
|
+
isSafePropertyName,
|
|
39
|
+
normalizeClientName,
|
|
40
|
+
} from './installer.mjs';
|
|
41
|
+
|
|
42
|
+
const CONFIG_DIR = path.join(os.homedir(), '.ultra-lean-mcp-proxy');
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Helpers for cloud discovery
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if a command exists on the system PATH (watcher-safe: never throws).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} name - Command name to check
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function commandExistsWatcher(name) {
|
|
55
|
+
const locator = process.platform === 'win32' ? 'where' : 'which';
|
|
56
|
+
try {
|
|
57
|
+
const result = spawnSync(locator, [name], {
|
|
58
|
+
stdio: 'ignore',
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
});
|
|
61
|
+
return result.status === 0;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a claude mcp command and return stdout (watcher-safe: never throws).
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} args - Arguments to pass to `claude mcp`
|
|
71
|
+
* @returns {string|null} - stdout on success, null on failure
|
|
72
|
+
*/
|
|
73
|
+
function cleanEnvForClaude() {
|
|
74
|
+
const blocked = new Set(['CLAUDECODE', 'CLAUDE_CODE']);
|
|
75
|
+
const env = {};
|
|
76
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
77
|
+
if (!blocked.has(key)) {
|
|
78
|
+
env[key] = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return env;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runClaudeMcpCommandWatcher(args) {
|
|
85
|
+
try {
|
|
86
|
+
const result = spawnSync('claude', ['mcp', ...args], {
|
|
87
|
+
encoding: 'utf-8',
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
timeout: 60000,
|
|
90
|
+
env: cleanEnvForClaude(),
|
|
91
|
+
});
|
|
92
|
+
if (result.error || result.status !== 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return String(result.stdout || '');
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Discover cloud connectors from `claude mcp` and merge them into the target config.
|
|
103
|
+
* Watcher-safe: logs warnings but never crashes.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} proxyPath - Absolute path to the proxy binary
|
|
106
|
+
* @param {string} runtime - Runtime identifier
|
|
107
|
+
* @param {string} suffix - Suffix to append to server names
|
|
108
|
+
* @param {boolean} verbose - Verbose logging
|
|
109
|
+
*/
|
|
110
|
+
async function discoverCloudConnectors(proxyPath, runtime, suffix, verbose) {
|
|
111
|
+
try {
|
|
112
|
+
// List all MCP servers
|
|
113
|
+
const listOutput = runClaudeMcpCommandWatcher(['list']);
|
|
114
|
+
if (!listOutput) {
|
|
115
|
+
if (verbose) {
|
|
116
|
+
process.stderr.write('[watcher] cloud-discovery: failed to run `claude mcp list`\n');
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const names = parseClaudeMcpListNames(listOutput);
|
|
122
|
+
const cloudConnectors = parseClaudeMcpListCloudConnectors(listOutput);
|
|
123
|
+
if (names.length === 0 && cloudConnectors.length === 0) {
|
|
124
|
+
if (verbose) {
|
|
125
|
+
process.stderr.write('[watcher] cloud-discovery: no servers found\n');
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// List-then-get flow for standard servers
|
|
131
|
+
const candidates = [];
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const getOutput = runClaudeMcpCommandWatcher(['get', name]);
|
|
134
|
+
if (!getOutput) {
|
|
135
|
+
if (verbose) {
|
|
136
|
+
process.stderr.write(`[watcher] cloud-discovery: failed to get details for "${name}"\n`);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const details = parseClaudeMcpGetDetails(getOutput);
|
|
142
|
+
|
|
143
|
+
// Filter: skip local scope
|
|
144
|
+
if (isClaudeLocalScope(details.scope)) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Filter: skip unknown scope
|
|
149
|
+
if (!isClaudeCloudScope(details.scope)) {
|
|
150
|
+
if (verbose) {
|
|
151
|
+
process.stderr.write(`[watcher] cloud-discovery: "${name}" has unknown scope: ${details.scope || 'empty'}\n`);
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Filter: only URL transports
|
|
157
|
+
const transport = String(details.type || '').toLowerCase();
|
|
158
|
+
if (!['sse', 'http', 'streamable-http'].includes(transport)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Filter: must have URL
|
|
163
|
+
if (!details.url) {
|
|
164
|
+
if (verbose) {
|
|
165
|
+
process.stderr.write(`[watcher] cloud-discovery: "${name}" is missing URL\n`);
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build target name and validate
|
|
171
|
+
const targetName = `${name}${suffix}`;
|
|
172
|
+
if (!isSafePropertyName(targetName)) {
|
|
173
|
+
if (verbose) {
|
|
174
|
+
process.stderr.write(`[watcher] cloud-discovery: target name "${targetName}" is not safe\n`);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build wrapped entry
|
|
180
|
+
const sourceEntry = {
|
|
181
|
+
url: details.url,
|
|
182
|
+
transport,
|
|
183
|
+
};
|
|
184
|
+
if (details.headers && Object.keys(details.headers).length > 0) {
|
|
185
|
+
sourceEntry.headers = details.headers;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const wrappedEntry = wrapUrlEntry(sourceEntry, proxyPath, runtime);
|
|
189
|
+
candidates.push({ targetName, wrappedEntry });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Cloud connector entries parsed directly from list output
|
|
193
|
+
const candidateTargetNames = new Set(candidates.map((c) => c.targetName));
|
|
194
|
+
for (const cc of cloudConnectors) {
|
|
195
|
+
const targetName = `${cc.safeName}${suffix}`;
|
|
196
|
+
if (!isSafePropertyName(targetName)) {
|
|
197
|
+
if (verbose) {
|
|
198
|
+
process.stderr.write(`[watcher] cloud-discovery: target name "${targetName}" is not safe\n`);
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (candidateTargetNames.has(targetName)) {
|
|
203
|
+
if (verbose) {
|
|
204
|
+
process.stderr.write(`[watcher] cloud-discovery: "${cc.displayName}" already collected via get\n`);
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sourceEntry = {
|
|
210
|
+
url: cc.url,
|
|
211
|
+
transport: cc.transport,
|
|
212
|
+
};
|
|
213
|
+
const wrappedEntry = wrapUrlEntry(sourceEntry, proxyPath, runtime);
|
|
214
|
+
candidates.push({ targetName, wrappedEntry });
|
|
215
|
+
candidateTargetNames.add(targetName);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (candidates.length === 0) {
|
|
219
|
+
if (verbose) {
|
|
220
|
+
process.stderr.write('[watcher] cloud-discovery: no cloud URL connectors to wrap\n');
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Find claude-code-user config location
|
|
226
|
+
const locations = await getConfigLocations(true);
|
|
227
|
+
const targetLoc = locations.find((loc) => normalizeClientName(loc.name) === 'claude-code-user') || {
|
|
228
|
+
name: 'claude-code-user',
|
|
229
|
+
path: path.join(os.homedir(), '.claude.json'),
|
|
230
|
+
serverKey: 'mcpServers',
|
|
231
|
+
};
|
|
232
|
+
const configPath = targetLoc.path;
|
|
233
|
+
const serverKey = targetLoc.serverKey || 'mcpServers';
|
|
234
|
+
|
|
235
|
+
// Lock, read, merge, write
|
|
236
|
+
if (!acquireConfigLock(configPath)) {
|
|
237
|
+
if (verbose) {
|
|
238
|
+
process.stderr.write('[watcher] cloud-discovery: could not acquire lock\n');
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
let config = {};
|
|
245
|
+
if (fs.existsSync(configPath)) {
|
|
246
|
+
config = readConfig(configPath);
|
|
247
|
+
if (!config || typeof config !== 'object') {
|
|
248
|
+
config = {};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!config[serverKey] || typeof config[serverKey] !== 'object') {
|
|
253
|
+
config[serverKey] = {};
|
|
254
|
+
}
|
|
255
|
+
const servers = config[serverKey];
|
|
256
|
+
|
|
257
|
+
let changed = false;
|
|
258
|
+
for (const candidate of candidates) {
|
|
259
|
+
const existing = servers[candidate.targetName];
|
|
260
|
+
// JSON equality check
|
|
261
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(candidate.wrappedEntry)) {
|
|
262
|
+
continue; // unchanged
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
servers[candidate.targetName] = candidate.wrappedEntry;
|
|
266
|
+
changed = true;
|
|
267
|
+
process.stderr.write(`[watcher] cloud-discovery: updated "${candidate.targetName}"\n`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (changed) {
|
|
271
|
+
backupConfig(configPath);
|
|
272
|
+
writeConfigAtomic(configPath, config);
|
|
273
|
+
if (verbose) {
|
|
274
|
+
process.stderr.write(`[watcher] cloud-discovery: config saved: ${configPath}\n`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
releaseConfigLock(configPath);
|
|
279
|
+
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
process.stderr.write(`[watcher] cloud-discovery: error: ${err.message}\n`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Config change handler
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Handle a detected change to a config file. Reads the config, wraps any
|
|
291
|
+
* unwrapped stdio servers, and writes back atomically under a file lock.
|
|
292
|
+
*
|
|
293
|
+
* @param {{name: string, path: string, serverKey: string}} loc
|
|
294
|
+
* @param {string} proxyPath
|
|
295
|
+
* @param {string} runtime
|
|
296
|
+
* @param {boolean} wrapUrl
|
|
297
|
+
* @param {boolean} canWrapUrl
|
|
298
|
+
* @param {boolean} verbose
|
|
299
|
+
*/
|
|
300
|
+
function handleConfigChange(loc, proxyPath, runtime, wrapUrl, canWrapUrl, verbose) {
|
|
301
|
+
const configPath = loc.path;
|
|
302
|
+
const serverKey = loc.serverKey || 'mcpServers';
|
|
303
|
+
|
|
304
|
+
if (!fs.existsSync(configPath)) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!acquireConfigLock(configPath)) {
|
|
309
|
+
if (verbose) {
|
|
310
|
+
process.stderr.write(`[watcher] ${loc.name}: could not acquire lock, skipping\n`);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const config = readConfig(configPath);
|
|
317
|
+
if (!config || typeof config !== 'object') return;
|
|
318
|
+
|
|
319
|
+
const servers = config[serverKey];
|
|
320
|
+
if (!servers || typeof servers !== 'object') return;
|
|
321
|
+
|
|
322
|
+
let changed = false;
|
|
323
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
324
|
+
const stdio = isStdioServer(entry);
|
|
325
|
+
const url = isUrlServer(entry);
|
|
326
|
+
if (!stdio && !url) continue;
|
|
327
|
+
if (isWrapped(entry)) continue;
|
|
328
|
+
if (url && !wrapUrl) continue;
|
|
329
|
+
if (url && !canWrapUrl) {
|
|
330
|
+
if (verbose) {
|
|
331
|
+
process.stderr.write(`[watcher] ${loc.name}: bridge unavailable for "${name}" (npx missing)\n`);
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
servers[name] = url
|
|
337
|
+
? wrapUrlEntry(entry, proxyPath, runtime)
|
|
338
|
+
: wrapEntry(entry, proxyPath, runtime);
|
|
339
|
+
changed = true;
|
|
340
|
+
process.stderr.write(`[watcher] ${loc.name}: wrapped "${name}" (${url ? 'url' : 'stdio'})\n`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (changed) {
|
|
344
|
+
backupConfig(configPath);
|
|
345
|
+
writeConfigAtomic(configPath, config);
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
process.stderr.write(`[watcher] ${loc.name}: error processing config: ${err.message}\n`);
|
|
349
|
+
} finally {
|
|
350
|
+
releaseConfigLock(configPath);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Watch loop
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Start watching config files for changes. Performs an initial scan and then
|
|
360
|
+
* polls files at the given interval.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} options
|
|
363
|
+
* @param {number} options.interval Polling interval in seconds (default: 5)
|
|
364
|
+
* @param {string} options.runtime Runtime identifier (default: 'npm')
|
|
365
|
+
* @param {boolean} options.offline Skip remote registry fetch (default: false)
|
|
366
|
+
* @param {boolean} options.wrapUrl Wrap URL entries too (default: true)
|
|
367
|
+
* @param {boolean} options.verbose Verbose logging (default: false)
|
|
368
|
+
* @param {string} options.suffix Suffix for cloud connector names (default: '-ulmp')
|
|
369
|
+
* @param {number} options.cloudInterval Cloud discovery interval in seconds (default: 60)
|
|
370
|
+
*/
|
|
371
|
+
export async function runWatch(options = {}) {
|
|
372
|
+
const { interval = 5, runtime = 'npm', offline = false, wrapUrl = true, verbose = false, suffix = '-ulmp', cloudInterval = 60 } = options;
|
|
373
|
+
|
|
374
|
+
const proxyPath = resolveProxyPath();
|
|
375
|
+
const locations = await getConfigLocations(offline);
|
|
376
|
+
const canWrapUrl = wrapUrl ? isUrlBridgeAvailable() : false;
|
|
377
|
+
if (wrapUrl && !canWrapUrl) {
|
|
378
|
+
process.stderr.write('[watcher] URL wrapping enabled but `npx` is unavailable; URL entries will be skipped.\n');
|
|
379
|
+
}
|
|
380
|
+
let watchCount = 0;
|
|
381
|
+
|
|
382
|
+
// Check for claude CLI
|
|
383
|
+
const claudeAvailable = commandExistsWatcher('claude');
|
|
384
|
+
if (claudeAvailable) {
|
|
385
|
+
process.stderr.write('[watcher] claude CLI found - cloud discovery enabled\n');
|
|
386
|
+
} else {
|
|
387
|
+
if (verbose) {
|
|
388
|
+
process.stderr.write('[watcher] claude CLI not found - cloud discovery disabled\n');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Initial scan
|
|
393
|
+
for (const loc of locations) {
|
|
394
|
+
if (fs.existsSync(loc.path)) {
|
|
395
|
+
handleConfigChange(loc, proxyPath, runtime, wrapUrl, canWrapUrl, verbose);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Initial cloud discovery
|
|
400
|
+
if (claudeAvailable) {
|
|
401
|
+
await discoverCloudConnectors(proxyPath, runtime, suffix, verbose);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Set up polling watchers (including currently-missing paths, so creation is detected)
|
|
405
|
+
for (const loc of locations) {
|
|
406
|
+
fs.watchFile(loc.path, { interval: interval * 1000 }, (curr, prev) => {
|
|
407
|
+
if (curr.mtimeMs === prev.mtimeMs) return;
|
|
408
|
+
if (verbose) {
|
|
409
|
+
process.stderr.write(`[watcher] ${loc.name}: change detected\n`);
|
|
410
|
+
}
|
|
411
|
+
handleConfigChange(loc, proxyPath, runtime, wrapUrl, canWrapUrl, verbose);
|
|
412
|
+
});
|
|
413
|
+
watchCount++;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
process.stderr.write(
|
|
417
|
+
`[ultra-lean-mcp-proxy] Watching ${watchCount} config files (interval: ${interval}s)\n`
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Keep the process alive
|
|
421
|
+
const keepAlive = setInterval(() => {}, 60000);
|
|
422
|
+
|
|
423
|
+
// Cloud discovery interval
|
|
424
|
+
let cloudDiscoveryInterval = null;
|
|
425
|
+
if (claudeAvailable) {
|
|
426
|
+
cloudDiscoveryInterval = setInterval(() => {
|
|
427
|
+
discoverCloudConnectors(proxyPath, runtime, suffix, verbose);
|
|
428
|
+
}, cloudInterval * 1000);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Cleanup on exit
|
|
432
|
+
function cleanup() {
|
|
433
|
+
clearInterval(keepAlive);
|
|
434
|
+
if (cloudDiscoveryInterval) {
|
|
435
|
+
clearInterval(cloudDiscoveryInterval);
|
|
436
|
+
}
|
|
437
|
+
for (const loc of locations) {
|
|
438
|
+
try { fs.unwatchFile(loc.path); } catch { /* ignore */ }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
443
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
444
|
+
|
|
445
|
+
// Return a promise that never resolves (watcher runs until killed)
|
|
446
|
+
return new Promise(() => {});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Daemon management
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Start the watcher as a detached background daemon. Writes its PID to
|
|
455
|
+
* ~/.ultra-lean-mcp-proxy/watch.pid and logs to watch.log.
|
|
456
|
+
*
|
|
457
|
+
* @param {object} options
|
|
458
|
+
* @param {number} options.interval Polling interval in seconds
|
|
459
|
+
* @param {string} options.runtime Runtime identifier
|
|
460
|
+
* @param {boolean} options.offline Skip remote registry
|
|
461
|
+
* @param {boolean} options.wrapUrl Wrap URL entries
|
|
462
|
+
* @param {boolean} options.verbose Verbose logging
|
|
463
|
+
* @param {string} options.suffix Suffix for cloud connector names
|
|
464
|
+
* @param {number} options.cloudInterval Cloud discovery interval in seconds
|
|
465
|
+
*/
|
|
466
|
+
export function startDaemon(options = {}) {
|
|
467
|
+
const { interval = 5, runtime = 'npm', offline = false, wrapUrl = true, verbose = false, suffix = '-ulmp', cloudInterval = 60 } = options;
|
|
468
|
+
|
|
469
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
470
|
+
const pidFile = path.join(CONFIG_DIR, 'watch.pid');
|
|
471
|
+
const logFile = path.join(CONFIG_DIR, 'watch.log');
|
|
472
|
+
|
|
473
|
+
// Check if a daemon is already running
|
|
474
|
+
if (fs.existsSync(pidFile)) {
|
|
475
|
+
try {
|
|
476
|
+
const existingPid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
477
|
+
if (isProcessAlive(existingPid)) {
|
|
478
|
+
process.stderr.write(`[ultra-lean-mcp-proxy] Daemon already running (PID: ${existingPid})\n`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Stale PID file - clean up
|
|
482
|
+
fs.unlinkSync(pidFile);
|
|
483
|
+
} catch { /* ignore */ }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Resolve CLI path from this module's location
|
|
487
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
488
|
+
const cliPath = path.join(thisDir, '..', 'bin', 'cli.mjs');
|
|
489
|
+
|
|
490
|
+
const childArgs = [
|
|
491
|
+
cliPath,
|
|
492
|
+
'watch',
|
|
493
|
+
'--interval', String(interval),
|
|
494
|
+
'--runtime', String(runtime),
|
|
495
|
+
'--suffix', String(suffix),
|
|
496
|
+
'--cloud-interval', String(cloudInterval),
|
|
497
|
+
...(offline ? ['--offline'] : []),
|
|
498
|
+
...(wrapUrl ? [] : ['--no-wrap-url']),
|
|
499
|
+
...(verbose ? ['-v'] : []),
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
503
|
+
const child = spawn(process.execPath, childArgs, {
|
|
504
|
+
detached: true,
|
|
505
|
+
stdio: ['ignore', logFd, logFd],
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
509
|
+
child.unref();
|
|
510
|
+
fs.closeSync(logFd);
|
|
511
|
+
|
|
512
|
+
process.stderr.write(`[ultra-lean-mcp-proxy] Daemon started (PID: ${child.pid})\n`);
|
|
513
|
+
process.stderr.write(`[ultra-lean-mcp-proxy] Log file: ${logFile}\n`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Stop a running watcher daemon by reading its PID file and sending SIGTERM.
|
|
518
|
+
*/
|
|
519
|
+
export function stopDaemon() {
|
|
520
|
+
const pidFile = path.join(CONFIG_DIR, 'watch.pid');
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
524
|
+
process.kill(pid, 'SIGTERM');
|
|
525
|
+
fs.unlinkSync(pidFile);
|
|
526
|
+
process.stderr.write(`[ultra-lean-mcp-proxy] Daemon stopped (PID: ${pid})\n`);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
process.stderr.write(`[ultra-lean-mcp-proxy] No daemon running or could not stop: ${err.message}\n`);
|
|
529
|
+
}
|
|
530
|
+
}
|