figmanage 1.1.0 → 1.2.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.
- package/dist/cli/completion.d.ts +7 -0
- package/dist/cli/completion.js +160 -0
- package/dist/cli/format.js +147 -2
- package/dist/cli/index.js +3 -0
- package/dist/mcp.js +13 -0
- package/dist/operations/compound-manager.js +3 -2
- package/dist/setup.js +21 -285
- package/package.json +3 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* Create the `completion` command. Needs the parent program reference
|
|
4
|
+
* so it can introspect the full command tree at runtime.
|
|
5
|
+
*/
|
|
6
|
+
export declare function completionCommand(program: Command): Command;
|
|
7
|
+
//# sourceMappingURL=completion.d.ts.map
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* Introspect a Commander program and extract its command tree.
|
|
4
|
+
* Returns a map of command names to their subcommand names.
|
|
5
|
+
* Top-level commands without subcommands (like login, whoami) get an empty array.
|
|
6
|
+
*/
|
|
7
|
+
function extractCommandTree(program) {
|
|
8
|
+
const tree = new Map();
|
|
9
|
+
for (const cmd of program.commands) {
|
|
10
|
+
const subs = cmd.commands.map((sub) => sub.name());
|
|
11
|
+
tree.set(cmd.name(), subs);
|
|
12
|
+
}
|
|
13
|
+
return tree;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Extract all option flags from a command (including inherited ones).
|
|
17
|
+
* Returns long-form flags like --json, --help.
|
|
18
|
+
*/
|
|
19
|
+
function extractOptions(cmd) {
|
|
20
|
+
const flags = [];
|
|
21
|
+
for (const opt of cmd.options) {
|
|
22
|
+
if (opt.long)
|
|
23
|
+
flags.push(opt.long);
|
|
24
|
+
}
|
|
25
|
+
return flags;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Collect global options that appear across most commands.
|
|
29
|
+
* These get offered at every completion point.
|
|
30
|
+
*/
|
|
31
|
+
function extractGlobalOptions(program) {
|
|
32
|
+
const flags = new Set();
|
|
33
|
+
// Program-level options (--version, etc.)
|
|
34
|
+
for (const opt of program.options) {
|
|
35
|
+
if (opt.long)
|
|
36
|
+
flags.add(opt.long);
|
|
37
|
+
}
|
|
38
|
+
// Common flags present on subcommands
|
|
39
|
+
flags.add('--help');
|
|
40
|
+
flags.add('--json');
|
|
41
|
+
return [...flags];
|
|
42
|
+
}
|
|
43
|
+
function generateZshScript(program) {
|
|
44
|
+
const tree = extractCommandTree(program);
|
|
45
|
+
const globalOpts = extractGlobalOptions(program);
|
|
46
|
+
const topLevelCmds = [...tree.keys()];
|
|
47
|
+
// Build case arms for subcommand completion
|
|
48
|
+
const caseArms = [];
|
|
49
|
+
for (const [group, subs] of tree) {
|
|
50
|
+
if (subs.length > 0) {
|
|
51
|
+
caseArms.push(` ${group})\n local subcmds=(${subs.join(' ')})\n _describe 'subcommand' subcmds\n ;;`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return `#compdef figmanage
|
|
55
|
+
|
|
56
|
+
# Shell completion for figmanage
|
|
57
|
+
# Add to ~/.zshrc: eval "$(figmanage completion)"
|
|
58
|
+
|
|
59
|
+
_figmanage() {
|
|
60
|
+
local -a commands
|
|
61
|
+
commands=(${topLevelCmds.join(' ')})
|
|
62
|
+
|
|
63
|
+
local global_opts=(${globalOpts.join(' ')})
|
|
64
|
+
|
|
65
|
+
_arguments -C \\
|
|
66
|
+
'1:command:->cmd' \\
|
|
67
|
+
'2:subcommand:->sub' \\
|
|
68
|
+
'*::options:->opts'
|
|
69
|
+
|
|
70
|
+
case $state in
|
|
71
|
+
cmd)
|
|
72
|
+
_describe 'command' commands
|
|
73
|
+
;;
|
|
74
|
+
sub)
|
|
75
|
+
case $words[1] in
|
|
76
|
+
${caseArms.join('\n')}
|
|
77
|
+
*)
|
|
78
|
+
_describe 'option' global_opts
|
|
79
|
+
;;
|
|
80
|
+
esac
|
|
81
|
+
;;
|
|
82
|
+
opts)
|
|
83
|
+
_values 'options' $global_opts
|
|
84
|
+
;;
|
|
85
|
+
esac
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
compdef _figmanage figmanage
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
function generateBashScript(program) {
|
|
92
|
+
const tree = extractCommandTree(program);
|
|
93
|
+
const globalOpts = extractGlobalOptions(program);
|
|
94
|
+
const topLevelCmds = [...tree.keys()];
|
|
95
|
+
// Build case arms for subcommand completion
|
|
96
|
+
const caseArms = [];
|
|
97
|
+
for (const [group, subs] of tree) {
|
|
98
|
+
if (subs.length > 0) {
|
|
99
|
+
caseArms.push(` ${group})\n COMPREPLY=($(compgen -W "${subs.join(' ')}" -- "$cur"))\n ;;`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return `# Shell completion for figmanage
|
|
103
|
+
# Add to ~/.bashrc: eval "$(figmanage completion)"
|
|
104
|
+
|
|
105
|
+
_figmanage() {
|
|
106
|
+
local cur prev words cword
|
|
107
|
+
_init_completion || return
|
|
108
|
+
|
|
109
|
+
local commands="${topLevelCmds.join(' ')}"
|
|
110
|
+
local global_opts="${globalOpts.join(' ')}"
|
|
111
|
+
|
|
112
|
+
case $cword in
|
|
113
|
+
1)
|
|
114
|
+
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
|
115
|
+
;;
|
|
116
|
+
2)
|
|
117
|
+
case "\${words[1]}" in
|
|
118
|
+
${caseArms.join('\n')}
|
|
119
|
+
*)
|
|
120
|
+
COMPREPLY=($(compgen -W "$global_opts" -- "$cur"))
|
|
121
|
+
;;
|
|
122
|
+
esac
|
|
123
|
+
;;
|
|
124
|
+
*)
|
|
125
|
+
COMPREPLY=($(compgen -W "$global_opts" -- "$cur"))
|
|
126
|
+
;;
|
|
127
|
+
esac
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
complete -F _figmanage figmanage
|
|
131
|
+
`;
|
|
132
|
+
}
|
|
133
|
+
function detectShell() {
|
|
134
|
+
const shell = process.env.SHELL ?? '';
|
|
135
|
+
if (shell.endsWith('/zsh'))
|
|
136
|
+
return 'zsh';
|
|
137
|
+
return 'bash';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Create the `completion` command. Needs the parent program reference
|
|
141
|
+
* so it can introspect the full command tree at runtime.
|
|
142
|
+
*/
|
|
143
|
+
export function completionCommand(program) {
|
|
144
|
+
const cmd = new Command('completion')
|
|
145
|
+
.description('Output shell completion script')
|
|
146
|
+
.option('--shell <shell>', 'Shell type (bash or zsh)')
|
|
147
|
+
.action((options) => {
|
|
148
|
+
const shell = options.shell ?? detectShell();
|
|
149
|
+
if (shell !== 'bash' && shell !== 'zsh') {
|
|
150
|
+
console.error(`Unsupported shell: ${shell}. Use --shell bash or --shell zsh.`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const script = shell === 'zsh'
|
|
154
|
+
? generateZshScript(program)
|
|
155
|
+
: generateBashScript(program);
|
|
156
|
+
process.stdout.write(script);
|
|
157
|
+
});
|
|
158
|
+
return cmd;
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=completion.js.map
|
package/dist/cli/format.js
CHANGED
|
@@ -2,13 +2,158 @@
|
|
|
2
2
|
export function isTTY() {
|
|
3
3
|
return process.stdout.isTTY === true;
|
|
4
4
|
}
|
|
5
|
+
/** Get terminal width, defaulting to 80 if unavailable */
|
|
6
|
+
function termWidth() {
|
|
7
|
+
return process.stdout.columns || 80;
|
|
8
|
+
}
|
|
9
|
+
/** Format a value for display in a table cell */
|
|
10
|
+
function formatCell(value) {
|
|
11
|
+
if (value === null || value === undefined)
|
|
12
|
+
return '';
|
|
13
|
+
if (typeof value === 'string')
|
|
14
|
+
return value;
|
|
15
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
16
|
+
return String(value);
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return `[${value.length} items]`;
|
|
19
|
+
if (typeof value === 'object')
|
|
20
|
+
return '{...}';
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
/** Truncate a string to maxLen, appending ... if truncated */
|
|
24
|
+
function truncate(str, maxLen) {
|
|
25
|
+
if (maxLen < 4)
|
|
26
|
+
return str.slice(0, maxLen);
|
|
27
|
+
if (str.length <= maxLen)
|
|
28
|
+
return str;
|
|
29
|
+
return str.slice(0, maxLen - 3) + '...';
|
|
30
|
+
}
|
|
31
|
+
/** Pad a string to the right with spaces */
|
|
32
|
+
function padRight(str, width) {
|
|
33
|
+
if (str.length >= width)
|
|
34
|
+
return str;
|
|
35
|
+
return str + ' '.repeat(width - str.length);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Render an array of objects as an aligned table.
|
|
39
|
+
* Columns are auto-sized to content, then shrunk proportionally if they
|
|
40
|
+
* exceed terminal width. A minimum gap of 2 spaces separates columns.
|
|
41
|
+
*/
|
|
42
|
+
function formatTable(rows) {
|
|
43
|
+
if (rows.length === 0)
|
|
44
|
+
return '';
|
|
45
|
+
// Collect all keys across all rows (preserving insertion order)
|
|
46
|
+
const keySet = new Set();
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
for (const key of Object.keys(row)) {
|
|
49
|
+
keySet.add(key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const keys = Array.from(keySet);
|
|
53
|
+
// Build string grid: headers + data
|
|
54
|
+
const headers = keys.map((k) => k.toUpperCase());
|
|
55
|
+
const grid = rows.map((row) => keys.map((k) => formatCell(row[k])));
|
|
56
|
+
// Compute natural column widths (max of header and all data cells)
|
|
57
|
+
const colWidths = keys.map((_, i) => {
|
|
58
|
+
let max = headers[i].length;
|
|
59
|
+
for (const row of grid) {
|
|
60
|
+
if (row[i].length > max)
|
|
61
|
+
max = row[i].length;
|
|
62
|
+
}
|
|
63
|
+
return max;
|
|
64
|
+
});
|
|
65
|
+
const gap = 2;
|
|
66
|
+
const maxWidth = termWidth();
|
|
67
|
+
const totalGap = gap * (keys.length - 1);
|
|
68
|
+
const totalNatural = colWidths.reduce((a, b) => a + b, 0) + totalGap;
|
|
69
|
+
// If columns exceed terminal width, shrink the widest columns first
|
|
70
|
+
if (totalNatural > maxWidth && keys.length > 0) {
|
|
71
|
+
const budget = maxWidth - totalGap;
|
|
72
|
+
if (budget > 0) {
|
|
73
|
+
// Give each column at least 4 chars, distribute remaining proportionally
|
|
74
|
+
const minCol = 4;
|
|
75
|
+
const guaranteed = Math.min(minCol, Math.floor(budget / keys.length));
|
|
76
|
+
let remaining = budget;
|
|
77
|
+
// First pass: cap each column to its natural width or proportional share
|
|
78
|
+
const totalContent = colWidths.reduce((a, b) => a + b, 0);
|
|
79
|
+
for (let i = 0; i < colWidths.length; i++) {
|
|
80
|
+
const share = Math.max(guaranteed, Math.floor((colWidths[i] / totalContent) * budget));
|
|
81
|
+
colWidths[i] = Math.min(colWidths[i], share);
|
|
82
|
+
remaining -= colWidths[i];
|
|
83
|
+
}
|
|
84
|
+
// Distribute leftover to columns that were truncated
|
|
85
|
+
if (remaining > 0) {
|
|
86
|
+
for (let i = 0; i < colWidths.length && remaining > 0; i++) {
|
|
87
|
+
const natural = headers[i].length;
|
|
88
|
+
const canGrow = Math.max(0, natural - colWidths[i]);
|
|
89
|
+
const give = Math.min(canGrow, remaining);
|
|
90
|
+
colWidths[i] += give;
|
|
91
|
+
remaining -= give;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Render rows
|
|
97
|
+
const lines = [];
|
|
98
|
+
// Header row
|
|
99
|
+
const headerLine = keys
|
|
100
|
+
.map((_, i) => padRight(truncate(headers[i], colWidths[i]), colWidths[i]))
|
|
101
|
+
.join(' '.repeat(gap));
|
|
102
|
+
lines.push(headerLine.trimEnd());
|
|
103
|
+
// Data rows
|
|
104
|
+
for (const row of grid) {
|
|
105
|
+
const line = keys
|
|
106
|
+
.map((_, i) => padRight(truncate(row[i], colWidths[i]), colWidths[i]))
|
|
107
|
+
.join(' '.repeat(gap));
|
|
108
|
+
lines.push(line.trimEnd());
|
|
109
|
+
}
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Render a single object as key-value pairs.
|
|
114
|
+
* Falls back to JSON for deeply nested objects.
|
|
115
|
+
*/
|
|
116
|
+
function formatKeyValue(obj) {
|
|
117
|
+
// If every value is a nested object/array, this won't be readable as k/v.
|
|
118
|
+
// Check if the majority of values are complex -- if so, fall back to JSON.
|
|
119
|
+
const values = Object.values(obj);
|
|
120
|
+
const complexCount = values.filter((v) => v !== null && typeof v === 'object').length;
|
|
121
|
+
if (complexCount > values.length / 2) {
|
|
122
|
+
return JSON.stringify(obj, null, 2);
|
|
123
|
+
}
|
|
124
|
+
const entries = Object.entries(obj);
|
|
125
|
+
if (entries.length === 0)
|
|
126
|
+
return '{}';
|
|
127
|
+
return entries.map(([key, val]) => `${key}: ${formatCell(val)}`).join('\n');
|
|
128
|
+
}
|
|
5
129
|
/** Format output: JSON if piped or --json flag, human-readable if TTY */
|
|
6
130
|
export function formatOutput(data, options) {
|
|
7
131
|
if (options.json || !isTTY()) {
|
|
8
132
|
return JSON.stringify(data, null, 2);
|
|
9
133
|
}
|
|
10
|
-
//
|
|
11
|
-
|
|
134
|
+
// Primitives: render as-is
|
|
135
|
+
if (data === null || data === undefined)
|
|
136
|
+
return '';
|
|
137
|
+
if (typeof data === 'string')
|
|
138
|
+
return data;
|
|
139
|
+
if (typeof data === 'number' || typeof data === 'boolean')
|
|
140
|
+
return String(data);
|
|
141
|
+
// Array of objects: table
|
|
142
|
+
if (Array.isArray(data)) {
|
|
143
|
+
if (data.length === 0)
|
|
144
|
+
return '';
|
|
145
|
+
// If every element is a plain object, render as table
|
|
146
|
+
if (data.every((item) => item !== null && typeof item === 'object' && !Array.isArray(item))) {
|
|
147
|
+
return formatTable(data);
|
|
148
|
+
}
|
|
149
|
+
// Array of primitives or mixed: one per line
|
|
150
|
+
return data.map((item) => formatCell(item)).join('\n');
|
|
151
|
+
}
|
|
152
|
+
// Single object
|
|
153
|
+
if (typeof data === 'object') {
|
|
154
|
+
return formatKeyValue(data);
|
|
155
|
+
}
|
|
156
|
+
return String(data);
|
|
12
157
|
}
|
|
13
158
|
/** Print formatted output to stdout */
|
|
14
159
|
export function output(data, options = {}) {
|
package/dist/cli/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { analyticsCommand } from './analytics.js';
|
|
|
14
14
|
import { orgCommand } from './org.js';
|
|
15
15
|
import { librariesCommand } from './libraries.js';
|
|
16
16
|
import { teamsCommand } from './teams.js';
|
|
17
|
+
import { completionCommand } from './completion.js';
|
|
17
18
|
import { fileSummaryCommand, workspaceOverviewCommand, openCommentsCommand, cleanupStaleFilesCommand, organizeProjectCommand, setupProjectStructureCommand, seatOptimizationCommand, permissionAuditCommand, branchCleanupCommand, offboardUserCommand, onboardUserCommand, quarterlyReportCommand, } from './compound-commands.js';
|
|
18
19
|
export function registerCliCommands(program) {
|
|
19
20
|
// Auth commands (flat -- not resource-scoped)
|
|
@@ -87,5 +88,7 @@ export function registerCliCommands(program) {
|
|
|
87
88
|
program.addCommand(org);
|
|
88
89
|
program.addCommand(libraries);
|
|
89
90
|
program.addCommand(teams);
|
|
91
|
+
// Completion must be registered last so it can introspect all commands above
|
|
92
|
+
program.addCommand(completionCommand(program));
|
|
90
93
|
}
|
|
91
94
|
//# sourceMappingURL=index.js.map
|
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
5
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
@@ -75,12 +76,24 @@ export async function startMcpServer() {
|
|
|
75
76
|
registerTools(server, config, enabledToolsets, readOnly);
|
|
76
77
|
const httpPort = parseHttpPort(process.argv);
|
|
77
78
|
if (httpPort) {
|
|
79
|
+
// Bearer token auth for HTTP transport
|
|
80
|
+
let httpToken = process.env.FIGMA_HTTP_TOKEN || '';
|
|
81
|
+
if (!httpToken) {
|
|
82
|
+
httpToken = randomBytes(32).toString('hex');
|
|
83
|
+
console.error(`Generated HTTP bearer token: ${httpToken}`);
|
|
84
|
+
console.error('Set FIGMA_HTTP_TOKEN to use a fixed token.');
|
|
85
|
+
}
|
|
78
86
|
const transport = new StreamableHTTPServerTransport({
|
|
79
87
|
sessionIdGenerator: undefined,
|
|
80
88
|
});
|
|
81
89
|
const httpServer = createServer(async (req, res) => {
|
|
82
90
|
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
83
91
|
if (url.pathname === '/mcp') {
|
|
92
|
+
const auth = req.headers.authorization || '';
|
|
93
|
+
if (auth !== `Bearer ${httpToken}`) {
|
|
94
|
+
res.writeHead(401).end('Unauthorized');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
84
97
|
await transport.handleRequest(req, res);
|
|
85
98
|
}
|
|
86
99
|
else {
|
|
@@ -460,6 +460,7 @@ export async function quarterlyDesignOpsReport(config, params) {
|
|
|
460
460
|
}),
|
|
461
461
|
]);
|
|
462
462
|
// Paginate all members (cursor-based, max 500)
|
|
463
|
+
const errors = [];
|
|
463
464
|
const allMembers = [];
|
|
464
465
|
let cursor;
|
|
465
466
|
const maxPages = 20;
|
|
@@ -478,7 +479,8 @@ export async function quarterlyDesignOpsReport(config, params) {
|
|
|
478
479
|
if (!cursor || batch.length < 25)
|
|
479
480
|
break;
|
|
480
481
|
}
|
|
481
|
-
catch {
|
|
482
|
+
catch (e) {
|
|
483
|
+
errors.push(`members: pagination stopped at page ${page} (${e.response?.status || e.message})`);
|
|
482
484
|
break;
|
|
483
485
|
}
|
|
484
486
|
}
|
|
@@ -515,7 +517,6 @@ export async function quarterlyDesignOpsReport(config, params) {
|
|
|
515
517
|
: 0;
|
|
516
518
|
// Billing
|
|
517
519
|
let billing = null;
|
|
518
|
-
const errors = [];
|
|
519
520
|
if (ratesResult.status === 'fulfilled') {
|
|
520
521
|
const prices = ratesResult.value.data?.meta?.product_prices || [];
|
|
521
522
|
const seatProducts = new Set(['expert', 'developer', 'collaborator']);
|
package/dist/setup.js
CHANGED
|
@@ -1,279 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { copyFileSync, unlinkSync, mkdtempSync, mkdirSync, existsSync, rmdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
5
4
|
import { join } from 'path';
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
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
|
-
}
|
|
5
|
+
import { homedir, platform } from 'os';
|
|
6
|
+
import { extractCookies, validateSession, validatePat } from './auth/cookie.js';
|
|
273
7
|
// --- MCP client detection and registration ---
|
|
274
8
|
function claudeCliAvailable() {
|
|
275
9
|
try {
|
|
276
|
-
|
|
10
|
+
const whichCmd = platform() === 'win32' ? 'where' : 'which';
|
|
11
|
+
execFileSync(whichCmd, ['claude'], {
|
|
277
12
|
encoding: 'utf-8',
|
|
278
13
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
279
14
|
});
|
|
@@ -285,9 +20,20 @@ function claudeCliAvailable() {
|
|
|
285
20
|
}
|
|
286
21
|
function registerWithClaude(envVars) {
|
|
287
22
|
try {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
23
|
+
try {
|
|
24
|
+
execFileSync('claude', ['mcp', 'remove', 'figmanage', '-s', 'user'], {
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore if not previously registered
|
|
31
|
+
}
|
|
32
|
+
execFileSync('claude', [
|
|
33
|
+
'mcp', 'add', 'figmanage', '--transport', 'stdio', '-s', 'user',
|
|
34
|
+
...Object.entries(envVars).flatMap(([k, v]) => ['--env', `${k}=${v}`]),
|
|
35
|
+
'--', 'npx', '-y', 'figmanage',
|
|
36
|
+
], { encoding: 'utf-8' });
|
|
291
37
|
return true;
|
|
292
38
|
}
|
|
293
39
|
catch {
|
|
@@ -414,17 +160,7 @@ async function setup() {
|
|
|
414
160
|
console.log(`Reading Chrome cookies${promptLabel}...`);
|
|
415
161
|
let accounts = [];
|
|
416
162
|
try {
|
|
417
|
-
|
|
418
|
-
for (const profilePath of profiles) {
|
|
419
|
-
try {
|
|
420
|
-
const rawCookie = extractCookie(profilePath);
|
|
421
|
-
const { userId, cookieValue } = parseCookieValue(rawCookie);
|
|
422
|
-
accounts.push({ userId, cookieValue, profile: profilePath.split(/[/\\]/).pop() });
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
// Profile doesn't have a Figma cookie
|
|
426
|
-
}
|
|
427
|
-
}
|
|
163
|
+
accounts = extractCookies();
|
|
428
164
|
}
|
|
429
165
|
catch (e) {
|
|
430
166
|
if (os === 'win32') {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figmanage",
|
|
3
3
|
"mcpName": "io.github.dannykeane/figmanage",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.2",
|
|
5
5
|
"description": "MCP server for managing your Figma workspace from the terminal.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/dannykeane/figmanage#readme",
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@modelcontextprotocol/sdk": "^1.25.
|
|
44
|
-
"axios": "^1.
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
44
|
+
"axios": "^1.13.5",
|
|
45
45
|
"axios-retry": "^4.4.0",
|
|
46
46
|
"commander": "^14.0.3",
|
|
47
47
|
"zod": "^3.23.0"
|