@muloka/figma-console-mcp 0.1.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 +898 -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-manager.js +157 -0
- package/dist/cloudflare/core/accessibility-tools.js +361 -0
- package/dist/cloudflare/core/annotation-tools.js +230 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +419 -0
- package/dist/cloudflare/core/cloud-websocket-relay.js +198 -0
- package/dist/cloudflare/core/comment-tools.js +292 -0
- package/dist/cloudflare/core/config.js +153 -0
- package/dist/cloudflare/core/console-monitor.js +427 -0
- package/dist/cloudflare/core/deep-component-tools.js +128 -0
- package/dist/cloudflare/core/design-code-tools.js +2793 -0
- package/dist/cloudflare/core/design-system-manifest.js +265 -0
- package/dist/cloudflare/core/design-system-tools.js +882 -0
- package/dist/cloudflare/core/diagnose-tool.js +100 -0
- package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
- package/dist/cloudflare/core/diff/diff-engine.js +334 -0
- package/dist/cloudflare/core/diff/property-compare.js +36 -0
- package/dist/cloudflare/core/diff/version-cache.js +74 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +374 -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 +346 -0
- package/dist/cloudflare/core/figjam-tools.js +547 -0
- package/dist/cloudflare/core/figma-api.js +509 -0
- package/dist/cloudflare/core/figma-connector.js +7 -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 +3302 -0
- package/dist/cloudflare/core/identity.js +96 -0
- package/dist/cloudflare/core/library-tools.js +580 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/port-discovery.js +935 -0
- package/dist/cloudflare/core/resolve-package-root.js +11 -0
- package/dist/cloudflare/core/slides-tools.js +714 -0
- package/dist/cloudflare/core/slot-tools.js +334 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/tokens/alias-resolver.js +205 -0
- package/dist/cloudflare/core/tokens/config.js +294 -0
- package/dist/cloudflare/core/tokens/dialect.js +232 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +323 -0
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +338 -0
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +376 -0
- package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
- package/dist/cloudflare/core/tokens/formatters/json.js +205 -0
- package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/scss.js +258 -0
- package/dist/cloudflare/core/tokens/formatters/stubs.js +13 -0
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +213 -0
- package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +237 -0
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +335 -0
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +256 -0
- package/dist/cloudflare/core/tokens/formatters/ts-module.js +198 -0
- package/dist/cloudflare/core/tokens/index.js +16 -0
- package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +280 -0
- package/dist/cloudflare/core/tokens/parsers/index.js +138 -0
- package/dist/cloudflare/core/tokens/parsers/json.js +7 -0
- package/dist/cloudflare/core/tokens/parsers/scss.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/stubs.js +20 -0
- package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +4 -0
- package/dist/cloudflare/core/tokens/schemas.js +152 -0
- package/dist/cloudflare/core/tokens/transforms/color.js +12 -0
- package/dist/cloudflare/core/tokens/transforms/index.js +29 -0
- package/dist/cloudflare/core/tokens/transforms/size.js +7 -0
- package/dist/cloudflare/core/tokens/types.js +18 -0
- package/dist/cloudflare/core/tokens-tools.js +1788 -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/variable-resolver.js +85 -0
- package/dist/cloudflare/core/version-tools.js +1199 -0
- package/dist/cloudflare/core/websocket-connector.js +436 -0
- package/dist/cloudflare/core/websocket-server.js +956 -0
- package/dist/cloudflare/core/write-tools.js +2506 -0
- package/dist/cloudflare/index.js +3010 -0
- package/dist/core/accessibility-tools.d.ts +21 -0
- package/dist/core/accessibility-tools.d.ts.map +1 -0
- package/dist/core/accessibility-tools.js +362 -0
- package/dist/core/accessibility-tools.js.map +1 -0
- package/dist/core/annotation-tools.d.ts +14 -0
- package/dist/core/annotation-tools.d.ts.map +1 -0
- package/dist/core/annotation-tools.js +231 -0
- package/dist/core/annotation-tools.js.map +1 -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 +154 -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/deep-component-tools.d.ts +14 -0
- package/dist/core/deep-component-tools.d.ts.map +1 -0
- package/dist/core/deep-component-tools.js +129 -0
- package/dist/core/deep-component-tools.js.map +1 -0
- package/dist/core/design-code-tools.d.ts +116 -0
- package/dist/core/design-code-tools.d.ts.map +1 -0
- package/dist/core/design-code-tools.js +2794 -0
- package/dist/core/design-code-tools.js.map +1 -0
- package/dist/core/design-system-manifest.d.ts +273 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -0
- package/dist/core/design-system-manifest.js +266 -0
- package/dist/core/design-system-manifest.js.map +1 -0
- package/dist/core/design-system-tools.d.ts +67 -0
- package/dist/core/design-system-tools.d.ts.map +1 -0
- package/dist/core/design-system-tools.js +883 -0
- package/dist/core/design-system-tools.js.map +1 -0
- package/dist/core/diagnose-tool.d.ts +41 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -0
- package/dist/core/diagnose-tool.js +101 -0
- package/dist/core/diagnose-tool.js.map +1 -0
- package/dist/core/diff/changelog-formatter.d.ts +35 -0
- package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
- package/dist/core/diff/changelog-formatter.js +276 -0
- package/dist/core/diff/changelog-formatter.js.map +1 -0
- package/dist/core/diff/diff-engine.d.ts +127 -0
- package/dist/core/diff/diff-engine.d.ts.map +1 -0
- package/dist/core/diff/diff-engine.js +335 -0
- package/dist/core/diff/diff-engine.js.map +1 -0
- package/dist/core/diff/property-compare.d.ts +19 -0
- package/dist/core/diff/property-compare.d.ts.map +1 -0
- package/dist/core/diff/property-compare.js +37 -0
- package/dist/core/diff/property-compare.js.map +1 -0
- package/dist/core/diff/version-cache.d.ts +40 -0
- package/dist/core/diff/version-cache.d.ts.map +1 -0
- package/dist/core/diff/version-cache.js +75 -0
- package/dist/core/diff/version-cache.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 +375 -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 +85 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +347 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figjam-tools.d.ts +8 -0
- package/dist/core/figjam-tools.d.ts.map +1 -0
- package/dist/core/figjam-tools.js +548 -0
- package/dist/core/figjam-tools.js.map +1 -0
- package/dist/core/figma-api.d.ts +254 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +510 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-connector.d.ts +214 -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 +312 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +1298 -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 +22 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +3303 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/identity.d.ts +41 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/identity.js +97 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/core/library-tools.d.ts +17 -0
- package/dist/core/library-tools.d.ts.map +1 -0
- package/dist/core/library-tools.js +581 -0
- package/dist/core/library-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 +211 -0
- package/dist/core/port-discovery.d.ts.map +1 -0
- package/dist/core/port-discovery.js +936 -0
- package/dist/core/port-discovery.js.map +1 -0
- package/dist/core/resolve-package-root.d.ts +2 -0
- package/dist/core/resolve-package-root.d.ts.map +1 -0
- package/dist/core/resolve-package-root.js +12 -0
- package/dist/core/resolve-package-root.js.map +1 -0
- package/dist/core/slides-tools.d.ts +8 -0
- package/dist/core/slides-tools.d.ts.map +1 -0
- package/dist/core/slides-tools.js +715 -0
- package/dist/core/slides-tools.js.map +1 -0
- package/dist/core/slot-tools.d.ts +7 -0
- package/dist/core/slot-tools.d.ts.map +1 -0
- package/dist/core/slot-tools.js +335 -0
- package/dist/core/slot-tools.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/tokens/alias-resolver.d.ts +97 -0
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -0
- package/dist/core/tokens/alias-resolver.js +206 -0
- package/dist/core/tokens/alias-resolver.js.map +1 -0
- package/dist/core/tokens/config.d.ts +380 -0
- package/dist/core/tokens/config.d.ts.map +1 -0
- package/dist/core/tokens/config.js +295 -0
- package/dist/core/tokens/config.js.map +1 -0
- 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 +102 -0
- package/dist/core/tokens/figma-converter.d.ts.map +1 -0
- package/dist/core/tokens/figma-converter.js +324 -0
- package/dist/core/tokens/figma-converter.js.map +1 -0
- package/dist/core/tokens/formatters/css-vars.d.ts +24 -0
- package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -0
- package/dist/core/tokens/formatters/css-vars.js +339 -0
- package/dist/core/tokens/formatters/css-vars.js.map +1 -0
- package/dist/core/tokens/formatters/dtcg.d.ts +28 -0
- package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -0
- package/dist/core/tokens/formatters/dtcg.js +377 -0
- package/dist/core/tokens/formatters/dtcg.js.map +1 -0
- package/dist/core/tokens/formatters/index.d.ts +30 -0
- package/dist/core/tokens/formatters/index.d.ts.map +1 -0
- package/dist/core/tokens/formatters/index.js +46 -0
- package/dist/core/tokens/formatters/index.js.map +1 -0
- package/dist/core/tokens/formatters/json.d.ts +37 -0
- package/dist/core/tokens/formatters/json.d.ts.map +1 -0
- package/dist/core/tokens/formatters/json.js +206 -0
- package/dist/core/tokens/formatters/json.js.map +1 -0
- package/dist/core/tokens/formatters/less.d.ts +4 -0
- package/dist/core/tokens/formatters/less.d.ts.map +1 -0
- package/dist/core/tokens/formatters/less.js +5 -0
- package/dist/core/tokens/formatters/less.js.map +1 -0
- package/dist/core/tokens/formatters/scss.d.ts +26 -0
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -0
- package/dist/core/tokens/formatters/scss.js +259 -0
- package/dist/core/tokens/formatters/scss.js.map +1 -0
- package/dist/core/tokens/formatters/stubs.d.ts +9 -0
- package/dist/core/tokens/formatters/stubs.d.ts.map +1 -0
- package/dist/core/tokens/formatters/stubs.js +14 -0
- package/dist/core/tokens/formatters/stubs.js.map +1 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts +45 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.js +214 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v3.d.ts +37 -0
- package/dist/core/tokens/formatters/tailwind-v3.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v3.js +238 -0
- package/dist/core/tokens/formatters/tailwind-v3.js.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v4.d.ts +41 -0
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v4.js +336 -0
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -0
- package/dist/core/tokens/formatters/tokens-studio.d.ts +44 -0
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tokens-studio.js +257 -0
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -0
- package/dist/core/tokens/formatters/ts-module.d.ts +35 -0
- package/dist/core/tokens/formatters/ts-module.d.ts.map +1 -0
- package/dist/core/tokens/formatters/ts-module.js +199 -0
- package/dist/core/tokens/formatters/ts-module.js.map +1 -0
- package/dist/core/tokens/index.d.ts +18 -0
- package/dist/core/tokens/index.d.ts.map +1 -0
- package/dist/core/tokens/index.js +17 -0
- package/dist/core/tokens/index.js.map +1 -0
- package/dist/core/tokens/parsers/css-vars.d.ts +3 -0
- package/dist/core/tokens/parsers/css-vars.d.ts.map +1 -0
- package/dist/core/tokens/parsers/css-vars.js +5 -0
- package/dist/core/tokens/parsers/css-vars.js.map +1 -0
- package/dist/core/tokens/parsers/dtcg.d.ts +21 -0
- package/dist/core/tokens/parsers/dtcg.d.ts.map +1 -0
- package/dist/core/tokens/parsers/dtcg.js +281 -0
- package/dist/core/tokens/parsers/dtcg.js.map +1 -0
- package/dist/core/tokens/parsers/index.d.ts +37 -0
- package/dist/core/tokens/parsers/index.d.ts.map +1 -0
- package/dist/core/tokens/parsers/index.js +139 -0
- package/dist/core/tokens/parsers/index.js.map +1 -0
- package/dist/core/tokens/parsers/json.d.ts +4 -0
- package/dist/core/tokens/parsers/json.d.ts.map +1 -0
- package/dist/core/tokens/parsers/json.js +8 -0
- package/dist/core/tokens/parsers/json.js.map +1 -0
- package/dist/core/tokens/parsers/scss.d.ts +3 -0
- package/dist/core/tokens/parsers/scss.d.ts.map +1 -0
- package/dist/core/tokens/parsers/scss.js +5 -0
- package/dist/core/tokens/parsers/scss.js.map +1 -0
- package/dist/core/tokens/parsers/stubs.d.ts +15 -0
- package/dist/core/tokens/parsers/stubs.d.ts.map +1 -0
- package/dist/core/tokens/parsers/stubs.js +21 -0
- package/dist/core/tokens/parsers/stubs.js.map +1 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.d.ts +3 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.d.ts.map +1 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.js +5 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.js.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v3.d.ts +3 -0
- package/dist/core/tokens/parsers/tailwind-v3.d.ts.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v3.js +5 -0
- package/dist/core/tokens/parsers/tailwind-v3.js.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v4.d.ts +3 -0
- package/dist/core/tokens/parsers/tailwind-v4.d.ts.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v4.js +5 -0
- package/dist/core/tokens/parsers/tailwind-v4.js.map +1 -0
- package/dist/core/tokens/parsers/tokens-studio.d.ts +3 -0
- package/dist/core/tokens/parsers/tokens-studio.d.ts.map +1 -0
- package/dist/core/tokens/parsers/tokens-studio.js +5 -0
- package/dist/core/tokens/parsers/tokens-studio.js.map +1 -0
- package/dist/core/tokens/schemas.d.ts +155 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -0
- package/dist/core/tokens/schemas.js +153 -0
- package/dist/core/tokens/schemas.js.map +1 -0
- package/dist/core/tokens/transforms/color.d.ts +9 -0
- package/dist/core/tokens/transforms/color.d.ts.map +1 -0
- package/dist/core/tokens/transforms/color.js +13 -0
- package/dist/core/tokens/transforms/color.js.map +1 -0
- package/dist/core/tokens/transforms/index.d.ts +36 -0
- package/dist/core/tokens/transforms/index.d.ts.map +1 -0
- package/dist/core/tokens/transforms/index.js +30 -0
- package/dist/core/tokens/transforms/index.js.map +1 -0
- package/dist/core/tokens/transforms/size.d.ts +7 -0
- package/dist/core/tokens/transforms/size.d.ts.map +1 -0
- package/dist/core/tokens/transforms/size.js +8 -0
- package/dist/core/tokens/transforms/size.js.map +1 -0
- package/dist/core/tokens/types.d.ts +284 -0
- package/dist/core/tokens/types.d.ts.map +1 -0
- package/dist/core/tokens/types.js +19 -0
- package/dist/core/tokens/types.js.map +1 -0
- package/dist/core/tokens-tools.d.ts +285 -0
- package/dist/core/tokens-tools.d.ts.map +1 -0
- package/dist/core/tokens-tools.js +1789 -0
- package/dist/core/tokens-tools.js.map +1 -0
- package/dist/core/types/design-code.d.ts +271 -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 +104 -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/variable-resolver.d.ts +45 -0
- package/dist/core/variable-resolver.d.ts.map +1 -0
- package/dist/core/variable-resolver.js +86 -0
- package/dist/core/variable-resolver.js.map +1 -0
- package/dist/core/version-tools.d.ts +59 -0
- package/dist/core/version-tools.d.ts.map +1 -0
- package/dist/core/version-tools.js +1200 -0
- package/dist/core/version-tools.js.map +1 -0
- package/dist/core/websocket-connector.d.ts +247 -0
- package/dist/core/websocket-connector.d.ts.map +1 -0
- package/dist/core/websocket-connector.js +437 -0
- package/dist/core/websocket-connector.js.map +1 -0
- package/dist/core/websocket-server.d.ts +290 -0
- package/dist/core/websocket-server.d.ts.map +1 -0
- package/dist/core/websocket-server.js +957 -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 +2507 -0
- package/dist/core/write-tools.js.map +1 -0
- package/dist/local.d.ts +105 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +3314 -0
- package/dist/local.js.map +1 -0
- package/figma-desktop-bridge/README.md +365 -0
- package/figma-desktop-bridge/code.js +7359 -0
- package/figma-desktop-bridge/icon.png +0 -0
- package/figma-desktop-bridge/manifest.json +67 -0
- package/figma-desktop-bridge/ui.html +2783 -0
- package/package.json +103 -0
|
@@ -0,0 +1,935 @@
|
|
|
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
|
+
* 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
|
+
*
|
|
26
|
+
* Data flow:
|
|
27
|
+
* Server binds port → writes /tmp/figma-console-mcp-{port}.json
|
|
28
|
+
* Server heartbeat → refreshes lastSeen every 30s
|
|
29
|
+
* Plugin scans ports 9223-9232 → connects to first responding server
|
|
30
|
+
* External tools read port files for discovery
|
|
31
|
+
*/
|
|
32
|
+
import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
|
|
33
|
+
import { join } from 'path';
|
|
34
|
+
import { tmpdir, devNull } from 'os';
|
|
35
|
+
import { execFile, execFileSync } from 'child_process';
|
|
36
|
+
import { promisify } from 'util';
|
|
37
|
+
import { createChildLogger } from './logger.js';
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
const logger = createChildLogger({ component: 'port-discovery' });
|
|
40
|
+
/** Default preferred WebSocket port */
|
|
41
|
+
export const DEFAULT_WS_PORT = 9223;
|
|
42
|
+
/** Number of ports in the fallback range (9223-9232 = 10 ports) */
|
|
43
|
+
export const PORT_RANGE_SIZE = 10;
|
|
44
|
+
/** Prefix for port advertisement files in /tmp */
|
|
45
|
+
const PORT_FILE_PREFIX = 'figma-console-mcp-';
|
|
46
|
+
/** Directory for port advertisement files */
|
|
47
|
+
const PORT_FILE_DIR = tmpdir();
|
|
48
|
+
/** Maximum age before a port file without heartbeat is considered stale (4 hours) */
|
|
49
|
+
export const MAX_PORT_FILE_AGE_MS = 4 * 60 * 60 * 1000;
|
|
50
|
+
/** Maximum time since last heartbeat before a process is considered stale (5 minutes) */
|
|
51
|
+
export const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
|
52
|
+
/** Grace period after SIGTERM before escalating to SIGKILL (ms) */
|
|
53
|
+
export const TERMINATE_GRACE_MS = 400;
|
|
54
|
+
/**
|
|
55
|
+
* Minimum process age before an orphan may be reaped (ms). Protects a sibling
|
|
56
|
+
* server that is mid-startup — it has bound a port but not yet written its
|
|
57
|
+
* advertisement file, so it would otherwise look like an orphan. By the time a
|
|
58
|
+
* real server is this old it has advertised and is in the known-PID set.
|
|
59
|
+
*/
|
|
60
|
+
export const ORPHAN_MIN_AGE_MS = 60 * 1000;
|
|
61
|
+
/** Interval for the periodic background reaper (ms) */
|
|
62
|
+
export const REAP_INTERVAL_MS = 5 * 60 * 1000;
|
|
63
|
+
/** Minimum age before an instance can be evicted as last resort (2 minutes) */
|
|
64
|
+
export const EVICTION_MIN_AGE_MS = 2 * 60 * 1000;
|
|
65
|
+
/** Interval between heartbeat refreshes (30 seconds) */
|
|
66
|
+
export const HEARTBEAT_INTERVAL_MS = 30 * 1000;
|
|
67
|
+
/**
|
|
68
|
+
* Try to bind a WebSocket server to ports in a range, starting from the preferred port.
|
|
69
|
+
* Returns the first port that binds successfully.
|
|
70
|
+
*
|
|
71
|
+
* @param preferredPort - The port to try first (default 9223)
|
|
72
|
+
* @param host - The host to bind to (default 'localhost')
|
|
73
|
+
* @returns The actual port that was bound
|
|
74
|
+
* @throws If all ports in the range are exhausted
|
|
75
|
+
*/
|
|
76
|
+
export function getPortRange(preferredPort = DEFAULT_WS_PORT) {
|
|
77
|
+
const ports = [];
|
|
78
|
+
for (let i = 0; i < PORT_RANGE_SIZE; i++) {
|
|
79
|
+
ports.push(preferredPort + i);
|
|
80
|
+
}
|
|
81
|
+
return ports;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get the file path for a port advertisement file.
|
|
85
|
+
*/
|
|
86
|
+
export function getPortFilePath(port) {
|
|
87
|
+
return join(PORT_FILE_DIR, `${PORT_FILE_PREFIX}${port}.json`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Write a port advertisement file so clients can discover this server instance.
|
|
91
|
+
* Includes PID for stale-file detection and lastSeen for heartbeat tracking.
|
|
92
|
+
*/
|
|
93
|
+
export function advertisePort(port, host = 'localhost') {
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
const data = {
|
|
96
|
+
port,
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
host,
|
|
99
|
+
startedAt: now,
|
|
100
|
+
lastSeen: now,
|
|
101
|
+
};
|
|
102
|
+
const filePath = getPortFilePath(port);
|
|
103
|
+
try {
|
|
104
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
105
|
+
logger.info({ port, filePath }, 'Port advertised');
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.warn({ port, filePath, error }, 'Failed to write port advertisement file');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Refresh the lastSeen timestamp in a port advertisement file.
|
|
113
|
+
* Called periodically as a heartbeat to prove this server is still active.
|
|
114
|
+
* Non-fatal — heartbeat failures are silently ignored.
|
|
115
|
+
*/
|
|
116
|
+
export function refreshPortAdvertisement(port) {
|
|
117
|
+
const filePath = getPortFilePath(port);
|
|
118
|
+
try {
|
|
119
|
+
if (!existsSync(filePath))
|
|
120
|
+
return;
|
|
121
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
122
|
+
const data = JSON.parse(raw);
|
|
123
|
+
// Only refresh our own port file
|
|
124
|
+
if (data.pid !== process.pid)
|
|
125
|
+
return;
|
|
126
|
+
data.lastSeen = new Date().toISOString();
|
|
127
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Best-effort — heartbeat failures are non-fatal
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Remove the port advertisement file for this instance.
|
|
135
|
+
* Call on clean shutdown.
|
|
136
|
+
*/
|
|
137
|
+
export function unadvertisePort(port) {
|
|
138
|
+
const filePath = getPortFilePath(port);
|
|
139
|
+
try {
|
|
140
|
+
if (existsSync(filePath)) {
|
|
141
|
+
unlinkSync(filePath);
|
|
142
|
+
logger.debug({ port, filePath }, 'Port advertisement removed');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Best-effort cleanup — file may already be gone
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check if a PID is still alive.
|
|
151
|
+
*/
|
|
152
|
+
function isProcessAlive(pid) {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(pid, 0); // Signal 0 = existence check, doesn't actually kill
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Determine if a port file represents a zombie/stale MCP instance.
|
|
163
|
+
*
|
|
164
|
+
* Detection layers:
|
|
165
|
+
* 1. If lastSeen exists (v1.12+): stale if older than HEARTBEAT_STALE_MS (5 min)
|
|
166
|
+
* 2. If lastSeen is missing (pre-v1.12): stale if startedAt older than MAX_PORT_FILE_AGE_MS (4h)
|
|
167
|
+
*
|
|
168
|
+
* Assumes the owning process IS alive (PID check should happen before calling this).
|
|
169
|
+
*/
|
|
170
|
+
export function isStaleInstance(data) {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
// If heartbeat exists, use it — active servers refresh every 30s
|
|
173
|
+
if (data.lastSeen) {
|
|
174
|
+
const lastSeenAge = now - new Date(data.lastSeen).getTime();
|
|
175
|
+
return lastSeenAge > HEARTBEAT_STALE_MS;
|
|
176
|
+
}
|
|
177
|
+
// No heartbeat (pre-v1.12 instance) — fall back to startup age
|
|
178
|
+
const startedAge = now - new Date(data.startedAt).getTime();
|
|
179
|
+
return startedAge > MAX_PORT_FILE_AGE_MS;
|
|
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
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Block the current thread for `ms` milliseconds (synchronous).
|
|
275
|
+
* Used between SIGTERM and SIGKILL so terminateProcess can stay synchronous
|
|
276
|
+
* (its callers — the startup-path cleanup functions — are synchronous).
|
|
277
|
+
*/
|
|
278
|
+
function sleepSyncMs(ms) {
|
|
279
|
+
if (ms <= 0)
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
// Cross-platform, no child process. SharedArrayBuffer/Atomics are standard in Node 18+.
|
|
283
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
const end = Date.now() + ms;
|
|
287
|
+
while (Date.now() < end) { /* best-effort spin fallback */ }
|
|
288
|
+
}
|
|
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
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Elapsed time since a process started, in milliseconds. Returns null if it
|
|
321
|
+
* cannot be determined (process gone, or `ps` unavailable/unparseable).
|
|
322
|
+
*/
|
|
323
|
+
function getProcessAgeMs(pid) {
|
|
324
|
+
if (process.platform === 'win32')
|
|
325
|
+
return null;
|
|
326
|
+
try {
|
|
327
|
+
const { execSync } = require('child_process');
|
|
328
|
+
const out = execSync(`ps -p ${pid} -o etime= 2>/dev/null`, {
|
|
329
|
+
encoding: 'utf-8',
|
|
330
|
+
timeout: 2000,
|
|
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);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Terminate a process by PID, escalating to SIGKILL if it ignores SIGTERM.
|
|
354
|
+
*
|
|
355
|
+
* A hung MCP server can catch SIGTERM (its shutdown handler runs) yet never
|
|
356
|
+
* reach process.exit() — e.g. when graceful WebSocket/HTTP close blocks on a
|
|
357
|
+
* lingering connection. SIGTERM alone then leaves a zombie holding its port
|
|
358
|
+
* (often with its advertisement file already removed by the handler). We send
|
|
359
|
+
* SIGTERM, wait briefly, and force-kill with SIGKILL if it is still alive.
|
|
360
|
+
*
|
|
361
|
+
* @returns true if the process is confirmed gone afterwards, false if it survived.
|
|
362
|
+
*/
|
|
363
|
+
function terminateProcess(pid, graceMs = TERMINATE_GRACE_MS) {
|
|
364
|
+
// SIGTERM first — give the process a chance to shut down gracefully.
|
|
365
|
+
try {
|
|
366
|
+
process.kill(pid, 'SIGTERM');
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return true; // already gone — nothing to terminate
|
|
370
|
+
}
|
|
371
|
+
// Windows: SIGTERM maps to TerminateProcess (immediate, uncatchable).
|
|
372
|
+
if (process.platform === 'win32')
|
|
373
|
+
return !isProcessAlive(pid);
|
|
374
|
+
// POSIX: let the graceful handler run, then force-kill if it ignored SIGTERM.
|
|
375
|
+
sleepSyncMs(graceMs);
|
|
376
|
+
if (!isProcessAlive(pid))
|
|
377
|
+
return true;
|
|
378
|
+
try {
|
|
379
|
+
process.kill(pid, 'SIGKILL');
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return true; // exited between the check and the kill
|
|
383
|
+
}
|
|
384
|
+
sleepSyncMs(Math.min(graceMs, 200));
|
|
385
|
+
return !isProcessAlive(pid);
|
|
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
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Read and validate a port advertisement file.
|
|
420
|
+
* Returns null if the file doesn't exist, is invalid, or the owning process is dead.
|
|
421
|
+
*/
|
|
422
|
+
export function readPortFile(port) {
|
|
423
|
+
const filePath = getPortFilePath(port);
|
|
424
|
+
if (!existsSync(filePath))
|
|
425
|
+
return null;
|
|
426
|
+
try {
|
|
427
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
428
|
+
const data = JSON.parse(raw);
|
|
429
|
+
// Validate the owning process is still alive
|
|
430
|
+
if (!isProcessAlive(data.pid)) {
|
|
431
|
+
logger.debug({ port, pid: data.pid }, 'Stale port file detected (process dead), cleaning up');
|
|
432
|
+
try {
|
|
433
|
+
unlinkSync(filePath);
|
|
434
|
+
}
|
|
435
|
+
catch { /* best-effort */ }
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
return data;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Discover all active Figma Console MCP server instances by scanning port files.
|
|
446
|
+
* Validates each file's PID to filter out stale entries.
|
|
447
|
+
*/
|
|
448
|
+
export function discoverActiveInstances(preferredPort = DEFAULT_WS_PORT) {
|
|
449
|
+
const instances = [];
|
|
450
|
+
for (const port of getPortRange(preferredPort)) {
|
|
451
|
+
const data = readPortFile(port);
|
|
452
|
+
if (data) {
|
|
453
|
+
instances.push(data);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return instances;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Clean up stale port files and terminate zombie MCP processes.
|
|
460
|
+
*
|
|
461
|
+
* Runs at startup before port binding. Detects stale instances via:
|
|
462
|
+
* 1. Dead PID — process no longer exists → delete file
|
|
463
|
+
* 2. Zombie process — alive but stale (no heartbeat or expired heartbeat)
|
|
464
|
+
* → send SIGTERM to free the port, then delete file
|
|
465
|
+
* 3. Corrupt file — invalid JSON → delete file
|
|
466
|
+
*/
|
|
467
|
+
export function cleanupStalePortFiles() {
|
|
468
|
+
let cleaned = 0;
|
|
469
|
+
try {
|
|
470
|
+
const files = readdirSync(PORT_FILE_DIR);
|
|
471
|
+
for (const file of files) {
|
|
472
|
+
if (file.startsWith(PORT_FILE_PREFIX) && file.endsWith('.json')) {
|
|
473
|
+
const filePath = join(PORT_FILE_DIR, file);
|
|
474
|
+
try {
|
|
475
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
476
|
+
const data = JSON.parse(raw);
|
|
477
|
+
if (!isProcessAlive(data.pid)) {
|
|
478
|
+
// Dead PID — just clean up the file
|
|
479
|
+
unlinkSync(filePath);
|
|
480
|
+
cleaned++;
|
|
481
|
+
logger.debug({ port: data.port, pid: data.pid }, 'Cleaned up stale port file (dead process)');
|
|
482
|
+
}
|
|
483
|
+
else if (data.pid !== process.pid && isStaleInstance(data)) {
|
|
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
|
+
}
|
|
506
|
+
}
|
|
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);
|
|
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
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Corrupt file — remove it
|
|
578
|
+
try {
|
|
579
|
+
unlinkSync(filePath);
|
|
580
|
+
cleaned++;
|
|
581
|
+
}
|
|
582
|
+
catch { /* ignore */ }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// Can't read /tmp — unusual but not fatal
|
|
589
|
+
}
|
|
590
|
+
return cleaned;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Deep scan for orphaned MCP server processes that hold ports but have no port files.
|
|
594
|
+
* These are processes left behind by Claude Desktop when tabs close without proper cleanup.
|
|
595
|
+
*
|
|
596
|
+
* Uses lsof (macOS/Linux) to find PIDs listening on each port in the range,
|
|
597
|
+
* then verifies they're figma-console-mcp before terminating.
|
|
598
|
+
*
|
|
599
|
+
* Call AFTER cleanupStalePortFiles() — that handles the port-file-based cleanup first,
|
|
600
|
+
* then this catches any remaining ghosts.
|
|
601
|
+
*/
|
|
602
|
+
export function cleanupOrphanedProcesses(preferredPort = DEFAULT_WS_PORT, options = {}) {
|
|
603
|
+
// Only supported on macOS/Linux (lsof)
|
|
604
|
+
if (process.platform === 'win32')
|
|
605
|
+
return 0;
|
|
606
|
+
const minAgeMs = options.minAgeMs ?? ORPHAN_MIN_AGE_MS;
|
|
607
|
+
let cleaned = 0;
|
|
608
|
+
const myPid = process.pid;
|
|
609
|
+
const ports = getPortRange(preferredPort);
|
|
610
|
+
// Collect PIDs that have valid port files (known-good servers)
|
|
611
|
+
const knownPids = new Set();
|
|
612
|
+
for (const port of ports) {
|
|
613
|
+
const data = readPortFile(port);
|
|
614
|
+
if (data)
|
|
615
|
+
knownPids.add(data.pid);
|
|
616
|
+
}
|
|
617
|
+
knownPids.add(myPid); // Never kill ourselves
|
|
618
|
+
for (const port of ports) {
|
|
619
|
+
try {
|
|
620
|
+
// Find PIDs listening on this port via lsof
|
|
621
|
+
const { execSync } = require('child_process');
|
|
622
|
+
const output = execSync(`lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null`, {
|
|
623
|
+
encoding: 'utf-8',
|
|
624
|
+
timeout: 3000,
|
|
625
|
+
}).trim();
|
|
626
|
+
if (!output)
|
|
627
|
+
continue;
|
|
628
|
+
const pids = output.split('\n').map(Number).filter(Boolean);
|
|
629
|
+
for (const pid of pids) {
|
|
630
|
+
if (knownPids.has(pid))
|
|
631
|
+
continue; // Skip known-good servers
|
|
632
|
+
// Verify this is actually a figma-console-mcp process before killing
|
|
633
|
+
try {
|
|
634
|
+
const cmdline = execSync(`ps -p ${pid} -o command= 2>/dev/null`, {
|
|
635
|
+
encoding: 'utf-8',
|
|
636
|
+
timeout: 2000,
|
|
637
|
+
}).trim();
|
|
638
|
+
if (isMcpServerCommand(cmdline)) {
|
|
639
|
+
// Don't reap a sibling that is still starting up (bound a port but
|
|
640
|
+
// hasn't advertised yet). Real orphans are far older than this.
|
|
641
|
+
const ageMs = getProcessAgeMs(pid);
|
|
642
|
+
if (minAgeMs > 0 && ageMs !== null && ageMs < minAgeMs) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port)');
|
|
646
|
+
// terminateProcess escalates SIGTERM -> SIGKILL. Only count it as
|
|
647
|
+
// cleaned when the process is confirmed gone, so the log reflects
|
|
648
|
+
// reality (the old code counted attempts and reported success even
|
|
649
|
+
// when a SIGTERM-ignoring zombie survived).
|
|
650
|
+
if (terminateProcess(pid)) {
|
|
651
|
+
cleaned++;
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
logger.warn({ port, pid }, 'Failed to terminate orphaned MCP server (survived SIGKILL)');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Can't read process info — skip to be safe
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// lsof failed for this port — skip
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (cleaned > 0) {
|
|
668
|
+
// Give terminated processes a moment to release their ports
|
|
669
|
+
try {
|
|
670
|
+
const { execSync } = require('child_process');
|
|
671
|
+
execSync('sleep 0.5', { timeout: 2000 });
|
|
672
|
+
}
|
|
673
|
+
catch { /* non-critical */ }
|
|
674
|
+
logger.info({ cleaned }, `Cleaned up ${cleaned} orphaned MCP server process(es)`);
|
|
675
|
+
}
|
|
676
|
+
return cleaned;
|
|
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
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Last-resort eviction: terminate the oldest MCP server instance to free a port.
|
|
784
|
+
*
|
|
785
|
+
* Called ONLY when all ports in the range are exhausted after both cleanup phases
|
|
786
|
+
* (cleanupStalePortFiles + cleanupOrphanedProcesses) have already run. This handles
|
|
787
|
+
* the case where old instances are still alive and heartbeating but no longer needed
|
|
788
|
+
* (e.g., from yesterday's Claude Desktop session that was closed without terminating
|
|
789
|
+
* the MCP server process).
|
|
790
|
+
*
|
|
791
|
+
* Safety guards:
|
|
792
|
+
* - Only evicts instances older than EVICTION_MIN_AGE_MS (2 min) to prevent cascade
|
|
793
|
+
* - Never evicts our own PID
|
|
794
|
+
* - Re-reads port file before kill to avoid TOCTOU race
|
|
795
|
+
* - Uses SIGTERM for graceful shutdown
|
|
796
|
+
* - Waits briefly for port release before returning
|
|
797
|
+
*
|
|
798
|
+
* @returns true if an instance was evicted (caller should retry port binding), false otherwise
|
|
799
|
+
*/
|
|
800
|
+
export function evictOldestInstance(preferredPort = DEFAULT_WS_PORT) {
|
|
801
|
+
const myPid = process.pid;
|
|
802
|
+
const ports = getPortRange(preferredPort);
|
|
803
|
+
const candidates = [];
|
|
804
|
+
// Collect all valid port file entries that aren't us
|
|
805
|
+
for (const port of ports) {
|
|
806
|
+
const filePath = getPortFilePath(port);
|
|
807
|
+
try {
|
|
808
|
+
if (!existsSync(filePath))
|
|
809
|
+
continue;
|
|
810
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
811
|
+
const data = JSON.parse(raw);
|
|
812
|
+
if (data.pid === myPid)
|
|
813
|
+
continue; // Never evict ourselves
|
|
814
|
+
if (!isProcessAlive(data.pid)) {
|
|
815
|
+
// Dead process — just clean up the file (port should already be free)
|
|
816
|
+
try {
|
|
817
|
+
unlinkSync(filePath);
|
|
818
|
+
}
|
|
819
|
+
catch { /* best-effort */ }
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
candidates.push({ ...data, filePath });
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
// Corrupt or unreadable — skip
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (candidates.length === 0) {
|
|
829
|
+
logger.debug('No eviction candidates — ports may be held by non-MCP processes');
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
// Sort by startedAt ascending — oldest first
|
|
833
|
+
candidates.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());
|
|
834
|
+
const oldest = candidates[0];
|
|
835
|
+
const ageMs = Date.now() - new Date(oldest.startedAt).getTime();
|
|
836
|
+
// Safety: don't evict instances that started recently (prevents cascade)
|
|
837
|
+
if (ageMs < EVICTION_MIN_AGE_MS) {
|
|
838
|
+
logger.info({ port: oldest.port, pid: oldest.pid, ageSeconds: Math.round(ageMs / 1000) }, 'Oldest instance is too recent to evict — skipping');
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
// Re-read the port file to avoid TOCTOU race
|
|
842
|
+
try {
|
|
843
|
+
const raw = readFileSync(oldest.filePath, 'utf-8');
|
|
844
|
+
const freshData = JSON.parse(raw);
|
|
845
|
+
if (freshData.pid !== oldest.pid) {
|
|
846
|
+
// PID changed between reads — another process took over, skip
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
// File disappeared — port may already be free
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
logger.info({ port: oldest.port, pid: oldest.pid, startedAt: oldest.startedAt, ageHours: Math.round(ageMs / 3600000 * 10) / 10 }, 'Evicting oldest MCP server instance to free port (all ports exhausted)');
|
|
855
|
+
terminateProcess(oldest.pid);
|
|
856
|
+
try {
|
|
857
|
+
unlinkSync(oldest.filePath);
|
|
858
|
+
}
|
|
859
|
+
catch { /* best-effort */ }
|
|
860
|
+
// Brief wait for the port to be released by the OS
|
|
861
|
+
try {
|
|
862
|
+
const { execSync } = require('child_process');
|
|
863
|
+
execSync('sleep 0.5', { timeout: 2000 });
|
|
864
|
+
}
|
|
865
|
+
catch { /* non-critical */ }
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Register process exit handlers to clean up port advertisement file.
|
|
870
|
+
* Should be called once after the port is successfully bound.
|
|
871
|
+
*/
|
|
872
|
+
export function registerPortCleanup(port) {
|
|
873
|
+
const cleanup = () => unadvertisePort(port);
|
|
874
|
+
process.on('exit', cleanup);
|
|
875
|
+
// Prepend our cleanup — it runs first before the existing SIGINT/SIGTERM
|
|
876
|
+
// handlers in local.ts main() call process.exit()
|
|
877
|
+
process.prependListener('SIGINT', cleanup);
|
|
878
|
+
process.prependListener('SIGTERM', cleanup);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Start a periodic background reaper that re-runs the cleanup passes while this
|
|
882
|
+
* server is alive. Startup-only reaping leaves orphans to accumulate between
|
|
883
|
+
* launches (a sibling client that closes without its server exiting cleanly
|
|
884
|
+
* keeps holding a port until the *next* server starts). Periodic reaping keeps
|
|
885
|
+
* the range clean continuously.
|
|
886
|
+
*
|
|
887
|
+
* Safe against live siblings: they hold fresh advertisement files (heartbeat
|
|
888
|
+
* every 30s) so they are in the known-PID set and skipped, and the age guard in
|
|
889
|
+
* cleanupOrphanedProcesses protects mid-startup siblings.
|
|
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
|
+
*
|
|
896
|
+
* The interval is unref'd so it never keeps the process alive on its own.
|
|
897
|
+
*
|
|
898
|
+
* @returns a stop function that clears the interval.
|
|
899
|
+
*/
|
|
900
|
+
export function startPeriodicReaper(preferredPort = DEFAULT_WS_PORT) {
|
|
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;
|
|
907
|
+
try {
|
|
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);
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
logger.warn({ error }, 'Periodic reaper tick failed');
|
|
923
|
+
}
|
|
924
|
+
finally {
|
|
925
|
+
running = false;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
const interval = setInterval(() => { void tick(); }, REAP_INTERVAL_MS);
|
|
929
|
+
if (typeof interval.unref === 'function')
|
|
930
|
+
interval.unref();
|
|
931
|
+
return () => {
|
|
932
|
+
stopped = true;
|
|
933
|
+
clearInterval(interval);
|
|
934
|
+
};
|
|
935
|
+
}
|