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
package/dist/cli/commands.js
DELETED
|
@@ -1,1204 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI command handlers. Each replicates the API calls from the
|
|
3
|
-
* corresponding MCP tool handler, adapted for CLI output.
|
|
4
|
-
*/
|
|
5
|
-
import { loadAuthConfig, hasPat, hasCookie } from '../auth/client.js';
|
|
6
|
-
import { internalClient } from '../clients/internal-api.js';
|
|
7
|
-
import { publicClient } from '../clients/public-api.js';
|
|
8
|
-
import { requireOrgId } from '../tools/register.js';
|
|
9
|
-
import { output, error } from './format.js';
|
|
10
|
-
// -- Shared helpers --
|
|
11
|
-
const ID_PATTERN = /^[\w.:-]+$/;
|
|
12
|
-
function validateId(value, label) {
|
|
13
|
-
if (!ID_PATTERN.test(value)) {
|
|
14
|
-
error(`Invalid ${label}: must contain only letters, numbers, hyphens, underscores, dots, or colons.`);
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
return value;
|
|
18
|
-
}
|
|
19
|
-
function parsePositiveInt(value, label, fallback) {
|
|
20
|
-
const n = parseInt(value, 10);
|
|
21
|
-
if (isNaN(n) || n < 1) {
|
|
22
|
-
error(`${label} must be a positive number.`);
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
return n;
|
|
26
|
-
}
|
|
27
|
-
function requireAuth() {
|
|
28
|
-
const config = loadAuthConfig();
|
|
29
|
-
if (!hasPat(config) && !hasCookie(config)) {
|
|
30
|
-
error('Not authenticated. Run `figmanage login` first.');
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
return config;
|
|
34
|
-
}
|
|
35
|
-
function requireCookie() {
|
|
36
|
-
const config = requireAuth();
|
|
37
|
-
if (!hasCookie(config)) {
|
|
38
|
-
error('This command requires cookie auth. Run `figmanage login` (not --pat-only).');
|
|
39
|
-
process.exit(1);
|
|
40
|
-
}
|
|
41
|
-
return config;
|
|
42
|
-
}
|
|
43
|
-
const BATCH_SIZE = 5;
|
|
44
|
-
async function batchProcess(items, fn) {
|
|
45
|
-
const results = [];
|
|
46
|
-
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
|
47
|
-
const batch = items.slice(i, i + BATCH_SIZE);
|
|
48
|
-
const batchResults = await Promise.allSettled(batch.map(fn));
|
|
49
|
-
results.push(...batchResults);
|
|
50
|
-
}
|
|
51
|
-
return results;
|
|
52
|
-
}
|
|
53
|
-
const SEAT_KEY_MAP = {
|
|
54
|
-
expert: 'full',
|
|
55
|
-
developer: 'dev',
|
|
56
|
-
collaborator: 'collab',
|
|
57
|
-
};
|
|
58
|
-
const LEVEL_NAMES = { 999: 'owner', 300: 'editor', 100: 'viewer' };
|
|
59
|
-
function levelName(level) {
|
|
60
|
-
return LEVEL_NAMES[level] || `level:${level}`;
|
|
61
|
-
}
|
|
62
|
-
// -- seat-optimization --
|
|
63
|
-
export async function runSeatOptimization(options) {
|
|
64
|
-
try {
|
|
65
|
-
const config = requireCookie();
|
|
66
|
-
const orgId = requireOrgId(config);
|
|
67
|
-
const api = internalClient(config);
|
|
68
|
-
const daysInactive = parsePositiveInt(options.daysInactive || '90', '--days-inactive', 90);
|
|
69
|
-
const includeCost = options.cost !== false;
|
|
70
|
-
const cutoff = Date.now() - daysInactive * 86400000;
|
|
71
|
-
const paidKeys = new Set(['expert', 'developer', 'collaborator']);
|
|
72
|
-
// Paginate org members (cursor-based)
|
|
73
|
-
const allMembers = [];
|
|
74
|
-
const MAX_PAGES = 20;
|
|
75
|
-
let cursor;
|
|
76
|
-
for (let page = 0; page < MAX_PAGES; page++) {
|
|
77
|
-
const params = { page_size: 25 };
|
|
78
|
-
if (cursor)
|
|
79
|
-
params.cursor = cursor;
|
|
80
|
-
const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
|
|
81
|
-
const meta = res.data?.meta || {};
|
|
82
|
-
const members = meta.users || [];
|
|
83
|
-
if (!Array.isArray(members) || members.length === 0)
|
|
84
|
-
break;
|
|
85
|
-
allMembers.push(...members);
|
|
86
|
-
cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
|
|
87
|
-
if (!cursor || members.length < 25)
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
// Fetch seat breakdown and optionally contract rates in parallel
|
|
91
|
-
const parallelCalls = [
|
|
92
|
-
api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
|
|
93
|
-
];
|
|
94
|
-
if (includeCost) {
|
|
95
|
-
parallelCalls.push(api.get('/api/pricing/contract_rates', {
|
|
96
|
-
params: { plan_parent_id: orgId, plan_type: 'organization' },
|
|
97
|
-
}));
|
|
98
|
-
}
|
|
99
|
-
const [seatsResult, ratesResult] = await Promise.allSettled(parallelCalls);
|
|
100
|
-
const seats = seatsResult.status === 'fulfilled'
|
|
101
|
-
? (seatsResult.value.data?.meta || seatsResult.value.data)
|
|
102
|
-
: null;
|
|
103
|
-
// Build cost lookup: seat key -> monthly cents
|
|
104
|
-
const costMap = {};
|
|
105
|
-
if (includeCost && ratesResult?.status === 'fulfilled') {
|
|
106
|
-
const prices = ratesResult.value.data?.meta?.product_prices || [];
|
|
107
|
-
for (const p of prices) {
|
|
108
|
-
if (paidKeys.has(p.billable_product_key)) {
|
|
109
|
-
costMap[p.billable_product_key] = p.amount;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
// Filter inactive paid members
|
|
114
|
-
const inactiveUsers = [];
|
|
115
|
-
let totalPaid = 0;
|
|
116
|
-
for (const m of allMembers) {
|
|
117
|
-
const seatKey = m.active_seat_type?.key;
|
|
118
|
-
if (!seatKey || !paidKeys.has(seatKey))
|
|
119
|
-
continue;
|
|
120
|
-
totalPaid++;
|
|
121
|
-
const lastSeen = m.last_seen;
|
|
122
|
-
const lastSeenMs = lastSeen ? new Date(lastSeen).getTime() : 0;
|
|
123
|
-
const isInactive = !lastSeen || (lastSeenMs > 0 && lastSeenMs < cutoff) || isNaN(lastSeenMs);
|
|
124
|
-
if (isInactive) {
|
|
125
|
-
inactiveUsers.push({
|
|
126
|
-
org_user_id: String(m.id),
|
|
127
|
-
user_id: m.user_id,
|
|
128
|
-
email: m.user?.email,
|
|
129
|
-
name: m.user?.handle,
|
|
130
|
-
seat_type: SEAT_KEY_MAP[seatKey] || seatKey,
|
|
131
|
-
seat_key: seatKey,
|
|
132
|
-
last_active: lastSeen || null,
|
|
133
|
-
monthly_cost_cents: costMap[seatKey] || null,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const monthlyWasteCents = inactiveUsers.reduce((sum, u) => sum + (u.monthly_cost_cents || 0), 0);
|
|
138
|
-
const recommendations = [];
|
|
139
|
-
if (inactiveUsers.length > 0) {
|
|
140
|
-
recommendations.push(`${inactiveUsers.length} paid seat(s) inactive for ${daysInactive}+ days. Review for downgrade to viewer.`);
|
|
141
|
-
}
|
|
142
|
-
const neverActive = inactiveUsers.filter((u) => !u.last_active);
|
|
143
|
-
if (neverActive.length > 0) {
|
|
144
|
-
recommendations.push(`${neverActive.length} paid user(s) have never been active. Likely unused invites.`);
|
|
145
|
-
}
|
|
146
|
-
if (monthlyWasteCents > 0) {
|
|
147
|
-
recommendations.push(`Potential monthly savings: $${(monthlyWasteCents / 100).toFixed(2)} ($${((monthlyWasteCents * 12) / 100).toFixed(2)}/yr).`);
|
|
148
|
-
}
|
|
149
|
-
const result = {
|
|
150
|
-
summary: {
|
|
151
|
-
total_paid: totalPaid,
|
|
152
|
-
inactive_paid: inactiveUsers.length,
|
|
153
|
-
monthly_waste_cents: monthlyWasteCents,
|
|
154
|
-
annual_savings_cents: monthlyWasteCents * 12,
|
|
155
|
-
},
|
|
156
|
-
seat_breakdown: seats,
|
|
157
|
-
inactive_users: inactiveUsers,
|
|
158
|
-
recommendations,
|
|
159
|
-
};
|
|
160
|
-
output(result, options);
|
|
161
|
-
}
|
|
162
|
-
catch (e) {
|
|
163
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
164
|
-
process.exit(1);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// -- offboard --
|
|
168
|
-
const MAX_REVOCATIONS = 25;
|
|
169
|
-
export async function runOffboard(user, options) {
|
|
170
|
-
try {
|
|
171
|
-
const config = requireCookie();
|
|
172
|
-
const orgId = requireOrgId(config);
|
|
173
|
-
const api = internalClient(config);
|
|
174
|
-
const execute = options.execute === true;
|
|
175
|
-
// Step 1: Resolve user
|
|
176
|
-
const userRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
|
|
177
|
-
params: user.includes('@') ? { search_query: user } : {},
|
|
178
|
-
});
|
|
179
|
-
const rawUsers = userRes.data?.meta?.users || userRes.data?.meta || userRes.data || [];
|
|
180
|
-
const members = Array.isArray(rawUsers) ? rawUsers : [];
|
|
181
|
-
const member = members.find((m) => user.includes('@')
|
|
182
|
-
? m.user?.email === user
|
|
183
|
-
: String(m.user_id) === String(user));
|
|
184
|
-
if (!member) {
|
|
185
|
-
error(`User not found: ${user}`);
|
|
186
|
-
process.exit(1);
|
|
187
|
-
}
|
|
188
|
-
const userId = String(member.user_id);
|
|
189
|
-
// Block self-offboarding
|
|
190
|
-
if (userId === String(config.userId)) {
|
|
191
|
-
error('Cannot audit yourself for offboarding.');
|
|
192
|
-
process.exit(1);
|
|
193
|
-
}
|
|
194
|
-
const userInfo = {
|
|
195
|
-
org_user_id: String(member.id),
|
|
196
|
-
user_id: userId,
|
|
197
|
-
name: member.user?.handle || null,
|
|
198
|
-
email: member.user?.email || null,
|
|
199
|
-
seat_type: member.active_seat_type?.key || null,
|
|
200
|
-
permission: member.permission || null,
|
|
201
|
-
};
|
|
202
|
-
// Step 2: Fetch all org teams
|
|
203
|
-
const teamsRes = await api.get(`/api/orgs/${orgId}/teams`);
|
|
204
|
-
const allTeams = (teamsRes.data?.meta || teamsRes.data || []);
|
|
205
|
-
const cappedTeams = allTeams.slice(0, 30);
|
|
206
|
-
// Step 3: Check team membership (batched)
|
|
207
|
-
const teamMemberships = [];
|
|
208
|
-
const memberResults = await batchProcess(cappedTeams, async (team) => {
|
|
209
|
-
const res = await api.get(`/api/teams/${team.id}/members`);
|
|
210
|
-
const teamMembers = res.data?.meta || res.data || [];
|
|
211
|
-
const match = teamMembers.find((m) => String(m.id) === userId);
|
|
212
|
-
return { team, match };
|
|
213
|
-
});
|
|
214
|
-
for (const r of memberResults) {
|
|
215
|
-
if (r.status === 'fulfilled' && r.value.match) {
|
|
216
|
-
const { team, match } = r.value;
|
|
217
|
-
teamMemberships.push({
|
|
218
|
-
team_id: String(team.id),
|
|
219
|
-
team_name: team.name,
|
|
220
|
-
role: match.team_role?.level ? levelName(match.team_role.level) : 'member',
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// Step 4: For each team the user belongs to (cap 10), get projects
|
|
225
|
-
const userTeams = teamMemberships.slice(0, 10);
|
|
226
|
-
const allProjects = [];
|
|
227
|
-
const projectResults = await batchProcess(userTeams, async (tm) => {
|
|
228
|
-
const res = await api.get(`/api/teams/${tm.team_id}/folders`);
|
|
229
|
-
const raw = res.data?.meta?.folder_rows || res.data?.meta || res.data || [];
|
|
230
|
-
const folders = Array.isArray(raw) ? raw : [];
|
|
231
|
-
return { team_name: tm.team_name, folders };
|
|
232
|
-
});
|
|
233
|
-
for (const r of projectResults) {
|
|
234
|
-
if (r.status === 'fulfilled') {
|
|
235
|
-
for (const f of r.value.folders) {
|
|
236
|
-
if (allProjects.length >= 50)
|
|
237
|
-
break;
|
|
238
|
-
allProjects.push({
|
|
239
|
-
project_id: String(f.id),
|
|
240
|
-
project_name: f.name,
|
|
241
|
-
team_name: r.value.team_name,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
// Step 5: Check project roles (batched)
|
|
247
|
-
const projectPermissions = [];
|
|
248
|
-
const roleResults = await batchProcess(allProjects, async (proj) => {
|
|
249
|
-
const res = await api.get(`/api/roles/folder/${proj.project_id}`);
|
|
250
|
-
const roles = res.data?.meta || [];
|
|
251
|
-
const match = roles.find((r) => String(r.user_id) === userId);
|
|
252
|
-
return { proj, match };
|
|
253
|
-
});
|
|
254
|
-
for (const r of roleResults) {
|
|
255
|
-
if (r.status === 'fulfilled' && r.value.match) {
|
|
256
|
-
const { proj, match } = r.value;
|
|
257
|
-
projectPermissions.push({
|
|
258
|
-
project_id: proj.project_id,
|
|
259
|
-
project_name: proj.project_name,
|
|
260
|
-
team_name: proj.team_name,
|
|
261
|
-
role: levelName(match.level),
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
// Step 6: Sample files from projects where user has access
|
|
266
|
-
const projectsWithAccess = projectPermissions.slice(0, 10);
|
|
267
|
-
const fileOwnership = [];
|
|
268
|
-
let totalFilesChecked = 0;
|
|
269
|
-
for (const proj of projectsWithAccess) {
|
|
270
|
-
if (totalFilesChecked >= 50)
|
|
271
|
-
break;
|
|
272
|
-
try {
|
|
273
|
-
const filesRes = await api.get(`/api/folders/${proj.project_id}/paginated_files`, {
|
|
274
|
-
params: { folderId: proj.project_id, page_size: 10 },
|
|
275
|
-
});
|
|
276
|
-
const meta = filesRes.data?.meta || filesRes.data;
|
|
277
|
-
const files = meta?.files || meta || [];
|
|
278
|
-
const fileRoleResults = await batchProcess(files.slice(0, Math.min(10, 50 - totalFilesChecked)), async (file) => {
|
|
279
|
-
const res = await api.get(`/api/roles/file/${file.key}`);
|
|
280
|
-
const roles = res.data?.meta || [];
|
|
281
|
-
const match = roles.find((r) => String(r.user_id) === userId && r.level === 999);
|
|
282
|
-
return { file, match };
|
|
283
|
-
});
|
|
284
|
-
for (const r of fileRoleResults) {
|
|
285
|
-
totalFilesChecked++;
|
|
286
|
-
if (r.status === 'fulfilled' && r.value.match) {
|
|
287
|
-
fileOwnership.push({
|
|
288
|
-
file_key: r.value.file.key,
|
|
289
|
-
file_name: r.value.file.name,
|
|
290
|
-
project_name: proj.project_name,
|
|
291
|
-
team_name: proj.team_name,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
catch {
|
|
297
|
-
// Skip projects where file listing fails
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
const summary = {
|
|
301
|
-
teams: teamMemberships.length,
|
|
302
|
-
projects_with_access: projectPermissions.length,
|
|
303
|
-
files_owned: fileOwnership.length,
|
|
304
|
-
};
|
|
305
|
-
const transferPlan = [];
|
|
306
|
-
if (fileOwnership.length > 0) {
|
|
307
|
-
transferPlan.push(`${fileOwnership.length} file(s) need ownership transfer before removal.`);
|
|
308
|
-
}
|
|
309
|
-
if (teamMemberships.length > 0) {
|
|
310
|
-
transferPlan.push(`Remove from ${teamMemberships.length} team(s).`);
|
|
311
|
-
}
|
|
312
|
-
if (projectPermissions.length > 0) {
|
|
313
|
-
transferPlan.push(`Revoke access to ${projectPermissions.length} project(s).`);
|
|
314
|
-
}
|
|
315
|
-
if (userInfo.seat_type) {
|
|
316
|
-
transferPlan.push(`Downgrade or remove ${userInfo.seat_type} seat.`);
|
|
317
|
-
}
|
|
318
|
-
// Audit-only mode
|
|
319
|
-
if (!execute) {
|
|
320
|
-
const result = {
|
|
321
|
-
user: userInfo,
|
|
322
|
-
team_memberships: teamMemberships,
|
|
323
|
-
project_permissions: projectPermissions,
|
|
324
|
-
file_ownership: fileOwnership,
|
|
325
|
-
summary,
|
|
326
|
-
transfer_plan: transferPlan,
|
|
327
|
-
note: 'Run with --execute to perform offboarding. Use --transfer-to if the user owns files.',
|
|
328
|
-
};
|
|
329
|
-
output(result, options);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
// --- Execute mode ---
|
|
333
|
-
// Validate: if user owns files, transfer_to is required
|
|
334
|
-
if (fileOwnership.length > 0 && !options.transferTo) {
|
|
335
|
-
error(`User owns ${fileOwnership.length} file(s). Use --transfer-to <email|user_id> to transfer ownership before revoking access.`);
|
|
336
|
-
process.exit(1);
|
|
337
|
-
}
|
|
338
|
-
// Resolve transfer_to user
|
|
339
|
-
let transferToUserId;
|
|
340
|
-
if (options.transferTo) {
|
|
341
|
-
const tRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
|
|
342
|
-
params: options.transferTo.includes('@') ? { search_query: options.transferTo } : {},
|
|
343
|
-
});
|
|
344
|
-
const tUsers = tRes.data?.meta?.users || tRes.data?.meta || tRes.data || [];
|
|
345
|
-
const tList = Array.isArray(tUsers) ? tUsers : [];
|
|
346
|
-
const tMatch = tList.find((m) => options.transferTo.includes('@')
|
|
347
|
-
? m.user?.email === options.transferTo
|
|
348
|
-
: String(m.user_id) === String(options.transferTo));
|
|
349
|
-
if (!tMatch) {
|
|
350
|
-
error(`Transfer target not found: ${options.transferTo}`);
|
|
351
|
-
process.exit(1);
|
|
352
|
-
}
|
|
353
|
-
transferToUserId = String(tMatch.user_id);
|
|
354
|
-
}
|
|
355
|
-
// Cap total mutations
|
|
356
|
-
const totalMutations = fileOwnership.length + teamMemberships.length + projectPermissions.length + 1;
|
|
357
|
-
if (totalMutations > MAX_REVOCATIONS) {
|
|
358
|
-
error(`Offboarding would require ${totalMutations} mutations (cap: ${MAX_REVOCATIONS}). ` +
|
|
359
|
-
`Reduce scope or execute manually with revoke_access and set_permissions.`);
|
|
360
|
-
process.exit(1);
|
|
361
|
-
}
|
|
362
|
-
const actions = [];
|
|
363
|
-
// Step A: Transfer file ownership
|
|
364
|
-
if (fileOwnership.length > 0 && transferToUserId) {
|
|
365
|
-
for (const file of fileOwnership) {
|
|
366
|
-
try {
|
|
367
|
-
const rolesRes = await api.get(`/api/roles/file/${file.file_key}`);
|
|
368
|
-
const roles = rolesRes.data?.meta || [];
|
|
369
|
-
const ownerRole = roles.find((r) => String(r.user_id) === userId && r.level === 999);
|
|
370
|
-
const transfereeRole = roles.find((r) => String(r.user_id) === transferToUserId);
|
|
371
|
-
if (transfereeRole) {
|
|
372
|
-
await api.put(`/api/roles/${transfereeRole.id}`, { level: 999 });
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
await api.post('/api/invites', {
|
|
376
|
-
resource_type: 'file',
|
|
377
|
-
resource_id_or_key: file.file_key,
|
|
378
|
-
emails: [options.transferTo],
|
|
379
|
-
level: 300,
|
|
380
|
-
});
|
|
381
|
-
const rolesRes2 = await api.get(`/api/roles/file/${file.file_key}`);
|
|
382
|
-
const roles2 = rolesRes2.data?.meta || [];
|
|
383
|
-
const newRole = roles2.find((r) => String(r.user_id) === transferToUserId);
|
|
384
|
-
if (newRole) {
|
|
385
|
-
await api.put(`/api/roles/${newRole.id}`, { level: 999 });
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
if (ownerRole) {
|
|
389
|
-
await api.put(`/api/roles/${ownerRole.id}`, { level: 300 });
|
|
390
|
-
}
|
|
391
|
-
actions.push({ action: 'transfer_ownership', status: 'done', detail: `${file.file_name} -> ${options.transferTo}` });
|
|
392
|
-
}
|
|
393
|
-
catch (e) {
|
|
394
|
-
actions.push({ action: 'transfer_ownership', status: 'failed', detail: `${file.file_name}: ${e.response?.status || e.message}` });
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
// Step B: Revoke file access (now that they're no longer owner)
|
|
399
|
-
for (const file of fileOwnership) {
|
|
400
|
-
try {
|
|
401
|
-
const rolesRes = await api.get(`/api/roles/file/${file.file_key}`);
|
|
402
|
-
const roles = rolesRes.data?.meta || [];
|
|
403
|
-
const role = roles.find((r) => String(r.user_id) === userId);
|
|
404
|
-
if (role) {
|
|
405
|
-
await api.delete(`/api/roles/${role.id}`);
|
|
406
|
-
actions.push({ action: 'revoke_file', status: 'done', detail: file.file_name });
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
catch (e) {
|
|
410
|
-
actions.push({ action: 'revoke_file', status: 'failed', detail: `${file.file_name}: ${e.response?.status || e.message}` });
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
// Step C: Revoke project access
|
|
414
|
-
const projResults = await batchProcess(projectPermissions, async (proj) => {
|
|
415
|
-
const rolesRes = await api.get(`/api/roles/folder/${proj.project_id}`);
|
|
416
|
-
const roles = rolesRes.data?.meta || [];
|
|
417
|
-
const role = roles.find((r) => String(r.user_id) === userId);
|
|
418
|
-
if (role)
|
|
419
|
-
await api.delete(`/api/roles/${role.id}`);
|
|
420
|
-
return proj;
|
|
421
|
-
});
|
|
422
|
-
for (let i = 0; i < projectPermissions.length; i++) {
|
|
423
|
-
const r = projResults[i];
|
|
424
|
-
actions.push({
|
|
425
|
-
action: 'revoke_project',
|
|
426
|
-
status: r.status === 'fulfilled' ? 'done' : 'failed',
|
|
427
|
-
detail: projectPermissions[i].project_name,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
// Step D: Revoke team memberships
|
|
431
|
-
const teamResults = await batchProcess(teamMemberships, async (tm) => {
|
|
432
|
-
const rolesRes = await api.get(`/api/roles/team/${tm.team_id}`);
|
|
433
|
-
const roles = rolesRes.data?.meta || [];
|
|
434
|
-
const role = roles.find((r) => String(r.user_id) === userId);
|
|
435
|
-
if (role)
|
|
436
|
-
await api.delete(`/api/roles/${role.id}`);
|
|
437
|
-
return tm;
|
|
438
|
-
});
|
|
439
|
-
for (let i = 0; i < teamMemberships.length; i++) {
|
|
440
|
-
const r = teamResults[i];
|
|
441
|
-
actions.push({
|
|
442
|
-
action: 'revoke_team',
|
|
443
|
-
status: r.status === 'fulfilled' ? 'done' : 'failed',
|
|
444
|
-
detail: teamMemberships[i].team_name,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
// Step E: Downgrade seat to viewer
|
|
448
|
-
if (userInfo.seat_type) {
|
|
449
|
-
try {
|
|
450
|
-
const viewStatuses = { collaborator: 'starter', developer: 'starter', expert: 'starter' };
|
|
451
|
-
await api.put(`/api/orgs/${orgId}/org_users`, {
|
|
452
|
-
org_user_ids: [userInfo.org_user_id],
|
|
453
|
-
paid_statuses: viewStatuses,
|
|
454
|
-
entry_point: 'members_tab',
|
|
455
|
-
seat_increase_authorized: 'true',
|
|
456
|
-
seat_swap_intended: 'false',
|
|
457
|
-
latest_ou_update: member.updated_at,
|
|
458
|
-
showing_billing_groups: 'true',
|
|
459
|
-
}, {
|
|
460
|
-
'axios-retry': { retries: 0 },
|
|
461
|
-
});
|
|
462
|
-
actions.push({ action: 'downgrade_seat', status: 'done', detail: `${userInfo.seat_type} -> viewer` });
|
|
463
|
-
}
|
|
464
|
-
catch (e) {
|
|
465
|
-
actions.push({ action: 'downgrade_seat', status: 'failed', detail: e.response?.status || e.message });
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
const succeeded = actions.filter(a => a.status === 'done').length;
|
|
469
|
-
const failed = actions.filter(a => a.status === 'failed').length;
|
|
470
|
-
const result = {
|
|
471
|
-
user: userInfo,
|
|
472
|
-
executed: true,
|
|
473
|
-
actions,
|
|
474
|
-
summary: { succeeded, failed, total: actions.length },
|
|
475
|
-
note: failed > 0
|
|
476
|
-
? `${failed} action(s) failed. Review and retry manually.`
|
|
477
|
-
: 'Offboarding complete.',
|
|
478
|
-
};
|
|
479
|
-
output(result, options);
|
|
480
|
-
}
|
|
481
|
-
catch (e) {
|
|
482
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
483
|
-
process.exit(1);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
// -- onboard --
|
|
487
|
-
const PAID_STATUSES = {
|
|
488
|
-
full: { expert: 'full' },
|
|
489
|
-
dev: { developer: 'full' },
|
|
490
|
-
collab: { collaborator: 'full' },
|
|
491
|
-
view: { collaborator: 'starter', developer: 'starter', expert: 'starter' },
|
|
492
|
-
};
|
|
493
|
-
const LEVEL_MAP = { editor: 300, viewer: 100 };
|
|
494
|
-
export async function runOnboard(email, options) {
|
|
495
|
-
try {
|
|
496
|
-
const config = requireCookie();
|
|
497
|
-
const orgId = requireOrgId(config);
|
|
498
|
-
const api = internalClient(config);
|
|
499
|
-
const teamIds = options.teams || [];
|
|
500
|
-
if (teamIds.length === 0) {
|
|
501
|
-
error('At least one team ID is required. Use --teams <id1,id2>.');
|
|
502
|
-
process.exit(1);
|
|
503
|
-
}
|
|
504
|
-
if (teamIds.length > 10) {
|
|
505
|
-
error(`Too many teams: ${teamIds.length}. Maximum is 10.`);
|
|
506
|
-
process.exit(1);
|
|
507
|
-
}
|
|
508
|
-
for (const id of teamIds)
|
|
509
|
-
validateId(id, 'team ID');
|
|
510
|
-
const shareFiles = options.shareFiles || [];
|
|
511
|
-
if (shareFiles.length > 20) {
|
|
512
|
-
error(`Too many files: ${shareFiles.length}. Maximum is 20.`);
|
|
513
|
-
process.exit(1);
|
|
514
|
-
}
|
|
515
|
-
for (const key of shareFiles)
|
|
516
|
-
validateId(key, 'file key');
|
|
517
|
-
const role = options.role || 'editor';
|
|
518
|
-
const level = LEVEL_MAP[role] || 300;
|
|
519
|
-
// Validate team_ids exist
|
|
520
|
-
const teamsRes = await api.get(`/api/orgs/${orgId}/teams`);
|
|
521
|
-
const orgTeams = teamsRes.data?.meta || teamsRes.data || [];
|
|
522
|
-
const orgTeamMap = new Map();
|
|
523
|
-
for (const t of orgTeams) {
|
|
524
|
-
orgTeamMap.set(String(t.id), t.name);
|
|
525
|
-
}
|
|
526
|
-
const invalidTeams = teamIds.filter(id => !orgTeamMap.has(id));
|
|
527
|
-
if (invalidTeams.length > 0) {
|
|
528
|
-
error(`Team(s) not found in org: ${invalidTeams.join(', ')}`);
|
|
529
|
-
process.exit(1);
|
|
530
|
-
}
|
|
531
|
-
// Invite to teams (batched)
|
|
532
|
-
const teamsJoined = [];
|
|
533
|
-
const teamInviteResults = await batchProcess(teamIds, async (teamId) => {
|
|
534
|
-
const res = await api.post('/api/invites', {
|
|
535
|
-
resource_type: 'team',
|
|
536
|
-
resource_id_or_key: teamId,
|
|
537
|
-
emails: [email],
|
|
538
|
-
level,
|
|
539
|
-
});
|
|
540
|
-
return { teamId, res };
|
|
541
|
-
});
|
|
542
|
-
for (let i = 0; i < teamIds.length; i++) {
|
|
543
|
-
const r = teamInviteResults[i];
|
|
544
|
-
teamsJoined.push({
|
|
545
|
-
team_id: teamIds[i],
|
|
546
|
-
team_name: orgTeamMap.get(teamIds[i]) || 'unknown',
|
|
547
|
-
role,
|
|
548
|
-
status: r.status === 'fulfilled' ? 'invited' : 'failed',
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
// Share files (batched, viewer access)
|
|
552
|
-
const filesShared = [];
|
|
553
|
-
if (shareFiles.length > 0) {
|
|
554
|
-
const fileShareResults = await batchProcess(shareFiles, async (fileKey) => {
|
|
555
|
-
await api.post('/api/invites', {
|
|
556
|
-
resource_type: 'file',
|
|
557
|
-
resource_id_or_key: fileKey,
|
|
558
|
-
emails: [email],
|
|
559
|
-
level: 100, // viewer
|
|
560
|
-
});
|
|
561
|
-
return fileKey;
|
|
562
|
-
});
|
|
563
|
-
for (let i = 0; i < shareFiles.length; i++) {
|
|
564
|
-
const r = fileShareResults[i];
|
|
565
|
-
filesShared.push({
|
|
566
|
-
file_key: shareFiles[i],
|
|
567
|
-
role: 'viewer',
|
|
568
|
-
status: r.status === 'fulfilled' ? 'shared' : 'failed',
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
// Seat change
|
|
573
|
-
let seatChange = { status: 'skipped', note: 'No seat type specified.' };
|
|
574
|
-
const seatType = options.seat;
|
|
575
|
-
if (seatType && options.confirm) {
|
|
576
|
-
if (!PAID_STATUSES[seatType]) {
|
|
577
|
-
error(`Invalid seat type: ${seatType}. Must be one of: full, dev, collab, view.`);
|
|
578
|
-
process.exit(1);
|
|
579
|
-
}
|
|
580
|
-
try {
|
|
581
|
-
const userRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
|
|
582
|
-
params: { search_query: email },
|
|
583
|
-
});
|
|
584
|
-
const rawU = userRes.data?.meta?.users || userRes.data?.meta || userRes.data || [];
|
|
585
|
-
const users = Array.isArray(rawU) ? rawU : [];
|
|
586
|
-
const found = users.find((m) => m.user?.email === email);
|
|
587
|
-
if (found) {
|
|
588
|
-
await api.put(`/api/orgs/${orgId}/org_users`, {
|
|
589
|
-
org_user_ids: [String(found.id)],
|
|
590
|
-
paid_statuses: PAID_STATUSES[seatType],
|
|
591
|
-
entry_point: 'members_tab',
|
|
592
|
-
seat_increase_authorized: 'true',
|
|
593
|
-
seat_swap_intended: 'false',
|
|
594
|
-
latest_ou_update: found.updated_at,
|
|
595
|
-
showing_billing_groups: 'true',
|
|
596
|
-
}, {
|
|
597
|
-
'axios-retry': { retries: 0 },
|
|
598
|
-
});
|
|
599
|
-
seatChange = { status: 'changed', note: `Set to ${seatType}.` };
|
|
600
|
-
}
|
|
601
|
-
else {
|
|
602
|
-
seatChange = { status: 'skipped', note: 'User not yet in org (invite pending). Seat change will need to be applied after they accept.' };
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
catch (e) {
|
|
606
|
-
seatChange = { status: 'failed', note: `Seat change failed: ${e.response?.status || e.message}` };
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
else if (seatType && !options.confirm) {
|
|
610
|
-
seatChange = { status: 'skipped', note: `Use --confirm to apply ${seatType} seat change.` };
|
|
611
|
-
}
|
|
612
|
-
const result = {
|
|
613
|
-
user_email: email,
|
|
614
|
-
setup_results: {
|
|
615
|
-
teams_joined: teamsJoined,
|
|
616
|
-
files_shared: filesShared,
|
|
617
|
-
seat_change: seatChange,
|
|
618
|
-
},
|
|
619
|
-
next_steps: [
|
|
620
|
-
'User will receive invite emails for each team.',
|
|
621
|
-
'Team-level projects are automatically accessible once they accept.',
|
|
622
|
-
],
|
|
623
|
-
};
|
|
624
|
-
output(result, options);
|
|
625
|
-
}
|
|
626
|
-
catch (e) {
|
|
627
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
628
|
-
process.exit(1);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
// -- quarterly-report --
|
|
632
|
-
export async function runQuarterlyReport(options) {
|
|
633
|
-
try {
|
|
634
|
-
const config = requireCookie();
|
|
635
|
-
const orgId = requireOrgId(config);
|
|
636
|
-
const api = internalClient(config);
|
|
637
|
-
const days = parsePositiveInt(options.days || '90', '--days', 90);
|
|
638
|
-
const now = new Date();
|
|
639
|
-
const periodStart = new Date(now.getTime() - days * 86400000);
|
|
640
|
-
// Phase 1: Parallel fetches
|
|
641
|
-
const [teamsResult, seatsResult, billingResult, upcomingResult, ratesResult] = await Promise.allSettled([
|
|
642
|
-
api.get(`/api/orgs/${orgId}/teams`, {
|
|
643
|
-
params: { include_member_count: true, include_project_count: true },
|
|
644
|
-
}),
|
|
645
|
-
api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
|
|
646
|
-
api.get(`/api/orgs/${orgId}/billing_data`),
|
|
647
|
-
api.get(`/api/plans/organization/${orgId}/invoices/upcoming`),
|
|
648
|
-
api.get(`/api/pricing/contract_rates`, {
|
|
649
|
-
params: { plan_parent_id: orgId, plan_type: 'organization' },
|
|
650
|
-
}),
|
|
651
|
-
]);
|
|
652
|
-
// Paginate all members (cursor-based)
|
|
653
|
-
const allMembers = [];
|
|
654
|
-
let cursor;
|
|
655
|
-
const maxPages = 20;
|
|
656
|
-
for (let page = 0; page < maxPages; page++) {
|
|
657
|
-
try {
|
|
658
|
-
const params = { page_size: 25 };
|
|
659
|
-
if (cursor)
|
|
660
|
-
params.cursor = cursor;
|
|
661
|
-
const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
|
|
662
|
-
const meta = res.data?.meta || {};
|
|
663
|
-
const batch = meta.users || [];
|
|
664
|
-
if (!Array.isArray(batch) || batch.length === 0)
|
|
665
|
-
break;
|
|
666
|
-
allMembers.push(...batch);
|
|
667
|
-
cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
|
|
668
|
-
if (!cursor || batch.length < 25)
|
|
669
|
-
break;
|
|
670
|
-
}
|
|
671
|
-
catch {
|
|
672
|
-
break;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
// Process teams
|
|
676
|
-
const teamsRaw = teamsResult.status === 'fulfilled'
|
|
677
|
-
? (teamsResult.value.data?.meta || teamsResult.value.data || [])
|
|
678
|
-
: [];
|
|
679
|
-
const teamsArray = Array.isArray(teamsRaw) ? teamsRaw : [];
|
|
680
|
-
const teams = teamsArray.map((t) => ({
|
|
681
|
-
team_name: t.name,
|
|
682
|
-
members: t.member_count || 0,
|
|
683
|
-
projects: t.project_count || 0,
|
|
684
|
-
}));
|
|
685
|
-
// Seat utilization from member list
|
|
686
|
-
const paidKeys = new Set(['expert', 'developer', 'collaborator']);
|
|
687
|
-
const cutoffMs = now.getTime() - days * 86400000;
|
|
688
|
-
let totalPaid = 0;
|
|
689
|
-
let inactivePaid = 0;
|
|
690
|
-
const seatCounts = {};
|
|
691
|
-
for (const m of allMembers) {
|
|
692
|
-
const seatKey = m.active_seat_type?.key;
|
|
693
|
-
if (seatKey && paidKeys.has(seatKey)) {
|
|
694
|
-
totalPaid++;
|
|
695
|
-
seatCounts[seatKey] = (seatCounts[seatKey] || 0) + 1;
|
|
696
|
-
const lastSeen = m.last_seen ? new Date(m.last_seen).getTime() : 0;
|
|
697
|
-
if (!m.last_seen || lastSeen < cutoffMs) {
|
|
698
|
-
inactivePaid++;
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
const activePaid = totalPaid - inactivePaid;
|
|
703
|
-
const utilizationRate = totalPaid > 0
|
|
704
|
-
? Number(((activePaid / totalPaid) * 100).toFixed(1))
|
|
705
|
-
: 0;
|
|
706
|
-
// Billing
|
|
707
|
-
let billing = null;
|
|
708
|
-
const errors = [];
|
|
709
|
-
if (ratesResult.status === 'fulfilled') {
|
|
710
|
-
const prices = ratesResult.value.data?.meta?.product_prices || [];
|
|
711
|
-
const seatProducts = new Set(['expert', 'developer', 'collaborator']);
|
|
712
|
-
let monthlySpendCents = 0;
|
|
713
|
-
for (const p of prices) {
|
|
714
|
-
if (seatProducts.has(p.billable_product_key)) {
|
|
715
|
-
const count = seatCounts[p.billable_product_key] || 0;
|
|
716
|
-
monthlySpendCents += count * (p.amount || 0);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
const monthlySpendDollars = Number((monthlySpendCents / 100).toFixed(2));
|
|
720
|
-
const costPerActiveUser = activePaid > 0
|
|
721
|
-
? Number((monthlySpendDollars / activePaid).toFixed(2))
|
|
722
|
-
: 0;
|
|
723
|
-
billing = {
|
|
724
|
-
monthly_spend_dollars: monthlySpendDollars,
|
|
725
|
-
cost_per_active_user: costPerActiveUser,
|
|
726
|
-
};
|
|
727
|
-
if (upcomingResult.status === 'fulfilled') {
|
|
728
|
-
const upcoming = upcomingResult.value.data;
|
|
729
|
-
billing.upcoming_invoice_date = upcoming?.date || upcoming?.period_end || null;
|
|
730
|
-
billing.upcoming_amount = upcoming?.amount_due ?? upcoming?.total ?? null;
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
else {
|
|
734
|
-
errors.push('billing: rates unavailable (may require admin)');
|
|
735
|
-
}
|
|
736
|
-
if (billingResult.status === 'fulfilled' && billing) {
|
|
737
|
-
const rawBilling = billingResult.value.data?.meta || billingResult.value.data;
|
|
738
|
-
if (rawBilling) {
|
|
739
|
-
const { shipping_address, ...safeBilling } = rawBilling;
|
|
740
|
-
billing.plan_name = safeBilling.plan_name || safeBilling.name || null;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
else if (billingResult.status === 'rejected') {
|
|
744
|
-
errors.push('billing_data: 403 (admin required)');
|
|
745
|
-
}
|
|
746
|
-
// Phase 2: Library adoption
|
|
747
|
-
const libraryAdoption = [];
|
|
748
|
-
try {
|
|
749
|
-
const libRes = await api.get('/api/design_systems/libraries', {
|
|
750
|
-
params: { org_id: orgId },
|
|
751
|
-
});
|
|
752
|
-
const libraries = libRes.data?.libraries || libRes.data?.meta?.libraries || [];
|
|
753
|
-
const cappedLibraries = libraries.slice(0, 5);
|
|
754
|
-
const endTs = Math.floor(now.getTime() / 1000);
|
|
755
|
-
const startTs = Math.floor(periodStart.getTime() / 1000);
|
|
756
|
-
const libResults = await batchProcess(cappedLibraries, async (lib) => {
|
|
757
|
-
const res = await api.get(`/api/dsa/library/${lib.file_key || lib.key}/team_usage`, {
|
|
758
|
-
params: { start_ts: startTs, end_ts: endTs },
|
|
759
|
-
});
|
|
760
|
-
return { lib, data: res.data };
|
|
761
|
-
});
|
|
762
|
-
for (const r of libResults) {
|
|
763
|
-
if (r.status === 'fulfilled') {
|
|
764
|
-
const { lib, data } = r.value;
|
|
765
|
-
const teamUsages = data?.rows || data?.teams || [];
|
|
766
|
-
let totalInsertions = 0;
|
|
767
|
-
for (const tu of teamUsages) {
|
|
768
|
-
totalInsertions += tu.insertions || tu.num_insertions || 0;
|
|
769
|
-
}
|
|
770
|
-
libraryAdoption.push({
|
|
771
|
-
library_name: lib.name || lib.file_name || 'unknown',
|
|
772
|
-
file_key: lib.file_key || lib.key,
|
|
773
|
-
insertions: totalInsertions,
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
catch {
|
|
779
|
-
errors.push('libraries: failed to fetch (may be 404)');
|
|
780
|
-
}
|
|
781
|
-
// Highlights
|
|
782
|
-
const highlights = [];
|
|
783
|
-
highlights.push(`${utilizationRate}% seat utilization (${activePaid} active of ${totalPaid} paid).`);
|
|
784
|
-
if (inactivePaid > 0) {
|
|
785
|
-
highlights.push(`${inactivePaid} paid seat(s) inactive for ${days}+ days.`);
|
|
786
|
-
}
|
|
787
|
-
if (teams.length > 0) {
|
|
788
|
-
highlights.push(`${teams.length} team(s), ${allMembers.length} total member(s).`);
|
|
789
|
-
}
|
|
790
|
-
if (libraryAdoption.length > 0) {
|
|
791
|
-
const totalInsertions = libraryAdoption.reduce((sum, l) => sum + l.insertions, 0);
|
|
792
|
-
highlights.push(`${totalInsertions} library component insertions across ${libraryAdoption.length} library/libraries.`);
|
|
793
|
-
}
|
|
794
|
-
const result = {
|
|
795
|
-
period: {
|
|
796
|
-
start: periodStart.toISOString().split('T')[0],
|
|
797
|
-
end: now.toISOString().split('T')[0],
|
|
798
|
-
days,
|
|
799
|
-
},
|
|
800
|
-
org_overview: {
|
|
801
|
-
total_teams: teams.length,
|
|
802
|
-
total_members: allMembers.length,
|
|
803
|
-
total_paid_seats: totalPaid,
|
|
804
|
-
},
|
|
805
|
-
seat_utilization: {
|
|
806
|
-
active_paid: activePaid,
|
|
807
|
-
inactive_paid: inactivePaid,
|
|
808
|
-
utilization_rate: utilizationRate,
|
|
809
|
-
},
|
|
810
|
-
teams,
|
|
811
|
-
billing,
|
|
812
|
-
library_adoption: libraryAdoption,
|
|
813
|
-
highlights,
|
|
814
|
-
};
|
|
815
|
-
if (errors.length > 0)
|
|
816
|
-
result.errors = errors;
|
|
817
|
-
output(result, options);
|
|
818
|
-
}
|
|
819
|
-
catch (e) {
|
|
820
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
821
|
-
process.exit(1);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
// -- members --
|
|
825
|
-
export async function runMembers(options) {
|
|
826
|
-
try {
|
|
827
|
-
const config = requireCookie();
|
|
828
|
-
const orgId = requireOrgId(config);
|
|
829
|
-
const api = internalClient(config);
|
|
830
|
-
// Paginate org members (cursor-based)
|
|
831
|
-
const allMembers = [];
|
|
832
|
-
const MAX_PAGES = 20;
|
|
833
|
-
let cursor;
|
|
834
|
-
for (let page = 0; page < MAX_PAGES; page++) {
|
|
835
|
-
const params = { page_size: 25 };
|
|
836
|
-
if (cursor)
|
|
837
|
-
params.cursor = cursor;
|
|
838
|
-
if (options.search)
|
|
839
|
-
params.search_query = options.search;
|
|
840
|
-
const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
|
|
841
|
-
const meta = res.data?.meta || {};
|
|
842
|
-
const members = meta.users || [];
|
|
843
|
-
if (!Array.isArray(members) || members.length === 0)
|
|
844
|
-
break;
|
|
845
|
-
allMembers.push(...members);
|
|
846
|
-
cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
|
|
847
|
-
if (!cursor || members.length < 25)
|
|
848
|
-
break;
|
|
849
|
-
}
|
|
850
|
-
const result = allMembers.map(m => ({
|
|
851
|
-
org_user_id: String(m.id),
|
|
852
|
-
user_id: m.user_id,
|
|
853
|
-
email: m.user?.email,
|
|
854
|
-
name: m.user?.handle,
|
|
855
|
-
permission: m.permission,
|
|
856
|
-
seat_type: m.active_seat_type?.key || null,
|
|
857
|
-
last_active: m.last_seen || null,
|
|
858
|
-
}));
|
|
859
|
-
output(result, options);
|
|
860
|
-
}
|
|
861
|
-
catch (e) {
|
|
862
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
863
|
-
process.exit(1);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
// -- teams --
|
|
867
|
-
export async function runTeams(options) {
|
|
868
|
-
try {
|
|
869
|
-
const config = requireCookie();
|
|
870
|
-
const orgId = requireOrgId(config);
|
|
871
|
-
const api = internalClient(config);
|
|
872
|
-
const res = await api.get(`/api/orgs/${orgId}/teams`, {
|
|
873
|
-
params: { include_member_count: true, include_project_count: true },
|
|
874
|
-
});
|
|
875
|
-
const teamsRaw = res.data?.meta || res.data || [];
|
|
876
|
-
const teams = (Array.isArray(teamsRaw) ? teamsRaw : []).map((t) => ({
|
|
877
|
-
team_id: String(t.id),
|
|
878
|
-
name: t.name,
|
|
879
|
-
member_count: t.member_count || 0,
|
|
880
|
-
project_count: t.project_count || 0,
|
|
881
|
-
}));
|
|
882
|
-
output(teams, options);
|
|
883
|
-
}
|
|
884
|
-
catch (e) {
|
|
885
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
886
|
-
process.exit(1);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
// -- permissions --
|
|
890
|
-
export async function runPermissions(type, id, options) {
|
|
891
|
-
try {
|
|
892
|
-
const validTypes = ['file', 'folder', 'team'];
|
|
893
|
-
if (!validTypes.includes(type)) {
|
|
894
|
-
error(`Invalid type: ${type}. Must be one of: ${validTypes.join(', ')}`);
|
|
895
|
-
process.exit(1);
|
|
896
|
-
}
|
|
897
|
-
validateId(id, 'resource ID');
|
|
898
|
-
const config = requireCookie();
|
|
899
|
-
const api = internalClient(config);
|
|
900
|
-
const res = await api.get(`/api/roles/${type}/${id}`);
|
|
901
|
-
const roles = Array.isArray(res.data?.meta) ? res.data.meta : [];
|
|
902
|
-
const result = roles.map((r) => ({
|
|
903
|
-
user_id: r.user_id ? String(r.user_id) : null,
|
|
904
|
-
email: r.user?.email || r.pending_email || null,
|
|
905
|
-
name: r.user?.handle || null,
|
|
906
|
-
role: levelName(r.level),
|
|
907
|
-
level: r.level,
|
|
908
|
-
}));
|
|
909
|
-
output(result, options);
|
|
910
|
-
}
|
|
911
|
-
catch (e) {
|
|
912
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
913
|
-
process.exit(1);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
// -- permission-audit --
|
|
917
|
-
export async function runPermissionAudit(options) {
|
|
918
|
-
try {
|
|
919
|
-
if (!options.id) {
|
|
920
|
-
error('--id is required. Provide a team or project ID.');
|
|
921
|
-
process.exit(1);
|
|
922
|
-
}
|
|
923
|
-
validateId(options.id, 'scope ID');
|
|
924
|
-
const scopeType = options.scope || 'team';
|
|
925
|
-
if (scopeType !== 'team' && scopeType !== 'project') {
|
|
926
|
-
error(`Invalid scope: ${scopeType}. Must be "team" or "project".`);
|
|
927
|
-
process.exit(1);
|
|
928
|
-
}
|
|
929
|
-
const config = requireCookie();
|
|
930
|
-
const api = internalClient(config);
|
|
931
|
-
// Resolve org for domain lookup
|
|
932
|
-
let orgId;
|
|
933
|
-
let domainCheckSkipped = false;
|
|
934
|
-
try {
|
|
935
|
-
orgId = requireOrgId(config);
|
|
936
|
-
}
|
|
937
|
-
catch {
|
|
938
|
-
domainCheckSkipped = true;
|
|
939
|
-
}
|
|
940
|
-
// Fetch org verified domains for external detection
|
|
941
|
-
let verifiedDomains = new Set();
|
|
942
|
-
if (orgId) {
|
|
943
|
-
try {
|
|
944
|
-
const domRes = await api.get(`/api/orgs/${orgId}/domains`);
|
|
945
|
-
const domains = domRes.data?.meta || [];
|
|
946
|
-
if (Array.isArray(domains)) {
|
|
947
|
-
for (const d of domains) {
|
|
948
|
-
if (d.domain)
|
|
949
|
-
verifiedDomains.add(d.domain.toLowerCase());
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
catch { /* domain lookup optional */ }
|
|
954
|
-
}
|
|
955
|
-
// Collect file keys to scan
|
|
956
|
-
let fileKeys = [];
|
|
957
|
-
const scopeId = options.id;
|
|
958
|
-
if (scopeType === 'team') {
|
|
959
|
-
const projectsRes = await api.get(`/api/teams/${scopeId}/folders`);
|
|
960
|
-
const rows = projectsRes.data?.meta?.folder_rows || projectsRes.data || [];
|
|
961
|
-
const projects = (Array.isArray(rows) ? rows : []).slice(0, 10);
|
|
962
|
-
for (const proj of projects) {
|
|
963
|
-
if (fileKeys.length >= 25)
|
|
964
|
-
break;
|
|
965
|
-
try {
|
|
966
|
-
const filesRes = await api.get(`/api/folders/${proj.id}/paginated_files`, {
|
|
967
|
-
params: { folderId: String(proj.id), page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
|
|
968
|
-
});
|
|
969
|
-
const meta = filesRes.data?.meta || filesRes.data;
|
|
970
|
-
const files = meta?.files || meta || [];
|
|
971
|
-
for (const f of (Array.isArray(files) ? files : [])) {
|
|
972
|
-
if (fileKeys.length >= 25)
|
|
973
|
-
break;
|
|
974
|
-
fileKeys.push({ key: f.key, name: f.name });
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
catch { /* skip inaccessible projects */ }
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
const filesRes = await api.get(`/api/folders/${scopeId}/paginated_files`, {
|
|
982
|
-
params: { folderId: scopeId, page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
|
|
983
|
-
});
|
|
984
|
-
const meta = filesRes.data?.meta || filesRes.data;
|
|
985
|
-
const files = meta?.files || meta || [];
|
|
986
|
-
for (const f of (Array.isArray(files) ? files : [])) {
|
|
987
|
-
if (fileKeys.length >= 25)
|
|
988
|
-
break;
|
|
989
|
-
fileKeys.push({ key: f.key, name: f.name });
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
// Fetch permissions and file metadata in parallel, batched 5 at a time
|
|
993
|
-
const allUsers = new Map();
|
|
994
|
-
const flags = [];
|
|
995
|
-
let filesScanned = 0;
|
|
996
|
-
for (let i = 0; i < fileKeys.length; i += 5) {
|
|
997
|
-
const batch = fileKeys.slice(i, i + 5);
|
|
998
|
-
const results = await Promise.allSettled(batch.map(async (file) => {
|
|
999
|
-
const [rolesRes, fileMetaRes] = await Promise.allSettled([
|
|
1000
|
-
api.get(`/api/roles/file/${file.key}`),
|
|
1001
|
-
api.get(`/api/files/${file.key}`),
|
|
1002
|
-
]);
|
|
1003
|
-
const roles = rolesRes.status === 'fulfilled'
|
|
1004
|
-
? (Array.isArray(rolesRes.value.data?.meta) ? rolesRes.value.data.meta : [])
|
|
1005
|
-
: [];
|
|
1006
|
-
const fileMeta = fileMetaRes.status === 'fulfilled'
|
|
1007
|
-
? (fileMetaRes.value.data?.meta || fileMetaRes.value.data || {})
|
|
1008
|
-
: {};
|
|
1009
|
-
return { file, roles, fileMeta };
|
|
1010
|
-
}));
|
|
1011
|
-
for (const r of results) {
|
|
1012
|
-
if (r.status === 'rejected')
|
|
1013
|
-
continue;
|
|
1014
|
-
filesScanned++;
|
|
1015
|
-
const { file, roles, fileMeta } = r.value;
|
|
1016
|
-
// Check link access
|
|
1017
|
-
const linkAccess = fileMeta.link_access;
|
|
1018
|
-
if (linkAccess === 'edit' || linkAccess === 'org_edit') {
|
|
1019
|
-
flags.push({
|
|
1020
|
-
severity: 'high',
|
|
1021
|
-
type: 'open_link_access',
|
|
1022
|
-
details: `${file.name} (${file.key}) has link_access="${linkAccess}"`,
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
// Process roles
|
|
1026
|
-
for (const role of roles) {
|
|
1027
|
-
const email = role.user?.email || role.pending_email;
|
|
1028
|
-
const userId = role.user_id ? String(role.user_id) : email;
|
|
1029
|
-
const level = role.level;
|
|
1030
|
-
const roleName = level >= 999 ? 'owner' : level >= 300 ? 'editor' : 'viewer';
|
|
1031
|
-
if (userId && !allUsers.has(userId)) {
|
|
1032
|
-
allUsers.set(userId, {
|
|
1033
|
-
user_id: userId,
|
|
1034
|
-
email,
|
|
1035
|
-
name: role.user?.handle,
|
|
1036
|
-
files_accessed: [],
|
|
1037
|
-
});
|
|
1038
|
-
}
|
|
1039
|
-
if (userId) {
|
|
1040
|
-
const u = allUsers.get(userId);
|
|
1041
|
-
u.files_accessed.push({
|
|
1042
|
-
file_key: file.key,
|
|
1043
|
-
file_name: file.name,
|
|
1044
|
-
role: roleName,
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
// External editor detection
|
|
1048
|
-
if (email && verifiedDomains.size > 0) {
|
|
1049
|
-
const domain = email.split('@')[1]?.toLowerCase();
|
|
1050
|
-
if (domain && !verifiedDomains.has(domain) && (roleName === 'editor' || roleName === 'owner')) {
|
|
1051
|
-
flags.push({
|
|
1052
|
-
severity: 'high',
|
|
1053
|
-
type: 'external_editor',
|
|
1054
|
-
details: `${email} (external) has ${roleName} access to ${file.name} (${file.key})`,
|
|
1055
|
-
});
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
if (domainCheckSkipped) {
|
|
1062
|
-
flags.push({
|
|
1063
|
-
severity: 'info',
|
|
1064
|
-
type: 'domain_check_skipped',
|
|
1065
|
-
details: 'Could not resolve org ID; external user detection was skipped. Provide org_id or set FIGMA_ORG_ID.',
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
const result = {
|
|
1069
|
-
scope: { type: scopeType, id: scopeId },
|
|
1070
|
-
summary: {
|
|
1071
|
-
unique_users: allUsers.size,
|
|
1072
|
-
files_scanned: filesScanned,
|
|
1073
|
-
total_files: fileKeys.length,
|
|
1074
|
-
flags_found: flags.length,
|
|
1075
|
-
},
|
|
1076
|
-
users: Array.from(allUsers.values()),
|
|
1077
|
-
flags,
|
|
1078
|
-
};
|
|
1079
|
-
output(result, options);
|
|
1080
|
-
}
|
|
1081
|
-
catch (e) {
|
|
1082
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
1083
|
-
process.exit(1);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
// -- branch-cleanup --
|
|
1087
|
-
export async function runBranchCleanup(projectId, options) {
|
|
1088
|
-
try {
|
|
1089
|
-
validateId(projectId, 'project ID');
|
|
1090
|
-
const config = requireAuth();
|
|
1091
|
-
const daysStale = parsePositiveInt(options.daysStale || '60', '--days-stale', 60);
|
|
1092
|
-
const dryRun = !options.execute;
|
|
1093
|
-
if (!dryRun && !hasCookie(config)) {
|
|
1094
|
-
error('Cookie auth required to archive branches. Omit --execute to preview, or run `figmanage login`.');
|
|
1095
|
-
process.exit(1);
|
|
1096
|
-
}
|
|
1097
|
-
// Fetch project files
|
|
1098
|
-
const MAX_FILES = 20;
|
|
1099
|
-
let files;
|
|
1100
|
-
if (hasPat(config)) {
|
|
1101
|
-
const res = await publicClient(config).get(`/v1/projects/${projectId}/files`);
|
|
1102
|
-
files = res.data?.files || [];
|
|
1103
|
-
}
|
|
1104
|
-
else {
|
|
1105
|
-
const res = await internalClient(config).get(`/api/folders/${projectId}/paginated_files`, { params: { folderId: projectId, sort_column: 'touched_at', sort_order: 'desc', page_size: MAX_FILES, file_type: '' } });
|
|
1106
|
-
const meta = res.data?.meta || res.data;
|
|
1107
|
-
files = meta?.files || meta || [];
|
|
1108
|
-
}
|
|
1109
|
-
const capped = files.length > 20;
|
|
1110
|
-
files = files.slice(0, 20);
|
|
1111
|
-
// Fetch branch data for each file in parallel
|
|
1112
|
-
const cutoff = Date.now() - daysStale * 86400000;
|
|
1113
|
-
const staleBranches = [];
|
|
1114
|
-
const activeBranches = [];
|
|
1115
|
-
let filesScanned = 0;
|
|
1116
|
-
let totalBranches = 0;
|
|
1117
|
-
const branchResults = await Promise.allSettled(files.map(async (file) => {
|
|
1118
|
-
let branches;
|
|
1119
|
-
if (hasPat(config)) {
|
|
1120
|
-
const res = await publicClient(config).get(`/v1/files/${file.key}`, {
|
|
1121
|
-
params: { branch_data: 'true', depth: '0' },
|
|
1122
|
-
});
|
|
1123
|
-
branches = res.data?.branches || [];
|
|
1124
|
-
}
|
|
1125
|
-
else {
|
|
1126
|
-
const res = await internalClient(config).get(`/api/files/${file.key}`);
|
|
1127
|
-
const f = res.data?.meta || res.data;
|
|
1128
|
-
branches = f.branches || [];
|
|
1129
|
-
}
|
|
1130
|
-
return { file, branches };
|
|
1131
|
-
}));
|
|
1132
|
-
for (const r of branchResults) {
|
|
1133
|
-
if (r.status === 'rejected')
|
|
1134
|
-
continue;
|
|
1135
|
-
filesScanned++;
|
|
1136
|
-
const { file, branches } = r.value;
|
|
1137
|
-
for (const branch of branches) {
|
|
1138
|
-
totalBranches++;
|
|
1139
|
-
const lastModified = branch.last_modified;
|
|
1140
|
-
const lastModifiedMs = lastModified ? new Date(lastModified).getTime() : 0;
|
|
1141
|
-
const isStale = !lastModified || (lastModifiedMs > 0 && lastModifiedMs < cutoff) || isNaN(lastModifiedMs);
|
|
1142
|
-
const entry = {
|
|
1143
|
-
branch_key: branch.key,
|
|
1144
|
-
branch_name: branch.name,
|
|
1145
|
-
parent_file_key: file.key,
|
|
1146
|
-
parent_file_name: file.name,
|
|
1147
|
-
last_modified: lastModified || null,
|
|
1148
|
-
};
|
|
1149
|
-
if (isStale) {
|
|
1150
|
-
staleBranches.push(entry);
|
|
1151
|
-
}
|
|
1152
|
-
else {
|
|
1153
|
-
activeBranches.push(entry);
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
const MAX_ARCHIVE_BATCH = 25;
|
|
1158
|
-
let archived = false;
|
|
1159
|
-
if (!dryRun && staleBranches.length > 0) {
|
|
1160
|
-
if (staleBranches.length > MAX_ARCHIVE_BATCH) {
|
|
1161
|
-
error(`${staleBranches.length} stale branches exceeds safety limit of ${MAX_ARCHIVE_BATCH}. ` +
|
|
1162
|
-
`Omit --execute to review, then archive in smaller batches.`);
|
|
1163
|
-
process.exit(1);
|
|
1164
|
-
}
|
|
1165
|
-
await internalClient(config).delete('/api/files_batch', {
|
|
1166
|
-
data: {
|
|
1167
|
-
files: staleBranches.map(b => ({ key: b.branch_key })),
|
|
1168
|
-
trashed: true,
|
|
1169
|
-
},
|
|
1170
|
-
});
|
|
1171
|
-
archived = true;
|
|
1172
|
-
}
|
|
1173
|
-
const recommendations = [];
|
|
1174
|
-
if (staleBranches.length > 0) {
|
|
1175
|
-
recommendations.push(`${staleBranches.length} branch(es) stale for ${daysStale}+ days. ${dryRun ? 'Use --execute to archive.' : 'Archived.'}`);
|
|
1176
|
-
}
|
|
1177
|
-
if (capped) {
|
|
1178
|
-
recommendations.push(`Project has more than 20 files; only the first 20 were scanned.`);
|
|
1179
|
-
}
|
|
1180
|
-
if (staleBranches.length === 0 && activeBranches.length === 0) {
|
|
1181
|
-
recommendations.push('No branches found in scanned files.');
|
|
1182
|
-
}
|
|
1183
|
-
const result = {
|
|
1184
|
-
project_id: projectId,
|
|
1185
|
-
summary: {
|
|
1186
|
-
files_scanned: filesScanned,
|
|
1187
|
-
total_branches: totalBranches,
|
|
1188
|
-
stale: staleBranches.length,
|
|
1189
|
-
active: activeBranches.length,
|
|
1190
|
-
},
|
|
1191
|
-
stale_branches: staleBranches,
|
|
1192
|
-
active_branches: activeBranches,
|
|
1193
|
-
dry_run: dryRun,
|
|
1194
|
-
archived,
|
|
1195
|
-
recommendations,
|
|
1196
|
-
};
|
|
1197
|
-
output(result, options);
|
|
1198
|
-
}
|
|
1199
|
-
catch (e) {
|
|
1200
|
-
error(e.response?.status ? `API error: ${e.response.status}` : e.message);
|
|
1201
|
-
process.exit(1);
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
//# sourceMappingURL=commands.js.map
|