@mp3wizard/figma-console-mcp 1.32.3 → 1.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -17
- package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
- package/dist/cloudflare/core/design-system-manifest.js +19 -14
- package/dist/cloudflare/core/design-system-tools.js +43 -34
- package/dist/cloudflare/core/diagnose-tool.js +4 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
- package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
- package/dist/cloudflare/core/figma-api.js +118 -54
- package/dist/cloudflare/core/figma-tools.js +179 -63
- package/dist/cloudflare/core/port-discovery.js +404 -31
- package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
- package/dist/cloudflare/core/tokens/config.js +10 -0
- package/dist/cloudflare/core/tokens/dialect.js +232 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
- package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
- package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/cloudflare/core/tokens/index.js +2 -1
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
- package/dist/cloudflare/core/tokens/schemas.js +4 -0
- package/dist/cloudflare/core/tokens-tools.js +1017 -88
- package/dist/cloudflare/core/version-tools.js +44 -3
- package/dist/cloudflare/core/websocket-connector.js +42 -0
- package/dist/cloudflare/core/websocket-server.js +99 -8
- package/dist/cloudflare/core/write-tools.js +355 -86
- package/dist/cloudflare/index.js +7 -7
- package/dist/core/design-system-manifest.d.ts +1 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -1
- package/dist/core/design-system-manifest.js +19 -14
- package/dist/core/design-system-manifest.js.map +1 -1
- package/dist/core/design-system-tools.d.ts.map +1 -1
- package/dist/core/design-system-tools.js +43 -34
- package/dist/core/design-system-tools.js.map +1 -1
- package/dist/core/diagnose-tool.d.ts +8 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -1
- package/dist/core/diagnose-tool.js +4 -0
- package/dist/core/diagnose-tool.js.map +1 -1
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
- package/dist/core/enrichment/enrichment-service.js +11 -5
- package/dist/core/enrichment/enrichment-service.js.map +1 -1
- package/dist/core/enrichment/style-resolver.d.ts +7 -2
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
- package/dist/core/enrichment/style-resolver.js +38 -18
- package/dist/core/enrichment/style-resolver.js.map +1 -1
- package/dist/core/figma-api.d.ts +18 -9
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +118 -54
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/figma-connector.d.ts +12 -0
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +179 -63
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/port-discovery.d.ts +40 -0
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +404 -31
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/tokens/alias-resolver.d.ts +45 -3
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
- package/dist/core/tokens/alias-resolver.js +75 -5
- package/dist/core/tokens/alias-resolver.js.map +1 -1
- package/dist/core/tokens/config.d.ts +28 -0
- package/dist/core/tokens/config.d.ts.map +1 -1
- package/dist/core/tokens/config.js +10 -0
- package/dist/core/tokens/config.js.map +1 -1
- package/dist/core/tokens/dialect.d.ts +107 -0
- package/dist/core/tokens/dialect.d.ts.map +1 -0
- package/dist/core/tokens/dialect.js +233 -0
- package/dist/core/tokens/dialect.js.map +1 -0
- package/dist/core/tokens/figma-converter.d.ts +23 -2
- package/dist/core/tokens/figma-converter.d.ts.map +1 -1
- package/dist/core/tokens/figma-converter.js +144 -16
- package/dist/core/tokens/figma-converter.js.map +1 -1
- package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
- package/dist/core/tokens/formatters/css-vars.js +21 -12
- package/dist/core/tokens/formatters/css-vars.js.map +1 -1
- package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
- package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
- package/dist/core/tokens/formatters/dtcg.js +106 -30
- package/dist/core/tokens/formatters/dtcg.js.map +1 -1
- package/dist/core/tokens/formatters/json.d.ts.map +1 -1
- package/dist/core/tokens/formatters/json.js +28 -10
- package/dist/core/tokens/formatters/json.js.map +1 -1
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
- package/dist/core/tokens/formatters/scss.js +19 -13
- package/dist/core/tokens/formatters/scss.js.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
- package/dist/core/tokens/index.d.ts +2 -1
- package/dist/core/tokens/index.d.ts.map +1 -1
- package/dist/core/tokens/index.js +2 -1
- package/dist/core/tokens/index.js.map +1 -1
- package/dist/core/tokens/parsers/dtcg.js +32 -5
- package/dist/core/tokens/parsers/dtcg.js.map +1 -1
- package/dist/core/tokens/schemas.d.ts +3 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -1
- package/dist/core/tokens/schemas.js +4 -0
- package/dist/core/tokens/schemas.js.map +1 -1
- package/dist/core/tokens/types.d.ts +57 -1
- package/dist/core/tokens/types.d.ts.map +1 -1
- package/dist/core/tokens/types.js.map +1 -1
- package/dist/core/tokens-tools.d.ts +250 -7
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +1017 -88
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/core/version-tools.d.ts.map +1 -1
- package/dist/core/version-tools.js +44 -3
- package/dist/core/version-tools.js.map +1 -1
- package/dist/core/websocket-connector.d.ts +38 -0
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-connector.js +42 -0
- package/dist/core/websocket-connector.js.map +1 -1
- package/dist/core/websocket-server.d.ts +23 -0
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +99 -8
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +355 -86
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts +0 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +253 -63
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +382 -28
- package/figma-desktop-bridge/ui.html +578 -292
- package/package.json +2 -2
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* 3. Age ceiling — startedAt older than 4 hours with no heartbeat (pre-v1.12 compat)
|
|
19
19
|
* Zombie processes are terminated with SIGTERM to free their ports.
|
|
20
20
|
*
|
|
21
|
+
* Cleanup exists in two flavors: synchronous (startup path, before the stdio
|
|
22
|
+
* transport serves requests — blocking is acceptable there) and async
|
|
23
|
+
* (periodic reaper — must never block the event loop, or in-flight MCP tool
|
|
24
|
+
* calls freeze while lsof/ps/curl run).
|
|
25
|
+
*
|
|
21
26
|
* Data flow:
|
|
22
27
|
* Server binds port → writes /tmp/figma-console-mcp-{port}.json
|
|
23
28
|
* Server heartbeat → refreshes lastSeen every 30s
|
|
@@ -26,8 +31,11 @@
|
|
|
26
31
|
*/
|
|
27
32
|
import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
|
|
28
33
|
import { join } from 'path';
|
|
29
|
-
import { tmpdir } from 'os';
|
|
34
|
+
import { tmpdir, devNull } from 'os';
|
|
35
|
+
import { execFile, execFileSync } from 'child_process';
|
|
36
|
+
import { promisify } from 'util';
|
|
30
37
|
import { createChildLogger } from './logger.js';
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
31
39
|
const logger = createChildLogger({ component: 'port-discovery' });
|
|
32
40
|
/** Default preferred WebSocket port */
|
|
33
41
|
export const DEFAULT_WS_PORT = 9223;
|
|
@@ -170,10 +178,102 @@ export function isStaleInstance(data) {
|
|
|
170
178
|
const startedAge = now - new Date(data.startedAt).getTime();
|
|
171
179
|
return startedAge > MAX_PORT_FILE_AGE_MS;
|
|
172
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Extra staleness margin required before a live-but-stale instance becomes
|
|
183
|
+
* kill-eligible: it must have missed at least 2 further heartbeats PAST the
|
|
184
|
+
* stale threshold. After a laptop sleeps, every instance's lastSeen is old
|
|
185
|
+
* simultaneously — this margin gives freshly-woken siblings time to refresh
|
|
186
|
+
* their advertisement files before any reaper considers killing them.
|
|
187
|
+
*/
|
|
188
|
+
export const KILL_ELIGIBLE_EXTRA_STALE_MS = 2 * HEARTBEAT_INTERVAL_MS;
|
|
189
|
+
/**
|
|
190
|
+
* Whether a stale instance has been stale long enough to be kill-eligible.
|
|
191
|
+
* Pre-v1.12 files (no lastSeen) already use the conservative 4-hour age
|
|
192
|
+
* ceiling, so plain staleness is sufficient there.
|
|
193
|
+
*/
|
|
194
|
+
function isKillEligible(data) {
|
|
195
|
+
if (!data.lastSeen)
|
|
196
|
+
return isStaleInstance(data);
|
|
197
|
+
const lastSeenAge = Date.now() - new Date(data.lastSeen).getTime();
|
|
198
|
+
return lastSeenAge > HEARTBEAT_STALE_MS + KILL_ELIGIBLE_EXTRA_STALE_MS;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Ports reaching the probes come from JSON port files in a world-writable
|
|
202
|
+
* temp dir, so treat them as untrusted input. A garbage port means a garbage
|
|
203
|
+
* file — verdict is inconclusive (do NOT kill), same as any other ambiguity.
|
|
204
|
+
*/
|
|
205
|
+
function isValidProbePort(port) {
|
|
206
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
207
|
+
}
|
|
208
|
+
/** curl arguments for the /health liveness probe against a sibling's port.
|
|
209
|
+
* `devNull` (not a literal /dev/null) — Windows ships curl.exe, and a bad
|
|
210
|
+
* output path there makes curl exit non-zero, which reads as "nothing
|
|
211
|
+
* responding" and would let the reaper kill a healthy sibling. */
|
|
212
|
+
function healthProbeArgs(port) {
|
|
213
|
+
return ['-s', '-o', devNull, '-m', '1', `http://127.0.0.1:${port}/health`];
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Liveness probe against a sibling server's HTTP `/health` endpoint (the
|
|
217
|
+
* WebSocket server serves it on the same port). Synchronous via curl —
|
|
218
|
+
* startup-path only; the periodic reaper uses probeServerHealthAsync.
|
|
219
|
+
* execFileSync (no shell) so the untrusted port can never be interpreted
|
|
220
|
+
* as shell syntax.
|
|
221
|
+
*
|
|
222
|
+
* @returns true — server responded (alive, do NOT kill)
|
|
223
|
+
* false — probe ran and failed (nothing responding on the port)
|
|
224
|
+
* null — inconclusive (curl missing / probe ambiguous — do NOT kill)
|
|
225
|
+
*/
|
|
226
|
+
function probeServerHealth(port) {
|
|
227
|
+
if (!isValidProbePort(port))
|
|
228
|
+
return null;
|
|
229
|
+
try {
|
|
230
|
+
execFileSync('curl', healthProbeArgs(port), {
|
|
231
|
+
timeout: 3000,
|
|
232
|
+
stdio: 'ignore',
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// curl binary not found (no shell, so ENOENT instead of exit 127/9009) — inconclusive
|
|
238
|
+
if (error?.code === 'ENOENT')
|
|
239
|
+
return null;
|
|
240
|
+
// execFileSync-level timeout (killed by signal, no exit status) — inconclusive
|
|
241
|
+
if (error?.signal)
|
|
242
|
+
return null;
|
|
243
|
+
// curl ran and exited non-zero (connection refused, curl -m timeout, …)
|
|
244
|
+
if (typeof error?.status === 'number')
|
|
245
|
+
return false;
|
|
246
|
+
return null; // unknown failure — err on the side of NOT killing
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Async variant of probeServerHealth for the periodic reaper — identical
|
|
251
|
+
* verdict semantics, but never blocks the event loop (execFile, no shell).
|
|
252
|
+
*/
|
|
253
|
+
async function probeServerHealthAsync(port) {
|
|
254
|
+
if (!isValidProbePort(port))
|
|
255
|
+
return null;
|
|
256
|
+
try {
|
|
257
|
+
await execFileAsync('curl', healthProbeArgs(port), { timeout: 3000 });
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
// curl binary not found (no shell, so ENOENT instead of exit 127) — inconclusive
|
|
262
|
+
if (error?.code === 'ENOENT')
|
|
263
|
+
return null;
|
|
264
|
+
// execFile-level timeout (process killed by signal) — inconclusive
|
|
265
|
+
if (error?.killed || error?.signal)
|
|
266
|
+
return null;
|
|
267
|
+
// curl ran and exited non-zero (connection refused, curl -m timeout, …)
|
|
268
|
+
if (typeof error?.code === 'number')
|
|
269
|
+
return false;
|
|
270
|
+
return null; // unknown failure — err on the side of NOT killing
|
|
271
|
+
}
|
|
272
|
+
}
|
|
173
273
|
/**
|
|
174
274
|
* Block the current thread for `ms` milliseconds (synchronous).
|
|
175
275
|
* Used between SIGTERM and SIGKILL so terminateProcess can stay synchronous
|
|
176
|
-
* (its callers — cleanup functions — are synchronous
|
|
276
|
+
* (its callers — the startup-path cleanup functions — are synchronous).
|
|
177
277
|
*/
|
|
178
278
|
function sleepSyncMs(ms) {
|
|
179
279
|
if (ms <= 0)
|
|
@@ -187,6 +287,35 @@ function sleepSyncMs(ms) {
|
|
|
187
287
|
while (Date.now() < end) { /* best-effort spin fallback */ }
|
|
188
288
|
}
|
|
189
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Non-blocking sleep for the async reaper path. The timer is unref'd so an
|
|
292
|
+
* in-flight reaper wait never keeps the process alive on its own.
|
|
293
|
+
*/
|
|
294
|
+
function sleepMs(ms) {
|
|
295
|
+
if (ms <= 0)
|
|
296
|
+
return Promise.resolve();
|
|
297
|
+
return new Promise((resolve) => {
|
|
298
|
+
const timer = setTimeout(resolve, ms);
|
|
299
|
+
if (typeof timer.unref === 'function')
|
|
300
|
+
timer.unref();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Parse `ps -o etime=` output into milliseconds.
|
|
305
|
+
* `etime` (formatted elapsed time) is portable across macOS and Linux;
|
|
306
|
+
* `etimes` (seconds) is Linux-only — macOS ps rejects it. Format is
|
|
307
|
+
* [[DD-]HH:]MM:SS, e.g. "05:51", "55:27", "01:13:31", "09-14:15:41".
|
|
308
|
+
*/
|
|
309
|
+
function parseEtimeMs(out) {
|
|
310
|
+
const m = out.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
311
|
+
if (!m)
|
|
312
|
+
return null;
|
|
313
|
+
const days = parseInt(m[1] || '0', 10);
|
|
314
|
+
const hours = parseInt(m[2] || '0', 10);
|
|
315
|
+
const mins = parseInt(m[3], 10);
|
|
316
|
+
const secs = parseInt(m[4], 10);
|
|
317
|
+
return ((((days * 24 + hours) * 60 + mins) * 60) + secs) * 1000;
|
|
318
|
+
}
|
|
190
319
|
/**
|
|
191
320
|
* Elapsed time since a process started, in milliseconds. Returns null if it
|
|
192
321
|
* cannot be determined (process gone, or `ps` unavailable/unparseable).
|
|
@@ -196,21 +325,25 @@ function getProcessAgeMs(pid) {
|
|
|
196
325
|
return null;
|
|
197
326
|
try {
|
|
198
327
|
const { execSync } = require('child_process');
|
|
199
|
-
// `etime` (formatted elapsed time) is portable across macOS and Linux;
|
|
200
|
-
// `etimes` (seconds) is Linux-only — macOS ps rejects it. Format is
|
|
201
|
-
// [[DD-]HH:]MM:SS, e.g. "05:51", "55:27", "01:13:31", "09-14:15:41".
|
|
202
328
|
const out = execSync(`ps -p ${pid} -o etime= 2>/dev/null`, {
|
|
203
329
|
encoding: 'utf-8',
|
|
204
330
|
timeout: 2000,
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
331
|
+
});
|
|
332
|
+
return parseEtimeMs(out);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Async variant of getProcessAgeMs for the periodic reaper. */
|
|
339
|
+
async function getProcessAgeMsAsync(pid) {
|
|
340
|
+
if (process.platform === 'win32')
|
|
341
|
+
return null;
|
|
342
|
+
try {
|
|
343
|
+
const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'etime='], {
|
|
344
|
+
timeout: 2000,
|
|
345
|
+
});
|
|
346
|
+
return parseEtimeMs(stdout);
|
|
214
347
|
}
|
|
215
348
|
catch {
|
|
216
349
|
return null;
|
|
@@ -251,6 +384,37 @@ function terminateProcess(pid, graceMs = TERMINATE_GRACE_MS) {
|
|
|
251
384
|
sleepSyncMs(Math.min(graceMs, 200));
|
|
252
385
|
return !isProcessAlive(pid);
|
|
253
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Async variant of terminateProcess for the periodic reaper — identical
|
|
389
|
+
* SIGTERM→SIGKILL escalation, but the grace waits yield the event loop
|
|
390
|
+
* instead of blocking it.
|
|
391
|
+
*
|
|
392
|
+
* @returns true if the process is confirmed gone afterwards, false if it survived.
|
|
393
|
+
*/
|
|
394
|
+
async function terminateProcessAsync(pid, graceMs = TERMINATE_GRACE_MS) {
|
|
395
|
+
// SIGTERM first — give the process a chance to shut down gracefully.
|
|
396
|
+
try {
|
|
397
|
+
process.kill(pid, 'SIGTERM');
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return true; // already gone — nothing to terminate
|
|
401
|
+
}
|
|
402
|
+
// Windows: SIGTERM maps to TerminateProcess (immediate, uncatchable).
|
|
403
|
+
if (process.platform === 'win32')
|
|
404
|
+
return !isProcessAlive(pid);
|
|
405
|
+
// POSIX: let the graceful handler run, then force-kill if it ignored SIGTERM.
|
|
406
|
+
await sleepMs(graceMs);
|
|
407
|
+
if (!isProcessAlive(pid))
|
|
408
|
+
return true;
|
|
409
|
+
try {
|
|
410
|
+
process.kill(pid, 'SIGKILL');
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return true; // exited between the check and the kill
|
|
414
|
+
}
|
|
415
|
+
await sleepMs(Math.min(graceMs, 200));
|
|
416
|
+
return !isProcessAlive(pid);
|
|
417
|
+
}
|
|
254
418
|
/**
|
|
255
419
|
* Read and validate a port advertisement file.
|
|
256
420
|
* Returns null if the file doesn't exist, is invalid, or the owning process is dead.
|
|
@@ -317,14 +481,96 @@ export function cleanupStalePortFiles() {
|
|
|
317
481
|
logger.debug({ port: data.port, pid: data.pid }, 'Cleaned up stale port file (dead process)');
|
|
318
482
|
}
|
|
319
483
|
else if (data.pid !== process.pid && isStaleInstance(data)) {
|
|
320
|
-
// Live PID but stale
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
484
|
+
// Live PID but stale heartbeat. Staleness alone is NOT proof of
|
|
485
|
+
// death — after the machine sleeps longer than HEARTBEAT_STALE_MS,
|
|
486
|
+
// ALL instances look stale at once and whichever reaper ticks first
|
|
487
|
+
// would kill healthy siblings. Verify actual deadness before
|
|
488
|
+
// terminating: extra staleness margin + failed liveness probe.
|
|
489
|
+
if (!isKillEligible(data)) {
|
|
490
|
+
logger.debug({ port: data.port, pid: data.pid, lastSeen: data.lastSeen }, 'Stale instance within kill-eligibility margin — skipping');
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const health = probeServerHealth(data.port);
|
|
494
|
+
if (health !== false) {
|
|
495
|
+
logger.debug({ port: data.port, pid: data.pid, probe: health === true ? 'responding' : 'inconclusive' }, 'Stale-looking instance not confirmed dead by liveness probe — skipping kill');
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
logger.info({ port: data.port, pid: data.pid, startedAt: data.startedAt, lastSeen: data.lastSeen }, 'Detected zombie MCP process (stale heartbeat, health probe failed) — sending SIGTERM to free port');
|
|
499
|
+
terminateProcess(data.pid);
|
|
500
|
+
try {
|
|
501
|
+
unlinkSync(filePath);
|
|
502
|
+
}
|
|
503
|
+
catch { /* best-effort */ }
|
|
504
|
+
cleaned++;
|
|
505
|
+
}
|
|
325
506
|
}
|
|
326
|
-
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Corrupt file — remove it
|
|
511
|
+
try {
|
|
512
|
+
unlinkSync(filePath);
|
|
513
|
+
cleaned++;
|
|
514
|
+
}
|
|
515
|
+
catch { /* ignore */ }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// Can't read /tmp — unusual but not fatal
|
|
522
|
+
}
|
|
523
|
+
return cleaned;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Async variant of cleanupStalePortFiles for the periodic reaper.
|
|
527
|
+
*
|
|
528
|
+
* Same detection layers and kill-safety gates as the sync version (dead PID →
|
|
529
|
+
* delete file; live-but-stale → kill-eligibility margin + failed liveness
|
|
530
|
+
* probe before terminating; corrupt → delete file), but the probe and the
|
|
531
|
+
* SIGTERM→SIGKILL grace waits are non-blocking so a slow pass never stalls
|
|
532
|
+
* the stdio MCP transport.
|
|
533
|
+
*/
|
|
534
|
+
export async function cleanupStalePortFilesAsync() {
|
|
535
|
+
let cleaned = 0;
|
|
536
|
+
try {
|
|
537
|
+
const files = readdirSync(PORT_FILE_DIR);
|
|
538
|
+
for (const file of files) {
|
|
539
|
+
if (file.startsWith(PORT_FILE_PREFIX) && file.endsWith('.json')) {
|
|
540
|
+
const filePath = join(PORT_FILE_DIR, file);
|
|
541
|
+
try {
|
|
542
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
543
|
+
const data = JSON.parse(raw);
|
|
544
|
+
if (!isProcessAlive(data.pid)) {
|
|
545
|
+
// Dead PID — just clean up the file
|
|
546
|
+
unlinkSync(filePath);
|
|
327
547
|
cleaned++;
|
|
548
|
+
logger.debug({ port: data.port, pid: data.pid }, 'Cleaned up stale port file (dead process)');
|
|
549
|
+
}
|
|
550
|
+
else if (data.pid !== process.pid && isStaleInstance(data)) {
|
|
551
|
+
// Live PID but stale heartbeat. Staleness alone is NOT proof of
|
|
552
|
+
// death — after the machine sleeps longer than HEARTBEAT_STALE_MS,
|
|
553
|
+
// ALL instances look stale at once and whichever reaper ticks first
|
|
554
|
+
// would kill healthy siblings. Verify actual deadness before
|
|
555
|
+
// terminating: extra staleness margin + failed liveness probe.
|
|
556
|
+
if (!isKillEligible(data)) {
|
|
557
|
+
logger.debug({ port: data.port, pid: data.pid, lastSeen: data.lastSeen }, 'Stale instance within kill-eligibility margin — skipping');
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const health = await probeServerHealthAsync(data.port);
|
|
561
|
+
if (health !== false) {
|
|
562
|
+
logger.debug({ port: data.port, pid: data.pid, probe: health === true ? 'responding' : 'inconclusive' }, 'Stale-looking instance not confirmed dead by liveness probe — skipping kill');
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
logger.info({ port: data.port, pid: data.pid, startedAt: data.startedAt, lastSeen: data.lastSeen }, 'Detected zombie MCP process (stale heartbeat, health probe failed) — sending SIGTERM to free port');
|
|
566
|
+
await terminateProcessAsync(data.pid);
|
|
567
|
+
try {
|
|
568
|
+
unlinkSync(filePath);
|
|
569
|
+
}
|
|
570
|
+
catch { /* best-effort */ }
|
|
571
|
+
cleaned++;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
328
574
|
}
|
|
329
575
|
}
|
|
330
576
|
catch {
|
|
@@ -389,7 +635,7 @@ export function cleanupOrphanedProcesses(preferredPort = DEFAULT_WS_PORT, option
|
|
|
389
635
|
encoding: 'utf-8',
|
|
390
636
|
timeout: 2000,
|
|
391
637
|
}).trim();
|
|
392
|
-
if (
|
|
638
|
+
if (isMcpServerCommand(cmdline)) {
|
|
393
639
|
// Don't reap a sibling that is still starting up (bound a port but
|
|
394
640
|
// hasn't advertised yet). Real orphans are far older than this.
|
|
395
641
|
const ageMs = getProcessAgeMs(pid);
|
|
@@ -429,6 +675,110 @@ export function cleanupOrphanedProcesses(preferredPort = DEFAULT_WS_PORT, option
|
|
|
429
675
|
}
|
|
430
676
|
return cleaned;
|
|
431
677
|
}
|
|
678
|
+
/** Whether a process command line identifies a figma-console-mcp server. */
|
|
679
|
+
function isMcpServerCommand(cmdline) {
|
|
680
|
+
return cmdline.includes('figma-console-mcp') || cmdline.includes('figma_console_mcp') || cmdline.includes('local.js');
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* List PIDs listening on ports in the range via a single batched lsof call.
|
|
684
|
+
* Returns a map of pid → first listening port seen (for logging). The sync
|
|
685
|
+
* startup path runs lsof once per port; batching matters here because the
|
|
686
|
+
* periodic reaper must not hold the event loop across 10 sequential calls.
|
|
687
|
+
*/
|
|
688
|
+
async function listListeningPidsAsync(ports) {
|
|
689
|
+
const pidToPort = new Map();
|
|
690
|
+
const low = ports[0];
|
|
691
|
+
const high = ports[ports.length - 1];
|
|
692
|
+
let stdout = '';
|
|
693
|
+
try {
|
|
694
|
+
({ stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${low}-${high}`, '-sTCP:LISTEN', '-Fpn'], { timeout: 5000 }));
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
// lsof exits 1 when nothing matches; any partial output is still usable
|
|
698
|
+
stdout = typeof error?.stdout === 'string' ? error.stdout : '';
|
|
699
|
+
}
|
|
700
|
+
// -F output: `p<pid>` starts a process group, `n<host:port>` lines follow
|
|
701
|
+
let currentPid = null;
|
|
702
|
+
for (const line of stdout.split('\n')) {
|
|
703
|
+
if (line.startsWith('p')) {
|
|
704
|
+
const pid = parseInt(line.slice(1), 10);
|
|
705
|
+
currentPid = Number.isFinite(pid) ? pid : null;
|
|
706
|
+
}
|
|
707
|
+
else if (line.startsWith('n') && currentPid !== null) {
|
|
708
|
+
const m = line.match(/:(\d+)(?:\s|$)/);
|
|
709
|
+
if (!m)
|
|
710
|
+
continue;
|
|
711
|
+
const port = parseInt(m[1], 10);
|
|
712
|
+
if (ports.includes(port) && !pidToPort.has(currentPid)) {
|
|
713
|
+
pidToPort.set(currentPid, port);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return pidToPort;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Async variant of cleanupOrphanedProcesses for the periodic reaper.
|
|
721
|
+
*
|
|
722
|
+
* Same safety guards as the sync version (known-PID skip, MCP command-line
|
|
723
|
+
* check, ORPHAN_MIN_AGE_MS age guard, confirmed-kill counting), but all child
|
|
724
|
+
* processes run via async execFile and the lsof scan is a single batched call
|
|
725
|
+
* over the port range — a slow tick no longer freezes in-flight tool calls on
|
|
726
|
+
* the stdio transport.
|
|
727
|
+
*/
|
|
728
|
+
export async function cleanupOrphanedProcessesAsync(preferredPort = DEFAULT_WS_PORT, options = {}) {
|
|
729
|
+
// Only supported on macOS/Linux (lsof)
|
|
730
|
+
if (process.platform === 'win32')
|
|
731
|
+
return 0;
|
|
732
|
+
const minAgeMs = options.minAgeMs ?? ORPHAN_MIN_AGE_MS;
|
|
733
|
+
let cleaned = 0;
|
|
734
|
+
const myPid = process.pid;
|
|
735
|
+
const ports = getPortRange(preferredPort);
|
|
736
|
+
// Collect PIDs that have valid port files (known-good servers)
|
|
737
|
+
const knownPids = new Set();
|
|
738
|
+
for (const port of ports) {
|
|
739
|
+
const data = readPortFile(port);
|
|
740
|
+
if (data)
|
|
741
|
+
knownPids.add(data.pid);
|
|
742
|
+
}
|
|
743
|
+
knownPids.add(myPid); // Never kill ourselves
|
|
744
|
+
const pidToPort = await listListeningPidsAsync(ports);
|
|
745
|
+
for (const [pid, port] of pidToPort) {
|
|
746
|
+
if (knownPids.has(pid))
|
|
747
|
+
continue; // Skip known-good servers
|
|
748
|
+
// Verify this is actually a figma-console-mcp process before killing
|
|
749
|
+
try {
|
|
750
|
+
const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
751
|
+
timeout: 2000,
|
|
752
|
+
});
|
|
753
|
+
const cmdline = stdout.trim();
|
|
754
|
+
if (isMcpServerCommand(cmdline)) {
|
|
755
|
+
// Don't reap a sibling that is still starting up (bound a port but
|
|
756
|
+
// hasn't advertised yet). Real orphans are far older than this.
|
|
757
|
+
const ageMs = await getProcessAgeMsAsync(pid);
|
|
758
|
+
if (minAgeMs > 0 && ageMs !== null && ageMs < minAgeMs) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port)');
|
|
762
|
+
// Only count confirmed kills, matching the sync path.
|
|
763
|
+
if (await terminateProcessAsync(pid)) {
|
|
764
|
+
cleaned++;
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
logger.warn({ port, pid }, 'Failed to terminate orphaned MCP server (survived SIGKILL)');
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// Can't read process info — skip to be safe
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (cleaned > 0) {
|
|
776
|
+
// Give terminated processes a moment to release their ports
|
|
777
|
+
await sleepMs(500);
|
|
778
|
+
logger.info({ cleaned }, `Cleaned up ${cleaned} orphaned MCP server process(es)`);
|
|
779
|
+
}
|
|
780
|
+
return cleaned;
|
|
781
|
+
}
|
|
432
782
|
/**
|
|
433
783
|
* Last-resort eviction: terminate the oldest MCP server instance to free a port.
|
|
434
784
|
*
|
|
@@ -522,11 +872,8 @@ export function evictOldestInstance(preferredPort = DEFAULT_WS_PORT) {
|
|
|
522
872
|
export function registerPortCleanup(port) {
|
|
523
873
|
const cleanup = () => unadvertisePort(port);
|
|
524
874
|
process.on('exit', cleanup);
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
const originalSigintListeners = process.listeners('SIGINT');
|
|
528
|
-
const originalSigtermListeners = process.listeners('SIGTERM');
|
|
529
|
-
// Prepend our cleanup — it runs first, then existing handlers take over
|
|
875
|
+
// Prepend our cleanup — it runs first before the existing SIGINT/SIGTERM
|
|
876
|
+
// handlers in local.ts main() call process.exit()
|
|
530
877
|
process.prependListener('SIGINT', cleanup);
|
|
531
878
|
process.prependListener('SIGTERM', cleanup);
|
|
532
879
|
}
|
|
@@ -541,22 +888,48 @@ export function registerPortCleanup(port) {
|
|
|
541
888
|
* every 30s) so they are in the known-PID set and skipped, and the age guard in
|
|
542
889
|
* cleanupOrphanedProcesses protects mid-startup siblings.
|
|
543
890
|
*
|
|
891
|
+
* The tick runs the fully-async cleanup variants — the sync ones issue
|
|
892
|
+
* blocking lsof/ps/curl calls (seconds each under load), which would stall
|
|
893
|
+
* the stdio MCP transport and freeze in-flight tool calls. Only the startup
|
|
894
|
+
* path (before the transport is serving requests) uses the sync variants.
|
|
895
|
+
*
|
|
544
896
|
* The interval is unref'd so it never keeps the process alive on its own.
|
|
545
897
|
*
|
|
546
898
|
* @returns a stop function that clears the interval.
|
|
547
899
|
*/
|
|
548
900
|
export function startPeriodicReaper(preferredPort = DEFAULT_WS_PORT) {
|
|
549
|
-
|
|
901
|
+
let running = false;
|
|
902
|
+
let stopped = false;
|
|
903
|
+
const tick = async () => {
|
|
904
|
+
if (running)
|
|
905
|
+
return; // previous tick still in flight — skip this one
|
|
906
|
+
running = true;
|
|
550
907
|
try {
|
|
551
|
-
|
|
552
|
-
|
|
908
|
+
// Refresh our OWN advertisement first so sibling reapers see us fresh.
|
|
909
|
+
// Post-sleep, every instance's lastSeen is stale simultaneously — the
|
|
910
|
+
// first reaper to tick must not look like a zombie to the others.
|
|
911
|
+
// refreshPortAdvertisement is a no-op for files owned by other PIDs,
|
|
912
|
+
// so scanning the whole range only touches our own file.
|
|
913
|
+
for (const port of getPortRange(preferredPort)) {
|
|
914
|
+
refreshPortAdvertisement(port);
|
|
915
|
+
}
|
|
916
|
+
await cleanupStalePortFilesAsync();
|
|
917
|
+
if (stopped)
|
|
918
|
+
return;
|
|
919
|
+
await cleanupOrphanedProcessesAsync(preferredPort);
|
|
553
920
|
}
|
|
554
921
|
catch (error) {
|
|
555
922
|
logger.warn({ error }, 'Periodic reaper tick failed');
|
|
556
923
|
}
|
|
924
|
+
finally {
|
|
925
|
+
running = false;
|
|
926
|
+
}
|
|
557
927
|
};
|
|
558
|
-
const interval = setInterval(tick, REAP_INTERVAL_MS);
|
|
928
|
+
const interval = setInterval(() => { void tick(); }, REAP_INTERVAL_MS);
|
|
559
929
|
if (typeof interval.unref === 'function')
|
|
560
930
|
interval.unref();
|
|
561
|
-
return () =>
|
|
931
|
+
return () => {
|
|
932
|
+
stopped = true;
|
|
933
|
+
clearInterval(interval);
|
|
934
|
+
};
|
|
562
935
|
}
|
|
@@ -12,19 +12,89 @@
|
|
|
12
12
|
* - Detects cycles.
|
|
13
13
|
*/
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Slugify a set/collection name into the key used for the top-level set
|
|
16
|
+
* group in DTCG output AND the set-qualifier prefix in alias references
|
|
17
|
+
* (`{<set-slug>.<path.to.token>}`). Must stay in sync with the DTCG
|
|
18
|
+
* formatter's group keys so emitted references resolve inside the file.
|
|
17
19
|
*/
|
|
18
|
-
export function
|
|
20
|
+
export function slugifySetName(name) {
|
|
21
|
+
return name
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
25
|
+
.replace(/^-+|-+$/g, "");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a lookup map for alias resolution. Every token is indexed under its
|
|
29
|
+
* set-qualified key (`<set-slug>.<dot.path>` — the same shape the converter
|
|
30
|
+
* emits in references), and additionally under its bare dot-path
|
|
31
|
+
* (`color.primary`) when that bare path is unambiguous across sets.
|
|
32
|
+
*
|
|
33
|
+
* When two collections both contain the same bare path (e.g. two collections
|
|
34
|
+
* each defining `color/primary`), the bare key is NOT indexed — resolving a
|
|
35
|
+
* bare reference to "whichever set was indexed last" is exactly the
|
|
36
|
+
* cross-collection misresolution bug this guards against. Callers that pass
|
|
37
|
+
* a `warnings` array get one warning per ambiguous bare path.
|
|
38
|
+
*/
|
|
39
|
+
export function buildTokenIndex(doc, warnings) {
|
|
40
|
+
const index = new Map();
|
|
41
|
+
for (const [key, entry] of buildTokenLookup(doc, warnings)) {
|
|
42
|
+
index.set(key, entry.token);
|
|
43
|
+
}
|
|
44
|
+
return index;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Set-aware variant of buildTokenIndex — identical key scheme and ambiguity
|
|
48
|
+
* rules (set-qualified keys always; bare-path fallback only when unambiguous
|
|
49
|
+
* across sets), but each entry carries the owning set's name alongside the
|
|
50
|
+
* token. buildTokenIndex delegates to this so the two can never drift.
|
|
51
|
+
*/
|
|
52
|
+
export function buildTokenLookup(doc, warnings) {
|
|
19
53
|
const index = new Map();
|
|
54
|
+
// barePath → owning entries, used to detect cross-set ambiguity.
|
|
55
|
+
const bareOwners = new Map();
|
|
20
56
|
for (const set of doc.sets) {
|
|
57
|
+
const setKey = slugifySetName(set.name);
|
|
21
58
|
for (const token of set.tokens) {
|
|
22
|
-
const
|
|
23
|
-
index.set(
|
|
59
|
+
const bare = token.path.join(".");
|
|
60
|
+
index.set(`${setKey}.${bare}`, { setName: set.name, token });
|
|
61
|
+
const owners = bareOwners.get(bare) ?? [];
|
|
62
|
+
owners.push({ setName: set.name, token });
|
|
63
|
+
bareOwners.set(bare, owners);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Bare-path fallback: only when a path exists in exactly one set, and only
|
|
67
|
+
// when it doesn't shadow a set-qualified key that's already indexed.
|
|
68
|
+
for (const [bare, owners] of bareOwners) {
|
|
69
|
+
if (owners.length === 1) {
|
|
70
|
+
if (!index.has(bare))
|
|
71
|
+
index.set(bare, owners[0]);
|
|
72
|
+
}
|
|
73
|
+
else if (warnings) {
|
|
74
|
+
warnings.push(`Token path "${bare}" exists in multiple collections (${owners
|
|
75
|
+
.map((o) => `"${o.setName}"`)
|
|
76
|
+
.join(", ")}) — bare alias references to it are ambiguous and will not resolve. Use a set-qualified reference like {${slugifySetName(owners[0].setName)}.${bare}}.`);
|
|
24
77
|
}
|
|
25
78
|
}
|
|
26
79
|
return index;
|
|
27
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a reference to the path segments of its TARGET token — for
|
|
83
|
+
* formatters that name-ify aliases (CSS `var(--...)`, SCSS `$...`,
|
|
84
|
+
* Tokens Studio / SD v3 `{...}` refs). Set-qualified references
|
|
85
|
+
* (`{set-slug.path.to.token}`) resolve to the target token's own path
|
|
86
|
+
* (WITHOUT the set qualifier), matching how those formatters name the
|
|
87
|
+
* target's declaration. Falls back to the raw reference path when the
|
|
88
|
+
* target isn't in the index (e.g. hand-written bare refs to tokens that
|
|
89
|
+
* weren't exported).
|
|
90
|
+
*/
|
|
91
|
+
export function referenceTargetPath(reference, index) {
|
|
92
|
+
const bare = reference.replace(/^\{|\}$/g, "");
|
|
93
|
+
const target = index.get(bare);
|
|
94
|
+
if (target)
|
|
95
|
+
return target.path;
|
|
96
|
+
return bare.split(".");
|
|
97
|
+
}
|
|
28
98
|
/**
|
|
29
99
|
* Resolve a single alias reference. Returns the eventual literal value, or
|
|
30
100
|
* throws if the reference is unresolvable or cyclic.
|
|
@@ -42,6 +42,16 @@ const OutputTargetSchema = z.object({
|
|
|
42
42
|
* for CSS/SCSS/Tailwind/etc. since they can't natively express aliases).
|
|
43
43
|
*/
|
|
44
44
|
resolveAliases: z.boolean().optional(),
|
|
45
|
+
/**
|
|
46
|
+
* DTCG dialect for dtcg/json outputs. 'legacy' (default): hex-string
|
|
47
|
+
* colors and bare-number dimensions, maximum compatibility (Style
|
|
48
|
+
* Dictionary v4, Tokens Studio). '2025': DTCG 2025.10 object colors
|
|
49
|
+
* ({ colorSpace, components, alpha?, hex }) and dimensions
|
|
50
|
+
* ({ value, unit }) for Style Dictionary v5+ and other 2025.10-aware
|
|
51
|
+
* tooling. Ignored by css/scss/tailwind/ts formatters, which render
|
|
52
|
+
* final code.
|
|
53
|
+
*/
|
|
54
|
+
dtcgDialect: z.enum(["legacy", "2025"]).optional(),
|
|
45
55
|
/** Per-target transform options. Override the global defaults. */
|
|
46
56
|
transforms: z
|
|
47
57
|
.object({
|