faux-studio 0.3.12 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/index.js +548 -46
- package/package.json +9 -3
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
|
@@ -10414,9 +10414,6 @@ var require_dist = __commonJS({
|
|
|
10414
10414
|
});
|
|
10415
10415
|
|
|
10416
10416
|
// src/auth.ts
|
|
10417
|
-
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
10418
|
-
import { join } from "path";
|
|
10419
|
-
import { homedir } from "os";
|
|
10420
10417
|
import { spawn } from "child_process";
|
|
10421
10418
|
|
|
10422
10419
|
// src/logger.ts
|
|
@@ -10448,31 +10445,129 @@ function error(message) {
|
|
|
10448
10445
|
send("error", message);
|
|
10449
10446
|
}
|
|
10450
10447
|
|
|
10451
|
-
// src/
|
|
10448
|
+
// src/credential-store.ts
|
|
10449
|
+
import { readFile, writeFile, mkdir, unlink } from "fs/promises";
|
|
10450
|
+
import { join } from "path";
|
|
10451
|
+
import { homedir } from "os";
|
|
10452
10452
|
var FAUX_DIR = join(homedir(), ".faux");
|
|
10453
10453
|
var CREDENTIALS_PATH = join(FAUX_DIR, "credentials.json");
|
|
10454
|
-
var
|
|
10455
|
-
var
|
|
10456
|
-
var
|
|
10457
|
-
|
|
10458
|
-
async
|
|
10454
|
+
var KEYCHAIN_SERVICE = "faux-studio";
|
|
10455
|
+
var KEYCHAIN_ACCOUNT = "credentials";
|
|
10456
|
+
var FileCredentialStore = class {
|
|
10457
|
+
name = "file";
|
|
10458
|
+
async load() {
|
|
10459
|
+
try {
|
|
10460
|
+
const raw = await readFile(CREDENTIALS_PATH, "utf-8");
|
|
10461
|
+
const creds = JSON.parse(raw);
|
|
10462
|
+
if (!creds.jwt || !creds.refreshToken || !creds.user) return null;
|
|
10463
|
+
return creds;
|
|
10464
|
+
} catch {
|
|
10465
|
+
return null;
|
|
10466
|
+
}
|
|
10467
|
+
}
|
|
10468
|
+
async save(creds) {
|
|
10469
|
+
try {
|
|
10470
|
+
await mkdir(FAUX_DIR, { recursive: true });
|
|
10471
|
+
await writeFile(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
10472
|
+
} catch (err) {
|
|
10473
|
+
warn(`Could not save credentials: ${err instanceof Error ? err.message : err}`);
|
|
10474
|
+
}
|
|
10475
|
+
}
|
|
10476
|
+
async clear() {
|
|
10477
|
+
try {
|
|
10478
|
+
await unlink(CREDENTIALS_PATH);
|
|
10479
|
+
} catch {
|
|
10480
|
+
}
|
|
10481
|
+
}
|
|
10482
|
+
};
|
|
10483
|
+
var KeychainCredentialStore = class {
|
|
10484
|
+
name = "keychain";
|
|
10485
|
+
entry;
|
|
10486
|
+
constructor(entry) {
|
|
10487
|
+
this.entry = entry;
|
|
10488
|
+
}
|
|
10489
|
+
async load() {
|
|
10490
|
+
try {
|
|
10491
|
+
const raw = await this.entry.getPassword();
|
|
10492
|
+
if (!raw) return null;
|
|
10493
|
+
const creds = JSON.parse(raw);
|
|
10494
|
+
if (!creds.jwt || !creds.refreshToken || !creds.user) return null;
|
|
10495
|
+
return creds;
|
|
10496
|
+
} catch {
|
|
10497
|
+
return null;
|
|
10498
|
+
}
|
|
10499
|
+
}
|
|
10500
|
+
async save(creds) {
|
|
10501
|
+
try {
|
|
10502
|
+
await this.entry.setPassword(JSON.stringify(creds));
|
|
10503
|
+
} catch (err) {
|
|
10504
|
+
warn(`Could not save to keychain: ${err instanceof Error ? err.message : err}`);
|
|
10505
|
+
}
|
|
10506
|
+
}
|
|
10507
|
+
async clear() {
|
|
10508
|
+
try {
|
|
10509
|
+
await this.entry.deletePassword();
|
|
10510
|
+
} catch {
|
|
10511
|
+
}
|
|
10512
|
+
}
|
|
10513
|
+
};
|
|
10514
|
+
async function tryCreateKeychainStore() {
|
|
10459
10515
|
try {
|
|
10460
|
-
const
|
|
10461
|
-
const
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
|
|
10516
|
+
const { AsyncEntry } = await import("@napi-rs/keyring");
|
|
10517
|
+
const probe = new AsyncEntry(KEYCHAIN_SERVICE, "__probe__");
|
|
10518
|
+
await probe.setPassword("probe");
|
|
10519
|
+
try {
|
|
10520
|
+
const val = await probe.getPassword();
|
|
10521
|
+
if (val !== "probe") throw new Error("Keychain probe read-back mismatch");
|
|
10522
|
+
} finally {
|
|
10523
|
+
await probe.deletePassword().catch(() => {
|
|
10524
|
+
});
|
|
10525
|
+
}
|
|
10526
|
+
const entry = new AsyncEntry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
10527
|
+
return new KeychainCredentialStore(entry);
|
|
10528
|
+
} catch (err) {
|
|
10529
|
+
warn(
|
|
10530
|
+
`OS keychain not available, using file-based credential storage: ${err instanceof Error ? err.message : err}`
|
|
10531
|
+
);
|
|
10465
10532
|
return null;
|
|
10466
10533
|
}
|
|
10467
10534
|
}
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
}
|
|
10473
|
-
|
|
10535
|
+
var initPromise = null;
|
|
10536
|
+
function createCredentialStore() {
|
|
10537
|
+
if (!initPromise) {
|
|
10538
|
+
initPromise = initStore();
|
|
10539
|
+
}
|
|
10540
|
+
return initPromise;
|
|
10541
|
+
}
|
|
10542
|
+
async function initStore() {
|
|
10543
|
+
const fileStore = new FileCredentialStore();
|
|
10544
|
+
const keychainStore = await tryCreateKeychainStore();
|
|
10545
|
+
if (!keychainStore) {
|
|
10546
|
+
return fileStore;
|
|
10547
|
+
}
|
|
10548
|
+
const keychainCreds = await keychainStore.load();
|
|
10549
|
+
if (!keychainCreds) {
|
|
10550
|
+
const fileCreds = await fileStore.load();
|
|
10551
|
+
if (fileCreds) {
|
|
10552
|
+
await keychainStore.save(fileCreds);
|
|
10553
|
+
const verified = await keychainStore.load();
|
|
10554
|
+
if (verified) {
|
|
10555
|
+
await fileStore.clear();
|
|
10556
|
+
log("Credentials migrated to OS keychain");
|
|
10557
|
+
} else {
|
|
10558
|
+
warn("Keychain migration could not be verified \u2014 keeping file-based credentials");
|
|
10559
|
+
return fileStore;
|
|
10560
|
+
}
|
|
10561
|
+
}
|
|
10474
10562
|
}
|
|
10563
|
+
return keychainStore;
|
|
10475
10564
|
}
|
|
10565
|
+
|
|
10566
|
+
// src/auth.ts
|
|
10567
|
+
var AUTH_BASE = process.env.FAUX_AUTH_URL || "https://auth.faux.design";
|
|
10568
|
+
var POLL_INTERVAL_MS = 2e3;
|
|
10569
|
+
var POLL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
10570
|
+
var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
|
|
10476
10571
|
function isExpiringSoon(creds) {
|
|
10477
10572
|
const expiresAt = new Date(creds.expiresAt).getTime();
|
|
10478
10573
|
return Date.now() > expiresAt - REFRESH_BUFFER_MS;
|
|
@@ -10543,7 +10638,7 @@ async function authenticate() {
|
|
|
10543
10638
|
expiresAt: new Date(Date.now() + 3600 * 1e3).toISOString(),
|
|
10544
10639
|
user: data.user
|
|
10545
10640
|
};
|
|
10546
|
-
await
|
|
10641
|
+
await (await createCredentialStore()).save(creds);
|
|
10547
10642
|
return creds;
|
|
10548
10643
|
}
|
|
10549
10644
|
if (data.status === "error") {
|
|
@@ -10570,7 +10665,8 @@ async function ensureAuth() {
|
|
|
10570
10665
|
source: "api-key"
|
|
10571
10666
|
};
|
|
10572
10667
|
}
|
|
10573
|
-
const
|
|
10668
|
+
const credStore = await createCredentialStore();
|
|
10669
|
+
const saved = await credStore.load();
|
|
10574
10670
|
if (saved) {
|
|
10575
10671
|
if (!isExpiringSoon(saved)) {
|
|
10576
10672
|
log(`Authenticated as ${saved.user.handle}`);
|
|
@@ -10584,7 +10680,7 @@ async function ensureAuth() {
|
|
|
10584
10680
|
try {
|
|
10585
10681
|
log("Refreshing authentication...");
|
|
10586
10682
|
const refreshed = await refreshJwt(saved);
|
|
10587
|
-
await
|
|
10683
|
+
await credStore.save(refreshed);
|
|
10588
10684
|
log(`Authenticated as ${refreshed.user.handle}`);
|
|
10589
10685
|
return {
|
|
10590
10686
|
jwt: refreshed.jwt,
|
|
@@ -10607,12 +10703,13 @@ async function ensureAuth() {
|
|
|
10607
10703
|
}
|
|
10608
10704
|
async function refreshIfNeeded(current, force = false) {
|
|
10609
10705
|
if (current.source === "api-key") return current;
|
|
10610
|
-
const
|
|
10706
|
+
const credStore = await createCredentialStore();
|
|
10707
|
+
const saved = await credStore.load();
|
|
10611
10708
|
if (!saved) return current;
|
|
10612
10709
|
if (!force && !isExpiringSoon(saved)) return current;
|
|
10613
10710
|
try {
|
|
10614
10711
|
const refreshed = await refreshJwt(saved);
|
|
10615
|
-
await
|
|
10712
|
+
await credStore.save(refreshed);
|
|
10616
10713
|
return {
|
|
10617
10714
|
jwt: refreshed.jwt,
|
|
10618
10715
|
refreshToken: refreshed.refreshToken,
|
|
@@ -10628,9 +10725,34 @@ async function refreshIfNeeded(current, force = false) {
|
|
|
10628
10725
|
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
10629
10726
|
import { join as join2 } from "path";
|
|
10630
10727
|
import { homedir as homedir2 } from "os";
|
|
10728
|
+
import { subtle } from "crypto";
|
|
10631
10729
|
var FAUX_DIR2 = join2(homedir2(), ".faux");
|
|
10632
10730
|
var CACHE_PATH = join2(FAUX_DIR2, "tool-cache.json");
|
|
10633
|
-
var API_BASE = "https://
|
|
10731
|
+
var API_BASE = process.env.FAUX_API_URL || "https://api.faux.design";
|
|
10732
|
+
var _publicKey = null;
|
|
10733
|
+
var _cachedVerifyKey = null;
|
|
10734
|
+
var _cachedKeyInput = null;
|
|
10735
|
+
function getVerifyKey() {
|
|
10736
|
+
return _publicKey;
|
|
10737
|
+
}
|
|
10738
|
+
async function importPublicKey(jwkJson) {
|
|
10739
|
+
if (_cachedVerifyKey && _cachedKeyInput === jwkJson) return _cachedVerifyKey;
|
|
10740
|
+
const jwk = JSON.parse(jwkJson);
|
|
10741
|
+
_cachedVerifyKey = await subtle.importKey("jwk", jwk, { name: "Ed25519" }, false, ["verify"]);
|
|
10742
|
+
_cachedKeyInput = jwkJson;
|
|
10743
|
+
return _cachedVerifyKey;
|
|
10744
|
+
}
|
|
10745
|
+
async function verifyScriptSignature(script, signature, keyJwk) {
|
|
10746
|
+
if (signature.length !== 128) return false;
|
|
10747
|
+
try {
|
|
10748
|
+
const key = await importPublicKey(keyJwk);
|
|
10749
|
+
const sigBytes = Uint8Array.from(signature.match(/.{2}/g).map((h) => parseInt(h, 16)));
|
|
10750
|
+
const scriptBytes = new TextEncoder().encode(script);
|
|
10751
|
+
return await subtle.verify("Ed25519", key, sigBytes, scriptBytes);
|
|
10752
|
+
} catch {
|
|
10753
|
+
return false;
|
|
10754
|
+
}
|
|
10755
|
+
}
|
|
10634
10756
|
async function loadCachedTools() {
|
|
10635
10757
|
try {
|
|
10636
10758
|
const raw = await readFile2(CACHE_PATH, "utf-8");
|
|
@@ -10679,6 +10801,9 @@ async function fetchRemoteTools(jwt2) {
|
|
|
10679
10801
|
throw new Error(`Failed to fetch tools (HTTP ${res.status})`);
|
|
10680
10802
|
}
|
|
10681
10803
|
const data = await res.json();
|
|
10804
|
+
if (data.signingKey) {
|
|
10805
|
+
_publicKey = data.signingKey;
|
|
10806
|
+
}
|
|
10682
10807
|
return sanitizeTools(data.tools);
|
|
10683
10808
|
}
|
|
10684
10809
|
async function getTools(jwt2) {
|
|
@@ -10732,7 +10857,7 @@ async function generateScript(jwt2, toolName, params) {
|
|
|
10732
10857
|
throw new Error(body.error || `Script generation failed (HTTP ${res.status})`);
|
|
10733
10858
|
}
|
|
10734
10859
|
const data = await res.json();
|
|
10735
|
-
return data.script;
|
|
10860
|
+
return { script: data.script, signature: data.signature };
|
|
10736
10861
|
}
|
|
10737
10862
|
|
|
10738
10863
|
// node_modules/ws/wrapper.mjs
|
|
@@ -10756,10 +10881,12 @@ var CdpClient = class {
|
|
|
10756
10881
|
pending = /* @__PURE__ */ new Map();
|
|
10757
10882
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
10758
10883
|
figmaContextId = null;
|
|
10884
|
+
_targetWsUrl = null;
|
|
10759
10885
|
// -------------------------------------------------------------------------
|
|
10760
10886
|
// Connection
|
|
10761
10887
|
// -------------------------------------------------------------------------
|
|
10762
10888
|
async connect(wsUrl) {
|
|
10889
|
+
this._targetWsUrl = wsUrl;
|
|
10763
10890
|
return new Promise((resolve, reject) => {
|
|
10764
10891
|
const ws = new wrapper_default(wsUrl);
|
|
10765
10892
|
const timeout = setTimeout(() => {
|
|
@@ -10865,7 +10992,7 @@ var CdpClient = class {
|
|
|
10865
10992
|
this.on("Runtime.executionContextCreated", handler);
|
|
10866
10993
|
try {
|
|
10867
10994
|
await this.send("Runtime.enable");
|
|
10868
|
-
await new Promise((r) => setTimeout(r,
|
|
10995
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
10869
10996
|
} finally {
|
|
10870
10997
|
this.off("Runtime.executionContextCreated", handler);
|
|
10871
10998
|
}
|
|
@@ -10928,6 +11055,15 @@ ${script}
|
|
|
10928
11055
|
})()`);
|
|
10929
11056
|
}
|
|
10930
11057
|
// -------------------------------------------------------------------------
|
|
11058
|
+
// Reconnection
|
|
11059
|
+
// -------------------------------------------------------------------------
|
|
11060
|
+
/** Close current connection and reconnect to a different CDP target. */
|
|
11061
|
+
async reconnectToTarget(wsUrl) {
|
|
11062
|
+
this.close();
|
|
11063
|
+
await this.connect(wsUrl);
|
|
11064
|
+
await this.discoverFigmaContext();
|
|
11065
|
+
}
|
|
11066
|
+
// -------------------------------------------------------------------------
|
|
10931
11067
|
// State
|
|
10932
11068
|
// -------------------------------------------------------------------------
|
|
10933
11069
|
get connected() {
|
|
@@ -10936,6 +11072,10 @@ ${script}
|
|
|
10936
11072
|
get hasContext() {
|
|
10937
11073
|
return this.figmaContextId !== null;
|
|
10938
11074
|
}
|
|
11075
|
+
/** The WebSocket URL this client is currently connected to. */
|
|
11076
|
+
get targetWsUrl() {
|
|
11077
|
+
return this._targetWsUrl;
|
|
11078
|
+
}
|
|
10939
11079
|
resetContext() {
|
|
10940
11080
|
this.figmaContextId = null;
|
|
10941
11081
|
}
|
|
@@ -10946,6 +11086,7 @@ ${script}
|
|
|
10946
11086
|
this.ws?.close();
|
|
10947
11087
|
this.ws = null;
|
|
10948
11088
|
this.figmaContextId = null;
|
|
11089
|
+
this._targetWsUrl = null;
|
|
10949
11090
|
}
|
|
10950
11091
|
};
|
|
10951
11092
|
|
|
@@ -11057,10 +11198,20 @@ async function listTargets(port) {
|
|
|
11057
11198
|
if (!res.ok) throw new Error(`Failed to list CDP targets (HTTP ${res.status})`);
|
|
11058
11199
|
return await res.json();
|
|
11059
11200
|
}
|
|
11060
|
-
var FIGMA_DESIGN_URL_RE = /figma\.com\/(file|design)
|
|
11201
|
+
var FIGMA_DESIGN_URL_RE = /figma\.com\/(file|design)\/([a-zA-Z0-9]+)/;
|
|
11061
11202
|
function findFigmaDesignTarget(targets) {
|
|
11062
11203
|
return targets.find((t) => t.type === "page" && FIGMA_DESIGN_URL_RE.test(t.url)) || targets.find((t) => t.type === "page") || null;
|
|
11063
11204
|
}
|
|
11205
|
+
function findShellTarget(targets) {
|
|
11206
|
+
return targets.find((t) => t.type === "page" && t.url?.includes("shell.html")) || null;
|
|
11207
|
+
}
|
|
11208
|
+
function listDesignTargets(targets) {
|
|
11209
|
+
return targets.filter((t) => t.type === "page" && FIGMA_DESIGN_URL_RE.test(t.url)).map((t) => {
|
|
11210
|
+
const fileKey = t.url.match(FIGMA_DESIGN_URL_RE)[2];
|
|
11211
|
+
const fileName = t.title.replace(/\s[–—-]\s*Figma\s*$/u, "").trim();
|
|
11212
|
+
return { ...t, fileKey, fileName };
|
|
11213
|
+
});
|
|
11214
|
+
}
|
|
11064
11215
|
async function probeCdpPorts() {
|
|
11065
11216
|
for (const port of KNOWN_CDP_PORTS) {
|
|
11066
11217
|
const { alive, isFigma } = await isCdpAlive(port);
|
|
@@ -11438,6 +11589,229 @@ var PluginWsServer = class {
|
|
|
11438
11589
|
}
|
|
11439
11590
|
};
|
|
11440
11591
|
|
|
11592
|
+
// src/file-tracker.ts
|
|
11593
|
+
var FileChangedError = class extends Error {
|
|
11594
|
+
previousFileName;
|
|
11595
|
+
currentFileName;
|
|
11596
|
+
currentFileKey;
|
|
11597
|
+
allFiles;
|
|
11598
|
+
constructor(previousFileName, currentFileName, currentFileKey, allFiles) {
|
|
11599
|
+
super(`Active Figma file changed from "${previousFileName}" to "${currentFileName}".`);
|
|
11600
|
+
this.name = "FileChangedError";
|
|
11601
|
+
this.previousFileName = previousFileName;
|
|
11602
|
+
this.currentFileName = currentFileName;
|
|
11603
|
+
this.currentFileKey = currentFileKey;
|
|
11604
|
+
this.allFiles = allFiles;
|
|
11605
|
+
}
|
|
11606
|
+
};
|
|
11607
|
+
var FileUnknownError = class extends Error {
|
|
11608
|
+
allFiles;
|
|
11609
|
+
constructor(allFiles) {
|
|
11610
|
+
super("Cannot detect which Figma file is active. Please confirm the target file.");
|
|
11611
|
+
this.name = "FileUnknownError";
|
|
11612
|
+
this.allFiles = allFiles;
|
|
11613
|
+
}
|
|
11614
|
+
};
|
|
11615
|
+
var LAST_KNOWN_STALE_MS = 5 * 6e4;
|
|
11616
|
+
var SHELL_DETECT_TIMEOUT_MS = 500;
|
|
11617
|
+
var CdpFileTracker = class {
|
|
11618
|
+
cdpPort;
|
|
11619
|
+
shellWsUrl = null;
|
|
11620
|
+
_activeFileKey = null;
|
|
11621
|
+
_lastKnownFileKey = null;
|
|
11622
|
+
_lastKnownFileName = null;
|
|
11623
|
+
_lastAcknowledgedAt = 0;
|
|
11624
|
+
_files = /* @__PURE__ */ new Map();
|
|
11625
|
+
_lastDetectTime = 0;
|
|
11626
|
+
_cachedResult = null;
|
|
11627
|
+
constructor(cdpPort) {
|
|
11628
|
+
this.cdpPort = cdpPort;
|
|
11629
|
+
}
|
|
11630
|
+
// -------------------------------------------------------------------------
|
|
11631
|
+
// Public API
|
|
11632
|
+
// -------------------------------------------------------------------------
|
|
11633
|
+
/**
|
|
11634
|
+
* Detect the currently active file. Uses cached result if fresh.
|
|
11635
|
+
* @param cacheTtlMs How long to trust cached results (0 = always re-detect).
|
|
11636
|
+
*/
|
|
11637
|
+
async detectActiveFile(cacheTtlMs = 2e3) {
|
|
11638
|
+
if (this._cachedResult && cacheTtlMs > 0 && Date.now() - this._lastDetectTime < cacheTtlMs) {
|
|
11639
|
+
return this._cachedResult;
|
|
11640
|
+
}
|
|
11641
|
+
const result = await this.detect();
|
|
11642
|
+
this._cachedResult = result;
|
|
11643
|
+
this._lastDetectTime = Date.now();
|
|
11644
|
+
if (result.activeFile) {
|
|
11645
|
+
this._activeFileKey = result.activeFile.fileKey;
|
|
11646
|
+
}
|
|
11647
|
+
return result;
|
|
11648
|
+
}
|
|
11649
|
+
/** List all open design files from /json/list. */
|
|
11650
|
+
async listOpenFiles() {
|
|
11651
|
+
try {
|
|
11652
|
+
const targets = await listTargets(this.cdpPort);
|
|
11653
|
+
return this.parseDesignTargets(targets);
|
|
11654
|
+
} catch {
|
|
11655
|
+
return Array.from(this._files.values());
|
|
11656
|
+
}
|
|
11657
|
+
}
|
|
11658
|
+
/** Get cached file info by fileKey. */
|
|
11659
|
+
getFileTarget(fileKey) {
|
|
11660
|
+
return this._files.get(fileKey) || null;
|
|
11661
|
+
}
|
|
11662
|
+
/** Whether the detected active file differs from the last acknowledged file. */
|
|
11663
|
+
hasFileChanged() {
|
|
11664
|
+
return this._activeFileKey !== null && this._lastKnownFileKey !== null && this._activeFileKey !== this._lastKnownFileKey;
|
|
11665
|
+
}
|
|
11666
|
+
/**
|
|
11667
|
+
* Acknowledge the current active file as the intended target.
|
|
11668
|
+
* Call AFTER successful execution (AR-3).
|
|
11669
|
+
*/
|
|
11670
|
+
acknowledgeActiveFile() {
|
|
11671
|
+
if (this._activeFileKey) {
|
|
11672
|
+
this._lastKnownFileKey = this._activeFileKey;
|
|
11673
|
+
this._lastKnownFileName = this._files.get(this._activeFileKey)?.fileName ?? null;
|
|
11674
|
+
this._lastAcknowledgedAt = Date.now();
|
|
11675
|
+
}
|
|
11676
|
+
}
|
|
11677
|
+
get activeFileKey() {
|
|
11678
|
+
return this._activeFileKey;
|
|
11679
|
+
}
|
|
11680
|
+
get lastKnownFileKey() {
|
|
11681
|
+
return this._lastKnownFileKey;
|
|
11682
|
+
}
|
|
11683
|
+
get lastKnownFileName() {
|
|
11684
|
+
return this._lastKnownFileName;
|
|
11685
|
+
}
|
|
11686
|
+
get activeFileName() {
|
|
11687
|
+
return this._activeFileKey ? this._files.get(this._activeFileKey)?.fileName ?? null : null;
|
|
11688
|
+
}
|
|
11689
|
+
// -------------------------------------------------------------------------
|
|
11690
|
+
// Private: Core Detection
|
|
11691
|
+
// -------------------------------------------------------------------------
|
|
11692
|
+
async detect() {
|
|
11693
|
+
let targets;
|
|
11694
|
+
try {
|
|
11695
|
+
targets = await listTargets(this.cdpPort);
|
|
11696
|
+
if (!Array.isArray(targets)) targets = [];
|
|
11697
|
+
} catch {
|
|
11698
|
+
return this.fallbackToLastKnown([]);
|
|
11699
|
+
}
|
|
11700
|
+
const designFiles = this.parseDesignTargets(targets);
|
|
11701
|
+
this._files.clear();
|
|
11702
|
+
for (const f of designFiles) {
|
|
11703
|
+
this._files.set(f.fileKey, f);
|
|
11704
|
+
}
|
|
11705
|
+
if (designFiles.length === 0) {
|
|
11706
|
+
return { activeFile: null, allFiles: [], method: "none", confidence: "none" };
|
|
11707
|
+
}
|
|
11708
|
+
if (designFiles.length === 1) {
|
|
11709
|
+
this._activeFileKey = designFiles[0].fileKey;
|
|
11710
|
+
return { activeFile: designFiles[0], allFiles: designFiles, method: "single-file", confidence: "high" };
|
|
11711
|
+
}
|
|
11712
|
+
const shellTabName = await this.detectViaShellTabBar(targets);
|
|
11713
|
+
if (shellTabName) {
|
|
11714
|
+
const matched = this.matchTabNameToFile(shellTabName, designFiles);
|
|
11715
|
+
if (matched) {
|
|
11716
|
+
this._activeFileKey = matched.fileKey;
|
|
11717
|
+
log(`File tracker: active file "${matched.fileName}" (${matched.fileKey}) via shell tab bar`);
|
|
11718
|
+
return { activeFile: matched, allFiles: designFiles, method: "shell-tab-bar", confidence: "high" };
|
|
11719
|
+
}
|
|
11720
|
+
}
|
|
11721
|
+
return this.fallbackToLastKnown(designFiles);
|
|
11722
|
+
}
|
|
11723
|
+
// -------------------------------------------------------------------------
|
|
11724
|
+
// Private: Shell.html Tab Bar Detection (PRIMARY)
|
|
11725
|
+
// -------------------------------------------------------------------------
|
|
11726
|
+
/**
|
|
11727
|
+
* Read the active tab name from Figma's shell.html tab bar.
|
|
11728
|
+
* Security: uses a fixed literal expression — never interpolate user input (SR-1).
|
|
11729
|
+
*/
|
|
11730
|
+
async detectViaShellTabBar(targets) {
|
|
11731
|
+
if (!this.shellWsUrl) {
|
|
11732
|
+
const shell = findShellTarget(targets);
|
|
11733
|
+
if (!shell?.webSocketDebuggerUrl) return null;
|
|
11734
|
+
if (!shell.webSocketDebuggerUrl.startsWith("ws://127.0.0.1:")) return null;
|
|
11735
|
+
this.shellWsUrl = shell.webSocketDebuggerUrl;
|
|
11736
|
+
}
|
|
11737
|
+
const client = new CdpClient();
|
|
11738
|
+
try {
|
|
11739
|
+
const connectPromise = client.connect(this.shellWsUrl);
|
|
11740
|
+
const timeoutPromise = new Promise(
|
|
11741
|
+
(_, reject) => setTimeout(() => reject(new Error("shell detect timeout")), SHELL_DETECT_TIMEOUT_MS)
|
|
11742
|
+
);
|
|
11743
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
11744
|
+
const result = await client.send("Runtime.evaluate", {
|
|
11745
|
+
expression: `document.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute('aria-label')`,
|
|
11746
|
+
returnByValue: true
|
|
11747
|
+
});
|
|
11748
|
+
const raw = result?.result?.value;
|
|
11749
|
+
return this.parseTabNameFromCdpResult(raw);
|
|
11750
|
+
} catch {
|
|
11751
|
+
this.shellWsUrl = null;
|
|
11752
|
+
return null;
|
|
11753
|
+
} finally {
|
|
11754
|
+
client.close();
|
|
11755
|
+
}
|
|
11756
|
+
}
|
|
11757
|
+
// -------------------------------------------------------------------------
|
|
11758
|
+
// Private: Fallback — Last Known
|
|
11759
|
+
// -------------------------------------------------------------------------
|
|
11760
|
+
fallbackToLastKnown(designFiles) {
|
|
11761
|
+
if (this._lastKnownFileKey && !this.isLastKnownStale()) {
|
|
11762
|
+
const existing = designFiles.find((f) => f.fileKey === this._lastKnownFileKey);
|
|
11763
|
+
if (existing) {
|
|
11764
|
+
this._activeFileKey = existing.fileKey;
|
|
11765
|
+
log(`File tracker: using last-known file "${existing.fileName}" (low confidence)`);
|
|
11766
|
+
return { activeFile: existing, allFiles: designFiles, method: "last-known", confidence: "low" };
|
|
11767
|
+
}
|
|
11768
|
+
}
|
|
11769
|
+
log("File tracker: cannot detect active file \u2014 shell detection failed and last-known is stale or missing");
|
|
11770
|
+
return { activeFile: null, allFiles: designFiles, method: "none", confidence: "none" };
|
|
11771
|
+
}
|
|
11772
|
+
/** True if the last acknowledged execution was more than 5 minutes ago. */
|
|
11773
|
+
isLastKnownStale() {
|
|
11774
|
+
if (this._lastAcknowledgedAt === 0) return false;
|
|
11775
|
+
return Date.now() - this._lastAcknowledgedAt > LAST_KNOWN_STALE_MS;
|
|
11776
|
+
}
|
|
11777
|
+
// -------------------------------------------------------------------------
|
|
11778
|
+
// Private: Matching & Validation
|
|
11779
|
+
// -------------------------------------------------------------------------
|
|
11780
|
+
/**
|
|
11781
|
+
* Match a tab name from shell.html against design file targets.
|
|
11782
|
+
* Returns null if ambiguous (multiple files with same name) — AR-9.
|
|
11783
|
+
*/
|
|
11784
|
+
matchTabNameToFile(tabName, designFiles) {
|
|
11785
|
+
const matches = designFiles.filter((f) => f.fileName === tabName);
|
|
11786
|
+
if (matches.length === 1) return matches[0];
|
|
11787
|
+
if (matches.length > 1) {
|
|
11788
|
+
warn(`File tracker: ${matches.length} files named "${tabName}" \u2014 cannot disambiguate`);
|
|
11789
|
+
return null;
|
|
11790
|
+
}
|
|
11791
|
+
return null;
|
|
11792
|
+
}
|
|
11793
|
+
/**
|
|
11794
|
+
* Validate and clean the CDP result from shell.html evaluation (SR-3).
|
|
11795
|
+
* Rejects non-string, empty, oversized, or control-character values.
|
|
11796
|
+
*/
|
|
11797
|
+
parseTabNameFromCdpResult(raw) {
|
|
11798
|
+
if (typeof raw !== "string") return null;
|
|
11799
|
+
const trimmed = raw.trim();
|
|
11800
|
+
if (trimmed.length === 0 || trimmed.length > 512) return null;
|
|
11801
|
+
if (/[\x00-\x1f\x7f]/.test(trimmed)) return null;
|
|
11802
|
+
return trimmed;
|
|
11803
|
+
}
|
|
11804
|
+
/** Parse design targets from /json/list into FigmaFileInfo[]. */
|
|
11805
|
+
parseDesignTargets(targets) {
|
|
11806
|
+
return listDesignTargets(targets).map((t) => ({
|
|
11807
|
+
fileKey: t.fileKey,
|
|
11808
|
+
fileName: t.fileName,
|
|
11809
|
+
targetId: t.id,
|
|
11810
|
+
wsUrl: t.webSocketDebuggerUrl
|
|
11811
|
+
}));
|
|
11812
|
+
}
|
|
11813
|
+
};
|
|
11814
|
+
|
|
11441
11815
|
// node_modules/zod/v3/helpers/util.js
|
|
11442
11816
|
var util;
|
|
11443
11817
|
(function(util2) {
|
|
@@ -25570,7 +25944,7 @@ Resources provide quick read-only access to Figma state without tool calls:
|
|
|
25570
25944
|
- Create components for reusable UI patterns.`;
|
|
25571
25945
|
function createMcpServer(deps) {
|
|
25572
25946
|
const server2 = new Server(
|
|
25573
|
-
{ name: "faux-studio", version: "0.
|
|
25947
|
+
{ name: "faux-studio", version: "0.4.1" },
|
|
25574
25948
|
{
|
|
25575
25949
|
capabilities: { tools: { listChanged: true }, resources: {}, logging: {} },
|
|
25576
25950
|
instructions: INSTRUCTIONS
|
|
@@ -25585,7 +25959,7 @@ function createMcpServer(deps) {
|
|
|
25585
25959
|
},
|
|
25586
25960
|
{
|
|
25587
25961
|
name: "setup_figma",
|
|
25588
|
-
description: "Ensure Figma Desktop is running and
|
|
25962
|
+
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
25963
|
inputSchema: {
|
|
25590
25964
|
type: "object",
|
|
25591
25965
|
properties: {},
|
|
@@ -25635,11 +26009,13 @@ function createMcpServer(deps) {
|
|
|
25635
26009
|
}) }]
|
|
25636
26010
|
};
|
|
25637
26011
|
} catch (err) {
|
|
26012
|
+
const transport = deps.getTransport();
|
|
26013
|
+
const restartHint = transport === "cdp" ? "Please disable and re-enable the faux-studio MCP server in your MCP client settings, then try again." : transport === "plugin" ? "Please close and reopen the faux-studio plugin in Figma, then try again." : "Please restart faux-studio and try again.";
|
|
25638
26014
|
return {
|
|
25639
26015
|
content: [{ type: "text", text: JSON.stringify({
|
|
25640
26016
|
authenticated: false,
|
|
25641
26017
|
error: err instanceof Error ? err.message : "Re-authentication failed",
|
|
25642
|
-
message:
|
|
26018
|
+
message: `Could not authenticate. ${restartHint}`
|
|
25643
26019
|
}) }],
|
|
25644
26020
|
isError: true
|
|
25645
26021
|
};
|
|
@@ -25675,7 +26051,10 @@ function createMcpServer(deps) {
|
|
|
25675
26051
|
macro: typeof params._intent_macro === "string" ? params._intent_macro : void 0
|
|
25676
26052
|
};
|
|
25677
26053
|
const script = await deps.generateScript(name, params);
|
|
25678
|
-
const
|
|
26054
|
+
const annotation = TOOL_ANNOTATIONS[name];
|
|
26055
|
+
const isReadOnly = annotation?.readOnlyHint === true;
|
|
26056
|
+
const cacheTtlMs = isReadOnly ? 2e3 : 0;
|
|
26057
|
+
const result = await deps.executeScript(script, intents, { cacheTtlMs });
|
|
25679
26058
|
const imageData = extractImageData(result);
|
|
25680
26059
|
if (imageData) {
|
|
25681
26060
|
const content = [
|
|
@@ -25691,14 +26070,56 @@ function createMcpServer(deps) {
|
|
|
25691
26070
|
content: [{ type: "text", text }]
|
|
25692
26071
|
};
|
|
25693
26072
|
} catch (err) {
|
|
26073
|
+
if (err instanceof FileChangedError) {
|
|
26074
|
+
const annotation = TOOL_ANNOTATIONS[name];
|
|
26075
|
+
const isReadOnly = annotation?.readOnlyHint === true;
|
|
26076
|
+
const payload = JSON.stringify({
|
|
26077
|
+
status: "confirmation_required",
|
|
26078
|
+
reason: "ACTIVE_FILE_CHANGED",
|
|
26079
|
+
previousFile: err.previousFileName,
|
|
26080
|
+
currentFile: err.currentFileName,
|
|
26081
|
+
currentFileKey: err.currentFileKey,
|
|
26082
|
+
message: `The active Figma file changed from "${err.previousFileName}" to "${err.currentFileName}". Please confirm which file you want to work on.`,
|
|
26083
|
+
availableFiles: err.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
|
|
26084
|
+
}, null, 2);
|
|
26085
|
+
if (isReadOnly) {
|
|
26086
|
+
return { content: [{ type: "text", text: payload }] };
|
|
26087
|
+
}
|
|
26088
|
+
return { content: [{ type: "text", text: payload }], isError: true };
|
|
26089
|
+
}
|
|
26090
|
+
if (err instanceof FileUnknownError) {
|
|
26091
|
+
return {
|
|
26092
|
+
content: [{
|
|
26093
|
+
type: "text",
|
|
26094
|
+
text: JSON.stringify({
|
|
26095
|
+
status: "confirmation_required",
|
|
26096
|
+
reason: "ACTIVE_FILE_UNKNOWN",
|
|
26097
|
+
message: "Cannot detect which Figma file is active. Please confirm the target file.",
|
|
26098
|
+
availableFiles: err.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
|
|
26099
|
+
}, null, 2)
|
|
26100
|
+
}],
|
|
26101
|
+
isError: true
|
|
26102
|
+
};
|
|
26103
|
+
}
|
|
25694
26104
|
const message = err instanceof Error ? err.message : String(err);
|
|
25695
26105
|
const isAuthError = message.includes("auth") || message.includes("401") || message.includes("AUTH_EXPIRED");
|
|
26106
|
+
if (isAuthError) {
|
|
26107
|
+
const transport = deps.getTransport();
|
|
26108
|
+
const reconnectHint = transport === "cdp" ? "If the `login` tool fails, ask the user to disable and re-enable the faux-studio MCP server in their MCP client settings, then try again." : transport === "plugin" ? "If the `login` tool fails, ask the user to close and reopen the faux-studio plugin in Figma, then try again." : "If the `login` tool fails, ask the user to restart faux-studio and try again.";
|
|
26109
|
+
return {
|
|
26110
|
+
content: [{
|
|
26111
|
+
type: "text",
|
|
26112
|
+
text: `Error: ${message}
|
|
26113
|
+
|
|
26114
|
+
Run the \`login\` tool to re-authenticate. ${reconnectHint}`
|
|
26115
|
+
}],
|
|
26116
|
+
isError: true
|
|
26117
|
+
};
|
|
26118
|
+
}
|
|
25696
26119
|
return {
|
|
25697
26120
|
content: [{
|
|
25698
26121
|
type: "text",
|
|
25699
|
-
text:
|
|
25700
|
-
|
|
25701
|
-
Run the \`login\` tool to re-authenticate.` : `Error: ${message}`
|
|
26122
|
+
text: `Error: ${message}`
|
|
25702
26123
|
}],
|
|
25703
26124
|
isError: true
|
|
25704
26125
|
};
|
|
@@ -25740,6 +26161,7 @@ async function startServer(server2) {
|
|
|
25740
26161
|
// src/index.ts
|
|
25741
26162
|
var auth;
|
|
25742
26163
|
var cdpClient = null;
|
|
26164
|
+
var fileTracker = null;
|
|
25743
26165
|
var pluginServer = new PluginWsServer();
|
|
25744
26166
|
var forceTransport = process.env.FAUX_TRANSPORT;
|
|
25745
26167
|
async function tryConnectCdp() {
|
|
@@ -25747,20 +26169,23 @@ async function tryConnectCdp() {
|
|
|
25747
26169
|
if (cdpClient?.connected && cdpClient.hasContext) return cdpClient;
|
|
25748
26170
|
cdpClient?.close();
|
|
25749
26171
|
cdpClient = null;
|
|
25750
|
-
const
|
|
25751
|
-
const target = findFigmaDesignTarget(targets);
|
|
26172
|
+
const connection = await ensureFigma();
|
|
26173
|
+
const target = findFigmaDesignTarget(connection.targets);
|
|
25752
26174
|
if (!target) return null;
|
|
25753
26175
|
const client = new CdpClient();
|
|
25754
26176
|
await client.connect(target.webSocketDebuggerUrl);
|
|
25755
26177
|
await client.discoverFigmaContext();
|
|
25756
26178
|
log(`CDP connected: ${target.title}`);
|
|
25757
26179
|
cdpClient = client;
|
|
26180
|
+
if (!fileTracker) {
|
|
26181
|
+
fileTracker = new CdpFileTracker(connection.port);
|
|
26182
|
+
}
|
|
25758
26183
|
return client;
|
|
25759
26184
|
} catch {
|
|
25760
26185
|
return null;
|
|
25761
26186
|
}
|
|
25762
26187
|
}
|
|
25763
|
-
async function executeScript(script, intents) {
|
|
26188
|
+
async function executeScript(script, intents, opts) {
|
|
25764
26189
|
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
25765
26190
|
log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
|
|
25766
26191
|
return pluginServer.executeScript(script, void 0, intents);
|
|
@@ -25768,12 +26193,34 @@ async function executeScript(script, intents) {
|
|
|
25768
26193
|
if (forceTransport !== "plugin") {
|
|
25769
26194
|
const client = await tryConnectCdp();
|
|
25770
26195
|
if (client) {
|
|
26196
|
+
if (fileTracker) {
|
|
26197
|
+
const detection = await fileTracker.detectActiveFile(opts?.cacheTtlMs);
|
|
26198
|
+
if (!detection.activeFile && detection.allFiles.length > 1) {
|
|
26199
|
+
throw new FileUnknownError(detection.allFiles);
|
|
26200
|
+
}
|
|
26201
|
+
if (fileTracker.hasFileChanged()) {
|
|
26202
|
+
throw new FileChangedError(
|
|
26203
|
+
fileTracker.lastKnownFileName,
|
|
26204
|
+
fileTracker.activeFileName,
|
|
26205
|
+
fileTracker.activeFileKey,
|
|
26206
|
+
detection.allFiles
|
|
26207
|
+
);
|
|
26208
|
+
}
|
|
26209
|
+
if (detection.activeFile && client.targetWsUrl !== detection.activeFile.wsUrl) {
|
|
26210
|
+
log(`Switching CDP target to "${detection.activeFile.fileName}" (${detection.activeFile.fileKey})`);
|
|
26211
|
+
await client.reconnectToTarget(detection.activeFile.wsUrl);
|
|
26212
|
+
}
|
|
26213
|
+
}
|
|
25771
26214
|
log("Executing via CDP");
|
|
25772
26215
|
try {
|
|
25773
|
-
|
|
26216
|
+
const result = await client.executeScript(script);
|
|
26217
|
+
fileTracker?.acknowledgeActiveFile();
|
|
26218
|
+
return result;
|
|
25774
26219
|
} catch (err) {
|
|
25775
26220
|
if (err instanceof ContextDestroyedError) {
|
|
25776
|
-
|
|
26221
|
+
const result = await recoverCdp(client, script);
|
|
26222
|
+
fileTracker?.acknowledgeActiveFile();
|
|
26223
|
+
return result;
|
|
25777
26224
|
}
|
|
25778
26225
|
throw err;
|
|
25779
26226
|
}
|
|
@@ -25803,16 +26250,29 @@ async function recoverCdp(client, script) {
|
|
|
25803
26250
|
}
|
|
25804
26251
|
async function generateWithAuth(toolName, params) {
|
|
25805
26252
|
auth = await refreshIfNeeded(auth);
|
|
26253
|
+
let result;
|
|
25806
26254
|
try {
|
|
25807
|
-
|
|
26255
|
+
result = await generateScript(auth.jwt, toolName, params);
|
|
25808
26256
|
} catch (err) {
|
|
25809
26257
|
if (err instanceof Error && err.message === "AUTH_EXPIRED") {
|
|
25810
26258
|
warn("JWT expired during request, refreshing...");
|
|
25811
26259
|
auth = await refreshIfNeeded(auth, true);
|
|
25812
|
-
|
|
26260
|
+
result = await generateScript(auth.jwt, toolName, params);
|
|
26261
|
+
} else {
|
|
26262
|
+
throw err;
|
|
25813
26263
|
}
|
|
25814
|
-
throw err;
|
|
25815
26264
|
}
|
|
26265
|
+
const verifyKey = getVerifyKey();
|
|
26266
|
+
if (verifyKey && result.signature) {
|
|
26267
|
+
if (!await verifyScriptSignature(result.script, result.signature, verifyKey)) {
|
|
26268
|
+
throw new Error("Script signature verification failed \u2014 possible tampering");
|
|
26269
|
+
}
|
|
26270
|
+
} else if (verifyKey && !result.signature) {
|
|
26271
|
+
throw new Error("Signing key is configured but response is missing a signature \u2014 possible signature stripping attack");
|
|
26272
|
+
} else if (!verifyKey) {
|
|
26273
|
+
warn("No signing key available \u2014 skipping script signature verification");
|
|
26274
|
+
}
|
|
26275
|
+
return result.script;
|
|
25816
26276
|
}
|
|
25817
26277
|
var PLUGIN_URL = "https://faux.design/plugin";
|
|
25818
26278
|
var PLUGIN_WAIT_MS = 1e4;
|
|
@@ -25841,6 +26301,16 @@ async function setupFigma(params) {
|
|
|
25841
26301
|
return pluginReadyResult();
|
|
25842
26302
|
}
|
|
25843
26303
|
if (forceTransport !== "plugin" && cdpClient?.connected && cdpClient.hasContext) {
|
|
26304
|
+
if (fileTracker) {
|
|
26305
|
+
const detection = await fileTracker.detectActiveFile();
|
|
26306
|
+
return {
|
|
26307
|
+
status: "ready",
|
|
26308
|
+
transport: "cdp",
|
|
26309
|
+
message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? "unknown"}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26310
|
+
activeFile: detection.activeFile?.fileName,
|
|
26311
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName }))
|
|
26312
|
+
};
|
|
26313
|
+
}
|
|
25844
26314
|
return {
|
|
25845
26315
|
status: "ready",
|
|
25846
26316
|
transport: "cdp",
|
|
@@ -25860,11 +26330,17 @@ async function setupFigma(params) {
|
|
|
25860
26330
|
await client.discoverFigmaContext();
|
|
25861
26331
|
cdpClient = client;
|
|
25862
26332
|
log(`CDP connected: ${target.title}`);
|
|
26333
|
+
if (!fileTracker) {
|
|
26334
|
+
fileTracker = new CdpFileTracker(existing.port);
|
|
26335
|
+
}
|
|
26336
|
+
const detection = await fileTracker.detectActiveFile();
|
|
26337
|
+
fileTracker.acknowledgeActiveFile();
|
|
25863
26338
|
return {
|
|
25864
26339
|
status: "ready",
|
|
25865
26340
|
transport: "cdp",
|
|
25866
|
-
message: `Connected via CDP. Active file: ${target.title}. Ready to design.`,
|
|
25867
|
-
activeFile: target.title,
|
|
26341
|
+
message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? target.title}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26342
|
+
activeFile: detection.activeFile?.fileName ?? target.title,
|
|
26343
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
25868
26344
|
port: existing.port
|
|
25869
26345
|
};
|
|
25870
26346
|
} catch {
|
|
@@ -25978,6 +26454,11 @@ async function main() {
|
|
|
25978
26454
|
source: auth.source,
|
|
25979
26455
|
isApiKey: auth.source === "api-key"
|
|
25980
26456
|
}),
|
|
26457
|
+
getTransport: () => {
|
|
26458
|
+
if (pluginServer.hasConnections) return "plugin";
|
|
26459
|
+
if (cdpClient?.connected) return "cdp";
|
|
26460
|
+
return "none";
|
|
26461
|
+
},
|
|
25981
26462
|
setupFigma
|
|
25982
26463
|
});
|
|
25983
26464
|
await startServer(server2);
|
|
@@ -26000,10 +26481,31 @@ async function main() {
|
|
|
26000
26481
|
function shutdown() {
|
|
26001
26482
|
pluginServer.close();
|
|
26002
26483
|
cdpClient?.close();
|
|
26484
|
+
fileTracker = null;
|
|
26003
26485
|
process.exit(0);
|
|
26004
26486
|
}
|
|
26005
26487
|
process.on("SIGINT", shutdown);
|
|
26006
26488
|
process.on("SIGTERM", shutdown);
|
|
26489
|
+
var ORPHAN_CHECK_MS = 5e3;
|
|
26490
|
+
var originalPpid = process.ppid;
|
|
26491
|
+
function isParentAlive() {
|
|
26492
|
+
try {
|
|
26493
|
+
process.kill(originalPpid, 0);
|
|
26494
|
+
return true;
|
|
26495
|
+
} catch {
|
|
26496
|
+
return false;
|
|
26497
|
+
}
|
|
26498
|
+
}
|
|
26499
|
+
process.stdin.on("end", () => {
|
|
26500
|
+
log("stdin closed \u2014 parent process gone, shutting down");
|
|
26501
|
+
shutdown();
|
|
26502
|
+
});
|
|
26503
|
+
setInterval(() => {
|
|
26504
|
+
if (!isParentAlive()) {
|
|
26505
|
+
log(`Parent process (PID ${originalPpid}) gone \u2014 shutting down`);
|
|
26506
|
+
shutdown();
|
|
26507
|
+
}
|
|
26508
|
+
}, ORPHAN_CHECK_MS).unref();
|
|
26007
26509
|
main().catch((err) => {
|
|
26008
26510
|
error(err instanceof Error ? err.message : String(err));
|
|
26009
26511
|
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.1",
|
|
4
4
|
"description": "AI-powered Figma design via MCP — connect any AI client to Figma Desktop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
13
|
+
"node": ">=20"
|
|
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",
|
|
21
|
+
"test": "npx tsx --test test/*.test.ts",
|
|
20
22
|
"prepublishOnly": "npm run build"
|
|
21
23
|
},
|
|
22
24
|
"devDependencies": {
|
|
@@ -25,6 +27,7 @@
|
|
|
25
27
|
"@types/node": "^22.0.0",
|
|
26
28
|
"@types/ws": "^8.5.0",
|
|
27
29
|
"tsup": "^8.4.0",
|
|
30
|
+
"tsx": "^4.19.0",
|
|
28
31
|
"typescript": "^5.8.0"
|
|
29
32
|
},
|
|
30
33
|
"keywords": [
|
|
@@ -40,5 +43,8 @@
|
|
|
40
43
|
"type": "git",
|
|
41
44
|
"url": "https://github.com/uxfreak/faux-studio.git"
|
|
42
45
|
},
|
|
43
|
-
"homepage": "https://faux.design"
|
|
46
|
+
"homepage": "https://faux.design",
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"@napi-rs/keyring": "^1.2.0"
|
|
49
|
+
}
|
|
44
50
|
}
|