figmanage 1.0.1 → 1.2.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.
Files changed (98) hide show
  1. package/README.md +74 -59
  2. package/dist/cli/analytics.d.ts +3 -0
  3. package/dist/cli/analytics.js +48 -0
  4. package/dist/cli/branching.d.ts +3 -0
  5. package/dist/cli/branching.js +56 -0
  6. package/dist/cli/comments.d.ts +3 -0
  7. package/dist/cli/comments.js +86 -0
  8. package/dist/cli/completion.d.ts +7 -0
  9. package/dist/cli/completion.js +160 -0
  10. package/dist/cli/components.d.ts +3 -0
  11. package/dist/cli/components.js +82 -0
  12. package/dist/cli/compound-commands.d.ts +14 -0
  13. package/dist/cli/compound-commands.js +291 -0
  14. package/dist/cli/export.d.ts +3 -0
  15. package/dist/cli/export.js +51 -0
  16. package/dist/cli/files.d.ts +3 -0
  17. package/dist/cli/files.js +156 -0
  18. package/dist/cli/format.js +147 -2
  19. package/dist/cli/helpers.d.ts +7 -0
  20. package/dist/cli/helpers.js +43 -0
  21. package/dist/cli/index.js +68 -89
  22. package/dist/cli/libraries.d.ts +3 -0
  23. package/dist/cli/libraries.js +26 -0
  24. package/dist/cli/navigate.d.ts +3 -0
  25. package/dist/cli/navigate.js +192 -0
  26. package/dist/cli/org.d.ts +3 -0
  27. package/dist/cli/org.js +227 -0
  28. package/dist/cli/permissions.d.ts +3 -0
  29. package/dist/cli/permissions.js +133 -0
  30. package/dist/cli/projects.d.ts +3 -0
  31. package/dist/cli/projects.js +110 -0
  32. package/dist/cli/reading.d.ts +3 -0
  33. package/dist/cli/reading.js +51 -0
  34. package/dist/cli/teams.d.ts +3 -0
  35. package/dist/cli/teams.js +56 -0
  36. package/dist/cli/variables.d.ts +3 -0
  37. package/dist/cli/variables.js +80 -0
  38. package/dist/cli/versions.d.ts +3 -0
  39. package/dist/cli/versions.js +46 -0
  40. package/dist/cli/webhooks.d.ts +3 -0
  41. package/dist/cli/webhooks.js +100 -0
  42. package/dist/operations/analytics.d.ts +10 -0
  43. package/dist/operations/analytics.js +15 -0
  44. package/dist/operations/branching.d.ts +24 -0
  45. package/dist/operations/branching.js +41 -0
  46. package/dist/operations/comments.d.ts +43 -0
  47. package/dist/operations/comments.js +65 -0
  48. package/dist/operations/components.d.ts +24 -0
  49. package/dist/operations/components.js +30 -0
  50. package/dist/operations/compound-manager.d.ts +101 -0
  51. package/dist/operations/compound-manager.js +629 -0
  52. package/dist/operations/compound.d.ts +102 -0
  53. package/dist/operations/compound.js +595 -0
  54. package/dist/operations/export.d.ts +19 -0
  55. package/dist/operations/export.js +27 -0
  56. package/dist/operations/files.d.ts +55 -0
  57. package/dist/operations/files.js +89 -0
  58. package/dist/operations/libraries.d.ts +5 -0
  59. package/dist/operations/libraries.js +10 -0
  60. package/dist/operations/navigate.d.ts +99 -0
  61. package/dist/operations/navigate.js +266 -0
  62. package/dist/operations/org.d.ts +95 -0
  63. package/dist/operations/org.js +205 -0
  64. package/dist/operations/permissions.d.ts +59 -0
  65. package/dist/operations/permissions.js +112 -0
  66. package/dist/operations/projects.d.ts +29 -0
  67. package/dist/operations/projects.js +40 -0
  68. package/dist/operations/reading.d.ts +12 -0
  69. package/dist/operations/reading.js +20 -0
  70. package/dist/operations/teams.d.ts +17 -0
  71. package/dist/operations/teams.js +17 -0
  72. package/dist/operations/variables.d.ts +17 -0
  73. package/dist/operations/variables.js +39 -0
  74. package/dist/operations/versions.d.ts +23 -0
  75. package/dist/operations/versions.js +27 -0
  76. package/dist/operations/webhooks.d.ts +25 -0
  77. package/dist/operations/webhooks.js +38 -0
  78. package/dist/tools/analytics.js +6 -16
  79. package/dist/tools/branching.js +7 -36
  80. package/dist/tools/comments.js +9 -56
  81. package/dist/tools/components.js +7 -19
  82. package/dist/tools/compound-manager.js +21 -644
  83. package/dist/tools/compound.js +32 -566
  84. package/dist/tools/export.js +4 -23
  85. package/dist/tools/files.js +21 -68
  86. package/dist/tools/libraries.js +4 -11
  87. package/dist/tools/navigate.js +23 -246
  88. package/dist/tools/org.js +29 -245
  89. package/dist/tools/permissions.js +18 -97
  90. package/dist/tools/projects.js +8 -27
  91. package/dist/tools/reading.js +5 -15
  92. package/dist/tools/teams.js +8 -16
  93. package/dist/tools/variables.js +13 -30
  94. package/dist/tools/versions.js +6 -24
  95. package/dist/tools/webhooks.js +7 -24
  96. package/package.json +1 -1
  97. package/dist/cli/commands.d.ts +0 -47
  98. package/dist/cli/commands.js +0 -1204
@@ -1,8 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { internalClient } from '../clients/internal-api.js';
3
- import { publicClient } from '../clients/public-api.js';
4
- import { hasPat, hasCookie } from '../auth/client.js';
5
- import { defineTool, toolResult, toolError, figmaId, requireOrgId } from './register.js';
2
+ import { defineTool, toolResult, toolError, figmaId } from './register.js';
3
+ import { fileSummary, workspaceOverview, openComments, cleanupStaleFiles, organizeProject, setupProjectStructure, seatOptimization, permissionAudit, branchCleanup, } from '../operations/compound.js';
6
4
  // -- file_summary --
7
5
  defineTool({
8
6
  toolset: 'compound',
@@ -15,33 +13,8 @@ defineTool({
15
13
  },
16
14
  }, async ({ file_key }) => {
17
15
  try {
18
- const api = publicClient(config);
19
- const [fileResult, componentsResult, stylesResult, commentsResult] = await Promise.allSettled([
20
- api.get(`/v1/files/${file_key}`, { params: { depth: '1' } }),
21
- api.get(`/v1/files/${file_key}/components`),
22
- api.get(`/v1/files/${file_key}/styles`),
23
- api.get(`/v1/files/${file_key}/comments`),
24
- ]);
25
- if (fileResult.status === 'rejected') {
26
- return toolError(`Failed to fetch file: ${fileResult.reason?.response?.status || fileResult.reason?.message}`);
27
- }
28
- const fileData = fileResult.value.data;
29
- const pages = (fileData.document?.children || []).map((c) => c.name);
30
- const components = componentsResult.status === 'fulfilled' ? componentsResult.value.data?.meta?.components || [] : [];
31
- const styles = stylesResult.status === 'fulfilled' ? stylesResult.value.data?.meta?.styles || [] : [];
32
- const comments = commentsResult.status === 'fulfilled' ? commentsResult.value.data?.comments || [] : [];
33
- const unresolved = comments.filter((c) => !c.resolved_at);
34
- const summary = {
35
- name: fileData.name,
36
- last_modified: fileData.lastModified,
37
- version: fileData.version,
38
- pages,
39
- component_count: components.length,
40
- style_count: styles.length,
41
- comment_count: comments.length,
42
- unresolved_comment_count: unresolved.length,
43
- };
44
- return toolResult(JSON.stringify(summary, null, 2));
16
+ const result = await fileSummary(config, { file_key });
17
+ return toolResult(JSON.stringify(result, null, 2));
45
18
  }
46
19
  catch (e) {
47
20
  return toolError(`Failed to summarize file: ${e.response?.status || e.message}`);
@@ -61,46 +34,8 @@ defineTool({
61
34
  },
62
35
  }, async ({ org_id }) => {
63
36
  try {
64
- let orgId;
65
- try {
66
- orgId = requireOrgId(config, org_id);
67
- }
68
- catch (e) {
69
- return toolError(e.message);
70
- }
71
- const api = internalClient(config);
72
- const [teamsResult, seatsResult, billingResult] = await Promise.allSettled([
73
- api.get(`/api/orgs/${orgId}/teams`, {
74
- params: { include_member_count: true, include_project_count: true, include_top_members: true },
75
- }),
76
- api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
77
- api.get(`/api/orgs/${orgId}/billing_data`),
78
- ]);
79
- const teamsData = teamsResult.status === 'fulfilled' ? (teamsResult.value.data?.meta || teamsResult.value.data) : null;
80
- const teamsRaw = teamsData ? (Array.isArray(teamsData) ? teamsData : (teamsData?.teams || [])) : [];
81
- const teams = teamsRaw.map((t) => ({
82
- id: String(t.id),
83
- name: t.name,
84
- members: t.member_count || 0,
85
- projects: t.project_count || 0,
86
- }));
87
- const seats = seatsResult.status === 'fulfilled' ? (seatsResult.value.data?.meta || seatsResult.value.data) : null;
88
- const rawBilling = billingResult.status === 'fulfilled' ? (billingResult.value.data?.meta || billingResult.value.data) : null;
89
- // Strip PII from billing data
90
- const billing = rawBilling ? { ...rawBilling } : null;
91
- if (billing)
92
- delete billing.shipping_address;
93
- const errors = [];
94
- if (teamsResult.status === 'rejected')
95
- errors.push(`teams: ${teamsResult.reason?.response?.status || teamsResult.reason?.message}`);
96
- if (seatsResult.status === 'rejected')
97
- errors.push(`seats: ${seatsResult.reason?.response?.status || seatsResult.reason?.message}`);
98
- if (billingResult.status === 'rejected')
99
- errors.push(`billing: ${billingResult.reason?.response?.status || billingResult.reason?.message}`);
100
- const overview = { teams, seats, billing };
101
- if (errors.length > 0)
102
- overview.errors = errors;
103
- return toolResult(JSON.stringify(overview, null, 2));
37
+ const result = await workspaceOverview(config, { org_id });
38
+ return toolResult(JSON.stringify(result, null, 2));
104
39
  }
105
40
  catch (e) {
106
41
  return toolError(`Failed to fetch workspace overview: ${e.response?.status || e.message}`);
@@ -120,49 +55,7 @@ defineTool({
120
55
  },
121
56
  }, async ({ project_id }) => {
122
57
  try {
123
- const api = publicClient(config);
124
- const filesRes = await api.get(`/v1/projects/${project_id}/files`);
125
- const allFiles = filesRes.data?.files || [];
126
- const capped = allFiles.length > 20;
127
- const files = allFiles.slice(0, 20);
128
- const commentResults = [];
129
- for (const f of files) {
130
- try {
131
- const commentsRes = await api.get(`/v1/files/${f.key}/comments`);
132
- const comments = (commentsRes.data?.comments || [])
133
- .filter((c) => !c.resolved_at);
134
- commentResults.push({ file_key: f.key, file_name: f.name, comments });
135
- }
136
- catch (e) {
137
- commentResults.push({ file_key: f.key, file_name: f.name, comments: [], error: e.response?.status || e.message });
138
- }
139
- }
140
- const filesWithComments = commentResults
141
- .filter(f => f.comments.length > 0)
142
- .map(f => ({
143
- file_key: f.file_key,
144
- file_name: f.file_name,
145
- comments: f.comments.map((c) => ({
146
- id: c.id,
147
- author: c.user?.handle || c.user?.email || 'unknown',
148
- message: c.message,
149
- created_at: c.created_at,
150
- })),
151
- }));
152
- const totalUnresolved = filesWithComments.reduce((sum, f) => sum + f.comments.length, 0);
153
- const errors = commentResults
154
- .filter((f) => f.error)
155
- .map((f) => ({ file_key: f.file_key, file_name: f.file_name, error: f.error }));
156
- const result = {
157
- total_unresolved: totalUnresolved,
158
- files: filesWithComments,
159
- };
160
- if (errors.length > 0) {
161
- result.errors = errors;
162
- }
163
- if (capped) {
164
- result.note = `Project has ${allFiles.length} files; only the first 20 were checked.`;
165
- }
58
+ const result = await openComments(config, { project_id });
166
59
  return toolResult(JSON.stringify(result, null, 2));
167
60
  }
168
61
  catch (e) {
@@ -187,51 +80,11 @@ defineTool({
187
80
  },
188
81
  }, async ({ project_id, days_stale, dry_run: rawDryRun }) => {
189
82
  try {
190
- const dry_run = rawDryRun ?? true;
191
- if (!dry_run && !hasCookie(config)) {
192
- return toolError('Cookie auth required to trash files. Run with dry_run=true to preview, or configure cookie auth.');
193
- }
194
- let files;
195
- if (hasPat(config)) {
196
- const res = await publicClient(config).get(`/v1/projects/${project_id}/files`);
197
- files = res.data?.files || [];
198
- }
199
- else {
200
- const res = await internalClient(config).get(`/api/folders/${project_id}/paginated_files`, { params: { folderId: project_id, sort_column: 'touched_at', sort_order: 'desc', page_size: 100, file_type: '' } });
201
- const meta = res.data?.meta || res.data;
202
- files = meta?.files || meta || [];
203
- }
204
- const cutoff = Date.now() - days_stale * 86400000;
205
- const staleFiles = files.filter((f) => {
206
- const raw = f.last_modified || f.touched_at;
207
- if (!raw)
208
- return false;
209
- const modified = new Date(raw).getTime();
210
- return !isNaN(modified) && modified < cutoff;
83
+ const result = await cleanupStaleFiles(config, {
84
+ project_id,
85
+ days_stale: days_stale ?? 90,
86
+ dry_run: rawDryRun ?? true,
211
87
  });
212
- const MAX_TRASH_BATCH = 25;
213
- const result = {
214
- stale_files: staleFiles.map((f) => ({
215
- key: f.key,
216
- name: f.name,
217
- last_modified: f.last_modified || f.touched_at,
218
- })),
219
- total_stale: staleFiles.length,
220
- dry_run,
221
- trashed: false,
222
- };
223
- if (!dry_run) {
224
- if (staleFiles.length > MAX_TRASH_BATCH) {
225
- return toolError(`${staleFiles.length} stale files exceeds safety limit of ${MAX_TRASH_BATCH}. ` +
226
- `Run with dry_run=true to review, then trash in smaller batches using trash_files.`);
227
- }
228
- if (staleFiles.length > 0) {
229
- await internalClient(config).delete('/api/files_batch', {
230
- data: { files: staleFiles.map((f) => ({ key: f.key })), trashed: true },
231
- });
232
- result.trashed = true;
233
- }
234
- }
235
88
  return toolResult(JSON.stringify(result, null, 2));
236
89
  }
237
90
  catch (e) {
@@ -254,23 +107,7 @@ defineTool({
254
107
  },
255
108
  }, async ({ file_keys, target_project_id }) => {
256
109
  try {
257
- const payload = {
258
- files: file_keys.map(key => ({
259
- key,
260
- folder_id: target_project_id,
261
- is_multi_move: true,
262
- restore_files: false,
263
- })),
264
- };
265
- const res = await internalClient(config).put('/api/files_batch', payload);
266
- const data = res.data?.meta || res.data;
267
- const moved = Object.keys(data?.success || {}).length;
268
- const failed = Object.keys(data?.errors || {}).length;
269
- const result = {
270
- moved: moved || (failed === 0 ? file_keys.length : 0),
271
- failed,
272
- errors: data?.errors || {},
273
- };
110
+ const result = await organizeProject(config, { file_keys, target_project_id });
274
111
  return toolResult(JSON.stringify(result, null, 2));
275
112
  }
276
113
  catch (e) {
@@ -296,34 +133,8 @@ defineTool({
296
133
  },
297
134
  }, async ({ team_id, projects }) => {
298
135
  try {
299
- const api = internalClient(config);
300
- const created = [];
301
- const failed = [];
302
- for (const project of projects) {
303
- try {
304
- const createRes = await api.post('/api/folders', {
305
- team_id,
306
- path: project.name,
307
- sharing_audience_control: 'org_view',
308
- team_access: 'team_edit',
309
- });
310
- const meta = createRes.data?.meta;
311
- const p = Array.isArray(meta) ? meta[0] : (meta?.folder || meta || createRes.data);
312
- const folderId = p?.id != null ? String(p.id) : null;
313
- if (!folderId) {
314
- failed.push({ name: project.name, error: 'Could not extract project ID from response' });
315
- continue;
316
- }
317
- if (project.description) {
318
- await api.put(`/api/folders/${folderId}`, { description: project.description });
319
- }
320
- created.push({ id: folderId, name: project.name, description: project.description || null });
321
- }
322
- catch (e) {
323
- failed.push({ name: project.name, error: e.response?.status || e.message });
324
- }
325
- }
326
- return toolResult(JSON.stringify({ created, failed }, null, 2));
136
+ const result = await setupProjectStructure(config, { team_id, projects });
137
+ return toolResult(JSON.stringify(result, null, 2));
327
138
  }
328
139
  catch (e) {
329
140
  return toolError(`Failed to setup project structure: ${e.response?.status || e.message}`);
@@ -332,11 +143,6 @@ defineTool({
332
143
  },
333
144
  });
334
145
  // -- seat_optimization --
335
- const SEAT_KEY_MAP = {
336
- expert: 'full',
337
- developer: 'dev',
338
- collaborator: 'collab',
339
- };
340
146
  defineTool({
341
147
  toolset: 'compound',
342
148
  auth: 'cookie',
@@ -348,106 +154,13 @@ defineTool({
348
154
  days_inactive: z.number().min(1).max(365).optional().default(90).describe('Days without activity to flag as inactive (default: 90)'),
349
155
  include_cost: z.boolean().optional().default(true).describe('Include cost analysis from contract rates (default: true)'),
350
156
  },
351
- }, async ({ org_id, days_inactive: rawDaysInactive, include_cost: rawIncludeCost }) => {
157
+ }, async ({ org_id, days_inactive, include_cost }) => {
352
158
  try {
353
- const days_inactive = rawDaysInactive ?? 90;
354
- const include_cost = rawIncludeCost ?? true;
355
- let orgId;
356
- try {
357
- orgId = requireOrgId(config, org_id);
358
- }
359
- catch (e) {
360
- return toolError(e.message);
361
- }
362
- const api = internalClient(config);
363
- const cutoff = Date.now() - days_inactive * 86400000;
364
- const paidKeys = new Set(['expert', 'developer', 'collaborator']);
365
- // Paginate org members (cursor-based, max 500)
366
- const allMembers = [];
367
- const MAX_PAGES = 20;
368
- let cursor;
369
- for (let page = 0; page < MAX_PAGES; page++) {
370
- const params = { page_size: 25 };
371
- if (cursor)
372
- params.cursor = cursor;
373
- const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
374
- const meta = res.data?.meta || {};
375
- const members = meta.users || [];
376
- if (!Array.isArray(members) || members.length === 0)
377
- break;
378
- allMembers.push(...members);
379
- cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
380
- if (!cursor || members.length < 25)
381
- break;
382
- }
383
- // Fetch seat breakdown and optionally contract rates in parallel
384
- const parallelCalls = [
385
- api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
386
- ];
387
- if (include_cost) {
388
- parallelCalls.push(api.get('/api/pricing/contract_rates', {
389
- params: { plan_parent_id: orgId, plan_type: 'organization' },
390
- }));
391
- }
392
- const [seatsResult, ratesResult] = await Promise.allSettled(parallelCalls);
393
- const seats = seatsResult.status === 'fulfilled' ? (seatsResult.value.data?.meta || seatsResult.value.data) : null;
394
- // Build cost lookup: seat key -> monthly cents
395
- const costMap = {};
396
- if (include_cost && ratesResult?.status === 'fulfilled') {
397
- const prices = ratesResult.value.data?.meta?.product_prices || [];
398
- for (const p of prices) {
399
- if (paidKeys.has(p.billable_product_key)) {
400
- costMap[p.billable_product_key] = p.amount;
401
- }
402
- }
403
- }
404
- // Filter inactive paid members
405
- const inactiveUsers = [];
406
- let totalPaid = 0;
407
- for (const m of allMembers) {
408
- const seatKey = m.active_seat_type?.key;
409
- if (!seatKey || !paidKeys.has(seatKey))
410
- continue;
411
- totalPaid++;
412
- const lastSeen = m.last_seen;
413
- const lastSeenMs = lastSeen ? new Date(lastSeen).getTime() : 0;
414
- const isInactive = !lastSeen || (lastSeenMs > 0 && lastSeenMs < cutoff) || isNaN(lastSeenMs);
415
- if (isInactive) {
416
- inactiveUsers.push({
417
- org_user_id: String(m.id),
418
- user_id: m.user_id,
419
- email: m.user?.email,
420
- name: m.user?.handle,
421
- seat_type: SEAT_KEY_MAP[seatKey] || seatKey,
422
- seat_key: seatKey,
423
- last_active: lastSeen || null,
424
- monthly_cost_cents: costMap[seatKey] || null,
425
- });
426
- }
427
- }
428
- const monthlyWasteCents = inactiveUsers.reduce((sum, u) => sum + (u.monthly_cost_cents || 0), 0);
429
- const recommendations = [];
430
- if (inactiveUsers.length > 0) {
431
- recommendations.push(`${inactiveUsers.length} paid seat(s) inactive for ${days_inactive}+ days. Review for downgrade to viewer.`);
432
- }
433
- const neverActive = inactiveUsers.filter((u) => !u.last_active);
434
- if (neverActive.length > 0) {
435
- recommendations.push(`${neverActive.length} paid user(s) have never been active. Likely unused invites.`);
436
- }
437
- if (monthlyWasteCents > 0) {
438
- recommendations.push(`Potential monthly savings: $${(monthlyWasteCents / 100).toFixed(2)} ($${((monthlyWasteCents * 12) / 100).toFixed(2)}/yr).`);
439
- }
440
- const result = {
441
- summary: {
442
- total_paid: totalPaid,
443
- inactive_paid: inactiveUsers.length,
444
- monthly_waste_cents: monthlyWasteCents,
445
- annual_savings_cents: monthlyWasteCents * 12,
446
- },
447
- seat_breakdown: seats,
448
- inactive_users: inactiveUsers,
449
- recommendations,
450
- };
159
+ const result = await seatOptimization(config, {
160
+ org_id,
161
+ days_inactive: days_inactive ?? 90,
162
+ include_cost: include_cost ?? true,
163
+ });
451
164
  return toolResult(JSON.stringify(result, null, 2));
452
165
  }
453
166
  catch (e) {
@@ -469,161 +182,14 @@ defineTool({
469
182
  flag_external: z.boolean().optional().default(true).describe('Flag external users (default: true)'),
470
183
  org_id: figmaId.optional().describe('Org ID override (defaults to current workspace)'),
471
184
  },
472
- }, async ({ scope_type, scope_id, flag_external: rawFlagExternal, org_id }) => {
185
+ }, async ({ scope_type, scope_id, flag_external, org_id }) => {
473
186
  try {
474
- const flag_external = rawFlagExternal ?? true;
475
- const api = internalClient(config);
476
- // Resolve org for domain lookup
477
- let orgId;
478
- let domainCheckSkipped = false;
479
- if (flag_external) {
480
- try {
481
- orgId = requireOrgId(config, org_id);
482
- }
483
- catch {
484
- domainCheckSkipped = true;
485
- }
486
- }
487
- // Fetch org verified domains for external detection
488
- let verifiedDomains = new Set();
489
- if (flag_external && orgId) {
490
- try {
491
- const domRes = await api.get(`/api/orgs/${orgId}/domains`);
492
- const domains = domRes.data?.meta || [];
493
- if (Array.isArray(domains)) {
494
- for (const d of domains) {
495
- if (d.domain)
496
- verifiedDomains.add(d.domain.toLowerCase());
497
- }
498
- }
499
- }
500
- catch { /* domain lookup optional, continue without */ }
501
- }
502
- // Collect file keys to scan
503
- let fileKeys = [];
504
- if (scope_type === 'team') {
505
- // Fetch team projects, cap at 10
506
- const projectsRes = await api.get(`/api/teams/${scope_id}/folders`);
507
- const rows = projectsRes.data?.meta?.folder_rows || projectsRes.data || [];
508
- const projects = (Array.isArray(rows) ? rows : []).slice(0, 10);
509
- for (const proj of projects) {
510
- if (fileKeys.length >= 25)
511
- break;
512
- try {
513
- const filesRes = await api.get(`/api/folders/${proj.id}/paginated_files`, {
514
- params: { folderId: String(proj.id), page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
515
- });
516
- const meta = filesRes.data?.meta || filesRes.data;
517
- const files = meta?.files || meta || [];
518
- for (const f of (Array.isArray(files) ? files : [])) {
519
- if (fileKeys.length >= 25)
520
- break;
521
- fileKeys.push({ key: f.key, name: f.name });
522
- }
523
- }
524
- catch { /* skip inaccessible projects */ }
525
- }
526
- }
527
- else {
528
- // Project scope
529
- const filesRes = await api.get(`/api/folders/${scope_id}/paginated_files`, {
530
- params: { folderId: scope_id, page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
531
- });
532
- const meta = filesRes.data?.meta || filesRes.data;
533
- const files = meta?.files || meta || [];
534
- for (const f of (Array.isArray(files) ? files : [])) {
535
- if (fileKeys.length >= 25)
536
- break;
537
- fileKeys.push({ key: f.key, name: f.name });
538
- }
539
- }
540
- // Fetch permissions and file metadata in parallel, batched 5 at a time
541
- const allUsers = new Map();
542
- const flags = [];
543
- let filesScanned = 0;
544
- for (let i = 0; i < fileKeys.length; i += 5) {
545
- const batch = fileKeys.slice(i, i + 5);
546
- const results = await Promise.allSettled(batch.map(async (file) => {
547
- const [rolesRes, fileMetaRes] = await Promise.allSettled([
548
- api.get(`/api/roles/file/${file.key}`),
549
- api.get(`/api/files/${file.key}`),
550
- ]);
551
- const roles = rolesRes.status === 'fulfilled'
552
- ? (Array.isArray(rolesRes.value.data?.meta) ? rolesRes.value.data.meta : [])
553
- : [];
554
- const fileMeta = fileMetaRes.status === 'fulfilled'
555
- ? (fileMetaRes.value.data?.meta || fileMetaRes.value.data || {})
556
- : {};
557
- return { file, roles, fileMeta };
558
- }));
559
- for (const r of results) {
560
- if (r.status === 'rejected')
561
- continue;
562
- filesScanned++;
563
- const { file, roles, fileMeta } = r.value;
564
- // Check link access
565
- const linkAccess = fileMeta.link_access;
566
- if (linkAccess === 'edit' || linkAccess === 'org_edit') {
567
- flags.push({
568
- severity: 'high',
569
- type: 'open_link_access',
570
- details: `${file.name} (${file.key}) has link_access="${linkAccess}"`,
571
- });
572
- }
573
- // Process roles
574
- for (const role of roles) {
575
- const email = role.user?.email || role.pending_email;
576
- const userId = role.user_id ? String(role.user_id) : email;
577
- const level = role.level;
578
- const levelName = level >= 999 ? 'owner' : level >= 300 ? 'editor' : 'viewer';
579
- if (userId && !allUsers.has(userId)) {
580
- allUsers.set(userId, {
581
- user_id: userId,
582
- email,
583
- name: role.user?.handle,
584
- files_accessed: [],
585
- });
586
- }
587
- if (userId) {
588
- const user = allUsers.get(userId);
589
- user.files_accessed.push({
590
- file_key: file.key,
591
- file_name: file.name,
592
- role: levelName,
593
- });
594
- }
595
- // External editor detection
596
- if (flag_external && email && verifiedDomains.size > 0) {
597
- const domain = email.split('@')[1]?.toLowerCase();
598
- if (domain && !verifiedDomains.has(domain) && (levelName === 'editor' || levelName === 'owner')) {
599
- flags.push({
600
- severity: 'high',
601
- type: 'external_editor',
602
- details: `${email} (external) has ${levelName} access to ${file.name} (${file.key})`,
603
- });
604
- }
605
- }
606
- }
607
- }
608
- }
609
- if (domainCheckSkipped) {
610
- flags.push({
611
- severity: 'info',
612
- type: 'domain_check_skipped',
613
- details: 'Could not resolve org ID; external user detection was skipped. Provide org_id or set FIGMA_ORG_ID.',
614
- });
615
- }
616
- const result = {
617
- scope: { type: scope_type, id: scope_id },
618
- summary: {
619
- unique_users: allUsers.size,
620
- files_scanned: filesScanned,
621
- total_files: fileKeys.length,
622
- flags_found: flags.length,
623
- },
624
- users: Array.from(allUsers.values()),
625
- flags,
626
- };
187
+ const result = await permissionAudit(config, {
188
+ scope_type,
189
+ scope_id,
190
+ flag_external: flag_external ?? true,
191
+ org_id,
192
+ });
627
193
  return toolResult(JSON.stringify(result, null, 2));
628
194
  }
629
195
  catch (e) {
@@ -646,113 +212,13 @@ defineTool({
646
212
  days_stale: z.number().min(1).max(365).optional().default(60).describe('Days since last modification to flag as stale (default: 60)'),
647
213
  dry_run: z.boolean().optional().default(true).describe('Preview only, no archiving (default: true)'),
648
214
  },
649
- }, async ({ project_id, days_stale: rawDaysStale, dry_run: rawDryRun }) => {
215
+ }, async ({ project_id, days_stale, dry_run: rawDryRun }) => {
650
216
  try {
651
- const days_stale = rawDaysStale ?? 60;
652
- const dry_run = rawDryRun ?? true;
653
- if (!dry_run && !hasCookie(config)) {
654
- return toolError('Cookie auth required to archive branches. Run with dry_run=true to preview, or configure cookie auth.');
655
- }
656
- // Fetch project files
657
- const MAX_FILES = 20;
658
- let files;
659
- if (hasPat(config)) {
660
- const res = await publicClient(config).get(`/v1/projects/${project_id}/files`);
661
- files = res.data?.files || [];
662
- }
663
- else {
664
- const res = await internalClient(config).get(`/api/folders/${project_id}/paginated_files`, { params: { folderId: project_id, sort_column: 'touched_at', sort_order: 'desc', page_size: MAX_FILES, file_type: '' } });
665
- const meta = res.data?.meta || res.data;
666
- files = meta?.files || meta || [];
667
- }
668
- // Cap at 20 files
669
- const capped = files.length > 20;
670
- files = files.slice(0, 20);
671
- // Fetch branch data for each file in parallel
672
- const cutoff = Date.now() - days_stale * 86400000;
673
- const staleBranches = [];
674
- const activeBranches = [];
675
- let filesScanned = 0;
676
- let totalBranches = 0;
677
- const branchResults = await Promise.allSettled(files.map(async (file) => {
678
- let branches;
679
- if (hasPat(config)) {
680
- const res = await publicClient(config).get(`/v1/files/${file.key}`, {
681
- params: { branch_data: 'true', depth: '0' },
682
- });
683
- branches = res.data?.branches || [];
684
- }
685
- else {
686
- const res = await internalClient(config).get(`/api/files/${file.key}`);
687
- const f = res.data?.meta || res.data;
688
- branches = f.branches || [];
689
- }
690
- return { file, branches };
691
- }));
692
- for (const r of branchResults) {
693
- if (r.status === 'rejected')
694
- continue;
695
- filesScanned++;
696
- const { file, branches } = r.value;
697
- for (const branch of branches) {
698
- totalBranches++;
699
- const lastModified = branch.last_modified;
700
- const lastModifiedMs = lastModified ? new Date(lastModified).getTime() : 0;
701
- const isStale = !lastModified || (lastModifiedMs > 0 && lastModifiedMs < cutoff) || isNaN(lastModifiedMs);
702
- const entry = {
703
- branch_key: branch.key,
704
- branch_name: branch.name,
705
- parent_file_key: file.key,
706
- parent_file_name: file.name,
707
- last_modified: lastModified || null,
708
- };
709
- if (isStale) {
710
- staleBranches.push(entry);
711
- }
712
- else {
713
- activeBranches.push(entry);
714
- }
715
- }
716
- }
717
- const MAX_ARCHIVE_BATCH = 25;
718
- let archived = false;
719
- if (!dry_run && staleBranches.length > 0) {
720
- if (staleBranches.length > MAX_ARCHIVE_BATCH) {
721
- return toolError(`${staleBranches.length} stale branches exceeds safety limit of ${MAX_ARCHIVE_BATCH}. ` +
722
- `Run with dry_run=true to review, then archive in smaller batches using delete_branch.`);
723
- }
724
- await internalClient(config).delete('/api/files_batch', {
725
- data: {
726
- files: staleBranches.map(b => ({ key: b.branch_key })),
727
- trashed: true,
728
- },
729
- });
730
- archived = true;
731
- }
732
- const recommendations = [];
733
- if (staleBranches.length > 0) {
734
- recommendations.push(`${staleBranches.length} branch(es) stale for ${days_stale}+ days. ${dry_run ? 'Set dry_run=false to archive.' : 'Archived.'}`);
735
- }
736
- if (capped) {
737
- recommendations.push(`Project has more than 20 files; only the first 20 were scanned.`);
738
- }
739
- if (staleBranches.length === 0 && activeBranches.length === 0) {
740
- recommendations.push('No branches found in scanned files.');
741
- }
742
- const result = {
217
+ const result = await branchCleanup(config, {
743
218
  project_id,
744
- summary: {
745
- files_scanned: filesScanned,
746
- total_branches: totalBranches,
747
- stale: staleBranches.length,
748
- active: activeBranches.length,
749
- },
750
- stale_branches: staleBranches,
751
- active_branches: activeBranches,
752
- dry_run,
753
- archived,
754
- recommendations,
755
- };
219
+ days_stale: days_stale ?? 60,
220
+ dry_run: rawDryRun ?? true,
221
+ });
756
222
  return toolResult(JSON.stringify(result, null, 2));
757
223
  }
758
224
  catch (e) {