figmanage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/dist/auth/client.d.ts +15 -0
- package/dist/auth/client.js +29 -0
- package/dist/auth/health.d.ts +5 -0
- package/dist/auth/health.js +93 -0
- package/dist/clients/internal-api.d.ts +4 -0
- package/dist/clients/internal-api.js +50 -0
- package/dist/clients/public-api.d.ts +4 -0
- package/dist/clients/public-api.js +47 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +100 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +565 -0
- package/dist/tools/analytics.d.ts +2 -0
- package/dist/tools/analytics.js +58 -0
- package/dist/tools/branching.d.ts +2 -0
- package/dist/tools/branching.js +103 -0
- package/dist/tools/comments.d.ts +2 -0
- package/dist/tools/comments.js +147 -0
- package/dist/tools/components.d.ts +2 -0
- package/dist/tools/components.js +104 -0
- package/dist/tools/compound.d.ts +2 -0
- package/dist/tools/compound.js +334 -0
- package/dist/tools/export.d.ts +2 -0
- package/dist/tools/export.js +70 -0
- package/dist/tools/files.d.ts +2 -0
- package/dist/tools/files.js +241 -0
- package/dist/tools/libraries.d.ts +2 -0
- package/dist/tools/libraries.js +31 -0
- package/dist/tools/navigate.d.ts +2 -0
- package/dist/tools/navigate.js +436 -0
- package/dist/tools/org.d.ts +2 -0
- package/dist/tools/org.js +311 -0
- package/dist/tools/permissions.d.ts +2 -0
- package/dist/tools/permissions.js +246 -0
- package/dist/tools/projects.d.ts +2 -0
- package/dist/tools/projects.js +160 -0
- package/dist/tools/reading.d.ts +2 -0
- package/dist/tools/reading.js +60 -0
- package/dist/tools/register.d.ts +32 -0
- package/dist/tools/register.js +76 -0
- package/dist/tools/teams.d.ts +2 -0
- package/dist/tools/teams.js +81 -0
- package/dist/tools/variables.d.ts +2 -0
- package/dist/tools/variables.js +102 -0
- package/dist/tools/versions.d.ts +2 -0
- package/dist/tools/versions.js +69 -0
- package/dist/tools/webhooks.d.ts +2 -0
- package/dist/tools/webhooks.js +126 -0
- package/dist/types/figma.d.ts +58 -0
- package/dist/types/figma.js +2 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import { loadAuthConfig, hasPat, hasCookie } from './auth/client.js';
|
|
7
|
+
import { registerTools } from './tools/register.js';
|
|
8
|
+
// Import tool modules (side-effect: registers via defineTool)
|
|
9
|
+
import './tools/navigate.js';
|
|
10
|
+
import './tools/files.js';
|
|
11
|
+
import './tools/projects.js';
|
|
12
|
+
import './tools/permissions.js';
|
|
13
|
+
import './tools/comments.js';
|
|
14
|
+
import './tools/export.js';
|
|
15
|
+
import './tools/versions.js';
|
|
16
|
+
import './tools/branching.js';
|
|
17
|
+
import './tools/components.js';
|
|
18
|
+
import './tools/webhooks.js';
|
|
19
|
+
import './tools/reading.js';
|
|
20
|
+
import './tools/analytics.js';
|
|
21
|
+
import './tools/variables.js';
|
|
22
|
+
import './tools/org.js';
|
|
23
|
+
import './tools/libraries.js';
|
|
24
|
+
import './tools/teams.js';
|
|
25
|
+
import './tools/compound.js';
|
|
26
|
+
const ALL_TOOLSETS = [
|
|
27
|
+
'navigate', 'files', 'projects', 'permissions', 'org',
|
|
28
|
+
'versions', 'branching', 'comments', 'export',
|
|
29
|
+
'analytics', 'reading', 'components', 'webhooks', 'variables',
|
|
30
|
+
'compound', 'teams', 'libraries',
|
|
31
|
+
];
|
|
32
|
+
const TOOLSET_PRESETS = {
|
|
33
|
+
starter: ['navigate', 'reading', 'comments', 'export'],
|
|
34
|
+
admin: ['navigate', 'org', 'permissions', 'analytics', 'teams', 'libraries'],
|
|
35
|
+
readonly: ['navigate', 'reading', 'comments', 'export', 'components', 'versions'],
|
|
36
|
+
full: ALL_TOOLSETS,
|
|
37
|
+
};
|
|
38
|
+
function parseToolsets(env) {
|
|
39
|
+
if (!env)
|
|
40
|
+
return new Set(ALL_TOOLSETS);
|
|
41
|
+
if (env in TOOLSET_PRESETS)
|
|
42
|
+
return new Set(TOOLSET_PRESETS[env]);
|
|
43
|
+
const requested = env.split(',').map(s => s.trim());
|
|
44
|
+
const valid = requested.filter(t => ALL_TOOLSETS.includes(t));
|
|
45
|
+
return new Set(valid.length > 0 ? valid : ALL_TOOLSETS);
|
|
46
|
+
}
|
|
47
|
+
function parseHttpPort(argv) {
|
|
48
|
+
const idx = argv.indexOf('--http');
|
|
49
|
+
if (idx === -1)
|
|
50
|
+
return undefined;
|
|
51
|
+
const port = Number(argv[idx + 1]);
|
|
52
|
+
if (!port || port < 1 || port > 65535) {
|
|
53
|
+
console.error('--http requires a valid port number (1-65535)');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
return port;
|
|
57
|
+
}
|
|
58
|
+
async function main() {
|
|
59
|
+
const config = loadAuthConfig();
|
|
60
|
+
const readOnly = process.env.FIGMA_READ_ONLY === '1' || process.env.FIGMA_READ_ONLY === 'true';
|
|
61
|
+
const enabledToolsets = parseToolsets(process.env.FIGMA_TOOLSETS);
|
|
62
|
+
if (!hasPat(config) && !hasCookie(config)) {
|
|
63
|
+
console.error('No auth configured. Set FIGMA_PAT for public API access, or ' +
|
|
64
|
+
'FIGMA_AUTH_COOKIE + FIGMA_USER_ID for internal API access.');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const server = new McpServer({
|
|
68
|
+
name: 'figmanage',
|
|
69
|
+
version: '0.1.0',
|
|
70
|
+
});
|
|
71
|
+
registerTools(server, config, enabledToolsets, readOnly);
|
|
72
|
+
const httpPort = parseHttpPort(process.argv);
|
|
73
|
+
if (httpPort) {
|
|
74
|
+
const transport = new StreamableHTTPServerTransport({
|
|
75
|
+
sessionIdGenerator: undefined,
|
|
76
|
+
});
|
|
77
|
+
const httpServer = createServer(async (req, res) => {
|
|
78
|
+
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
79
|
+
if (url.pathname === '/mcp') {
|
|
80
|
+
await transport.handleRequest(req, res);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
res.writeHead(404).end('Not found');
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
await server.connect(transport);
|
|
87
|
+
httpServer.listen(httpPort, () => {
|
|
88
|
+
console.error(`figmanage HTTP server listening on http://localhost:${httpPort}/mcp`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const transport = new StdioServerTransport();
|
|
93
|
+
await server.connect(transport);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
main().catch((err) => {
|
|
97
|
+
console.error('Fatal:', err);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
100
|
+
//# sourceMappingURL=index.js.map
|
package/dist/setup.d.ts
ADDED
package/dist/setup.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync, execFileSync } from 'child_process';
|
|
3
|
+
import { createDecipheriv, pbkdf2Sync } from 'crypto';
|
|
4
|
+
import { copyFileSync, unlinkSync, mkdtempSync, existsSync, rmdirSync, readFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { tmpdir, homedir, platform } from 'os';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
const COOKIE_NAME = '__Host-figma.authn';
|
|
9
|
+
// --- Platform-specific Chrome paths ---
|
|
10
|
+
function getChromePaths() {
|
|
11
|
+
switch (platform()) {
|
|
12
|
+
case 'darwin':
|
|
13
|
+
return [join(homedir(), 'Library/Application Support/Google/Chrome')];
|
|
14
|
+
case 'linux':
|
|
15
|
+
return [
|
|
16
|
+
join(homedir(), '.config/google-chrome'),
|
|
17
|
+
join(homedir(), '.config/chromium'),
|
|
18
|
+
];
|
|
19
|
+
case 'win32': {
|
|
20
|
+
const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local');
|
|
21
|
+
return [join(localAppData, 'Google/Chrome/User Data')];
|
|
22
|
+
}
|
|
23
|
+
default:
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// --- Chrome profile discovery ---
|
|
28
|
+
function findChromeProfiles() {
|
|
29
|
+
const chromePaths = getChromePaths();
|
|
30
|
+
const profiles = [];
|
|
31
|
+
for (const base of chromePaths) {
|
|
32
|
+
if (!existsSync(base))
|
|
33
|
+
continue;
|
|
34
|
+
const defaultProfile = join(base, 'Default');
|
|
35
|
+
if (existsSync(join(defaultProfile, 'Cookies')))
|
|
36
|
+
profiles.push(defaultProfile);
|
|
37
|
+
for (let i = 1; i <= 20; i++) {
|
|
38
|
+
const profile = join(base, `Profile ${i}`);
|
|
39
|
+
if (existsSync(join(profile, 'Cookies')))
|
|
40
|
+
profiles.push(profile);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (profiles.length === 0)
|
|
44
|
+
throw new Error('No Chrome profiles with Cookies found.');
|
|
45
|
+
return profiles;
|
|
46
|
+
}
|
|
47
|
+
// --- macOS cookie decryption ---
|
|
48
|
+
function getMacDecryptionKey() {
|
|
49
|
+
const password = execFileSync('security', ['find-generic-password', '-w', '-s', 'Chrome Safe Storage'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
50
|
+
return pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
|
|
51
|
+
}
|
|
52
|
+
// --- Linux cookie decryption ---
|
|
53
|
+
function getLinuxDecryptionKey() {
|
|
54
|
+
// Try GNOME Keyring first via secret-tool
|
|
55
|
+
try {
|
|
56
|
+
const password = execSync('secret-tool lookup application chrome', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
57
|
+
if (password)
|
|
58
|
+
return pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// secret-tool not available or no entry
|
|
62
|
+
}
|
|
63
|
+
// Fall back to default Chrome password
|
|
64
|
+
return pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1');
|
|
65
|
+
}
|
|
66
|
+
// --- Windows cookie decryption ---
|
|
67
|
+
function getWindowsDecryptionKey(chromeBase) {
|
|
68
|
+
const localStatePath = join(chromeBase, 'Local State');
|
|
69
|
+
if (!existsSync(localStatePath)) {
|
|
70
|
+
throw new Error('Chrome Local State file not found. Cannot decrypt cookies on Windows.');
|
|
71
|
+
}
|
|
72
|
+
const localState = JSON.parse(readFileSync(localStatePath, 'utf-8'));
|
|
73
|
+
const encryptedKeyB64 = localState?.os_crypt?.encrypted_key;
|
|
74
|
+
if (!encryptedKeyB64) {
|
|
75
|
+
throw new Error('No encrypted_key in Chrome Local State.');
|
|
76
|
+
}
|
|
77
|
+
// The key is base64-encoded, with a 'DPAPI' prefix (5 bytes) before the actual DPAPI blob
|
|
78
|
+
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64');
|
|
79
|
+
if (encryptedKey.toString('utf-8', 0, 5) !== 'DPAPI') {
|
|
80
|
+
throw new Error('Unexpected encrypted_key format (missing DPAPI prefix).');
|
|
81
|
+
}
|
|
82
|
+
const dpapiBlob = encryptedKey.slice(5).toString('base64');
|
|
83
|
+
// Use PowerShell to call DPAPI Unprotect
|
|
84
|
+
const psScript = `
|
|
85
|
+
Add-Type -AssemblyName System.Security
|
|
86
|
+
$blob = [Convert]::FromBase64String('${dpapiBlob}')
|
|
87
|
+
$dec = [Security.Cryptography.ProtectedData]::Unprotect($blob, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
88
|
+
[Convert]::ToBase64String($dec)
|
|
89
|
+
`.trim().replace(/\n/g, '; ');
|
|
90
|
+
const decryptedB64 = execSync(`powershell -NoProfile -NonInteractive -Command "${psScript}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
91
|
+
return Buffer.from(decryptedB64, 'base64');
|
|
92
|
+
}
|
|
93
|
+
// --- Decryption ---
|
|
94
|
+
function decryptCBC(encrypted, key) {
|
|
95
|
+
// v10 prefix = Chrome AES-128-CBC encryption (macOS and Linux)
|
|
96
|
+
if (encrypted.length > 3 && encrypted[0] === 0x76 && encrypted[1] === 0x31 && encrypted[2] === 0x30) {
|
|
97
|
+
const iv = Buffer.alloc(16, 0x20); // Chrome uses space (0x20) as IV
|
|
98
|
+
const decipher = createDecipheriv('aes-128-cbc', key, iv);
|
|
99
|
+
const decrypted = Buffer.concat([decipher.update(encrypted.slice(3)), decipher.final()]);
|
|
100
|
+
// Chrome may prepend binary metadata before the cookie value.
|
|
101
|
+
// The actual cookie is URL-encoded JSON starting with %7B or raw JSON starting with {
|
|
102
|
+
const str = decrypted.toString('binary');
|
|
103
|
+
const jsonStart = str.indexOf('%7B');
|
|
104
|
+
if (jsonStart >= 0)
|
|
105
|
+
return str.slice(jsonStart);
|
|
106
|
+
const rawJsonStart = str.indexOf('{');
|
|
107
|
+
if (rawJsonStart >= 0)
|
|
108
|
+
return str.slice(rawJsonStart);
|
|
109
|
+
throw new Error('Decrypted cookie data does not contain expected JSON value');
|
|
110
|
+
}
|
|
111
|
+
return encrypted.toString('utf-8');
|
|
112
|
+
}
|
|
113
|
+
function decryptWindows(encrypted, key) {
|
|
114
|
+
// Windows Chrome uses AES-256-GCM with v10 prefix
|
|
115
|
+
// Format: v10 (3 bytes) + nonce (12 bytes) + ciphertext + tag (16 bytes)
|
|
116
|
+
if (encrypted.length > 3 && encrypted[0] === 0x76 && encrypted[1] === 0x31 && encrypted[2] === 0x30) {
|
|
117
|
+
const nonce = encrypted.slice(3, 15);
|
|
118
|
+
const ciphertextWithTag = encrypted.slice(15);
|
|
119
|
+
const tag = ciphertextWithTag.slice(ciphertextWithTag.length - 16);
|
|
120
|
+
const ciphertext = ciphertextWithTag.slice(0, ciphertextWithTag.length - 16);
|
|
121
|
+
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
|
122
|
+
decipher.setAuthTag(tag);
|
|
123
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
124
|
+
const str = decrypted.toString('utf-8');
|
|
125
|
+
const jsonStart = str.indexOf('%7B');
|
|
126
|
+
if (jsonStart >= 0)
|
|
127
|
+
return str.slice(jsonStart);
|
|
128
|
+
const rawJsonStart = str.indexOf('{');
|
|
129
|
+
if (rawJsonStart >= 0)
|
|
130
|
+
return str.slice(rawJsonStart);
|
|
131
|
+
throw new Error('Decrypted cookie data does not contain expected JSON value');
|
|
132
|
+
}
|
|
133
|
+
return encrypted.toString('utf-8');
|
|
134
|
+
}
|
|
135
|
+
// --- Cookie extraction ---
|
|
136
|
+
function extractCookie(profilePath) {
|
|
137
|
+
const cookiesDb = join(profilePath, 'Cookies');
|
|
138
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'figmanage-'));
|
|
139
|
+
const tmpDb = join(tmpDir, 'Cookies');
|
|
140
|
+
// Copy DB + WAL/SHM (Chrome locks the original)
|
|
141
|
+
copyFileSync(cookiesDb, tmpDb);
|
|
142
|
+
for (const ext of ['-wal', '-shm']) {
|
|
143
|
+
const src = cookiesDb + ext;
|
|
144
|
+
if (existsSync(src))
|
|
145
|
+
copyFileSync(src, tmpDb + ext);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// Query for the auth cookie -- could be on figma.com or www.figma.com
|
|
149
|
+
const sqliteBin = platform() === 'win32' ? 'sqlite3.exe' : 'sqlite3';
|
|
150
|
+
const hex = execSync(`${sqliteBin} "${tmpDb}" "SELECT hex(encrypted_value) FROM cookies WHERE name = '${COOKIE_NAME}' AND host_key LIKE '%figma.com' ORDER BY last_access_utc DESC LIMIT 1;"`, { encoding: 'utf-8' }).trim();
|
|
151
|
+
if (!hex)
|
|
152
|
+
throw new Error(`No ${COOKIE_NAME} cookie found. Are you logged into figma.com in Chrome?`);
|
|
153
|
+
const encrypted = Buffer.from(hex, 'hex');
|
|
154
|
+
const os = platform();
|
|
155
|
+
if (os === 'darwin') {
|
|
156
|
+
const key = getMacDecryptionKey();
|
|
157
|
+
return decryptCBC(encrypted, key);
|
|
158
|
+
}
|
|
159
|
+
else if (os === 'linux') {
|
|
160
|
+
const key = getLinuxDecryptionKey();
|
|
161
|
+
return decryptCBC(encrypted, key);
|
|
162
|
+
}
|
|
163
|
+
else if (os === 'win32') {
|
|
164
|
+
// Derive the chrome base from the profile path (go up one level from Default/Profile N)
|
|
165
|
+
const chromeBase = join(profilePath, '..');
|
|
166
|
+
const key = getWindowsDecryptionKey(chromeBase);
|
|
167
|
+
return decryptWindows(encrypted, key);
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Unsupported platform: ${os}`);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
for (const f of [tmpDb, tmpDb + '-wal', tmpDb + '-shm']) {
|
|
173
|
+
try {
|
|
174
|
+
unlinkSync(f);
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
rmdirSync(tmpDir);
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// --- Figma API validation ---
|
|
185
|
+
function parseCookieValue(raw) {
|
|
186
|
+
// Cookie value is JSON: {"userId":"token"} (may be URL-encoded)
|
|
187
|
+
let decoded = raw;
|
|
188
|
+
try {
|
|
189
|
+
decoded = decodeURIComponent(raw);
|
|
190
|
+
}
|
|
191
|
+
catch { }
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(decoded);
|
|
194
|
+
const entries = Object.entries(parsed);
|
|
195
|
+
if (entries.length === 0)
|
|
196
|
+
throw new Error('Empty cookie JSON');
|
|
197
|
+
const [userId, token] = entries[0];
|
|
198
|
+
return { userId, token, cookieValue: raw };
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw new Error('Unexpected cookie format. Expected URL-encoded JSON with userId field.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function validateSession(cookieValue, userId) {
|
|
205
|
+
const headers = {
|
|
206
|
+
'Cookie': `${COOKIE_NAME}=${cookieValue}`,
|
|
207
|
+
'X-CSRF-Bypass': 'yes',
|
|
208
|
+
'X-Figma-User-Id': userId,
|
|
209
|
+
};
|
|
210
|
+
const res = await axios.get('https://www.figma.com/api/user/state', {
|
|
211
|
+
headers,
|
|
212
|
+
timeout: 15000,
|
|
213
|
+
});
|
|
214
|
+
if (res.data?.error !== false)
|
|
215
|
+
throw new Error('Session invalid');
|
|
216
|
+
const meta = res.data.meta || {};
|
|
217
|
+
const teams = (meta.teams || []).map((t) => ({ id: String(t.id), name: t.name }));
|
|
218
|
+
const orgs = (meta.orgs || []).map((o) => ({ id: String(o.id), name: o.name }));
|
|
219
|
+
// Try to find org_id: check orgs array, team_users, or follow the recents redirect
|
|
220
|
+
let orgId = '';
|
|
221
|
+
if (orgs.length > 0) {
|
|
222
|
+
orgId = orgs[0].id;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Figma redirects /files/recents-and-sharing to /files/{org_id}/recents-and-sharing
|
|
226
|
+
try {
|
|
227
|
+
const redirect = await axios.get('https://www.figma.com/files/recents-and-sharing', {
|
|
228
|
+
headers,
|
|
229
|
+
maxRedirects: 0,
|
|
230
|
+
validateStatus: (s) => s >= 200 && s < 400,
|
|
231
|
+
timeout: 10000,
|
|
232
|
+
});
|
|
233
|
+
// Check final URL for org_id pattern: /files/{org_id}/
|
|
234
|
+
const finalUrl = redirect.request?.res?.responseUrl || redirect.headers?.location || '';
|
|
235
|
+
const match = finalUrl.match(/\/files\/(\d+)\//);
|
|
236
|
+
if (match)
|
|
237
|
+
orgId = match[1];
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
// Check redirect location header
|
|
241
|
+
const loc = e.response?.headers?.location || '';
|
|
242
|
+
const match = loc.match(/\/files\/(\d+)\//);
|
|
243
|
+
if (match)
|
|
244
|
+
orgId = match[1];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// If orgId found but no orgs entry, try to derive a name from the org's domain
|
|
248
|
+
if (orgId && orgs.length === 0) {
|
|
249
|
+
let name = orgId;
|
|
250
|
+
try {
|
|
251
|
+
const domRes = await axios.get(`https://www.figma.com/api/orgs/${orgId}/domains`, {
|
|
252
|
+
headers,
|
|
253
|
+
timeout: 10000,
|
|
254
|
+
});
|
|
255
|
+
const domains = domRes.data?.meta || [];
|
|
256
|
+
if (Array.isArray(domains) && domains.length > 0 && domains[0].domain) {
|
|
257
|
+
name = domains[0].domain;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch { /* domain lookup optional */ }
|
|
261
|
+
orgs.push({ id: orgId, name });
|
|
262
|
+
}
|
|
263
|
+
return { orgId, orgs, teams };
|
|
264
|
+
}
|
|
265
|
+
// --- PAT validation ---
|
|
266
|
+
async function validatePat(pat) {
|
|
267
|
+
const res = await axios.get('https://api.figma.com/v1/me', {
|
|
268
|
+
headers: { 'X-Figma-Token': pat },
|
|
269
|
+
timeout: 15000,
|
|
270
|
+
});
|
|
271
|
+
return res.data.handle || res.data.email || 'valid';
|
|
272
|
+
}
|
|
273
|
+
// --- MCP client detection and registration ---
|
|
274
|
+
function claudeCliAvailable() {
|
|
275
|
+
try {
|
|
276
|
+
execSync('which claude 2>/dev/null || where claude 2>nul', {
|
|
277
|
+
encoding: 'utf-8',
|
|
278
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
279
|
+
});
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function registerWithClaude(envVars, serverPath) {
|
|
287
|
+
try {
|
|
288
|
+
execSync('claude mcp remove figmanage -s user 2>/dev/null || true', { encoding: 'utf-8' });
|
|
289
|
+
const envFlags = Object.entries(envVars).map(([k, v]) => `--env ${k}=${v}`).join(' ');
|
|
290
|
+
execSync(`claude mcp add figmanage --transport stdio -s user ${envFlags} -- node ${serverPath}`, { encoding: 'utf-8' });
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function printManualConfig(envVars, serverPath) {
|
|
298
|
+
console.log('\nClaude CLI not found. Configure your MCP client manually.\n');
|
|
299
|
+
console.log('Environment variables:');
|
|
300
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
301
|
+
const display = (k === 'FIGMA_AUTH_COOKIE' || k === 'FIGMA_PAT') ? '******' : v;
|
|
302
|
+
console.log(` ${k}=${display}`);
|
|
303
|
+
}
|
|
304
|
+
console.log('\nMCP server config (JSON):');
|
|
305
|
+
const config = {
|
|
306
|
+
figmanage: {
|
|
307
|
+
command: 'node',
|
|
308
|
+
args: [serverPath],
|
|
309
|
+
env: Object.fromEntries(Object.entries(envVars).map(([k, v]) => [k, k === 'FIGMA_AUTH_COOKIE' || k === 'FIGMA_PAT' ? '<paste value>' : v])),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
console.log(JSON.stringify(config, null, 2));
|
|
313
|
+
}
|
|
314
|
+
// --- CLI arg parsing ---
|
|
315
|
+
function parseArgs() {
|
|
316
|
+
const args = process.argv.slice(2);
|
|
317
|
+
let noPrompt = false;
|
|
318
|
+
let pat;
|
|
319
|
+
for (let i = 0; i < args.length; i++) {
|
|
320
|
+
if (args[i] === '--no-prompt') {
|
|
321
|
+
noPrompt = true;
|
|
322
|
+
}
|
|
323
|
+
else if (args[i] === '--pat' && i + 1 < args.length) {
|
|
324
|
+
pat = args[++i];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { noPrompt, pat };
|
|
328
|
+
}
|
|
329
|
+
// --- Main ---
|
|
330
|
+
async function setup() {
|
|
331
|
+
console.log('figmanage setup\n');
|
|
332
|
+
const { noPrompt, pat: patArg } = parseArgs();
|
|
333
|
+
const os = platform();
|
|
334
|
+
// Build env vars to register
|
|
335
|
+
const envVars = {};
|
|
336
|
+
const serverPath = join(import.meta.dirname, 'index.js');
|
|
337
|
+
if (noPrompt) {
|
|
338
|
+
// Non-interactive mode: skip cookie extraction, require --pat
|
|
339
|
+
if (!patArg) {
|
|
340
|
+
console.error('--no-prompt requires --pat <token>');
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
console.log('Non-interactive mode. Validating PAT...');
|
|
344
|
+
try {
|
|
345
|
+
const patUser = await validatePat(patArg);
|
|
346
|
+
console.log(` PAT valid (${patUser})`);
|
|
347
|
+
envVars.FIGMA_PAT = patArg;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
console.error(' PAT invalid or expired.');
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
// Register with whatever client is available
|
|
354
|
+
if (claudeCliAvailable()) {
|
|
355
|
+
console.log('\nRegistering with Claude...');
|
|
356
|
+
if (registerWithClaude(envVars, serverPath)) {
|
|
357
|
+
console.log(' PAT stored in MCP server config');
|
|
358
|
+
console.log(' Done. Restart Claude Code to use figmanage.');
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
printManualConfig(envVars, serverPath);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
printManualConfig(envVars, serverPath);
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// Interactive mode
|
|
370
|
+
if (os !== 'darwin' && os !== 'linux' && os !== 'win32') {
|
|
371
|
+
console.error(`Unsupported platform: ${os}. Provide credentials via --no-prompt --pat <token>.`);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
// 1. Extract cookies from all Chrome profiles
|
|
375
|
+
const promptLabel = os === 'darwin' ? ' (Keychain prompt may appear)' : '';
|
|
376
|
+
console.log(`Reading Chrome cookies${promptLabel}...`);
|
|
377
|
+
let accounts = [];
|
|
378
|
+
try {
|
|
379
|
+
const profiles = findChromeProfiles();
|
|
380
|
+
for (const profilePath of profiles) {
|
|
381
|
+
try {
|
|
382
|
+
const rawCookie = extractCookie(profilePath);
|
|
383
|
+
const { userId, cookieValue } = parseCookieValue(rawCookie);
|
|
384
|
+
accounts.push({ userId, cookieValue, profile: profilePath.split(/[/\\]/).pop() });
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// Profile doesn't have a Figma cookie
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
if (os === 'win32') {
|
|
393
|
+
console.log(` Cookie extraction failed: ${e.message}`);
|
|
394
|
+
console.log(' Windows cookie extraction is best-effort. Provide credentials manually.');
|
|
395
|
+
console.log(' You can still enter a PAT below.\n');
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
let userId = '';
|
|
402
|
+
let cookieValue = '';
|
|
403
|
+
if (accounts.length === 0) {
|
|
404
|
+
if (os === 'win32') {
|
|
405
|
+
console.log(' No Figma auth cookies extracted. Continuing with PAT-only setup.');
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
console.error('No Figma auth cookies found. Log into figma.com in Chrome.');
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
// If multiple accounts, let user pick
|
|
414
|
+
let selected = accounts[0];
|
|
415
|
+
if (accounts.length > 1) {
|
|
416
|
+
console.log(`\n Found ${accounts.length} Figma accounts:\n`);
|
|
417
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
418
|
+
const a = accounts[i];
|
|
419
|
+
console.log(` [${i + 1}] User ${a.userId} (${a.profile})`);
|
|
420
|
+
}
|
|
421
|
+
const { createInterface } = await import('readline');
|
|
422
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
423
|
+
const answer = await new Promise((resolve) => {
|
|
424
|
+
rl.question(`\n Select account [1-${accounts.length}]: `, resolve);
|
|
425
|
+
});
|
|
426
|
+
rl.close();
|
|
427
|
+
const idx = parseInt(answer, 10) - 1;
|
|
428
|
+
if (idx < 0 || idx >= accounts.length) {
|
|
429
|
+
console.error(' Invalid selection.');
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
selected = accounts[idx];
|
|
433
|
+
}
|
|
434
|
+
userId = selected.userId;
|
|
435
|
+
cookieValue = selected.cookieValue;
|
|
436
|
+
console.log(` Cookie found for user ${userId}`);
|
|
437
|
+
}
|
|
438
|
+
// 2. Validate cookie against Figma (if we have one)
|
|
439
|
+
let orgId = '';
|
|
440
|
+
let orgs = [];
|
|
441
|
+
if (cookieValue && userId) {
|
|
442
|
+
console.log('Validating session...');
|
|
443
|
+
try {
|
|
444
|
+
const session = await validateSession(cookieValue, userId);
|
|
445
|
+
console.log(` Session valid (user ${userId})`);
|
|
446
|
+
if (session.teams.length > 0) {
|
|
447
|
+
console.log(` Teams: ${session.teams.map(t => t.name).join(', ')}`);
|
|
448
|
+
}
|
|
449
|
+
orgId = session.orgId;
|
|
450
|
+
orgs = session.orgs;
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
const status = e.response?.status;
|
|
454
|
+
if (status === 401 || status === 403) {
|
|
455
|
+
console.error(' Cookie expired. Log into figma.com in Chrome and try again.');
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
console.error(` Validation failed: ${e.message}`);
|
|
459
|
+
}
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
if (orgs.length > 1) {
|
|
463
|
+
console.log(`\n Found ${orgs.length} workspaces:\n`);
|
|
464
|
+
for (let i = 0; i < orgs.length; i++) {
|
|
465
|
+
const o = orgs[i];
|
|
466
|
+
const marker = o.id === orgId ? ' (current)' : '';
|
|
467
|
+
console.log(` [${i + 1}] ${o.name} (${o.id})${marker}`);
|
|
468
|
+
}
|
|
469
|
+
const { createInterface } = await import('readline');
|
|
470
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
471
|
+
const answer = await new Promise((resolve) => {
|
|
472
|
+
rl.question(`\n Default workspace [1-${orgs.length}] (Enter for 1): `, resolve);
|
|
473
|
+
});
|
|
474
|
+
rl.close();
|
|
475
|
+
const input = answer.trim();
|
|
476
|
+
if (input) {
|
|
477
|
+
const idx = parseInt(input, 10) - 1;
|
|
478
|
+
if (idx >= 0 && idx < orgs.length) {
|
|
479
|
+
orgId = orgs[idx].id;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (orgId) {
|
|
484
|
+
const orgName = orgs.find(o => o.id === orgId)?.name;
|
|
485
|
+
console.log(` Workspace: ${orgName ? `${orgName} (${orgId})` : orgId}`);
|
|
486
|
+
}
|
|
487
|
+
// Store cookie credentials
|
|
488
|
+
envVars.FIGMA_AUTH_COOKIE = cookieValue;
|
|
489
|
+
envVars.FIGMA_USER_ID = userId;
|
|
490
|
+
if (orgId)
|
|
491
|
+
envVars.FIGMA_ORG_ID = orgId;
|
|
492
|
+
if (orgs.length > 0)
|
|
493
|
+
envVars.FIGMA_ORGS = JSON.stringify(orgs);
|
|
494
|
+
}
|
|
495
|
+
// 3. PAT: arg > env > prompt
|
|
496
|
+
let pat = patArg || process.env.FIGMA_PAT || '';
|
|
497
|
+
if (pat) {
|
|
498
|
+
console.log('Validating PAT...');
|
|
499
|
+
try {
|
|
500
|
+
const patUser = await validatePat(pat);
|
|
501
|
+
console.log(` PAT valid (${patUser})`);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
console.log(' PAT invalid or expired.');
|
|
505
|
+
pat = '';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!pat) {
|
|
509
|
+
console.log('\nA Personal Access Token enables comments, export, and version history.');
|
|
510
|
+
console.log('Generate one at: https://www.figma.com/settings (Security > Personal access tokens)');
|
|
511
|
+
const { createInterface } = await import('readline');
|
|
512
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
513
|
+
const answer = await new Promise((resolve) => {
|
|
514
|
+
rl.question('Paste your PAT (or press Enter to skip): ', resolve);
|
|
515
|
+
});
|
|
516
|
+
rl.close();
|
|
517
|
+
pat = answer.trim();
|
|
518
|
+
if (pat) {
|
|
519
|
+
try {
|
|
520
|
+
const patUser = await validatePat(pat);
|
|
521
|
+
console.log(` PAT valid (${patUser})`);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
console.log(' PAT invalid -- skipping');
|
|
525
|
+
pat = '';
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (pat) {
|
|
530
|
+
envVars.FIGMA_PAT = pat;
|
|
531
|
+
}
|
|
532
|
+
// Must have at least one credential
|
|
533
|
+
if (!envVars.FIGMA_PAT && !envVars.FIGMA_AUTH_COOKIE) {
|
|
534
|
+
console.error('\nNo credentials configured. Need at least a PAT or browser cookie.');
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
// 4. Register with MCP client
|
|
538
|
+
console.log('\n--- Configuration ---\n');
|
|
539
|
+
console.log(`FIGMA_USER_ID=${userId || '(none)'}`);
|
|
540
|
+
console.log('FIGMA_AUTH_COOKIE=****** (stored in MCP server config)');
|
|
541
|
+
if (orgId)
|
|
542
|
+
console.log(`FIGMA_ORG_ID=${orgId}`);
|
|
543
|
+
if (pat)
|
|
544
|
+
console.log('FIGMA_PAT=****** (stored in MCP server config)');
|
|
545
|
+
if (claudeCliAvailable()) {
|
|
546
|
+
console.log('\nRegistering with Claude...');
|
|
547
|
+
if (registerWithClaude(envVars, serverPath)) {
|
|
548
|
+
if (pat)
|
|
549
|
+
console.log(' PAT stored in MCP server config');
|
|
550
|
+
console.log(' Done. Restart Claude Code to use figmanage.');
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
console.log(' Could not register automatically.');
|
|
554
|
+
printManualConfig(envVars, serverPath);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
printManualConfig(envVars, serverPath);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
setup().catch((err) => {
|
|
562
|
+
console.error(`\nSetup failed: ${err.message}`);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
});
|
|
565
|
+
//# sourceMappingURL=setup.js.map
|