faux-studio 0.3.12 → 0.4.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/README.md +21 -0
- package/dist/index.js +359 -13
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -67,6 +67,27 @@ faux-studio runs locally and bridges your AI client to Figma Desktop:
|
|
|
67
67
|
- Setup guide: [faux.design/docs/setup](https://faux.design/docs/setup)
|
|
68
68
|
- Issues: [github.com/uxfreak/faux-studio/issues](https://github.com/uxfreak/faux-studio/issues)
|
|
69
69
|
|
|
70
|
+
## Publishing a New Version
|
|
71
|
+
|
|
72
|
+
To publish a new version to npm:
|
|
73
|
+
|
|
74
|
+
1. Bump the version in `package.json`:
|
|
75
|
+
```bash
|
|
76
|
+
npm version patch # 0.4.0 → 0.4.1
|
|
77
|
+
npm version minor # 0.4.0 → 0.5.0
|
|
78
|
+
npm version major # 0.4.0 → 1.0.0
|
|
79
|
+
```
|
|
80
|
+
This updates `package.json` and creates a git commit + tag automatically.
|
|
81
|
+
|
|
82
|
+
2. Push the commit and tag:
|
|
83
|
+
```bash
|
|
84
|
+
git push && git push --tags
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
3. The [publish workflow](.github/workflows/publish.yml) will build and publish to npm automatically on any `v*` tag push.
|
|
88
|
+
|
|
89
|
+
> **Note:** Ensure the `NPM_TOKEN` secret is configured in the repo's GitHub Settings → Secrets and variables → Actions.
|
|
90
|
+
|
|
70
91
|
## License
|
|
71
92
|
|
|
72
93
|
MIT
|
package/dist/index.js
CHANGED
|
@@ -10451,7 +10451,7 @@ function error(message) {
|
|
|
10451
10451
|
// src/auth.ts
|
|
10452
10452
|
var FAUX_DIR = join(homedir(), ".faux");
|
|
10453
10453
|
var CREDENTIALS_PATH = join(FAUX_DIR, "credentials.json");
|
|
10454
|
-
var AUTH_BASE = "https://auth.faux.design";
|
|
10454
|
+
var AUTH_BASE = process.env.FAUX_AUTH_URL || "https://auth.faux.design";
|
|
10455
10455
|
var POLL_INTERVAL_MS = 2e3;
|
|
10456
10456
|
var POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
10457
10457
|
var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
|
|
@@ -10630,7 +10630,7 @@ import { join as join2 } from "path";
|
|
|
10630
10630
|
import { homedir as homedir2 } from "os";
|
|
10631
10631
|
var FAUX_DIR2 = join2(homedir2(), ".faux");
|
|
10632
10632
|
var CACHE_PATH = join2(FAUX_DIR2, "tool-cache.json");
|
|
10633
|
-
var API_BASE = "https://
|
|
10633
|
+
var API_BASE = process.env.FAUX_API_URL || "https://api.faux.design";
|
|
10634
10634
|
async function loadCachedTools() {
|
|
10635
10635
|
try {
|
|
10636
10636
|
const raw = await readFile2(CACHE_PATH, "utf-8");
|
|
@@ -10756,10 +10756,12 @@ var CdpClient = class {
|
|
|
10756
10756
|
pending = /* @__PURE__ */ new Map();
|
|
10757
10757
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
10758
10758
|
figmaContextId = null;
|
|
10759
|
+
_targetWsUrl = null;
|
|
10759
10760
|
// -------------------------------------------------------------------------
|
|
10760
10761
|
// Connection
|
|
10761
10762
|
// -------------------------------------------------------------------------
|
|
10762
10763
|
async connect(wsUrl) {
|
|
10764
|
+
this._targetWsUrl = wsUrl;
|
|
10763
10765
|
return new Promise((resolve, reject) => {
|
|
10764
10766
|
const ws = new wrapper_default(wsUrl);
|
|
10765
10767
|
const timeout = setTimeout(() => {
|
|
@@ -10865,7 +10867,7 @@ var CdpClient = class {
|
|
|
10865
10867
|
this.on("Runtime.executionContextCreated", handler);
|
|
10866
10868
|
try {
|
|
10867
10869
|
await this.send("Runtime.enable");
|
|
10868
|
-
await new Promise((r) => setTimeout(r,
|
|
10870
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
10869
10871
|
} finally {
|
|
10870
10872
|
this.off("Runtime.executionContextCreated", handler);
|
|
10871
10873
|
}
|
|
@@ -10928,6 +10930,15 @@ ${script}
|
|
|
10928
10930
|
})()`);
|
|
10929
10931
|
}
|
|
10930
10932
|
// -------------------------------------------------------------------------
|
|
10933
|
+
// Reconnection
|
|
10934
|
+
// -------------------------------------------------------------------------
|
|
10935
|
+
/** Close current connection and reconnect to a different CDP target. */
|
|
10936
|
+
async reconnectToTarget(wsUrl) {
|
|
10937
|
+
this.close();
|
|
10938
|
+
await this.connect(wsUrl);
|
|
10939
|
+
await this.discoverFigmaContext();
|
|
10940
|
+
}
|
|
10941
|
+
// -------------------------------------------------------------------------
|
|
10931
10942
|
// State
|
|
10932
10943
|
// -------------------------------------------------------------------------
|
|
10933
10944
|
get connected() {
|
|
@@ -10936,6 +10947,10 @@ ${script}
|
|
|
10936
10947
|
get hasContext() {
|
|
10937
10948
|
return this.figmaContextId !== null;
|
|
10938
10949
|
}
|
|
10950
|
+
/** The WebSocket URL this client is currently connected to. */
|
|
10951
|
+
get targetWsUrl() {
|
|
10952
|
+
return this._targetWsUrl;
|
|
10953
|
+
}
|
|
10939
10954
|
resetContext() {
|
|
10940
10955
|
this.figmaContextId = null;
|
|
10941
10956
|
}
|
|
@@ -10946,6 +10961,7 @@ ${script}
|
|
|
10946
10961
|
this.ws?.close();
|
|
10947
10962
|
this.ws = null;
|
|
10948
10963
|
this.figmaContextId = null;
|
|
10964
|
+
this._targetWsUrl = null;
|
|
10949
10965
|
}
|
|
10950
10966
|
};
|
|
10951
10967
|
|
|
@@ -11057,10 +11073,20 @@ async function listTargets(port) {
|
|
|
11057
11073
|
if (!res.ok) throw new Error(`Failed to list CDP targets (HTTP ${res.status})`);
|
|
11058
11074
|
return await res.json();
|
|
11059
11075
|
}
|
|
11060
|
-
var FIGMA_DESIGN_URL_RE = /figma\.com\/(file|design)
|
|
11076
|
+
var FIGMA_DESIGN_URL_RE = /figma\.com\/(file|design)\/([a-zA-Z0-9]+)/;
|
|
11061
11077
|
function findFigmaDesignTarget(targets) {
|
|
11062
11078
|
return targets.find((t) => t.type === "page" && FIGMA_DESIGN_URL_RE.test(t.url)) || targets.find((t) => t.type === "page") || null;
|
|
11063
11079
|
}
|
|
11080
|
+
function findShellTarget(targets) {
|
|
11081
|
+
return targets.find((t) => t.type === "page" && t.url?.includes("shell.html")) || null;
|
|
11082
|
+
}
|
|
11083
|
+
function listDesignTargets(targets) {
|
|
11084
|
+
return targets.filter((t) => t.type === "page" && FIGMA_DESIGN_URL_RE.test(t.url)).map((t) => {
|
|
11085
|
+
const fileKey = t.url.match(FIGMA_DESIGN_URL_RE)[2];
|
|
11086
|
+
const fileName = t.title.replace(/\s[–—-]\s*Figma\s*$/u, "").trim();
|
|
11087
|
+
return { ...t, fileKey, fileName };
|
|
11088
|
+
});
|
|
11089
|
+
}
|
|
11064
11090
|
async function probeCdpPorts() {
|
|
11065
11091
|
for (const port of KNOWN_CDP_PORTS) {
|
|
11066
11092
|
const { alive, isFigma } = await isCdpAlive(port);
|
|
@@ -11438,6 +11464,229 @@ var PluginWsServer = class {
|
|
|
11438
11464
|
}
|
|
11439
11465
|
};
|
|
11440
11466
|
|
|
11467
|
+
// src/file-tracker.ts
|
|
11468
|
+
var FileChangedError = class extends Error {
|
|
11469
|
+
previousFileName;
|
|
11470
|
+
currentFileName;
|
|
11471
|
+
currentFileKey;
|
|
11472
|
+
allFiles;
|
|
11473
|
+
constructor(previousFileName, currentFileName, currentFileKey, allFiles) {
|
|
11474
|
+
super(`Active Figma file changed from "${previousFileName}" to "${currentFileName}".`);
|
|
11475
|
+
this.name = "FileChangedError";
|
|
11476
|
+
this.previousFileName = previousFileName;
|
|
11477
|
+
this.currentFileName = currentFileName;
|
|
11478
|
+
this.currentFileKey = currentFileKey;
|
|
11479
|
+
this.allFiles = allFiles;
|
|
11480
|
+
}
|
|
11481
|
+
};
|
|
11482
|
+
var FileUnknownError = class extends Error {
|
|
11483
|
+
allFiles;
|
|
11484
|
+
constructor(allFiles) {
|
|
11485
|
+
super("Cannot detect which Figma file is active. Please confirm the target file.");
|
|
11486
|
+
this.name = "FileUnknownError";
|
|
11487
|
+
this.allFiles = allFiles;
|
|
11488
|
+
}
|
|
11489
|
+
};
|
|
11490
|
+
var LAST_KNOWN_STALE_MS = 5 * 6e4;
|
|
11491
|
+
var SHELL_DETECT_TIMEOUT_MS = 500;
|
|
11492
|
+
var CdpFileTracker = class {
|
|
11493
|
+
cdpPort;
|
|
11494
|
+
shellWsUrl = null;
|
|
11495
|
+
_activeFileKey = null;
|
|
11496
|
+
_lastKnownFileKey = null;
|
|
11497
|
+
_lastKnownFileName = null;
|
|
11498
|
+
_lastAcknowledgedAt = 0;
|
|
11499
|
+
_files = /* @__PURE__ */ new Map();
|
|
11500
|
+
_lastDetectTime = 0;
|
|
11501
|
+
_cachedResult = null;
|
|
11502
|
+
constructor(cdpPort) {
|
|
11503
|
+
this.cdpPort = cdpPort;
|
|
11504
|
+
}
|
|
11505
|
+
// -------------------------------------------------------------------------
|
|
11506
|
+
// Public API
|
|
11507
|
+
// -------------------------------------------------------------------------
|
|
11508
|
+
/**
|
|
11509
|
+
* Detect the currently active file. Uses cached result if fresh.
|
|
11510
|
+
* @param cacheTtlMs How long to trust cached results (0 = always re-detect).
|
|
11511
|
+
*/
|
|
11512
|
+
async detectActiveFile(cacheTtlMs = 2e3) {
|
|
11513
|
+
if (this._cachedResult && cacheTtlMs > 0 && Date.now() - this._lastDetectTime < cacheTtlMs) {
|
|
11514
|
+
return this._cachedResult;
|
|
11515
|
+
}
|
|
11516
|
+
const result = await this.detect();
|
|
11517
|
+
this._cachedResult = result;
|
|
11518
|
+
this._lastDetectTime = Date.now();
|
|
11519
|
+
if (result.activeFile) {
|
|
11520
|
+
this._activeFileKey = result.activeFile.fileKey;
|
|
11521
|
+
}
|
|
11522
|
+
return result;
|
|
11523
|
+
}
|
|
11524
|
+
/** List all open design files from /json/list. */
|
|
11525
|
+
async listOpenFiles() {
|
|
11526
|
+
try {
|
|
11527
|
+
const targets = await listTargets(this.cdpPort);
|
|
11528
|
+
return this.parseDesignTargets(targets);
|
|
11529
|
+
} catch {
|
|
11530
|
+
return Array.from(this._files.values());
|
|
11531
|
+
}
|
|
11532
|
+
}
|
|
11533
|
+
/** Get cached file info by fileKey. */
|
|
11534
|
+
getFileTarget(fileKey) {
|
|
11535
|
+
return this._files.get(fileKey) || null;
|
|
11536
|
+
}
|
|
11537
|
+
/** Whether the detected active file differs from the last acknowledged file. */
|
|
11538
|
+
hasFileChanged() {
|
|
11539
|
+
return this._activeFileKey !== null && this._lastKnownFileKey !== null && this._activeFileKey !== this._lastKnownFileKey;
|
|
11540
|
+
}
|
|
11541
|
+
/**
|
|
11542
|
+
* Acknowledge the current active file as the intended target.
|
|
11543
|
+
* Call AFTER successful execution (AR-3).
|
|
11544
|
+
*/
|
|
11545
|
+
acknowledgeActiveFile() {
|
|
11546
|
+
if (this._activeFileKey) {
|
|
11547
|
+
this._lastKnownFileKey = this._activeFileKey;
|
|
11548
|
+
this._lastKnownFileName = this._files.get(this._activeFileKey)?.fileName ?? null;
|
|
11549
|
+
this._lastAcknowledgedAt = Date.now();
|
|
11550
|
+
}
|
|
11551
|
+
}
|
|
11552
|
+
get activeFileKey() {
|
|
11553
|
+
return this._activeFileKey;
|
|
11554
|
+
}
|
|
11555
|
+
get lastKnownFileKey() {
|
|
11556
|
+
return this._lastKnownFileKey;
|
|
11557
|
+
}
|
|
11558
|
+
get lastKnownFileName() {
|
|
11559
|
+
return this._lastKnownFileName;
|
|
11560
|
+
}
|
|
11561
|
+
get activeFileName() {
|
|
11562
|
+
return this._activeFileKey ? this._files.get(this._activeFileKey)?.fileName ?? null : null;
|
|
11563
|
+
}
|
|
11564
|
+
// -------------------------------------------------------------------------
|
|
11565
|
+
// Private: Core Detection
|
|
11566
|
+
// -------------------------------------------------------------------------
|
|
11567
|
+
async detect() {
|
|
11568
|
+
let targets;
|
|
11569
|
+
try {
|
|
11570
|
+
targets = await listTargets(this.cdpPort);
|
|
11571
|
+
if (!Array.isArray(targets)) targets = [];
|
|
11572
|
+
} catch {
|
|
11573
|
+
return this.fallbackToLastKnown([]);
|
|
11574
|
+
}
|
|
11575
|
+
const designFiles = this.parseDesignTargets(targets);
|
|
11576
|
+
this._files.clear();
|
|
11577
|
+
for (const f of designFiles) {
|
|
11578
|
+
this._files.set(f.fileKey, f);
|
|
11579
|
+
}
|
|
11580
|
+
if (designFiles.length === 0) {
|
|
11581
|
+
return { activeFile: null, allFiles: [], method: "none", confidence: "none" };
|
|
11582
|
+
}
|
|
11583
|
+
if (designFiles.length === 1) {
|
|
11584
|
+
this._activeFileKey = designFiles[0].fileKey;
|
|
11585
|
+
return { activeFile: designFiles[0], allFiles: designFiles, method: "single-file", confidence: "high" };
|
|
11586
|
+
}
|
|
11587
|
+
const shellTabName = await this.detectViaShellTabBar(targets);
|
|
11588
|
+
if (shellTabName) {
|
|
11589
|
+
const matched = this.matchTabNameToFile(shellTabName, designFiles);
|
|
11590
|
+
if (matched) {
|
|
11591
|
+
this._activeFileKey = matched.fileKey;
|
|
11592
|
+
log(`File tracker: active file "${matched.fileName}" (${matched.fileKey}) via shell tab bar`);
|
|
11593
|
+
return { activeFile: matched, allFiles: designFiles, method: "shell-tab-bar", confidence: "high" };
|
|
11594
|
+
}
|
|
11595
|
+
}
|
|
11596
|
+
return this.fallbackToLastKnown(designFiles);
|
|
11597
|
+
}
|
|
11598
|
+
// -------------------------------------------------------------------------
|
|
11599
|
+
// Private: Shell.html Tab Bar Detection (PRIMARY)
|
|
11600
|
+
// -------------------------------------------------------------------------
|
|
11601
|
+
/**
|
|
11602
|
+
* Read the active tab name from Figma's shell.html tab bar.
|
|
11603
|
+
* Security: uses a fixed literal expression — never interpolate user input (SR-1).
|
|
11604
|
+
*/
|
|
11605
|
+
async detectViaShellTabBar(targets) {
|
|
11606
|
+
if (!this.shellWsUrl) {
|
|
11607
|
+
const shell = findShellTarget(targets);
|
|
11608
|
+
if (!shell?.webSocketDebuggerUrl) return null;
|
|
11609
|
+
if (!shell.webSocketDebuggerUrl.startsWith("ws://127.0.0.1:")) return null;
|
|
11610
|
+
this.shellWsUrl = shell.webSocketDebuggerUrl;
|
|
11611
|
+
}
|
|
11612
|
+
const client = new CdpClient();
|
|
11613
|
+
try {
|
|
11614
|
+
const connectPromise = client.connect(this.shellWsUrl);
|
|
11615
|
+
const timeoutPromise = new Promise(
|
|
11616
|
+
(_, reject) => setTimeout(() => reject(new Error("shell detect timeout")), SHELL_DETECT_TIMEOUT_MS)
|
|
11617
|
+
);
|
|
11618
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
11619
|
+
const result = await client.send("Runtime.evaluate", {
|
|
11620
|
+
expression: `document.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute('aria-label')`,
|
|
11621
|
+
returnByValue: true
|
|
11622
|
+
});
|
|
11623
|
+
const raw = result?.result?.value;
|
|
11624
|
+
return this.parseTabNameFromCdpResult(raw);
|
|
11625
|
+
} catch {
|
|
11626
|
+
this.shellWsUrl = null;
|
|
11627
|
+
return null;
|
|
11628
|
+
} finally {
|
|
11629
|
+
client.close();
|
|
11630
|
+
}
|
|
11631
|
+
}
|
|
11632
|
+
// -------------------------------------------------------------------------
|
|
11633
|
+
// Private: Fallback — Last Known
|
|
11634
|
+
// -------------------------------------------------------------------------
|
|
11635
|
+
fallbackToLastKnown(designFiles) {
|
|
11636
|
+
if (this._lastKnownFileKey && !this.isLastKnownStale()) {
|
|
11637
|
+
const existing = designFiles.find((f) => f.fileKey === this._lastKnownFileKey);
|
|
11638
|
+
if (existing) {
|
|
11639
|
+
this._activeFileKey = existing.fileKey;
|
|
11640
|
+
log(`File tracker: using last-known file "${existing.fileName}" (low confidence)`);
|
|
11641
|
+
return { activeFile: existing, allFiles: designFiles, method: "last-known", confidence: "low" };
|
|
11642
|
+
}
|
|
11643
|
+
}
|
|
11644
|
+
log("File tracker: cannot detect active file \u2014 shell detection failed and last-known is stale or missing");
|
|
11645
|
+
return { activeFile: null, allFiles: designFiles, method: "none", confidence: "none" };
|
|
11646
|
+
}
|
|
11647
|
+
/** True if the last acknowledged execution was more than 5 minutes ago. */
|
|
11648
|
+
isLastKnownStale() {
|
|
11649
|
+
if (this._lastAcknowledgedAt === 0) return false;
|
|
11650
|
+
return Date.now() - this._lastAcknowledgedAt > LAST_KNOWN_STALE_MS;
|
|
11651
|
+
}
|
|
11652
|
+
// -------------------------------------------------------------------------
|
|
11653
|
+
// Private: Matching & Validation
|
|
11654
|
+
// -------------------------------------------------------------------------
|
|
11655
|
+
/**
|
|
11656
|
+
* Match a tab name from shell.html against design file targets.
|
|
11657
|
+
* Returns null if ambiguous (multiple files with same name) — AR-9.
|
|
11658
|
+
*/
|
|
11659
|
+
matchTabNameToFile(tabName, designFiles) {
|
|
11660
|
+
const matches = designFiles.filter((f) => f.fileName === tabName);
|
|
11661
|
+
if (matches.length === 1) return matches[0];
|
|
11662
|
+
if (matches.length > 1) {
|
|
11663
|
+
warn(`File tracker: ${matches.length} files named "${tabName}" \u2014 cannot disambiguate`);
|
|
11664
|
+
return null;
|
|
11665
|
+
}
|
|
11666
|
+
return null;
|
|
11667
|
+
}
|
|
11668
|
+
/**
|
|
11669
|
+
* Validate and clean the CDP result from shell.html evaluation (SR-3).
|
|
11670
|
+
* Rejects non-string, empty, oversized, or control-character values.
|
|
11671
|
+
*/
|
|
11672
|
+
parseTabNameFromCdpResult(raw) {
|
|
11673
|
+
if (typeof raw !== "string") return null;
|
|
11674
|
+
const trimmed = raw.trim();
|
|
11675
|
+
if (trimmed.length === 0 || trimmed.length > 512) return null;
|
|
11676
|
+
if (/[\x00-\x1f\x7f]/.test(trimmed)) return null;
|
|
11677
|
+
return trimmed;
|
|
11678
|
+
}
|
|
11679
|
+
/** Parse design targets from /json/list into FigmaFileInfo[]. */
|
|
11680
|
+
parseDesignTargets(targets) {
|
|
11681
|
+
return listDesignTargets(targets).map((t) => ({
|
|
11682
|
+
fileKey: t.fileKey,
|
|
11683
|
+
fileName: t.fileName,
|
|
11684
|
+
targetId: t.id,
|
|
11685
|
+
wsUrl: t.webSocketDebuggerUrl
|
|
11686
|
+
}));
|
|
11687
|
+
}
|
|
11688
|
+
};
|
|
11689
|
+
|
|
11441
11690
|
// node_modules/zod/v3/helpers/util.js
|
|
11442
11691
|
var util;
|
|
11443
11692
|
(function(util2) {
|
|
@@ -25585,7 +25834,7 @@ function createMcpServer(deps) {
|
|
|
25585
25834
|
},
|
|
25586
25835
|
{
|
|
25587
25836
|
name: "setup_figma",
|
|
25588
|
-
description: "Ensure Figma Desktop is running and
|
|
25837
|
+
description: "Ensure Figma Desktop is running and connected. Call this before any design work. Launches Figma if needed, detects open files and the active tab, and provides setup guidance. Returns connection status, active file, and list of all open design files. Idempotent \u2014 safe to call multiple times.",
|
|
25589
25838
|
inputSchema: {
|
|
25590
25839
|
type: "object",
|
|
25591
25840
|
properties: {},
|
|
@@ -25675,7 +25924,10 @@ function createMcpServer(deps) {
|
|
|
25675
25924
|
macro: typeof params._intent_macro === "string" ? params._intent_macro : void 0
|
|
25676
25925
|
};
|
|
25677
25926
|
const script = await deps.generateScript(name, params);
|
|
25678
|
-
const
|
|
25927
|
+
const annotation = TOOL_ANNOTATIONS[name];
|
|
25928
|
+
const isReadOnly = annotation?.readOnlyHint === true;
|
|
25929
|
+
const cacheTtlMs = isReadOnly ? 2e3 : 0;
|
|
25930
|
+
const result = await deps.executeScript(script, intents, { cacheTtlMs });
|
|
25679
25931
|
const imageData = extractImageData(result);
|
|
25680
25932
|
if (imageData) {
|
|
25681
25933
|
const content = [
|
|
@@ -25691,6 +25943,37 @@ function createMcpServer(deps) {
|
|
|
25691
25943
|
content: [{ type: "text", text }]
|
|
25692
25944
|
};
|
|
25693
25945
|
} catch (err) {
|
|
25946
|
+
if (err instanceof FileChangedError) {
|
|
25947
|
+
const annotation = TOOL_ANNOTATIONS[name];
|
|
25948
|
+
const isReadOnly = annotation?.readOnlyHint === true;
|
|
25949
|
+
const payload = JSON.stringify({
|
|
25950
|
+
status: "confirmation_required",
|
|
25951
|
+
reason: "ACTIVE_FILE_CHANGED",
|
|
25952
|
+
previousFile: err.previousFileName,
|
|
25953
|
+
currentFile: err.currentFileName,
|
|
25954
|
+
currentFileKey: err.currentFileKey,
|
|
25955
|
+
message: `The active Figma file changed from "${err.previousFileName}" to "${err.currentFileName}". Please confirm which file you want to work on.`,
|
|
25956
|
+
availableFiles: err.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
|
|
25957
|
+
}, null, 2);
|
|
25958
|
+
if (isReadOnly) {
|
|
25959
|
+
return { content: [{ type: "text", text: payload }] };
|
|
25960
|
+
}
|
|
25961
|
+
return { content: [{ type: "text", text: payload }], isError: true };
|
|
25962
|
+
}
|
|
25963
|
+
if (err instanceof FileUnknownError) {
|
|
25964
|
+
return {
|
|
25965
|
+
content: [{
|
|
25966
|
+
type: "text",
|
|
25967
|
+
text: JSON.stringify({
|
|
25968
|
+
status: "confirmation_required",
|
|
25969
|
+
reason: "ACTIVE_FILE_UNKNOWN",
|
|
25970
|
+
message: "Cannot detect which Figma file is active. Please confirm the target file.",
|
|
25971
|
+
availableFiles: err.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
|
|
25972
|
+
}, null, 2)
|
|
25973
|
+
}],
|
|
25974
|
+
isError: true
|
|
25975
|
+
};
|
|
25976
|
+
}
|
|
25694
25977
|
const message = err instanceof Error ? err.message : String(err);
|
|
25695
25978
|
const isAuthError = message.includes("auth") || message.includes("401") || message.includes("AUTH_EXPIRED");
|
|
25696
25979
|
return {
|
|
@@ -25740,6 +26023,7 @@ async function startServer(server2) {
|
|
|
25740
26023
|
// src/index.ts
|
|
25741
26024
|
var auth;
|
|
25742
26025
|
var cdpClient = null;
|
|
26026
|
+
var fileTracker = null;
|
|
25743
26027
|
var pluginServer = new PluginWsServer();
|
|
25744
26028
|
var forceTransport = process.env.FAUX_TRANSPORT;
|
|
25745
26029
|
async function tryConnectCdp() {
|
|
@@ -25747,20 +26031,23 @@ async function tryConnectCdp() {
|
|
|
25747
26031
|
if (cdpClient?.connected && cdpClient.hasContext) return cdpClient;
|
|
25748
26032
|
cdpClient?.close();
|
|
25749
26033
|
cdpClient = null;
|
|
25750
|
-
const
|
|
25751
|
-
const target = findFigmaDesignTarget(targets);
|
|
26034
|
+
const connection = await ensureFigma();
|
|
26035
|
+
const target = findFigmaDesignTarget(connection.targets);
|
|
25752
26036
|
if (!target) return null;
|
|
25753
26037
|
const client = new CdpClient();
|
|
25754
26038
|
await client.connect(target.webSocketDebuggerUrl);
|
|
25755
26039
|
await client.discoverFigmaContext();
|
|
25756
26040
|
log(`CDP connected: ${target.title}`);
|
|
25757
26041
|
cdpClient = client;
|
|
26042
|
+
if (!fileTracker) {
|
|
26043
|
+
fileTracker = new CdpFileTracker(connection.port);
|
|
26044
|
+
}
|
|
25758
26045
|
return client;
|
|
25759
26046
|
} catch {
|
|
25760
26047
|
return null;
|
|
25761
26048
|
}
|
|
25762
26049
|
}
|
|
25763
|
-
async function executeScript(script, intents) {
|
|
26050
|
+
async function executeScript(script, intents, opts) {
|
|
25764
26051
|
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
25765
26052
|
log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
|
|
25766
26053
|
return pluginServer.executeScript(script, void 0, intents);
|
|
@@ -25768,12 +26055,34 @@ async function executeScript(script, intents) {
|
|
|
25768
26055
|
if (forceTransport !== "plugin") {
|
|
25769
26056
|
const client = await tryConnectCdp();
|
|
25770
26057
|
if (client) {
|
|
26058
|
+
if (fileTracker) {
|
|
26059
|
+
const detection = await fileTracker.detectActiveFile(opts?.cacheTtlMs);
|
|
26060
|
+
if (!detection.activeFile && detection.allFiles.length > 1) {
|
|
26061
|
+
throw new FileUnknownError(detection.allFiles);
|
|
26062
|
+
}
|
|
26063
|
+
if (fileTracker.hasFileChanged()) {
|
|
26064
|
+
throw new FileChangedError(
|
|
26065
|
+
fileTracker.lastKnownFileName,
|
|
26066
|
+
fileTracker.activeFileName,
|
|
26067
|
+
fileTracker.activeFileKey,
|
|
26068
|
+
detection.allFiles
|
|
26069
|
+
);
|
|
26070
|
+
}
|
|
26071
|
+
if (detection.activeFile && client.targetWsUrl !== detection.activeFile.wsUrl) {
|
|
26072
|
+
log(`Switching CDP target to "${detection.activeFile.fileName}" (${detection.activeFile.fileKey})`);
|
|
26073
|
+
await client.reconnectToTarget(detection.activeFile.wsUrl);
|
|
26074
|
+
}
|
|
26075
|
+
}
|
|
25771
26076
|
log("Executing via CDP");
|
|
25772
26077
|
try {
|
|
25773
|
-
|
|
26078
|
+
const result = await client.executeScript(script);
|
|
26079
|
+
fileTracker?.acknowledgeActiveFile();
|
|
26080
|
+
return result;
|
|
25774
26081
|
} catch (err) {
|
|
25775
26082
|
if (err instanceof ContextDestroyedError) {
|
|
25776
|
-
|
|
26083
|
+
const result = await recoverCdp(client, script);
|
|
26084
|
+
fileTracker?.acknowledgeActiveFile();
|
|
26085
|
+
return result;
|
|
25777
26086
|
}
|
|
25778
26087
|
throw err;
|
|
25779
26088
|
}
|
|
@@ -25841,6 +26150,16 @@ async function setupFigma(params) {
|
|
|
25841
26150
|
return pluginReadyResult();
|
|
25842
26151
|
}
|
|
25843
26152
|
if (forceTransport !== "plugin" && cdpClient?.connected && cdpClient.hasContext) {
|
|
26153
|
+
if (fileTracker) {
|
|
26154
|
+
const detection = await fileTracker.detectActiveFile();
|
|
26155
|
+
return {
|
|
26156
|
+
status: "ready",
|
|
26157
|
+
transport: "cdp",
|
|
26158
|
+
message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? "unknown"}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26159
|
+
activeFile: detection.activeFile?.fileName,
|
|
26160
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
|
|
26161
|
+
};
|
|
26162
|
+
}
|
|
25844
26163
|
return {
|
|
25845
26164
|
status: "ready",
|
|
25846
26165
|
transport: "cdp",
|
|
@@ -25860,11 +26179,17 @@ async function setupFigma(params) {
|
|
|
25860
26179
|
await client.discoverFigmaContext();
|
|
25861
26180
|
cdpClient = client;
|
|
25862
26181
|
log(`CDP connected: ${target.title}`);
|
|
26182
|
+
if (!fileTracker) {
|
|
26183
|
+
fileTracker = new CdpFileTracker(existing.port);
|
|
26184
|
+
}
|
|
26185
|
+
const detection = await fileTracker.detectActiveFile();
|
|
26186
|
+
fileTracker.acknowledgeActiveFile();
|
|
25863
26187
|
return {
|
|
25864
26188
|
status: "ready",
|
|
25865
26189
|
transport: "cdp",
|
|
25866
|
-
message: `Connected via CDP. Active file: ${target.title}. Ready to design.`,
|
|
25867
|
-
activeFile: target.title,
|
|
26190
|
+
message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? target.title}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26191
|
+
activeFile: detection.activeFile?.fileName ?? target.title,
|
|
26192
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
25868
26193
|
port: existing.port
|
|
25869
26194
|
};
|
|
25870
26195
|
} catch {
|
|
@@ -26000,10 +26325,31 @@ async function main() {
|
|
|
26000
26325
|
function shutdown() {
|
|
26001
26326
|
pluginServer.close();
|
|
26002
26327
|
cdpClient?.close();
|
|
26328
|
+
fileTracker = null;
|
|
26003
26329
|
process.exit(0);
|
|
26004
26330
|
}
|
|
26005
26331
|
process.on("SIGINT", shutdown);
|
|
26006
26332
|
process.on("SIGTERM", shutdown);
|
|
26333
|
+
var ORPHAN_CHECK_MS = 5e3;
|
|
26334
|
+
var originalPpid = process.ppid;
|
|
26335
|
+
function isParentAlive() {
|
|
26336
|
+
try {
|
|
26337
|
+
process.kill(originalPpid, 0);
|
|
26338
|
+
return true;
|
|
26339
|
+
} catch {
|
|
26340
|
+
return false;
|
|
26341
|
+
}
|
|
26342
|
+
}
|
|
26343
|
+
process.stdin.on("end", () => {
|
|
26344
|
+
log("stdin closed \u2014 parent process gone, shutting down");
|
|
26345
|
+
shutdown();
|
|
26346
|
+
});
|
|
26347
|
+
setInterval(() => {
|
|
26348
|
+
if (!isParentAlive()) {
|
|
26349
|
+
log(`Parent process (PID ${originalPpid}) gone \u2014 shutting down`);
|
|
26350
|
+
shutdown();
|
|
26351
|
+
}
|
|
26352
|
+
}, ORPHAN_CHECK_MS).unref();
|
|
26007
26353
|
main().catch((err) => {
|
|
26008
26354
|
error(err instanceof Error ? err.message : String(err));
|
|
26009
26355
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faux-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "AI-powered Figma design via MCP — connect any AI client to Figma Desktop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsup",
|
|
17
|
+
"build:staging": "tsup --env.FAUX_API_URL https://staging-api.faux.design --env.FAUX_AUTH_URL https://staging-auth.faux.design",
|
|
17
18
|
"build:plugin": "cd plugin && npm run build",
|
|
18
19
|
"build:all": "npm run build && npm run build:plugin",
|
|
19
20
|
"dev": "tsup --watch",
|