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.
- package/README.md +74 -59
- package/dist/cli/analytics.d.ts +3 -0
- package/dist/cli/analytics.js +48 -0
- package/dist/cli/branching.d.ts +3 -0
- package/dist/cli/branching.js +56 -0
- package/dist/cli/comments.d.ts +3 -0
- package/dist/cli/comments.js +86 -0
- package/dist/cli/completion.d.ts +7 -0
- package/dist/cli/completion.js +160 -0
- package/dist/cli/components.d.ts +3 -0
- package/dist/cli/components.js +82 -0
- package/dist/cli/compound-commands.d.ts +14 -0
- package/dist/cli/compound-commands.js +291 -0
- package/dist/cli/export.d.ts +3 -0
- package/dist/cli/export.js +51 -0
- package/dist/cli/files.d.ts +3 -0
- package/dist/cli/files.js +156 -0
- package/dist/cli/format.js +147 -2
- package/dist/cli/helpers.d.ts +7 -0
- package/dist/cli/helpers.js +43 -0
- package/dist/cli/index.js +68 -89
- package/dist/cli/libraries.d.ts +3 -0
- package/dist/cli/libraries.js +26 -0
- package/dist/cli/navigate.d.ts +3 -0
- package/dist/cli/navigate.js +192 -0
- package/dist/cli/org.d.ts +3 -0
- package/dist/cli/org.js +227 -0
- package/dist/cli/permissions.d.ts +3 -0
- package/dist/cli/permissions.js +133 -0
- package/dist/cli/projects.d.ts +3 -0
- package/dist/cli/projects.js +110 -0
- package/dist/cli/reading.d.ts +3 -0
- package/dist/cli/reading.js +51 -0
- package/dist/cli/teams.d.ts +3 -0
- package/dist/cli/teams.js +56 -0
- package/dist/cli/variables.d.ts +3 -0
- package/dist/cli/variables.js +80 -0
- package/dist/cli/versions.d.ts +3 -0
- package/dist/cli/versions.js +46 -0
- package/dist/cli/webhooks.d.ts +3 -0
- package/dist/cli/webhooks.js +100 -0
- package/dist/operations/analytics.d.ts +10 -0
- package/dist/operations/analytics.js +15 -0
- package/dist/operations/branching.d.ts +24 -0
- package/dist/operations/branching.js +41 -0
- package/dist/operations/comments.d.ts +43 -0
- package/dist/operations/comments.js +65 -0
- package/dist/operations/components.d.ts +24 -0
- package/dist/operations/components.js +30 -0
- package/dist/operations/compound-manager.d.ts +101 -0
- package/dist/operations/compound-manager.js +629 -0
- package/dist/operations/compound.d.ts +102 -0
- package/dist/operations/compound.js +595 -0
- package/dist/operations/export.d.ts +19 -0
- package/dist/operations/export.js +27 -0
- package/dist/operations/files.d.ts +55 -0
- package/dist/operations/files.js +89 -0
- package/dist/operations/libraries.d.ts +5 -0
- package/dist/operations/libraries.js +10 -0
- package/dist/operations/navigate.d.ts +99 -0
- package/dist/operations/navigate.js +266 -0
- package/dist/operations/org.d.ts +95 -0
- package/dist/operations/org.js +205 -0
- package/dist/operations/permissions.d.ts +59 -0
- package/dist/operations/permissions.js +112 -0
- package/dist/operations/projects.d.ts +29 -0
- package/dist/operations/projects.js +40 -0
- package/dist/operations/reading.d.ts +12 -0
- package/dist/operations/reading.js +20 -0
- package/dist/operations/teams.d.ts +17 -0
- package/dist/operations/teams.js +17 -0
- package/dist/operations/variables.d.ts +17 -0
- package/dist/operations/variables.js +39 -0
- package/dist/operations/versions.d.ts +23 -0
- package/dist/operations/versions.js +27 -0
- package/dist/operations/webhooks.d.ts +25 -0
- package/dist/operations/webhooks.js +38 -0
- package/dist/tools/analytics.js +6 -16
- package/dist/tools/branching.js +7 -36
- package/dist/tools/comments.js +9 -56
- package/dist/tools/components.js +7 -19
- package/dist/tools/compound-manager.js +21 -644
- package/dist/tools/compound.js +32 -566
- package/dist/tools/export.js +4 -23
- package/dist/tools/files.js +21 -68
- package/dist/tools/libraries.js +4 -11
- package/dist/tools/navigate.js +23 -246
- package/dist/tools/org.js +29 -245
- package/dist/tools/permissions.js +18 -97
- package/dist/tools/projects.js +8 -27
- package/dist/tools/reading.js +5 -15
- package/dist/tools/teams.js +8 -16
- package/dist/tools/variables.js +13 -30
- package/dist/tools/versions.js +6 -24
- package/dist/tools/webhooks.js +7 -24
- package/package.json +1 -1
- package/dist/cli/commands.d.ts +0 -47
- package/dist/cli/commands.js +0 -1204
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { AuthConfig } from '../auth/client.js';
|
|
2
|
+
export declare const SEAT_KEY_MAP: Record<string, string>;
|
|
3
|
+
export declare const LEVEL_NAMES: Record<number, string>;
|
|
4
|
+
export declare function levelName(level: number): string;
|
|
5
|
+
export declare function fileSummary(config: AuthConfig, params: {
|
|
6
|
+
file_key: string;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
name: any;
|
|
9
|
+
last_modified: any;
|
|
10
|
+
version: any;
|
|
11
|
+
pages: any;
|
|
12
|
+
component_count: any;
|
|
13
|
+
style_count: any;
|
|
14
|
+
comment_count: any;
|
|
15
|
+
unresolved_comment_count: any;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function workspaceOverview(config: AuthConfig, params: {
|
|
18
|
+
org_id?: string;
|
|
19
|
+
}): Promise<any>;
|
|
20
|
+
export declare function openComments(config: AuthConfig, params: {
|
|
21
|
+
project_id: string;
|
|
22
|
+
}): Promise<any>;
|
|
23
|
+
export declare function cleanupStaleFiles(config: AuthConfig, params: {
|
|
24
|
+
project_id: string;
|
|
25
|
+
days_stale: number;
|
|
26
|
+
dry_run: boolean;
|
|
27
|
+
}): Promise<any>;
|
|
28
|
+
export declare function organizeProject(config: AuthConfig, params: {
|
|
29
|
+
file_keys: string[];
|
|
30
|
+
target_project_id: string;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
moved: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
errors: any;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function setupProjectStructure(config: AuthConfig, params: {
|
|
37
|
+
team_id: string;
|
|
38
|
+
projects: Array<{
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
}>;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
created: any[];
|
|
44
|
+
failed: any[];
|
|
45
|
+
}>;
|
|
46
|
+
export declare function seatOptimization(config: AuthConfig, params: {
|
|
47
|
+
org_id?: string;
|
|
48
|
+
days_inactive: number;
|
|
49
|
+
include_cost: boolean;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
summary: {
|
|
52
|
+
total_paid: number;
|
|
53
|
+
inactive_paid: number;
|
|
54
|
+
monthly_waste_cents: any;
|
|
55
|
+
annual_savings_cents: number;
|
|
56
|
+
};
|
|
57
|
+
seat_breakdown: any;
|
|
58
|
+
inactive_users: any[];
|
|
59
|
+
recommendations: string[];
|
|
60
|
+
}>;
|
|
61
|
+
export declare function permissionAudit(config: AuthConfig, params: {
|
|
62
|
+
scope_type: 'project' | 'team';
|
|
63
|
+
scope_id: string;
|
|
64
|
+
flag_external: boolean;
|
|
65
|
+
org_id?: string;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
scope: {
|
|
68
|
+
type: "team" | "project";
|
|
69
|
+
id: string;
|
|
70
|
+
};
|
|
71
|
+
summary: {
|
|
72
|
+
unique_users: number;
|
|
73
|
+
files_scanned: number;
|
|
74
|
+
total_files: number;
|
|
75
|
+
flags_found: number;
|
|
76
|
+
};
|
|
77
|
+
users: any[];
|
|
78
|
+
flags: {
|
|
79
|
+
severity: string;
|
|
80
|
+
type: string;
|
|
81
|
+
details: string;
|
|
82
|
+
}[];
|
|
83
|
+
}>;
|
|
84
|
+
export declare function branchCleanup(config: AuthConfig, params: {
|
|
85
|
+
project_id: string;
|
|
86
|
+
days_stale: number;
|
|
87
|
+
dry_run: boolean;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
project_id: string;
|
|
90
|
+
summary: {
|
|
91
|
+
files_scanned: number;
|
|
92
|
+
total_branches: number;
|
|
93
|
+
stale: number;
|
|
94
|
+
active: number;
|
|
95
|
+
};
|
|
96
|
+
stale_branches: any[];
|
|
97
|
+
active_branches: any[];
|
|
98
|
+
dry_run: boolean;
|
|
99
|
+
archived: boolean;
|
|
100
|
+
recommendations: string[];
|
|
101
|
+
}>;
|
|
102
|
+
//# sourceMappingURL=compound.d.ts.map
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { hasPat, hasCookie } from '../auth/client.js';
|
|
2
|
+
import { publicClient } from '../clients/public-api.js';
|
|
3
|
+
import { internalClient } from '../clients/internal-api.js';
|
|
4
|
+
import { requireOrgId } from '../tools/register.js';
|
|
5
|
+
// -- Shared helpers --
|
|
6
|
+
const BATCH_SIZE = 5;
|
|
7
|
+
async function batchProcess(items, fn) {
|
|
8
|
+
const results = [];
|
|
9
|
+
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
|
10
|
+
const batch = items.slice(i, i + BATCH_SIZE);
|
|
11
|
+
const batchResults = await Promise.allSettled(batch.map(fn));
|
|
12
|
+
results.push(...batchResults);
|
|
13
|
+
}
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
export const SEAT_KEY_MAP = {
|
|
17
|
+
expert: 'full',
|
|
18
|
+
developer: 'dev',
|
|
19
|
+
collaborator: 'collab',
|
|
20
|
+
};
|
|
21
|
+
export const LEVEL_NAMES = { 999: 'owner', 300: 'editor', 100: 'viewer' };
|
|
22
|
+
export function levelName(level) {
|
|
23
|
+
return LEVEL_NAMES[level] || `level:${level}`;
|
|
24
|
+
}
|
|
25
|
+
// -- file_summary --
|
|
26
|
+
export async function fileSummary(config, params) {
|
|
27
|
+
const api = publicClient(config);
|
|
28
|
+
const [fileResult, componentsResult, stylesResult, commentsResult] = await Promise.allSettled([
|
|
29
|
+
api.get(`/v1/files/${params.file_key}`, { params: { depth: '1' } }),
|
|
30
|
+
api.get(`/v1/files/${params.file_key}/components`),
|
|
31
|
+
api.get(`/v1/files/${params.file_key}/styles`),
|
|
32
|
+
api.get(`/v1/files/${params.file_key}/comments`),
|
|
33
|
+
]);
|
|
34
|
+
if (fileResult.status === 'rejected') {
|
|
35
|
+
throw new Error(`Failed to fetch file: ${fileResult.reason?.response?.status || fileResult.reason?.message}`);
|
|
36
|
+
}
|
|
37
|
+
const fileData = fileResult.value.data;
|
|
38
|
+
const pages = (fileData.document?.children || []).map((c) => c.name);
|
|
39
|
+
const components = componentsResult.status === 'fulfilled' ? componentsResult.value.data?.meta?.components || [] : [];
|
|
40
|
+
const styles = stylesResult.status === 'fulfilled' ? stylesResult.value.data?.meta?.styles || [] : [];
|
|
41
|
+
const comments = commentsResult.status === 'fulfilled' ? commentsResult.value.data?.comments || [] : [];
|
|
42
|
+
const unresolved = comments.filter((c) => !c.resolved_at);
|
|
43
|
+
return {
|
|
44
|
+
name: fileData.name,
|
|
45
|
+
last_modified: fileData.lastModified,
|
|
46
|
+
version: fileData.version,
|
|
47
|
+
pages,
|
|
48
|
+
component_count: components.length,
|
|
49
|
+
style_count: styles.length,
|
|
50
|
+
comment_count: comments.length,
|
|
51
|
+
unresolved_comment_count: unresolved.length,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// -- workspace_overview --
|
|
55
|
+
export async function workspaceOverview(config, params) {
|
|
56
|
+
const orgId = requireOrgId(config, params.org_id);
|
|
57
|
+
const api = internalClient(config);
|
|
58
|
+
const [teamsResult, seatsResult, billingResult] = await Promise.allSettled([
|
|
59
|
+
api.get(`/api/orgs/${orgId}/teams`, {
|
|
60
|
+
params: { include_member_count: true, include_project_count: true, include_top_members: true },
|
|
61
|
+
}),
|
|
62
|
+
api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
|
|
63
|
+
api.get(`/api/orgs/${orgId}/billing_data`),
|
|
64
|
+
]);
|
|
65
|
+
const teamsData = teamsResult.status === 'fulfilled' ? (teamsResult.value.data?.meta || teamsResult.value.data) : null;
|
|
66
|
+
const teamsRaw = teamsData ? (Array.isArray(teamsData) ? teamsData : (teamsData?.teams || [])) : [];
|
|
67
|
+
const teams = teamsRaw.map((t) => ({
|
|
68
|
+
id: String(t.id),
|
|
69
|
+
name: t.name,
|
|
70
|
+
members: t.member_count || 0,
|
|
71
|
+
projects: t.project_count || 0,
|
|
72
|
+
}));
|
|
73
|
+
const seats = seatsResult.status === 'fulfilled' ? (seatsResult.value.data?.meta || seatsResult.value.data) : null;
|
|
74
|
+
const rawBilling = billingResult.status === 'fulfilled' ? (billingResult.value.data?.meta || billingResult.value.data) : null;
|
|
75
|
+
// Strip PII from billing data
|
|
76
|
+
const billing = rawBilling ? { ...rawBilling } : null;
|
|
77
|
+
if (billing)
|
|
78
|
+
delete billing.shipping_address;
|
|
79
|
+
const errors = [];
|
|
80
|
+
if (teamsResult.status === 'rejected')
|
|
81
|
+
errors.push(`teams: ${teamsResult.reason?.response?.status || teamsResult.reason?.message}`);
|
|
82
|
+
if (seatsResult.status === 'rejected')
|
|
83
|
+
errors.push(`seats: ${seatsResult.reason?.response?.status || seatsResult.reason?.message}`);
|
|
84
|
+
if (billingResult.status === 'rejected')
|
|
85
|
+
errors.push(`billing: ${billingResult.reason?.response?.status || billingResult.reason?.message}`);
|
|
86
|
+
const overview = { teams, seats, billing };
|
|
87
|
+
if (errors.length > 0)
|
|
88
|
+
overview.errors = errors;
|
|
89
|
+
return overview;
|
|
90
|
+
}
|
|
91
|
+
// -- open_comments --
|
|
92
|
+
export async function openComments(config, params) {
|
|
93
|
+
const api = publicClient(config);
|
|
94
|
+
const filesRes = await api.get(`/v1/projects/${params.project_id}/files`);
|
|
95
|
+
const allFiles = filesRes.data?.files || [];
|
|
96
|
+
const capped = allFiles.length > 20;
|
|
97
|
+
const files = allFiles.slice(0, 20);
|
|
98
|
+
const commentResults = [];
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
try {
|
|
101
|
+
const commentsRes = await api.get(`/v1/files/${f.key}/comments`);
|
|
102
|
+
const comments = (commentsRes.data?.comments || [])
|
|
103
|
+
.filter((c) => !c.resolved_at);
|
|
104
|
+
commentResults.push({ file_key: f.key, file_name: f.name, comments });
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
commentResults.push({ file_key: f.key, file_name: f.name, comments: [], error: e.response?.status || e.message });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const filesWithComments = commentResults
|
|
111
|
+
.filter(f => f.comments.length > 0)
|
|
112
|
+
.map(f => ({
|
|
113
|
+
file_key: f.file_key,
|
|
114
|
+
file_name: f.file_name,
|
|
115
|
+
comments: f.comments.map((c) => ({
|
|
116
|
+
id: c.id,
|
|
117
|
+
author: c.user?.handle || c.user?.email || 'unknown',
|
|
118
|
+
message: c.message,
|
|
119
|
+
created_at: c.created_at,
|
|
120
|
+
})),
|
|
121
|
+
}));
|
|
122
|
+
const totalUnresolved = filesWithComments.reduce((sum, f) => sum + f.comments.length, 0);
|
|
123
|
+
const errors = commentResults
|
|
124
|
+
.filter((f) => f.error)
|
|
125
|
+
.map((f) => ({ file_key: f.file_key, file_name: f.file_name, error: f.error }));
|
|
126
|
+
const result = {
|
|
127
|
+
total_unresolved: totalUnresolved,
|
|
128
|
+
files: filesWithComments,
|
|
129
|
+
};
|
|
130
|
+
if (errors.length > 0) {
|
|
131
|
+
result.errors = errors;
|
|
132
|
+
}
|
|
133
|
+
if (capped) {
|
|
134
|
+
result.note = `Project has ${allFiles.length} files; only the first 20 were checked.`;
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
// -- cleanup_stale_files --
|
|
139
|
+
const MAX_TRASH_BATCH = 25;
|
|
140
|
+
export async function cleanupStaleFiles(config, params) {
|
|
141
|
+
const { project_id, days_stale, dry_run } = params;
|
|
142
|
+
if (!dry_run && !hasCookie(config)) {
|
|
143
|
+
throw new Error('Cookie auth required to trash files. Run with dry_run=true to preview, or configure cookie auth.');
|
|
144
|
+
}
|
|
145
|
+
let files;
|
|
146
|
+
if (hasPat(config)) {
|
|
147
|
+
const res = await publicClient(config).get(`/v1/projects/${project_id}/files`);
|
|
148
|
+
files = res.data?.files || [];
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
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: '' } });
|
|
152
|
+
const meta = res.data?.meta || res.data;
|
|
153
|
+
files = meta?.files || meta || [];
|
|
154
|
+
}
|
|
155
|
+
const cutoff = Date.now() - days_stale * 86400000;
|
|
156
|
+
const staleFiles = files.filter((f) => {
|
|
157
|
+
const raw = f.last_modified || f.touched_at;
|
|
158
|
+
if (!raw)
|
|
159
|
+
return false;
|
|
160
|
+
const modified = new Date(raw).getTime();
|
|
161
|
+
return !isNaN(modified) && modified < cutoff;
|
|
162
|
+
});
|
|
163
|
+
const result = {
|
|
164
|
+
stale_files: staleFiles.map((f) => ({
|
|
165
|
+
key: f.key,
|
|
166
|
+
name: f.name,
|
|
167
|
+
last_modified: f.last_modified || f.touched_at,
|
|
168
|
+
})),
|
|
169
|
+
total_stale: staleFiles.length,
|
|
170
|
+
dry_run,
|
|
171
|
+
trashed: false,
|
|
172
|
+
};
|
|
173
|
+
if (!dry_run) {
|
|
174
|
+
if (staleFiles.length > MAX_TRASH_BATCH) {
|
|
175
|
+
throw new Error(`${staleFiles.length} stale files exceeds safety limit of ${MAX_TRASH_BATCH}. ` +
|
|
176
|
+
`Run with dry_run=true to review, then trash in smaller batches using trash_files.`);
|
|
177
|
+
}
|
|
178
|
+
if (staleFiles.length > 0) {
|
|
179
|
+
await internalClient(config).delete('/api/files_batch', {
|
|
180
|
+
data: { files: staleFiles.map((f) => ({ key: f.key })), trashed: true },
|
|
181
|
+
});
|
|
182
|
+
result.trashed = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
// -- organize_project --
|
|
188
|
+
export async function organizeProject(config, params) {
|
|
189
|
+
const payload = {
|
|
190
|
+
files: params.file_keys.map(key => ({
|
|
191
|
+
key,
|
|
192
|
+
folder_id: params.target_project_id,
|
|
193
|
+
is_multi_move: true,
|
|
194
|
+
restore_files: false,
|
|
195
|
+
})),
|
|
196
|
+
};
|
|
197
|
+
const res = await internalClient(config).put('/api/files_batch', payload);
|
|
198
|
+
const data = res.data?.meta || res.data;
|
|
199
|
+
const moved = Object.keys(data?.success || {}).length;
|
|
200
|
+
const failed = Object.keys(data?.errors || {}).length;
|
|
201
|
+
return {
|
|
202
|
+
moved: moved || (failed === 0 ? params.file_keys.length : 0),
|
|
203
|
+
failed,
|
|
204
|
+
errors: data?.errors || {},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// -- setup_project_structure --
|
|
208
|
+
export async function setupProjectStructure(config, params) {
|
|
209
|
+
const api = internalClient(config);
|
|
210
|
+
const created = [];
|
|
211
|
+
const failed = [];
|
|
212
|
+
for (const project of params.projects) {
|
|
213
|
+
try {
|
|
214
|
+
const createRes = await api.post('/api/folders', {
|
|
215
|
+
team_id: params.team_id,
|
|
216
|
+
path: project.name,
|
|
217
|
+
sharing_audience_control: 'org_view',
|
|
218
|
+
team_access: 'team_edit',
|
|
219
|
+
});
|
|
220
|
+
const meta = createRes.data?.meta;
|
|
221
|
+
const p = Array.isArray(meta) ? meta[0] : (meta?.folder || meta || createRes.data);
|
|
222
|
+
const folderId = p?.id != null ? String(p.id) : null;
|
|
223
|
+
if (!folderId) {
|
|
224
|
+
failed.push({ name: project.name, error: 'Could not extract project ID from response' });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (project.description) {
|
|
228
|
+
await api.put(`/api/folders/${folderId}`, { description: project.description });
|
|
229
|
+
}
|
|
230
|
+
created.push({ id: folderId, name: project.name, description: project.description || null });
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
failed.push({ name: project.name, error: e.response?.status || e.message });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { created, failed };
|
|
237
|
+
}
|
|
238
|
+
// -- seat_optimization --
|
|
239
|
+
export async function seatOptimization(config, params) {
|
|
240
|
+
const { days_inactive, include_cost } = params;
|
|
241
|
+
const orgId = requireOrgId(config, params.org_id);
|
|
242
|
+
const api = internalClient(config);
|
|
243
|
+
const cutoff = Date.now() - days_inactive * 86400000;
|
|
244
|
+
const paidKeys = new Set(['expert', 'developer', 'collaborator']);
|
|
245
|
+
// Paginate org members (cursor-based, max 500)
|
|
246
|
+
const allMembers = [];
|
|
247
|
+
const MAX_PAGES = 20;
|
|
248
|
+
let cursor;
|
|
249
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
250
|
+
const params = { page_size: 25 };
|
|
251
|
+
if (cursor)
|
|
252
|
+
params.cursor = cursor;
|
|
253
|
+
const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
|
|
254
|
+
const meta = res.data?.meta || {};
|
|
255
|
+
const members = meta.users || [];
|
|
256
|
+
if (!Array.isArray(members) || members.length === 0)
|
|
257
|
+
break;
|
|
258
|
+
allMembers.push(...members);
|
|
259
|
+
cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
|
|
260
|
+
if (!cursor || members.length < 25)
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
// Fetch seat breakdown and optionally contract rates in parallel
|
|
264
|
+
const parallelCalls = [
|
|
265
|
+
api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
|
|
266
|
+
];
|
|
267
|
+
if (include_cost) {
|
|
268
|
+
parallelCalls.push(api.get('/api/pricing/contract_rates', {
|
|
269
|
+
params: { plan_parent_id: orgId, plan_type: 'organization' },
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
const [seatsResult, ratesResult] = await Promise.allSettled(parallelCalls);
|
|
273
|
+
const seats = seatsResult.status === 'fulfilled' ? (seatsResult.value.data?.meta || seatsResult.value.data) : null;
|
|
274
|
+
// Build cost lookup: seat key -> monthly cents
|
|
275
|
+
const costMap = {};
|
|
276
|
+
if (include_cost && ratesResult?.status === 'fulfilled') {
|
|
277
|
+
const prices = ratesResult.value.data?.meta?.product_prices || [];
|
|
278
|
+
for (const p of prices) {
|
|
279
|
+
if (paidKeys.has(p.billable_product_key)) {
|
|
280
|
+
costMap[p.billable_product_key] = p.amount;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Filter inactive paid members
|
|
285
|
+
const inactiveUsers = [];
|
|
286
|
+
let totalPaid = 0;
|
|
287
|
+
for (const m of allMembers) {
|
|
288
|
+
const seatKey = m.active_seat_type?.key;
|
|
289
|
+
if (!seatKey || !paidKeys.has(seatKey))
|
|
290
|
+
continue;
|
|
291
|
+
totalPaid++;
|
|
292
|
+
const lastSeen = m.last_seen;
|
|
293
|
+
const lastSeenMs = lastSeen ? new Date(lastSeen).getTime() : 0;
|
|
294
|
+
const isInactive = !lastSeen || (lastSeenMs > 0 && lastSeenMs < cutoff) || isNaN(lastSeenMs);
|
|
295
|
+
if (isInactive) {
|
|
296
|
+
inactiveUsers.push({
|
|
297
|
+
org_user_id: String(m.id),
|
|
298
|
+
user_id: m.user_id,
|
|
299
|
+
email: m.user?.email,
|
|
300
|
+
name: m.user?.handle,
|
|
301
|
+
seat_type: SEAT_KEY_MAP[seatKey] || seatKey,
|
|
302
|
+
seat_key: seatKey,
|
|
303
|
+
last_active: lastSeen || null,
|
|
304
|
+
monthly_cost_cents: costMap[seatKey] || null,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const monthlyWasteCents = inactiveUsers.reduce((sum, u) => sum + (u.monthly_cost_cents || 0), 0);
|
|
309
|
+
const recommendations = [];
|
|
310
|
+
if (inactiveUsers.length > 0) {
|
|
311
|
+
recommendations.push(`${inactiveUsers.length} paid seat(s) inactive for ${days_inactive}+ days. Review for downgrade to viewer.`);
|
|
312
|
+
}
|
|
313
|
+
const neverActive = inactiveUsers.filter((u) => !u.last_active);
|
|
314
|
+
if (neverActive.length > 0) {
|
|
315
|
+
recommendations.push(`${neverActive.length} paid user(s) have never been active. Likely unused invites.`);
|
|
316
|
+
}
|
|
317
|
+
if (monthlyWasteCents > 0) {
|
|
318
|
+
recommendations.push(`Potential monthly savings: $${(monthlyWasteCents / 100).toFixed(2)} ($${((monthlyWasteCents * 12) / 100).toFixed(2)}/yr).`);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
summary: {
|
|
322
|
+
total_paid: totalPaid,
|
|
323
|
+
inactive_paid: inactiveUsers.length,
|
|
324
|
+
monthly_waste_cents: monthlyWasteCents,
|
|
325
|
+
annual_savings_cents: monthlyWasteCents * 12,
|
|
326
|
+
},
|
|
327
|
+
seat_breakdown: seats,
|
|
328
|
+
inactive_users: inactiveUsers,
|
|
329
|
+
recommendations,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// -- permission_audit --
|
|
333
|
+
export async function permissionAudit(config, params) {
|
|
334
|
+
const { scope_type, scope_id, flag_external } = params;
|
|
335
|
+
const api = internalClient(config);
|
|
336
|
+
// Resolve org for domain lookup
|
|
337
|
+
let orgId;
|
|
338
|
+
let domainCheckSkipped = false;
|
|
339
|
+
if (flag_external) {
|
|
340
|
+
try {
|
|
341
|
+
orgId = requireOrgId(config, params.org_id);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
domainCheckSkipped = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Fetch org verified domains for external detection
|
|
348
|
+
let verifiedDomains = new Set();
|
|
349
|
+
if (flag_external && orgId) {
|
|
350
|
+
try {
|
|
351
|
+
const domRes = await api.get(`/api/orgs/${orgId}/domains`);
|
|
352
|
+
const domains = domRes.data?.meta || [];
|
|
353
|
+
if (Array.isArray(domains)) {
|
|
354
|
+
for (const d of domains) {
|
|
355
|
+
if (d.domain)
|
|
356
|
+
verifiedDomains.add(d.domain.toLowerCase());
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch { /* domain lookup optional, continue without */ }
|
|
361
|
+
}
|
|
362
|
+
// Collect file keys to scan
|
|
363
|
+
let fileKeys = [];
|
|
364
|
+
if (scope_type === 'team') {
|
|
365
|
+
// Fetch team projects, cap at 10
|
|
366
|
+
const projectsRes = await api.get(`/api/teams/${scope_id}/folders`);
|
|
367
|
+
const rows = projectsRes.data?.meta?.folder_rows || projectsRes.data || [];
|
|
368
|
+
const projects = (Array.isArray(rows) ? rows : []).slice(0, 10);
|
|
369
|
+
for (const proj of projects) {
|
|
370
|
+
if (fileKeys.length >= 25)
|
|
371
|
+
break;
|
|
372
|
+
try {
|
|
373
|
+
const filesRes = await api.get(`/api/folders/${proj.id}/paginated_files`, {
|
|
374
|
+
params: { folderId: String(proj.id), page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
|
|
375
|
+
});
|
|
376
|
+
const meta = filesRes.data?.meta || filesRes.data;
|
|
377
|
+
const files = meta?.files || meta || [];
|
|
378
|
+
for (const f of (Array.isArray(files) ? files : [])) {
|
|
379
|
+
if (fileKeys.length >= 25)
|
|
380
|
+
break;
|
|
381
|
+
fileKeys.push({ key: f.key, name: f.name });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch { /* skip inaccessible projects */ }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Project scope
|
|
389
|
+
const filesRes = await api.get(`/api/folders/${scope_id}/paginated_files`, {
|
|
390
|
+
params: { folderId: scope_id, page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
|
|
391
|
+
});
|
|
392
|
+
const meta = filesRes.data?.meta || filesRes.data;
|
|
393
|
+
const files = meta?.files || meta || [];
|
|
394
|
+
for (const f of (Array.isArray(files) ? files : [])) {
|
|
395
|
+
if (fileKeys.length >= 25)
|
|
396
|
+
break;
|
|
397
|
+
fileKeys.push({ key: f.key, name: f.name });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Fetch permissions and file metadata in parallel, batched 5 at a time
|
|
401
|
+
const allUsers = new Map();
|
|
402
|
+
const flags = [];
|
|
403
|
+
let filesScanned = 0;
|
|
404
|
+
for (let i = 0; i < fileKeys.length; i += 5) {
|
|
405
|
+
const batch = fileKeys.slice(i, i + 5);
|
|
406
|
+
const results = await Promise.allSettled(batch.map(async (file) => {
|
|
407
|
+
const [rolesRes, fileMetaRes] = await Promise.allSettled([
|
|
408
|
+
api.get(`/api/roles/file/${file.key}`),
|
|
409
|
+
api.get(`/api/files/${file.key}`),
|
|
410
|
+
]);
|
|
411
|
+
const roles = rolesRes.status === 'fulfilled'
|
|
412
|
+
? (Array.isArray(rolesRes.value.data?.meta) ? rolesRes.value.data.meta : [])
|
|
413
|
+
: [];
|
|
414
|
+
const fileMeta = fileMetaRes.status === 'fulfilled'
|
|
415
|
+
? (fileMetaRes.value.data?.meta || fileMetaRes.value.data || {})
|
|
416
|
+
: {};
|
|
417
|
+
return { file, roles, fileMeta };
|
|
418
|
+
}));
|
|
419
|
+
for (const r of results) {
|
|
420
|
+
if (r.status === 'rejected')
|
|
421
|
+
continue;
|
|
422
|
+
filesScanned++;
|
|
423
|
+
const { file, roles, fileMeta } = r.value;
|
|
424
|
+
// Check link access
|
|
425
|
+
const linkAccess = fileMeta.link_access;
|
|
426
|
+
if (linkAccess === 'edit' || linkAccess === 'org_edit') {
|
|
427
|
+
flags.push({
|
|
428
|
+
severity: 'high',
|
|
429
|
+
type: 'open_link_access',
|
|
430
|
+
details: `${file.name} (${file.key}) has link_access="${linkAccess}"`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Process roles
|
|
434
|
+
for (const role of roles) {
|
|
435
|
+
const email = role.user?.email || role.pending_email;
|
|
436
|
+
const userId = role.user_id ? String(role.user_id) : email;
|
|
437
|
+
const level = role.level;
|
|
438
|
+
const roleName = level >= 999 ? 'owner' : level >= 300 ? 'editor' : 'viewer';
|
|
439
|
+
if (userId && !allUsers.has(userId)) {
|
|
440
|
+
allUsers.set(userId, {
|
|
441
|
+
user_id: userId,
|
|
442
|
+
email,
|
|
443
|
+
name: role.user?.handle,
|
|
444
|
+
files_accessed: [],
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (userId) {
|
|
448
|
+
const user = allUsers.get(userId);
|
|
449
|
+
user.files_accessed.push({
|
|
450
|
+
file_key: file.key,
|
|
451
|
+
file_name: file.name,
|
|
452
|
+
role: roleName,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
// External editor detection
|
|
456
|
+
if (flag_external && email && verifiedDomains.size > 0) {
|
|
457
|
+
const domain = email.split('@')[1]?.toLowerCase();
|
|
458
|
+
if (domain && !verifiedDomains.has(domain) && (roleName === 'editor' || roleName === 'owner')) {
|
|
459
|
+
flags.push({
|
|
460
|
+
severity: 'high',
|
|
461
|
+
type: 'external_editor',
|
|
462
|
+
details: `${email} (external) has ${roleName} access to ${file.name} (${file.key})`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (domainCheckSkipped) {
|
|
470
|
+
flags.push({
|
|
471
|
+
severity: 'info',
|
|
472
|
+
type: 'domain_check_skipped',
|
|
473
|
+
details: 'Could not resolve org ID; external user detection was skipped. Provide org_id or set FIGMA_ORG_ID.',
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
scope: { type: scope_type, id: scope_id },
|
|
478
|
+
summary: {
|
|
479
|
+
unique_users: allUsers.size,
|
|
480
|
+
files_scanned: filesScanned,
|
|
481
|
+
total_files: fileKeys.length,
|
|
482
|
+
flags_found: flags.length,
|
|
483
|
+
},
|
|
484
|
+
users: Array.from(allUsers.values()),
|
|
485
|
+
flags,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// -- branch_cleanup --
|
|
489
|
+
const MAX_ARCHIVE_BATCH = 25;
|
|
490
|
+
export async function branchCleanup(config, params) {
|
|
491
|
+
const { project_id, days_stale, dry_run } = params;
|
|
492
|
+
if (!dry_run && !hasCookie(config)) {
|
|
493
|
+
throw new Error('Cookie auth required to archive branches. Run with dry_run=true to preview, or configure cookie auth.');
|
|
494
|
+
}
|
|
495
|
+
// Fetch project files
|
|
496
|
+
const MAX_FILES = 20;
|
|
497
|
+
let files;
|
|
498
|
+
if (hasPat(config)) {
|
|
499
|
+
const res = await publicClient(config).get(`/v1/projects/${project_id}/files`);
|
|
500
|
+
files = res.data?.files || [];
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
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: '' } });
|
|
504
|
+
const meta = res.data?.meta || res.data;
|
|
505
|
+
files = meta?.files || meta || [];
|
|
506
|
+
}
|
|
507
|
+
// Cap at 20 files
|
|
508
|
+
const capped = files.length > 20;
|
|
509
|
+
files = files.slice(0, 20);
|
|
510
|
+
// Fetch branch data for each file in parallel
|
|
511
|
+
const cutoff = Date.now() - days_stale * 86400000;
|
|
512
|
+
const staleBranches = [];
|
|
513
|
+
const activeBranches = [];
|
|
514
|
+
let filesScanned = 0;
|
|
515
|
+
let totalBranches = 0;
|
|
516
|
+
const branchResults = await Promise.allSettled(files.map(async (file) => {
|
|
517
|
+
let branches;
|
|
518
|
+
if (hasPat(config)) {
|
|
519
|
+
const res = await publicClient(config).get(`/v1/files/${file.key}`, {
|
|
520
|
+
params: { branch_data: 'true', depth: '0' },
|
|
521
|
+
});
|
|
522
|
+
branches = res.data?.branches || [];
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
const res = await internalClient(config).get(`/api/files/${file.key}`);
|
|
526
|
+
const f = res.data?.meta || res.data;
|
|
527
|
+
branches = f.branches || [];
|
|
528
|
+
}
|
|
529
|
+
return { file, branches };
|
|
530
|
+
}));
|
|
531
|
+
for (const r of branchResults) {
|
|
532
|
+
if (r.status === 'rejected')
|
|
533
|
+
continue;
|
|
534
|
+
filesScanned++;
|
|
535
|
+
const { file, branches } = r.value;
|
|
536
|
+
for (const branch of branches) {
|
|
537
|
+
totalBranches++;
|
|
538
|
+
const lastModified = branch.last_modified;
|
|
539
|
+
const lastModifiedMs = lastModified ? new Date(lastModified).getTime() : 0;
|
|
540
|
+
const isStale = !lastModified || (lastModifiedMs > 0 && lastModifiedMs < cutoff) || isNaN(lastModifiedMs);
|
|
541
|
+
const entry = {
|
|
542
|
+
branch_key: branch.key,
|
|
543
|
+
branch_name: branch.name,
|
|
544
|
+
parent_file_key: file.key,
|
|
545
|
+
parent_file_name: file.name,
|
|
546
|
+
last_modified: lastModified || null,
|
|
547
|
+
};
|
|
548
|
+
if (isStale) {
|
|
549
|
+
staleBranches.push(entry);
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
activeBranches.push(entry);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
let archived = false;
|
|
557
|
+
if (!dry_run && staleBranches.length > 0) {
|
|
558
|
+
if (staleBranches.length > MAX_ARCHIVE_BATCH) {
|
|
559
|
+
throw new Error(`${staleBranches.length} stale branches exceeds safety limit of ${MAX_ARCHIVE_BATCH}. ` +
|
|
560
|
+
`Run with dry_run=true to review, then archive in smaller batches using delete_branch.`);
|
|
561
|
+
}
|
|
562
|
+
await internalClient(config).delete('/api/files_batch', {
|
|
563
|
+
data: {
|
|
564
|
+
files: staleBranches.map(b => ({ key: b.branch_key })),
|
|
565
|
+
trashed: true,
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
archived = true;
|
|
569
|
+
}
|
|
570
|
+
const recommendations = [];
|
|
571
|
+
if (staleBranches.length > 0) {
|
|
572
|
+
recommendations.push(`${staleBranches.length} branch(es) stale for ${days_stale}+ days. ${dry_run ? 'Set dry_run=false to archive.' : 'Archived.'}`);
|
|
573
|
+
}
|
|
574
|
+
if (capped) {
|
|
575
|
+
recommendations.push(`Project has more than 20 files; only the first 20 were scanned.`);
|
|
576
|
+
}
|
|
577
|
+
if (staleBranches.length === 0 && activeBranches.length === 0) {
|
|
578
|
+
recommendations.push('No branches found in scanned files.');
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
project_id,
|
|
582
|
+
summary: {
|
|
583
|
+
files_scanned: filesScanned,
|
|
584
|
+
total_branches: totalBranches,
|
|
585
|
+
stale: staleBranches.length,
|
|
586
|
+
active: activeBranches.length,
|
|
587
|
+
},
|
|
588
|
+
stale_branches: staleBranches,
|
|
589
|
+
active_branches: activeBranches,
|
|
590
|
+
dry_run,
|
|
591
|
+
archived,
|
|
592
|
+
recommendations,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
//# sourceMappingURL=compound.js.map
|