figma-relai 0.5.1 → 0.5.2

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.
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/state.ts
4
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ function stateDir() {
8
+ return process.env.FIGMA_RELAI_STATE_DIR ?? join(homedir(), ".figma-relai");
9
+ }
10
+ function statePath() {
11
+ return join(stateDir(), "state.json");
12
+ }
13
+ function loadState() {
14
+ try {
15
+ return JSON.parse(readFileSync(statePath(), "utf8"));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+ function saveState(patch) {
21
+ try {
22
+ mkdirSync(stateDir(), { recursive: true });
23
+ const next = { ...loadState(), ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
24
+ writeFileSync(statePath(), JSON.stringify(next, null, 2) + "\n");
25
+ } catch {
26
+ }
27
+ }
28
+
29
+ // src/credentials.ts
30
+ import { chmodSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
31
+ import { join as join2 } from "path";
32
+ function credentialsPath() {
33
+ return join2(stateDir(), "credentials.json");
34
+ }
35
+ function readFile() {
36
+ try {
37
+ return JSON.parse(readFileSync2(credentialsPath(), "utf8"));
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+ function loadToken() {
43
+ const fromEnv = process.env.FIGMA_TOKEN?.trim();
44
+ if (fromEnv) return fromEnv;
45
+ const fromFile = readFile().token?.trim();
46
+ return fromFile || void 0;
47
+ }
48
+ function tokenSource() {
49
+ if (process.env.FIGMA_TOKEN?.trim()) return "env";
50
+ if (readFile().token?.trim()) return "file";
51
+ return null;
52
+ }
53
+ function saveToken(token) {
54
+ const dir = stateDir();
55
+ mkdirSync2(dir, { recursive: true });
56
+ try {
57
+ chmodSync(dir, 448);
58
+ } catch {
59
+ }
60
+ const path = credentialsPath();
61
+ writeFileSync2(path, JSON.stringify({ token, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n", {
62
+ mode: 384
63
+ });
64
+ try {
65
+ chmodSync(path, 384);
66
+ } catch {
67
+ }
68
+ return path;
69
+ }
70
+ function clearToken() {
71
+ try {
72
+ rmSync(credentialsPath());
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ async function verifyToken(token) {
79
+ try {
80
+ const res = await fetch("https://api.figma.com/v1/me", {
81
+ headers: { "X-Figma-Token": token }
82
+ });
83
+ if (res.status === 403 || res.status === 401) {
84
+ return { ok: false, reason: "Figma rejected the token (401/403) \u2014 check it was copied whole." };
85
+ }
86
+ if (!res.ok) {
87
+ return { ok: false, reason: `Figma answered ${res.status} ${res.statusText}.` };
88
+ }
89
+ const me = await res.json();
90
+ return { ok: true, handle: me.handle ?? "(unknown)", email: me.email };
91
+ } catch (err) {
92
+ return { ok: false, reason: `Could not reach api.figma.com: ${err.message}` };
93
+ }
94
+ }
95
+
96
+ export {
97
+ loadState,
98
+ saveState,
99
+ credentialsPath,
100
+ loadToken,
101
+ tokenSource,
102
+ saveToken,
103
+ clearToken,
104
+ verifyToken
105
+ };
106
+ //# sourceMappingURL=chunk-6ABTHOFJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/state.ts","../src/credentials.ts"],"sourcesContent":["import { mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\n// Persisted pairing state so the AI reconnects to the same room across\n// sessions without the designer re-pasting a join command.\n\nexport interface RelaiState {\n room?: string;\n updatedAt?: string;\n}\n\nexport function stateDir(): string {\n return process.env.FIGMA_RELAI_STATE_DIR ?? join(homedir(), \".figma-relai\");\n}\n\nfunction statePath(): string {\n return join(stateDir(), \"state.json\");\n}\n\nexport function loadState(): RelaiState {\n try {\n return JSON.parse(readFileSync(statePath(), \"utf8\")) as RelaiState;\n } catch {\n return {};\n }\n}\n\nexport function saveState(patch: Partial<RelaiState>): void {\n try {\n mkdirSync(stateDir(), { recursive: true });\n const next = { ...loadState(), ...patch, updatedAt: new Date().toISOString() };\n writeFileSync(statePath(), JSON.stringify(next, null, 2) + \"\\n\");\n } catch {\n // State persistence is best-effort; pairing still works via list_rooms\n }\n}\n","import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { stateDir } from \"./state.js\";\nimport { join } from \"node:path\";\n\n// A Figma personal access token is account-wide and long-lived, so it never\n// travels the relay socket — the plugin has no business holding it. It lives\n// either in the MCP config env or in a 0600 file this process owns, and both\n// are read here so every consumer resolves it the same way.\n\nexport type TokenSource = \"env\" | \"file\" | null;\n\ninterface CredentialsFile {\n token?: string;\n savedAt?: string;\n}\n\nexport function credentialsPath(): string {\n return join(stateDir(), \"credentials.json\");\n}\n\nfunction readFile(): CredentialsFile {\n try {\n return JSON.parse(readFileSync(credentialsPath(), \"utf8\")) as CredentialsFile;\n } catch {\n return {};\n }\n}\n\n/** env wins over the stored file so a per-project override stays possible. */\nexport function loadToken(): string | undefined {\n const fromEnv = process.env.FIGMA_TOKEN?.trim();\n if (fromEnv) return fromEnv;\n const fromFile = readFile().token?.trim();\n return fromFile || undefined;\n}\n\nexport function tokenSource(): TokenSource {\n if (process.env.FIGMA_TOKEN?.trim()) return \"env\";\n if (readFile().token?.trim()) return \"file\";\n return null;\n}\n\nexport function saveToken(token: string): string {\n const dir = stateDir();\n mkdirSync(dir, { recursive: true });\n try {\n chmodSync(dir, 0o700);\n } catch {\n // Directory permissions are best-effort (Windows, exotic filesystems)\n }\n const path = credentialsPath();\n // Create with the restrictive mode rather than widening then narrowing:\n // an 0644 window, however brief, is a window.\n writeFileSync(path, JSON.stringify({ token, savedAt: new Date().toISOString() }, null, 2) + \"\\n\", {\n mode: 0o600,\n });\n try {\n chmodSync(path, 0o600);\n } catch {\n // Pre-existing file may keep its mode on some platforms; reported by doctor\n }\n return path;\n}\n\nexport function clearToken(): boolean {\n try {\n rmSync(credentialsPath());\n return true;\n } catch {\n return false;\n }\n}\n\n/** Confirms the token is live and tells the caller whose it is. */\nexport async function verifyToken(\n token: string\n): Promise<{ ok: true; handle: string; email?: string } | { ok: false; reason: string }> {\n try {\n const res = await fetch(\"https://api.figma.com/v1/me\", {\n headers: { \"X-Figma-Token\": token },\n });\n if (res.status === 403 || res.status === 401) {\n return { ok: false, reason: \"Figma rejected the token (401/403) — check it was copied whole.\" };\n }\n if (!res.ok) {\n return { ok: false, reason: `Figma answered ${res.status} ${res.statusText}.` };\n }\n const me = (await res.json()) as { handle?: string; email?: string };\n return { ok: true, handle: me.handle ?? \"(unknown)\", email: me.email };\n } catch (err) {\n return { ok: false, reason: `Could not reach api.figma.com: ${(err as Error).message}` };\n }\n}\n"],"mappings":";;;AAAA,SAAS,WAAW,cAAc,qBAAqB;AACvD,SAAS,eAAe;AACxB,SAAS,YAAY;AAUd,SAAS,WAAmB;AACjC,SAAO,QAAQ,IAAI,yBAAyB,KAAK,QAAQ,GAAG,cAAc;AAC5E;AAEA,SAAS,YAAoB;AAC3B,SAAO,KAAK,SAAS,GAAG,YAAY;AACtC;AAEO,SAAS,YAAwB;AACtC,MAAI;AACF,WAAO,KAAK,MAAM,aAAa,UAAU,GAAG,MAAM,CAAC;AAAA,EACrD,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEO,SAAS,UAAU,OAAkC;AAC1D,MAAI;AACF,cAAU,SAAS,GAAG,EAAE,WAAW,KAAK,CAAC;AACzC,UAAM,OAAO,EAAE,GAAG,UAAU,GAAG,GAAG,OAAO,YAAW,oBAAI,KAAK,GAAE,YAAY,EAAE;AAC7E,kBAAc,UAAU,GAAG,KAAK,UAAU,MAAM,MAAM,CAAC,IAAI,IAAI;AAAA,EACjE,QAAQ;AAAA,EAER;AACF;;;ACpCA,SAAS,WAAW,aAAAA,YAAW,gBAAAC,eAAc,QAAQ,iBAAAC,sBAAqB;AAE1E,SAAS,QAAAC,aAAY;AAcd,SAAS,kBAA0B;AACxC,SAAOA,MAAK,SAAS,GAAG,kBAAkB;AAC5C;AAEA,SAAS,WAA4B;AACnC,MAAI;AACF,WAAO,KAAK,MAAMC,cAAa,gBAAgB,GAAG,MAAM,CAAC;AAAA,EAC3D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAGO,SAAS,YAAgC;AAC9C,QAAM,UAAU,QAAQ,IAAI,aAAa,KAAK;AAC9C,MAAI,QAAS,QAAO;AACpB,QAAM,WAAW,SAAS,EAAE,OAAO,KAAK;AACxC,SAAO,YAAY;AACrB;AAEO,SAAS,cAA2B;AACzC,MAAI,QAAQ,IAAI,aAAa,KAAK,EAAG,QAAO;AAC5C,MAAI,SAAS,EAAE,OAAO,KAAK,EAAG,QAAO;AACrC,SAAO;AACT;AAEO,SAAS,UAAU,OAAuB;AAC/C,QAAM,MAAM,SAAS;AACrB,EAAAC,WAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AAClC,MAAI;AACF,cAAU,KAAK,GAAK;AAAA,EACtB,QAAQ;AAAA,EAER;AACA,QAAM,OAAO,gBAAgB;AAG7B,EAAAC,eAAc,MAAM,KAAK,UAAU,EAAE,OAAO,UAAS,oBAAI,KAAK,GAAE,YAAY,EAAE,GAAG,MAAM,CAAC,IAAI,MAAM;AAAA,IAChG,MAAM;AAAA,EACR,CAAC;AACD,MAAI;AACF,cAAU,MAAM,GAAK;AAAA,EACvB,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAEO,SAAS,aAAsB;AACpC,MAAI;AACF,WAAO,gBAAgB,CAAC;AACxB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAsB,YACpB,OACuF;AACvF,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,+BAA+B;AAAA,MACrD,SAAS,EAAE,iBAAiB,MAAM;AAAA,IACpC,CAAC;AACD,QAAI,IAAI,WAAW,OAAO,IAAI,WAAW,KAAK;AAC5C,aAAO,EAAE,IAAI,OAAO,QAAQ,uEAAkE;AAAA,IAChG;AACA,QAAI,CAAC,IAAI,IAAI;AACX,aAAO,EAAE,IAAI,OAAO,QAAQ,kBAAkB,IAAI,MAAM,IAAI,IAAI,UAAU,IAAI;AAAA,IAChF;AACA,UAAM,KAAM,MAAM,IAAI,KAAK;AAC3B,WAAO,EAAE,IAAI,MAAM,QAAQ,GAAG,UAAU,aAAa,OAAO,GAAG,MAAM;AAAA,EACvE,SAAS,KAAK;AACZ,WAAO,EAAE,IAAI,OAAO,QAAQ,kCAAmC,IAAc,OAAO,GAAG;AAAA,EACzF;AACF;","names":["mkdirSync","readFileSync","writeFileSync","join","readFileSync","mkdirSync","writeFileSync"]}
@@ -2,6 +2,9 @@
2
2
  import {
3
3
  loadUserSkills
4
4
  } from "./chunk-SDINVROO.js";
5
+ import {
6
+ loadToken
7
+ } from "./chunk-6ABTHOFJ.js";
5
8
  import {
6
9
  __export
7
10
  } from "./chunk-2H7UOFLK.js";
@@ -12,7 +15,7 @@ function createServer() {
12
15
  return new McpServer(
13
16
  {
14
17
  name: "Relai",
15
- version: "0.5.1"
18
+ version: "0.5.2"
16
19
  },
17
20
  {
18
21
  instructions: `
@@ -515,10 +518,12 @@ var RelayCore = class {
515
518
  staleTimer = null;
516
519
  version;
517
520
  log;
521
+ features;
518
522
  constructor(options = {}) {
519
523
  this.version = options.version ?? "unknown";
520
524
  this.log = options.log ?? (() => {
521
525
  });
526
+ this.features = options.features ?? null;
522
527
  }
523
528
  handleOpen(ws) {
524
529
  this.lastActivity.set(ws, Date.now());
@@ -534,9 +539,21 @@ var RelayCore = class {
534
539
  return;
535
540
  }
536
541
  switch (data.type) {
537
- case "hello":
538
- this.send(ws, { type: "hello", server: "figma-relai", version: this.version });
542
+ case "hello": {
543
+ const hello = {
544
+ type: "hello",
545
+ server: "figma-relai",
546
+ version: this.version
547
+ };
548
+ if (this.features) {
549
+ try {
550
+ hello.features = this.features();
551
+ } catch {
552
+ }
553
+ }
554
+ this.send(ws, hello);
539
555
  return;
556
+ }
540
557
  case "join": {
541
558
  const room = data.room;
542
559
  if (!room || typeof room !== "string") {
@@ -1747,10 +1764,10 @@ function register15(server, sendCommand) {
1747
1764
  unresolved: z16.boolean().optional().describe("list: only unresolved threads")
1748
1765
  },
1749
1766
  async ({ action, fileUrl, message, commentId, nodeId, x, y, since, unresolved }) => {
1750
- const token = process.env.FIGMA_TOKEN;
1767
+ const token = loadToken();
1751
1768
  if (!token) {
1752
1769
  return textResult(
1753
- 'Comments need a Figma personal access token. Generate one at figma.com \u2192 Settings \u2192 Security \u2192 Personal access tokens (enable comment scopes), then add it to the MCP config: "env": { "FIGMA_TOKEN": "figd_..." } and restart. Everything else works without it.'
1770
+ 'Comments need a Figma personal access token. Generate one at figma.com \u2192 Settings \u2192 Security \u2192 Personal access tokens (enable comment scopes), then store it with `npx figma-relai login` (it is verified and written to ~/.figma-relai/credentials.json, mode 0600). An MCP config "env": { "FIGMA_TOKEN": "figd_..." } still works and takes precedence. Everything else works without it.'
1754
1771
  );
1755
1772
  }
1756
1773
  try {
@@ -1922,10 +1939,10 @@ function register16(server, sendCommand) {
1922
1939
  );
1923
1940
  }
1924
1941
  async function fetchLibraryCatalog(libraryFileUrl) {
1925
- const token = process.env.FIGMA_TOKEN;
1942
+ const token = loadToken();
1926
1943
  if (!token) {
1927
1944
  return {
1928
- note: "Full library catalogs need a FIGMA_TOKEN env var (personal access token, file read scope). Without it, components the file already uses are still listed above \u2014 or keep a 'DS palette' page with one instance of each key component so they show up there."
1945
+ note: "Full library catalogs need a personal access token (file content read scope): store it once with `npx figma-relai login`, or set FIGMA_TOKEN in the MCP config env. Without it, components the file already uses are still listed above \u2014 or keep a 'DS palette' page with one instance of each key component so they show up there."
1929
1946
  };
1930
1947
  }
1931
1948
  const fileKey = parseFileKey(libraryFileUrl);
@@ -3870,4 +3887,4 @@ export {
3870
3887
  listToolCatalog,
3871
3888
  registerPrompts
3872
3889
  };
3873
- //# sourceMappingURL=chunk-5H4UHDQI.js.map
3890
+ //# sourceMappingURL=chunk-7KACLWNP.js.map