faux-studio 0.3.11 → 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 +360 -46
- 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);
|
|
@@ -11152,7 +11178,6 @@ async function ensureFigma() {
|
|
|
11152
11178
|
}
|
|
11153
11179
|
|
|
11154
11180
|
// src/plugin-ws.ts
|
|
11155
|
-
import { execSync as execSync2 } from "child_process";
|
|
11156
11181
|
import { randomUUID } from "crypto";
|
|
11157
11182
|
import { writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
11158
11183
|
import { join as join4 } from "path";
|
|
@@ -11192,8 +11217,6 @@ var PluginWsServer = class {
|
|
|
11192
11217
|
// Server Lifecycle
|
|
11193
11218
|
// -------------------------------------------------------------------------
|
|
11194
11219
|
async start() {
|
|
11195
|
-
this.killStaleInstances();
|
|
11196
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
11197
11220
|
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_RANGE; port++) {
|
|
11198
11221
|
try {
|
|
11199
11222
|
await this.listen(port);
|
|
@@ -11209,35 +11232,6 @@ var PluginWsServer = class {
|
|
|
11209
11232
|
`No available port in range ${DEFAULT_PORT}-${DEFAULT_PORT + PORT_RANGE - 1}. Another faux-studio instance may be running.`
|
|
11210
11233
|
);
|
|
11211
11234
|
}
|
|
11212
|
-
/**
|
|
11213
|
-
* Kill any node processes listening on our port range that are running
|
|
11214
|
-
* faux-studio. Uses OS-level detection — works regardless of the old
|
|
11215
|
-
* instance's version or protocol support.
|
|
11216
|
-
*/
|
|
11217
|
-
killStaleInstances() {
|
|
11218
|
-
const myPid = process.pid;
|
|
11219
|
-
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_RANGE; port++) {
|
|
11220
|
-
try {
|
|
11221
|
-
let pid;
|
|
11222
|
-
if (process.platform === "win32") {
|
|
11223
|
-
const output = execSync2(
|
|
11224
|
-
`netstat -ano | findstr "LISTENING" | findstr ":${port} "`,
|
|
11225
|
-
{ encoding: "utf-8" }
|
|
11226
|
-
);
|
|
11227
|
-
pid = output.trim().split(/\s+/).pop();
|
|
11228
|
-
} else {
|
|
11229
|
-
pid = execSync2(`lsof -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8" }).trim().split("\n")[0];
|
|
11230
|
-
}
|
|
11231
|
-
if (!pid || Number(pid) === myPid) continue;
|
|
11232
|
-
const cmd = process.platform === "win32" ? execSync2(`wmic process where "ProcessId=${pid}" get CommandLine /format:list`, { encoding: "utf-8" }) : execSync2(`ps -p ${pid} -o command=`, { encoding: "utf-8" });
|
|
11233
|
-
if (cmd.includes("faux-studio")) {
|
|
11234
|
-
log(`Killing stale faux-studio on port ${port} (pid ${pid})`);
|
|
11235
|
-
process.kill(Number(pid), "SIGTERM");
|
|
11236
|
-
}
|
|
11237
|
-
} catch {
|
|
11238
|
-
}
|
|
11239
|
-
}
|
|
11240
|
-
}
|
|
11241
11235
|
listen(port) {
|
|
11242
11236
|
return new Promise((resolve, reject) => {
|
|
11243
11237
|
const wss = new import_websocket_server.default({ port, host: "127.0.0.1" });
|
|
@@ -11470,6 +11464,229 @@ var PluginWsServer = class {
|
|
|
11470
11464
|
}
|
|
11471
11465
|
};
|
|
11472
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
|
+
|
|
11473
11690
|
// node_modules/zod/v3/helpers/util.js
|
|
11474
11691
|
var util;
|
|
11475
11692
|
(function(util2) {
|
|
@@ -25602,7 +25819,7 @@ Resources provide quick read-only access to Figma state without tool calls:
|
|
|
25602
25819
|
- Create components for reusable UI patterns.`;
|
|
25603
25820
|
function createMcpServer(deps) {
|
|
25604
25821
|
const server2 = new Server(
|
|
25605
|
-
{ name: "faux-studio", version: "0.3.
|
|
25822
|
+
{ name: "faux-studio", version: "0.3.12" },
|
|
25606
25823
|
{
|
|
25607
25824
|
capabilities: { tools: { listChanged: true }, resources: {}, logging: {} },
|
|
25608
25825
|
instructions: INSTRUCTIONS
|
|
@@ -25617,7 +25834,7 @@ function createMcpServer(deps) {
|
|
|
25617
25834
|
},
|
|
25618
25835
|
{
|
|
25619
25836
|
name: "setup_figma",
|
|
25620
|
-
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.",
|
|
25621
25838
|
inputSchema: {
|
|
25622
25839
|
type: "object",
|
|
25623
25840
|
properties: {},
|
|
@@ -25707,7 +25924,10 @@ function createMcpServer(deps) {
|
|
|
25707
25924
|
macro: typeof params._intent_macro === "string" ? params._intent_macro : void 0
|
|
25708
25925
|
};
|
|
25709
25926
|
const script = await deps.generateScript(name, params);
|
|
25710
|
-
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 });
|
|
25711
25931
|
const imageData = extractImageData(result);
|
|
25712
25932
|
if (imageData) {
|
|
25713
25933
|
const content = [
|
|
@@ -25723,6 +25943,37 @@ function createMcpServer(deps) {
|
|
|
25723
25943
|
content: [{ type: "text", text }]
|
|
25724
25944
|
};
|
|
25725
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
|
+
}
|
|
25726
25977
|
const message = err instanceof Error ? err.message : String(err);
|
|
25727
25978
|
const isAuthError = message.includes("auth") || message.includes("401") || message.includes("AUTH_EXPIRED");
|
|
25728
25979
|
return {
|
|
@@ -25772,6 +26023,7 @@ async function startServer(server2) {
|
|
|
25772
26023
|
// src/index.ts
|
|
25773
26024
|
var auth;
|
|
25774
26025
|
var cdpClient = null;
|
|
26026
|
+
var fileTracker = null;
|
|
25775
26027
|
var pluginServer = new PluginWsServer();
|
|
25776
26028
|
var forceTransport = process.env.FAUX_TRANSPORT;
|
|
25777
26029
|
async function tryConnectCdp() {
|
|
@@ -25779,20 +26031,23 @@ async function tryConnectCdp() {
|
|
|
25779
26031
|
if (cdpClient?.connected && cdpClient.hasContext) return cdpClient;
|
|
25780
26032
|
cdpClient?.close();
|
|
25781
26033
|
cdpClient = null;
|
|
25782
|
-
const
|
|
25783
|
-
const target = findFigmaDesignTarget(targets);
|
|
26034
|
+
const connection = await ensureFigma();
|
|
26035
|
+
const target = findFigmaDesignTarget(connection.targets);
|
|
25784
26036
|
if (!target) return null;
|
|
25785
26037
|
const client = new CdpClient();
|
|
25786
26038
|
await client.connect(target.webSocketDebuggerUrl);
|
|
25787
26039
|
await client.discoverFigmaContext();
|
|
25788
26040
|
log(`CDP connected: ${target.title}`);
|
|
25789
26041
|
cdpClient = client;
|
|
26042
|
+
if (!fileTracker) {
|
|
26043
|
+
fileTracker = new CdpFileTracker(connection.port);
|
|
26044
|
+
}
|
|
25790
26045
|
return client;
|
|
25791
26046
|
} catch {
|
|
25792
26047
|
return null;
|
|
25793
26048
|
}
|
|
25794
26049
|
}
|
|
25795
|
-
async function executeScript(script, intents) {
|
|
26050
|
+
async function executeScript(script, intents, opts) {
|
|
25796
26051
|
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
25797
26052
|
log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
|
|
25798
26053
|
return pluginServer.executeScript(script, void 0, intents);
|
|
@@ -25800,12 +26055,34 @@ async function executeScript(script, intents) {
|
|
|
25800
26055
|
if (forceTransport !== "plugin") {
|
|
25801
26056
|
const client = await tryConnectCdp();
|
|
25802
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
|
+
}
|
|
25803
26076
|
log("Executing via CDP");
|
|
25804
26077
|
try {
|
|
25805
|
-
|
|
26078
|
+
const result = await client.executeScript(script);
|
|
26079
|
+
fileTracker?.acknowledgeActiveFile();
|
|
26080
|
+
return result;
|
|
25806
26081
|
} catch (err) {
|
|
25807
26082
|
if (err instanceof ContextDestroyedError) {
|
|
25808
|
-
|
|
26083
|
+
const result = await recoverCdp(client, script);
|
|
26084
|
+
fileTracker?.acknowledgeActiveFile();
|
|
26085
|
+
return result;
|
|
25809
26086
|
}
|
|
25810
26087
|
throw err;
|
|
25811
26088
|
}
|
|
@@ -25873,6 +26150,16 @@ async function setupFigma(params) {
|
|
|
25873
26150
|
return pluginReadyResult();
|
|
25874
26151
|
}
|
|
25875
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
|
+
}
|
|
25876
26163
|
return {
|
|
25877
26164
|
status: "ready",
|
|
25878
26165
|
transport: "cdp",
|
|
@@ -25892,11 +26179,17 @@ async function setupFigma(params) {
|
|
|
25892
26179
|
await client.discoverFigmaContext();
|
|
25893
26180
|
cdpClient = client;
|
|
25894
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();
|
|
25895
26187
|
return {
|
|
25896
26188
|
status: "ready",
|
|
25897
26189
|
transport: "cdp",
|
|
25898
|
-
message: `Connected via CDP. Active file: ${target.title}. Ready to design.`,
|
|
25899
|
-
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 })),
|
|
25900
26193
|
port: existing.port
|
|
25901
26194
|
};
|
|
25902
26195
|
} catch {
|
|
@@ -26032,10 +26325,31 @@ async function main() {
|
|
|
26032
26325
|
function shutdown() {
|
|
26033
26326
|
pluginServer.close();
|
|
26034
26327
|
cdpClient?.close();
|
|
26328
|
+
fileTracker = null;
|
|
26035
26329
|
process.exit(0);
|
|
26036
26330
|
}
|
|
26037
26331
|
process.on("SIGINT", shutdown);
|
|
26038
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();
|
|
26039
26353
|
main().catch((err) => {
|
|
26040
26354
|
error(err instanceof Error ? err.message : String(err));
|
|
26041
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",
|