@mp3wizard/figma-console-mcp 1.14.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/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
- package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
- package/dist/apps/design-system-dashboard/server.d.ts +24 -0
- package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/server.js +160 -0
- package/dist/apps/design-system-dashboard/server.js.map +1 -0
- package/dist/apps/token-browser/server.d.ts +26 -0
- package/dist/apps/token-browser/server.d.ts.map +1 -0
- package/dist/apps/token-browser/server.js +137 -0
- package/dist/apps/token-browser/server.js.map +1 -0
- package/dist/browser/base.d.ts +58 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +87 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +318 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
- package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
- package/dist/cloudflare/apps/token-browser/server.js +136 -0
- package/dist/cloudflare/browser/base.js +5 -0
- package/dist/cloudflare/browser/cloudflare.js +156 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +267 -0
- package/dist/cloudflare/core/cloud-websocket-relay.js +199 -0
- package/dist/cloudflare/core/comment-tools.js +292 -0
- package/dist/cloudflare/core/config.js +161 -0
- package/dist/cloudflare/core/console-monitor.js +427 -0
- package/dist/cloudflare/core/design-code-tools.js +2504 -0
- package/dist/cloudflare/core/design-system-manifest.js +260 -0
- package/dist/cloudflare/core/design-system-tools.js +863 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
- package/dist/cloudflare/core/figma-api.js +409 -0
- package/dist/cloudflare/core/figma-connector.js +7 -0
- package/dist/cloudflare/core/figma-desktop-connector.js +1184 -0
- package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +2947 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/port-discovery.js +282 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/types/design-code.js +4 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/core/websocket-connector.js +256 -0
- package/dist/cloudflare/core/websocket-server.js +646 -0
- package/dist/cloudflare/core/write-tools.js +2091 -0
- package/dist/cloudflare/index.js +2899 -0
- package/dist/cloudflare/test-browser.js +88 -0
- package/dist/core/comment-tools.d.ts +11 -0
- package/dist/core/comment-tools.d.ts.map +1 -0
- package/dist/core/comment-tools.js +293 -0
- package/dist/core/comment-tools.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +162 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +82 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +428 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/design-code-tools.d.ts +127 -0
- package/dist/core/design-code-tools.d.ts.map +1 -0
- package/dist/core/design-code-tools.js +2505 -0
- package/dist/core/design-code-tools.js.map +1 -0
- package/dist/core/design-system-manifest.d.ts +272 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -0
- package/dist/core/design-system-manifest.js +261 -0
- package/dist/core/design-system-manifest.js.map +1 -0
- package/dist/core/design-system-tools.d.ts +17 -0
- package/dist/core/design-system-tools.d.ts.map +1 -0
- package/dist/core/design-system-tools.js +864 -0
- package/dist/core/design-system-tools.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +273 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +80 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +327 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figma-api.d.ts +201 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +410 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-connector.d.ts +48 -0
- package/dist/core/figma-connector.d.ts.map +1 -0
- package/dist/core/figma-connector.js +8 -0
- package/dist/core/figma-connector.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +265 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +1184 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-reconstruction-spec.d.ts +166 -0
- package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
- package/dist/core/figma-reconstruction-spec.js +403 -0
- package/dist/core/figma-reconstruction-spec.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +23 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +2948 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/port-discovery.d.ts +110 -0
- package/dist/core/port-discovery.d.ts.map +1 -0
- package/dist/core/port-discovery.js +283 -0
- package/dist/core/port-discovery.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/types/design-code.d.ts +262 -0
- package/dist/core/types/design-code.d.ts.map +1 -0
- package/dist/core/types/design-code.js +5 -0
- package/dist/core/types/design-code.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +112 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/core/websocket-connector.d.ts +55 -0
- package/dist/core/websocket-connector.d.ts.map +1 -0
- package/dist/core/websocket-connector.js +257 -0
- package/dist/core/websocket-connector.js.map +1 -0
- package/dist/core/websocket-server.d.ts +191 -0
- package/dist/core/websocket-server.d.ts.map +1 -0
- package/dist/core/websocket-server.js +647 -0
- package/dist/core/websocket-server.js.map +1 -0
- package/dist/core/write-tools.d.ts +7 -0
- package/dist/core/write-tools.d.ts.map +1 -0
- package/dist/core/write-tools.js +2092 -0
- package/dist/core/write-tools.js.map +1 -0
- package/dist/local.d.ts +84 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +5039 -0
- package/dist/local.js.map +1 -0
- package/figma-desktop-bridge/README.md +313 -0
- package/figma-desktop-bridge/code.js +2818 -0
- package/figma-desktop-bridge/manifest.json +67 -0
- package/figma-desktop-bridge/ui.html +1236 -0
- package/package.json +87 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging infrastructure using pino
|
|
3
|
+
*/
|
|
4
|
+
import pino from 'pino';
|
|
5
|
+
/**
|
|
6
|
+
* Create logger instance
|
|
7
|
+
* Note: In Cloudflare Workers, console methods are automatically captured
|
|
8
|
+
*/
|
|
9
|
+
export function createLogger(level = 'info') {
|
|
10
|
+
// Check if running in Cloudflare Workers environment
|
|
11
|
+
const isWorkers = typeof globalThis.caches !== 'undefined';
|
|
12
|
+
if (isWorkers) {
|
|
13
|
+
// Cloudflare Workers: use simple console-based logging
|
|
14
|
+
return pino({
|
|
15
|
+
level: process.env.LOG_LEVEL || level,
|
|
16
|
+
browser: {
|
|
17
|
+
asObject: true,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// Node.js environment: detect MCP stdio mode
|
|
22
|
+
// When stdout is not a TTY, we're likely in MCP stdio mode
|
|
23
|
+
const isMCPStdio = !process.stdout.isTTY;
|
|
24
|
+
// MCP stdio mode: NO pretty printing, stderr only
|
|
25
|
+
if (isMCPStdio) {
|
|
26
|
+
return pino({ level: process.env.LOG_LEVEL || level }, pino.destination({ dest: 2, sync: false }));
|
|
27
|
+
}
|
|
28
|
+
// Development/terminal mode: use pretty printing
|
|
29
|
+
return pino({
|
|
30
|
+
level: process.env.LOG_LEVEL || level,
|
|
31
|
+
transport: process.env.NODE_ENV !== 'production'
|
|
32
|
+
? {
|
|
33
|
+
target: 'pino-pretty',
|
|
34
|
+
options: {
|
|
35
|
+
colorize: true,
|
|
36
|
+
translateTime: 'HH:MM:ss',
|
|
37
|
+
ignore: 'pid,hostname',
|
|
38
|
+
destination: 2, // Explicit stderr for transport
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: undefined,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Default logger instance
|
|
46
|
+
*/
|
|
47
|
+
export const logger = createLogger();
|
|
48
|
+
/**
|
|
49
|
+
* Create child logger with additional context
|
|
50
|
+
*/
|
|
51
|
+
export function createChildLogger(bindings) {
|
|
52
|
+
return logger.child(bindings);
|
|
53
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port Discovery Module
|
|
3
|
+
*
|
|
4
|
+
* Handles dynamic WebSocket port assignment with range-based fallback.
|
|
5
|
+
* When the preferred port (default 9223) is taken by another MCP server instance
|
|
6
|
+
* (e.g., Claude Desktop Chat tab vs Code tab), the server automatically tries
|
|
7
|
+
* the next port in a fixed range (9223-9232).
|
|
8
|
+
*
|
|
9
|
+
* Port advertisement files are written to /tmp so the Figma plugin can discover
|
|
10
|
+
* which port to connect to. Each instance writes its own file with PID for
|
|
11
|
+
* stale-file detection.
|
|
12
|
+
*
|
|
13
|
+
* Zombie process detection:
|
|
14
|
+
* Active servers refresh their port file every 30s (heartbeat).
|
|
15
|
+
* On startup, cleanupStalePortFiles() detects zombies via:
|
|
16
|
+
* 1. Dead PID — process no longer exists (existing behavior)
|
|
17
|
+
* 2. Stale heartbeat — lastSeen older than 5 minutes (process frozen/hung)
|
|
18
|
+
* 3. Age ceiling — startedAt older than 4 hours with no heartbeat (pre-v1.12 compat)
|
|
19
|
+
* Zombie processes are terminated with SIGTERM to free their ports.
|
|
20
|
+
*
|
|
21
|
+
* Data flow:
|
|
22
|
+
* Server binds port → writes /tmp/figma-console-mcp-{port}.json
|
|
23
|
+
* Server heartbeat → refreshes lastSeen every 30s
|
|
24
|
+
* Plugin scans ports 9223-9232 → connects to first responding server
|
|
25
|
+
* External tools read port files for discovery
|
|
26
|
+
*/
|
|
27
|
+
import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { tmpdir } from 'os';
|
|
30
|
+
import { createChildLogger } from './logger.js';
|
|
31
|
+
const logger = createChildLogger({ component: 'port-discovery' });
|
|
32
|
+
/** Default preferred WebSocket port */
|
|
33
|
+
export const DEFAULT_WS_PORT = 9223;
|
|
34
|
+
/** Number of ports in the fallback range (9223-9232 = 10 ports) */
|
|
35
|
+
export const PORT_RANGE_SIZE = 10;
|
|
36
|
+
/** Prefix for port advertisement files in /tmp */
|
|
37
|
+
const PORT_FILE_PREFIX = 'figma-console-mcp-';
|
|
38
|
+
/** Directory for port advertisement files */
|
|
39
|
+
const PORT_FILE_DIR = tmpdir();
|
|
40
|
+
/** Maximum age before a port file without heartbeat is considered stale (4 hours) */
|
|
41
|
+
export const MAX_PORT_FILE_AGE_MS = 4 * 60 * 60 * 1000;
|
|
42
|
+
/** Maximum time since last heartbeat before a process is considered stale (5 minutes) */
|
|
43
|
+
export const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
|
44
|
+
/** Interval between heartbeat refreshes (30 seconds) */
|
|
45
|
+
export const HEARTBEAT_INTERVAL_MS = 30 * 1000;
|
|
46
|
+
/**
|
|
47
|
+
* Try to bind a WebSocket server to ports in a range, starting from the preferred port.
|
|
48
|
+
* Returns the first port that binds successfully.
|
|
49
|
+
*
|
|
50
|
+
* @param preferredPort - The port to try first (default 9223)
|
|
51
|
+
* @param host - The host to bind to (default 'localhost')
|
|
52
|
+
* @returns The actual port that was bound
|
|
53
|
+
* @throws If all ports in the range are exhausted
|
|
54
|
+
*/
|
|
55
|
+
export function getPortRange(preferredPort = DEFAULT_WS_PORT) {
|
|
56
|
+
const ports = [];
|
|
57
|
+
for (let i = 0; i < PORT_RANGE_SIZE; i++) {
|
|
58
|
+
ports.push(preferredPort + i);
|
|
59
|
+
}
|
|
60
|
+
return ports;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get the file path for a port advertisement file.
|
|
64
|
+
*/
|
|
65
|
+
export function getPortFilePath(port) {
|
|
66
|
+
return join(PORT_FILE_DIR, `${PORT_FILE_PREFIX}${port}.json`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Write a port advertisement file so clients can discover this server instance.
|
|
70
|
+
* Includes PID for stale-file detection and lastSeen for heartbeat tracking.
|
|
71
|
+
*/
|
|
72
|
+
export function advertisePort(port, host = 'localhost') {
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
const data = {
|
|
75
|
+
port,
|
|
76
|
+
pid: process.pid,
|
|
77
|
+
host,
|
|
78
|
+
startedAt: now,
|
|
79
|
+
lastSeen: now,
|
|
80
|
+
};
|
|
81
|
+
const filePath = getPortFilePath(port);
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
84
|
+
logger.info({ port, filePath }, 'Port advertised');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
logger.warn({ port, filePath, error }, 'Failed to write port advertisement file');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Refresh the lastSeen timestamp in a port advertisement file.
|
|
92
|
+
* Called periodically as a heartbeat to prove this server is still active.
|
|
93
|
+
* Non-fatal — heartbeat failures are silently ignored.
|
|
94
|
+
*/
|
|
95
|
+
export function refreshPortAdvertisement(port) {
|
|
96
|
+
const filePath = getPortFilePath(port);
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(filePath))
|
|
99
|
+
return;
|
|
100
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
101
|
+
const data = JSON.parse(raw);
|
|
102
|
+
// Only refresh our own port file
|
|
103
|
+
if (data.pid !== process.pid)
|
|
104
|
+
return;
|
|
105
|
+
data.lastSeen = new Date().toISOString();
|
|
106
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Best-effort — heartbeat failures are non-fatal
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove the port advertisement file for this instance.
|
|
114
|
+
* Call on clean shutdown.
|
|
115
|
+
*/
|
|
116
|
+
export function unadvertisePort(port) {
|
|
117
|
+
const filePath = getPortFilePath(port);
|
|
118
|
+
try {
|
|
119
|
+
if (existsSync(filePath)) {
|
|
120
|
+
unlinkSync(filePath);
|
|
121
|
+
logger.debug({ port, filePath }, 'Port advertisement removed');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Best-effort cleanup — file may already be gone
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if a PID is still alive.
|
|
130
|
+
*/
|
|
131
|
+
function isProcessAlive(pid) {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 0); // Signal 0 = existence check, doesn't actually kill
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Determine if a port file represents a zombie/stale MCP instance.
|
|
142
|
+
*
|
|
143
|
+
* Detection layers:
|
|
144
|
+
* 1. If lastSeen exists (v1.12+): stale if older than HEARTBEAT_STALE_MS (5 min)
|
|
145
|
+
* 2. If lastSeen is missing (pre-v1.12): stale if startedAt older than MAX_PORT_FILE_AGE_MS (4h)
|
|
146
|
+
*
|
|
147
|
+
* Assumes the owning process IS alive (PID check should happen before calling this).
|
|
148
|
+
*/
|
|
149
|
+
export function isStaleInstance(data) {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
// If heartbeat exists, use it — active servers refresh every 30s
|
|
152
|
+
if (data.lastSeen) {
|
|
153
|
+
const lastSeenAge = now - new Date(data.lastSeen).getTime();
|
|
154
|
+
return lastSeenAge > HEARTBEAT_STALE_MS;
|
|
155
|
+
}
|
|
156
|
+
// No heartbeat (pre-v1.12 instance) — fall back to startup age
|
|
157
|
+
const startedAge = now - new Date(data.startedAt).getTime();
|
|
158
|
+
return startedAge > MAX_PORT_FILE_AGE_MS;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Attempt to terminate a process by PID.
|
|
162
|
+
* Uses SIGTERM for graceful shutdown. On Windows, this calls TerminateProcess
|
|
163
|
+
* which is immediate and cannot be caught.
|
|
164
|
+
*
|
|
165
|
+
* @returns true if the signal was sent successfully, false if the process was already gone
|
|
166
|
+
*/
|
|
167
|
+
function terminateProcess(pid) {
|
|
168
|
+
try {
|
|
169
|
+
process.kill(pid, 'SIGTERM');
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false; // Process may have already exited
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Read and validate a port advertisement file.
|
|
178
|
+
* Returns null if the file doesn't exist, is invalid, or the owning process is dead.
|
|
179
|
+
*/
|
|
180
|
+
export function readPortFile(port) {
|
|
181
|
+
const filePath = getPortFilePath(port);
|
|
182
|
+
if (!existsSync(filePath))
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
186
|
+
const data = JSON.parse(raw);
|
|
187
|
+
// Validate the owning process is still alive
|
|
188
|
+
if (!isProcessAlive(data.pid)) {
|
|
189
|
+
logger.debug({ port, pid: data.pid }, 'Stale port file detected (process dead), cleaning up');
|
|
190
|
+
try {
|
|
191
|
+
unlinkSync(filePath);
|
|
192
|
+
}
|
|
193
|
+
catch { /* best-effort */ }
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return data;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Discover all active Figma Console MCP server instances by scanning port files.
|
|
204
|
+
* Validates each file's PID to filter out stale entries.
|
|
205
|
+
*/
|
|
206
|
+
export function discoverActiveInstances(preferredPort = DEFAULT_WS_PORT) {
|
|
207
|
+
const instances = [];
|
|
208
|
+
for (const port of getPortRange(preferredPort)) {
|
|
209
|
+
const data = readPortFile(port);
|
|
210
|
+
if (data) {
|
|
211
|
+
instances.push(data);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return instances;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Clean up stale port files and terminate zombie MCP processes.
|
|
218
|
+
*
|
|
219
|
+
* Runs at startup before port binding. Detects stale instances via:
|
|
220
|
+
* 1. Dead PID — process no longer exists → delete file
|
|
221
|
+
* 2. Zombie process — alive but stale (no heartbeat or expired heartbeat)
|
|
222
|
+
* → send SIGTERM to free the port, then delete file
|
|
223
|
+
* 3. Corrupt file — invalid JSON → delete file
|
|
224
|
+
*/
|
|
225
|
+
export function cleanupStalePortFiles() {
|
|
226
|
+
let cleaned = 0;
|
|
227
|
+
try {
|
|
228
|
+
const files = readdirSync(PORT_FILE_DIR);
|
|
229
|
+
for (const file of files) {
|
|
230
|
+
if (file.startsWith(PORT_FILE_PREFIX) && file.endsWith('.json')) {
|
|
231
|
+
const filePath = join(PORT_FILE_DIR, file);
|
|
232
|
+
try {
|
|
233
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
234
|
+
const data = JSON.parse(raw);
|
|
235
|
+
if (!isProcessAlive(data.pid)) {
|
|
236
|
+
// Dead PID — just clean up the file
|
|
237
|
+
unlinkSync(filePath);
|
|
238
|
+
cleaned++;
|
|
239
|
+
logger.debug({ port: data.port, pid: data.pid }, 'Cleaned up stale port file (dead process)');
|
|
240
|
+
}
|
|
241
|
+
else if (data.pid !== process.pid && isStaleInstance(data)) {
|
|
242
|
+
// Live PID but stale — zombie process, terminate it to free the port
|
|
243
|
+
logger.info({ port: data.port, pid: data.pid, startedAt: data.startedAt, lastSeen: data.lastSeen }, 'Detected zombie MCP process — sending SIGTERM to free port');
|
|
244
|
+
terminateProcess(data.pid);
|
|
245
|
+
try {
|
|
246
|
+
unlinkSync(filePath);
|
|
247
|
+
}
|
|
248
|
+
catch { /* best-effort */ }
|
|
249
|
+
cleaned++;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Corrupt file — remove it
|
|
254
|
+
try {
|
|
255
|
+
unlinkSync(filePath);
|
|
256
|
+
cleaned++;
|
|
257
|
+
}
|
|
258
|
+
catch { /* ignore */ }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Can't read /tmp — unusual but not fatal
|
|
265
|
+
}
|
|
266
|
+
return cleaned;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Register process exit handlers to clean up port advertisement file.
|
|
270
|
+
* Should be called once after the port is successfully bound.
|
|
271
|
+
*/
|
|
272
|
+
export function registerPortCleanup(port) {
|
|
273
|
+
const cleanup = () => unadvertisePort(port);
|
|
274
|
+
process.on('exit', cleanup);
|
|
275
|
+
// Re-register SIGINT/SIGTERM to ensure cleanup runs before the
|
|
276
|
+
// existing handlers in local.ts main() call process.exit()
|
|
277
|
+
const originalSigintListeners = process.listeners('SIGINT');
|
|
278
|
+
const originalSigtermListeners = process.listeners('SIGTERM');
|
|
279
|
+
// Prepend our cleanup — it runs first, then existing handlers take over
|
|
280
|
+
process.prependListener('SIGINT', cleanup);
|
|
281
|
+
process.prependListener('SIGTERM', cleanup);
|
|
282
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippet Injector
|
|
3
|
+
* Generates and manages console-based data extraction snippets for Figma
|
|
4
|
+
*/
|
|
5
|
+
import { createChildLogger } from './logger.js';
|
|
6
|
+
const logger = createChildLogger({ component: 'snippet-injector' });
|
|
7
|
+
export class SnippetInjector {
|
|
8
|
+
/**
|
|
9
|
+
* Generate variables extraction snippet for Figma console
|
|
10
|
+
*/
|
|
11
|
+
generateVariablesSnippet() {
|
|
12
|
+
return `
|
|
13
|
+
(async () => {
|
|
14
|
+
try {
|
|
15
|
+
const vars = await figma.variables.getLocalVariablesAsync();
|
|
16
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
17
|
+
|
|
18
|
+
const payload = {
|
|
19
|
+
timestamp: Date.now(),
|
|
20
|
+
variables: vars.map(v => ({
|
|
21
|
+
id: v.id,
|
|
22
|
+
name: v.name,
|
|
23
|
+
key: v.key,
|
|
24
|
+
resolvedType: v.resolvedType,
|
|
25
|
+
valuesByMode: v.valuesByMode,
|
|
26
|
+
variableCollectionId: v.variableCollectionId,
|
|
27
|
+
scopes: v.scopes,
|
|
28
|
+
description: v.description,
|
|
29
|
+
hiddenFromPublishing: v.hiddenFromPublishing
|
|
30
|
+
})),
|
|
31
|
+
variableCollections: collections.map(c => ({
|
|
32
|
+
id: c.id,
|
|
33
|
+
name: c.name,
|
|
34
|
+
key: c.key,
|
|
35
|
+
modes: c.modes.map(m => ({ modeId: m.modeId, name: m.name })),
|
|
36
|
+
defaultModeId: c.defaultModeId,
|
|
37
|
+
variableIds: c.variableIds
|
|
38
|
+
}))
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
console.log('[MCP_VARIABLES]', JSON.stringify(payload), '[MCP_VARIABLES_END]');
|
|
42
|
+
console.log('✅ Variables data captured! Run figma_get_variables({ parseFromConsole: true }) in Claude to retrieve.');
|
|
43
|
+
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('[MCP_VARIABLES_ERROR]', error.message);
|
|
46
|
+
console.log('❌ Make sure you\\'re running this in a Figma file with variables.');
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
`.trim();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse variables from console log entry
|
|
53
|
+
*/
|
|
54
|
+
parseVariablesFromLog(logEntry) {
|
|
55
|
+
try {
|
|
56
|
+
// Check for marker
|
|
57
|
+
if (!logEntry.message.includes('[MCP_VARIABLES]')) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Extract JSON from args
|
|
61
|
+
// The snippet logs: console.log('[MCP_VARIABLES]', JSON.stringify(payload), '[MCP_VARIABLES_END]')
|
|
62
|
+
// So args[0] is the marker, args[1] is the JSON string
|
|
63
|
+
const jsonStr = logEntry.args[1] || logEntry.args[0];
|
|
64
|
+
if (!jsonStr) {
|
|
65
|
+
throw new Error('No data found in console log');
|
|
66
|
+
}
|
|
67
|
+
const data = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr;
|
|
68
|
+
logger.info({
|
|
69
|
+
variableCount: data.variables?.length || 0,
|
|
70
|
+
collectionCount: data.variableCollections?.length || 0,
|
|
71
|
+
}, 'Successfully parsed variables from console log');
|
|
72
|
+
return {
|
|
73
|
+
variables: data.variables || [],
|
|
74
|
+
variableCollections: data.variableCollections || [],
|
|
75
|
+
timestamp: data.timestamp || Date.now(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.error({ error }, 'Failed to parse variables from console log');
|
|
80
|
+
throw new Error(`Failed to parse variables from console log: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Find the most recent variables log entry
|
|
85
|
+
*/
|
|
86
|
+
findVariablesLog(logs) {
|
|
87
|
+
// Search in reverse (most recent first)
|
|
88
|
+
for (let i = logs.length - 1; i >= 0; i--) {
|
|
89
|
+
const log = logs[i];
|
|
90
|
+
if (log.message.includes('[MCP_VARIABLES]')) {
|
|
91
|
+
return log;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|