figmanage 0.3.0 → 1.0.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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Chrome cookie extraction and Figma session validation.
3
+ *
4
+ * Extracted from setup.ts so both the setup flow and the CLI login
5
+ * command can reuse this logic.
6
+ */
7
+ export interface ParsedCookie {
8
+ userId: string;
9
+ token: string;
10
+ cookieValue: string;
11
+ }
12
+ export declare function parseCookieValue(raw: string): ParsedCookie;
13
+ export interface SessionInfo {
14
+ orgId: string;
15
+ orgs: {
16
+ id: string;
17
+ name: string;
18
+ }[];
19
+ teams: {
20
+ id: string;
21
+ name: string;
22
+ }[];
23
+ }
24
+ export declare function validateSession(cookieValue: string, userId: string): Promise<SessionInfo>;
25
+ export declare function validatePat(pat: string): Promise<string>;
26
+ export interface FigmaAccount {
27
+ userId: string;
28
+ cookieValue: string;
29
+ profile: string;
30
+ }
31
+ /**
32
+ * Extract Figma auth cookies from all Chrome profiles.
33
+ * Returns an array of accounts found (may be empty on Windows failure).
34
+ * Throws on non-Windows platforms if no Chrome profiles exist.
35
+ */
36
+ export declare function extractCookies(): FigmaAccount[];
37
+ //# sourceMappingURL=cookie.d.ts.map
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Chrome cookie extraction and Figma session validation.
3
+ *
4
+ * Extracted from setup.ts so both the setup flow and the CLI login
5
+ * command can reuse this logic.
6
+ */
7
+ import { execFileSync } from 'child_process';
8
+ import { createDecipheriv, pbkdf2Sync } from 'crypto';
9
+ import { copyFileSync, unlinkSync, mkdtempSync, existsSync, rmdirSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { tmpdir, homedir, platform } from 'os';
12
+ import axios from 'axios';
13
+ const COOKIE_NAME = '__Host-figma.authn';
14
+ // --- Platform-specific Chrome paths ---
15
+ function getChromePaths() {
16
+ switch (platform()) {
17
+ case 'darwin':
18
+ return [join(homedir(), 'Library/Application Support/Google/Chrome')];
19
+ case 'linux':
20
+ return [
21
+ join(homedir(), '.config/google-chrome'),
22
+ join(homedir(), '.config/chromium'),
23
+ ];
24
+ case 'win32': {
25
+ const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local');
26
+ return [join(localAppData, 'Google/Chrome/User Data')];
27
+ }
28
+ default:
29
+ return [];
30
+ }
31
+ }
32
+ // --- Chrome profile discovery ---
33
+ function findChromeProfiles() {
34
+ const chromePaths = getChromePaths();
35
+ const profiles = [];
36
+ for (const base of chromePaths) {
37
+ if (!existsSync(base))
38
+ continue;
39
+ const defaultProfile = join(base, 'Default');
40
+ if (existsSync(join(defaultProfile, 'Cookies')))
41
+ profiles.push(defaultProfile);
42
+ for (let i = 1; i <= 20; i++) {
43
+ const profile = join(base, `Profile ${i}`);
44
+ if (existsSync(join(profile, 'Cookies')))
45
+ profiles.push(profile);
46
+ }
47
+ }
48
+ if (profiles.length === 0)
49
+ throw new Error('No Chrome profiles with Cookies found.');
50
+ return profiles;
51
+ }
52
+ // --- macOS cookie decryption ---
53
+ function getMacDecryptionKey() {
54
+ const password = execFileSync('security', ['find-generic-password', '-w', '-s', 'Chrome Safe Storage'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
55
+ return pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
56
+ }
57
+ // --- Linux cookie decryption ---
58
+ function getLinuxDecryptionKey() {
59
+ try {
60
+ const password = execFileSync('secret-tool', ['lookup', 'application', 'chrome'], {
61
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
62
+ }).trim();
63
+ if (password)
64
+ return pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
65
+ }
66
+ catch {
67
+ // secret-tool not available or no entry
68
+ }
69
+ return pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1');
70
+ }
71
+ // --- Windows cookie decryption ---
72
+ function getWindowsDecryptionKey(chromeBase) {
73
+ const localStatePath = join(chromeBase, 'Local State');
74
+ if (!existsSync(localStatePath)) {
75
+ throw new Error('Chrome Local State file not found. Cannot decrypt cookies on Windows.');
76
+ }
77
+ const localState = JSON.parse(readFileSync(localStatePath, 'utf-8'));
78
+ const encryptedKeyB64 = localState?.os_crypt?.encrypted_key;
79
+ if (!encryptedKeyB64) {
80
+ throw new Error('No encrypted_key in Chrome Local State.');
81
+ }
82
+ const encryptedKey = Buffer.from(encryptedKeyB64, 'base64');
83
+ if (encryptedKey.toString('utf-8', 0, 5) !== 'DPAPI') {
84
+ throw new Error('Unexpected encrypted_key format (missing DPAPI prefix).');
85
+ }
86
+ const dpapiBlob = encryptedKey.slice(5).toString('base64');
87
+ if (!/^[A-Za-z0-9+/=]+$/.test(dpapiBlob)) {
88
+ throw new Error('Invalid base64 in Chrome encrypted_key.');
89
+ }
90
+ const psScript = `
91
+ Add-Type -AssemblyName System.Security
92
+ $blob = [Convert]::FromBase64String('${dpapiBlob}')
93
+ $dec = [Security.Cryptography.ProtectedData]::Unprotect($blob, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
94
+ [Convert]::ToBase64String($dec)
95
+ `.trim().replace(/\n/g, '; ');
96
+ const decryptedB64 = execFileSync('powershell', [
97
+ '-NoProfile', '-NonInteractive', '-Command', psScript,
98
+ ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
99
+ return Buffer.from(decryptedB64, 'base64');
100
+ }
101
+ // --- Decryption ---
102
+ function decryptCBC(encrypted, key) {
103
+ if (encrypted.length > 3 && encrypted[0] === 0x76 && encrypted[1] === 0x31 && encrypted[2] === 0x30) {
104
+ const iv = Buffer.alloc(16, 0x20);
105
+ const decipher = createDecipheriv('aes-128-cbc', key, iv);
106
+ const decrypted = Buffer.concat([decipher.update(encrypted.slice(3)), decipher.final()]);
107
+ const str = decrypted.toString('binary');
108
+ const jsonStart = str.indexOf('%7B');
109
+ if (jsonStart >= 0)
110
+ return str.slice(jsonStart);
111
+ const rawJsonStart = str.indexOf('{');
112
+ if (rawJsonStart >= 0)
113
+ return str.slice(rawJsonStart);
114
+ throw new Error('Decrypted cookie data does not contain expected JSON value');
115
+ }
116
+ return encrypted.toString('utf-8');
117
+ }
118
+ function decryptWindows(encrypted, key) {
119
+ if (encrypted.length > 3 && encrypted[0] === 0x76 && encrypted[1] === 0x31 && encrypted[2] === 0x30) {
120
+ const nonce = encrypted.slice(3, 15);
121
+ const ciphertextWithTag = encrypted.slice(15);
122
+ const tag = ciphertextWithTag.slice(ciphertextWithTag.length - 16);
123
+ const ciphertext = ciphertextWithTag.slice(0, ciphertextWithTag.length - 16);
124
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce);
125
+ decipher.setAuthTag(tag);
126
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
127
+ const str = decrypted.toString('utf-8');
128
+ const jsonStart = str.indexOf('%7B');
129
+ if (jsonStart >= 0)
130
+ return str.slice(jsonStart);
131
+ const rawJsonStart = str.indexOf('{');
132
+ if (rawJsonStart >= 0)
133
+ return str.slice(rawJsonStart);
134
+ throw new Error('Decrypted cookie data does not contain expected JSON value');
135
+ }
136
+ return encrypted.toString('utf-8');
137
+ }
138
+ // --- Cookie extraction ---
139
+ function extractCookieFromProfile(profilePath) {
140
+ const cookiesDb = join(profilePath, 'Cookies');
141
+ const tmpDir = mkdtempSync(join(tmpdir(), 'figmanage-'));
142
+ const tmpDb = join(tmpDir, 'Cookies');
143
+ copyFileSync(cookiesDb, tmpDb);
144
+ for (const ext of ['-wal', '-shm']) {
145
+ const src = cookiesDb + ext;
146
+ if (existsSync(src))
147
+ copyFileSync(src, tmpDb + ext);
148
+ }
149
+ try {
150
+ const sqliteBin = platform() === 'win32' ? 'sqlite3.exe' : 'sqlite3';
151
+ const hex = execFileSync(sqliteBin, [
152
+ tmpDb,
153
+ `SELECT hex(encrypted_value) FROM cookies WHERE name = '${COOKIE_NAME}' AND host_key LIKE '%figma.com' ORDER BY last_access_utc DESC LIMIT 1;`,
154
+ ], { encoding: 'utf-8' }).trim();
155
+ if (!hex)
156
+ throw new Error(`No ${COOKIE_NAME} cookie found. Are you logged into figma.com in Chrome?`);
157
+ const encrypted = Buffer.from(hex, 'hex');
158
+ const os = platform();
159
+ if (os === 'darwin') {
160
+ return decryptCBC(encrypted, getMacDecryptionKey());
161
+ }
162
+ else if (os === 'linux') {
163
+ return decryptCBC(encrypted, getLinuxDecryptionKey());
164
+ }
165
+ else if (os === 'win32') {
166
+ const chromeBase = join(profilePath, '..');
167
+ return decryptWindows(encrypted, getWindowsDecryptionKey(chromeBase));
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
+ export function parseCookieValue(raw) {
185
+ let decoded = raw;
186
+ try {
187
+ decoded = decodeURIComponent(raw);
188
+ }
189
+ catch { }
190
+ try {
191
+ const parsed = JSON.parse(decoded);
192
+ const entries = Object.entries(parsed);
193
+ if (entries.length === 0)
194
+ throw new Error('Empty cookie JSON');
195
+ const [userId, token] = entries[0];
196
+ return { userId, token, cookieValue: raw };
197
+ }
198
+ catch {
199
+ throw new Error('Unexpected cookie format. Expected URL-encoded JSON with userId field.');
200
+ }
201
+ }
202
+ export async function validateSession(cookieValue, userId) {
203
+ const headers = {
204
+ 'Cookie': `${COOKIE_NAME}=${cookieValue}`,
205
+ 'X-CSRF-Bypass': 'yes',
206
+ 'X-Figma-User-Id': userId,
207
+ };
208
+ const res = await axios.get('https://www.figma.com/api/user/state', {
209
+ headers,
210
+ timeout: 15000,
211
+ });
212
+ if (res.data?.error !== false)
213
+ throw new Error('Session invalid');
214
+ const meta = res.data.meta || {};
215
+ const teams = (meta.teams || []).map((t) => ({ id: String(t.id), name: t.name }));
216
+ const orgs = (meta.orgs || []).map((o) => ({ id: String(o.id), name: o.name }));
217
+ let orgId = '';
218
+ if (orgs.length > 0) {
219
+ orgId = orgs[0].id;
220
+ }
221
+ else {
222
+ try {
223
+ const redirect = await axios.get('https://www.figma.com/files/recents-and-sharing', {
224
+ headers,
225
+ maxRedirects: 0,
226
+ validateStatus: (s) => s >= 200 && s < 400,
227
+ timeout: 10000,
228
+ });
229
+ const finalUrl = redirect.request?.res?.responseUrl || redirect.headers?.location || '';
230
+ const match = finalUrl.match(/\/files\/(\d+)\//);
231
+ if (match)
232
+ orgId = match[1];
233
+ }
234
+ catch (e) {
235
+ const loc = e.response?.headers?.location || '';
236
+ const match = loc.match(/\/files\/(\d+)\//);
237
+ if (match)
238
+ orgId = match[1];
239
+ }
240
+ }
241
+ if (orgId && orgs.length === 0) {
242
+ let name = orgId;
243
+ try {
244
+ const domRes = await axios.get(`https://www.figma.com/api/orgs/${orgId}/domains`, {
245
+ headers,
246
+ timeout: 10000,
247
+ });
248
+ const domains = domRes.data?.meta || [];
249
+ if (Array.isArray(domains) && domains.length > 0 && domains[0].domain) {
250
+ name = domains[0].domain;
251
+ }
252
+ }
253
+ catch { /* domain lookup optional */ }
254
+ orgs.push({ id: orgId, name });
255
+ }
256
+ return { orgId, orgs, teams };
257
+ }
258
+ // --- PAT validation ---
259
+ export async function validatePat(pat) {
260
+ const res = await axios.get('https://api.figma.com/v1/me', {
261
+ headers: { 'X-Figma-Token': pat },
262
+ timeout: 15000,
263
+ });
264
+ return res.data.handle || res.data.email || 'valid';
265
+ }
266
+ /**
267
+ * Extract Figma auth cookies from all Chrome profiles.
268
+ * Returns an array of accounts found (may be empty on Windows failure).
269
+ * Throws on non-Windows platforms if no Chrome profiles exist.
270
+ */
271
+ export function extractCookies() {
272
+ const accounts = [];
273
+ const profiles = findChromeProfiles();
274
+ for (const profilePath of profiles) {
275
+ try {
276
+ const rawCookie = extractCookieFromProfile(profilePath);
277
+ const { userId, cookieValue } = parseCookieValue(rawCookie);
278
+ accounts.push({ userId, cookieValue, profile: profilePath.split(/[/\\]/).pop() });
279
+ }
280
+ catch {
281
+ // Profile doesn't have a Figma cookie
282
+ }
283
+ }
284
+ return accounts;
285
+ }
286
+ //# sourceMappingURL=cookie.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CLI command handlers. Each replicates the API calls from the
3
+ * corresponding MCP tool handler, adapted for CLI output.
4
+ */
5
+ export declare function runSeatOptimization(options: {
6
+ daysInactive?: string;
7
+ cost?: boolean;
8
+ json?: boolean;
9
+ }): Promise<void>;
10
+ export declare function runOffboard(user: string, options: {
11
+ execute?: boolean;
12
+ transferTo?: string;
13
+ json?: boolean;
14
+ }): Promise<void>;
15
+ export declare function runOnboard(email: string, options: {
16
+ teams?: string[];
17
+ role?: string;
18
+ shareFiles?: string[];
19
+ seat?: string;
20
+ confirm?: boolean;
21
+ json?: boolean;
22
+ }): Promise<void>;
23
+ export declare function runQuarterlyReport(options: {
24
+ days?: string;
25
+ json?: boolean;
26
+ }): Promise<void>;
27
+ export declare function runMembers(options: {
28
+ search?: string;
29
+ json?: boolean;
30
+ }): Promise<void>;
31
+ export declare function runTeams(options: {
32
+ json?: boolean;
33
+ }): Promise<void>;
34
+ export declare function runPermissions(type: string, id: string, options: {
35
+ json?: boolean;
36
+ }): Promise<void>;
37
+ export declare function runPermissionAudit(options: {
38
+ scope?: string;
39
+ id?: string;
40
+ json?: boolean;
41
+ }): Promise<void>;
42
+ export declare function runBranchCleanup(projectId: string, options: {
43
+ daysStale?: string;
44
+ execute?: boolean;
45
+ json?: boolean;
46
+ }): Promise<void>;
47
+ //# sourceMappingURL=commands.d.ts.map