Setting the file. One moment.
Vercel · Vercel Optimize · vercel-labs/agent-skills · Skills Docs
ContentsBack to the top of the page — line 183
This file
Number 7.151
Position 151 of 155
Type JavaScript
Size 30 KB
Lines 864 lib/ vercel.mjs
JavaScript · 864 lines · 30 KB
from
'./throttle.mjs'
;
9
10 const exec = promisify (execFile);
11
12 // On Windows, execFile cannot run the `vercel.cmd` shim directly: PATHEXT is not
13 // applied by execFile, and .cmd/.bat require `shell: true` since Node 20 — but a
14 // shell would mangle args containing spaces or URL query strings (e.g.
15 // `vercel api '/v9/projects/:id?teamId=:org'`, `-f 'http_status ge 500'`). So we
16 // resolve the Vercel package's JS entry from PATH and run it via `node` directly:
17 // no shell, every arg passed verbatim. POSIX is unchanged (`vercel` on PATH).
18 export function resolveVercelCommand ({
19 platform = process.platform,
20 env = process.env,
21 execPath = process.execPath,
22 exists = existsSync,
23 readText = ( file ) => readFileSync (file, 'utf-8' ),
24 } = {}) {
25 if (platform !== 'win32' ) return { file: 'vercel' , prefix: [] };
26
27 const pathValue = env. PATH || env.Path || env.path || '' ;
28 for ( const dir of pathValue. split (win32.delimiter). filter (Boolean)) {
29 const entry = resolveVercelPackageEntry (dir, exists);
30 if (entry) return { file: execPath, prefix: [entry] };
31
32 const shimEntry = resolveVercelShimEntry (dir, exists, readText);
33 if (shimEntry) return { file: execPath, prefix: [shimEntry] };
34 }
35
36 return { file: execPath, prefix: [], missing: true };
37 }
38
39 function resolveVercelPackageEntry ( dir , exists ) {
40 const packageRoots = [
41 win32. join (dir, 'node_modules' , 'vercel' ),
42 win32. join (win32. dirname (dir), 'vercel' ),
43 ];
44 for ( const root of packageRoots) {
45 for ( const rel of [ 'dist/vc.js' , 'dist/index.js' ]) {
46 const entry = win32. join (root, rel);
47 if ( exists (entry)) return entry;
48 }
49 }
50 return null ;
51 }
52
53 function resolveVercelShimEntry ( dir , exists , readText ) {
54 for ( const bin of [ 'vercel.cmd' , 'vc.cmd' ]) {
55 const shim = win32. join (dir, bin);
56 if ( ! exists (shim)) continue ;
57 let raw;
58 try {
59 raw = readText (shim);
60 } catch {
61 continue ;
62 }
63 const match = raw. match ( / ["'] ( [ ^ "'\r\n] * vercel [ \\ /] + dist [ \\ /] + (?:vc | index) \. js) ["'] / i );
64 if ( ! match) continue ;
65 const entry = normalizeWindowsShimTarget (match[ 1 ], dir);
66 if ( exists (entry)) return entry;
67 }
68 return null ;
69 }
70
71 function normalizeWindowsShimTarget ( target , dir ) {
72 const baseDir = `${ dir }${ win32 . sep }` ;
73 const expanded = target
74 . replace ( /%~dp0/ gi , baseDir)
75 . replace ( /%dp0%/ gi , baseDir)
76 . replace ( / \$ basedir/ g , dir);
77 return win32. normalize (win32. isAbsolute (expanded) ? expanded : win32. resolve (dir, expanded));
78 }
79
80 async function runVercel ( args , opts = {}) {
81 const command = resolveVercelCommand ({ env: opts.env });
82 if (command.missing) {
83 const err = new Error ( 'VERCEL_NOT_INSTALLED: `vercel` CLI not found in PATH. Install with `npm i -g vercel@latest`.' );
84 err.code = 'ENOENT' ;
85 throw err;
86 }
87 return await exec (command.file, [ ... command.prefix, ... args], { windowsHide: true , ... opts });
88 }
89
90 const MIN_CLI_VERSION = [ 53 , 0 , 0 ];
91
92 // Pre-v53 lacks `vercel metrics` and `vercel contract`.
93 export async function checkCliVersion () {
94 let raw;
95 try {
96 const { stdout } = await runVercel ([ '--version' ]);
97 raw = stdout. trim ();
98 } catch (err) {
99 throw new Error ( 'VERCEL_NOT_INSTALLED: `vercel` CLI not found in PATH. Install with `npm i -g vercel@latest`.' );
100 }
101 const m = raw. match ( /( \d + ) \. ( \d + ) \. ( \d + )/ );
102 if ( ! m) throw new Error ( `VERCEL_VERSION_UNPARSEABLE: ${ raw }` );
103 const v = [ Number (m[ 1 ]), Number (m[ 2 ]), Number (m[ 3 ])];
104 for ( let i = 0 ; i < 3 ; i ++ ) {
105 if (v[i] > MIN_CLI_VERSION [i]) return v;
106 if (v[i] < MIN_CLI_VERSION [i]) {
107 throw new Error (
108 `VERCEL_CLI_TOO_OLD: have ${ v . join ( '.' ) }, need >= ${ MIN_CLI_VERSION . join ( '.' ) }. Upgrade with \` npm i -g vercel@latest \` .`
109 );
110 }
111 }
112 return v;
113 }
114
115 export async function checkAuth () {
116 try {
117 await runVercel ([ 'whoami' ]);
118 } catch {
119 throw new Error ( 'NOT_AUTH: run `vercel login`.' );
120 }
121 }
122
123 export async function getCliIdentity () {
124 const r = await runVercelJson ([ 'whoami' , '--format' , 'json' ]);
125 return r.ok ? r.data : null ;
126 }
127
128 // Supports newer `.vercel/repo.json` (multi-project) + legacy `.vercel/project.json` (single-project).
129 export async function readProjectJson ( cwd = process. cwd ()) {
130 try {
131 const raw = await readFile ( join (cwd, '.vercel' , 'repo.json' ), 'utf-8' );
132 const parsed = JSON . parse (raw);
133 const projects = Array. isArray (parsed?.projects) ? parsed.projects. filter (( p ) => p?.id) : [];
134 if (projects. length > 1 ) {
135 throw new Error ( 'AMBIGUOUS_PROJECT_LINK: `.vercel/repo.json` contains multiple projects. Run from the linked app directory, or pass the intended projectId together with VERCEL_ORG_ID.' );
136 }
137 const first = projects[ 0 ];
138 if (first?.id) {
139 return { projectId: first.id, orgId: first.orgId ?? null , source: 'repo.json' };
140 }
141 } catch (err) {
142 if (err?.message?. startsWith ( 'AMBIGUOUS_PROJECT_LINK:' )) throw err;
143 /* fall through */
144 }
145
146 // Legacy single-project format.
147 try {
148 const raw = await readFile ( join (cwd, '.vercel' , 'project.json' ), 'utf-8' );
149 const parsed = JSON . parse (raw);
150 if (parsed?.projectId) {
151 return { projectId: parsed.projectId, orgId: parsed.orgId ?? null , source: 'project.json' };
152 }
153 } catch { /* fall through */ }
154
155 return null ;
156 }
157
158 // Does NOT auto-run `vercel link` — interactive surprises bad.
159 export async function resolveProjectId ( explicit , cwd = process. cwd ()) {
160 if (explicit) {
161 const linked = process.env. VERCEL_ORG_ID
162 ? null
163 : await readLinkedOwnerForProjectId (explicit, cwd);
164 return {
165 projectId: explicit,
166 orgId: process.env. VERCEL_ORG_ID || linked?.orgId || null ,
167 source: linked?.source ? `arg+${ linked . source }` : 'arg' ,
168 };
169 }
170 if (process.env. VERCEL_PROJECT_ID ) {
171 const linked = process.env. VERCEL_ORG_ID
172 ? null
173 : await readLinkedOwnerForProjectId (process.env. VERCEL_PROJECT_ID , cwd);
174 return {
175 projectId: process.env. VERCEL_PROJECT_ID ,
176 orgId: process.env. VERCEL_ORG_ID || linked?.orgId || null ,
177 source: linked?.source ? `env+${ linked . source }` : 'env' ,
178 };
179 }
180 return await readProjectJson (cwd);
181 }
182
183 async function readLinkedOwnerForProjectId ( projectId , cwd = process. cwd ()) {
184 try {
185 const raw = await readFile ( join (cwd, '.vercel' , 'repo.json' ), 'utf-8' );
186 const parsed = JSON . parse (raw);
187 const matches = (Array. isArray (parsed?.projects) ? parsed.projects : [])
188 . filter (( p ) => p?.id && String (p.id) === String (projectId));
189 if (matches. length > 1 ) {
190 throw new Error ( 'AMBIGUOUS_PROJECT_LINK: `.vercel/repo.json` contains multiple entries for the requested projectId. Ask the user to confirm the intended Vercel team/personal scope.' );
191 }
192 const match = matches[ 0 ];
193 if (match?.orgId) return { orgId: match.orgId, source: 'repo.json' };
194 } catch (err) {
195 if (err?.message?. startsWith ( 'AMBIGUOUS_PROJECT_LINK:' )) throw err;
196 /* fall through */
197 }
198
199 try {
200 const raw = await readFile ( join (cwd, '.vercel' , 'project.json' ), 'utf-8' );
201 const parsed = JSON . parse (raw);
202 if ( String (parsed?.projectId ?? '' ) === String (projectId) && parsed?.orgId) {
203 return { orgId: parsed.orgId, source: 'project.json' };
204 }
205 } catch { /* fall through */ }
206
207 return null ;
208 }
209
210 export async function resolveCommandScope ( project = {}) {
211 const orgId = project?.orgId ?? null ;
212
213 if ( ! orgId) {
214 return {
215 ok: false ,
216 cliScope: null ,
217 source: 'missing-org-scope' ,
218 required: true ,
219 error: 'PROJECT_SCOPE_UNRESOLVED' ,
220 detail: 'The project was resolved without an owner account, so the collector cannot prove which Vercel scope to query.' ,
221 };
222 }
223
224 const identity = await getCliIdentity ();
225 const currentTeam = identity?.team ?? null ;
226
227 if ( String (orgId). startsWith ( 'team_' )) {
228 if (currentTeam?.id === orgId && currentTeam?.slug) {
229 return {
230 ok: true ,
231 cliScope: currentTeam.slug,
232 source: 'whoami-current-team' ,
233 required: true ,
234 teamId: orgId,
235 detail: 'Resolved linked team ID to the current CLI team slug.' ,
236 };
237 }
238
239 const team = await getTeamInfo (orgId);
240 if (team.ok && team.slug) {
241 return {
242 ok: true ,
243 cliScope: team.slug,
244 source: 'team-api' ,
245 required: true ,
246 teamId: orgId,
247 detail: 'Resolved linked team ID to a Vercel CLI scope slug.' ,
248 };
249 }
250
251 return {
252 ok: false ,
253 cliScope: null ,
254 source: 'team-api' ,
255 required: true ,
256 teamId: orgId,
257 error: team.error ?? 'TEAM_SCOPE_UNRESOLVED' ,
258 detail: 'Could not resolve the linked team ID to a Vercel CLI scope slug.' ,
259 };
260 }
261
262 if ( String (orgId). startsWith ( 'usr_' )) {
263 const user = identity?.user ?? identity ?? {};
264 const userId = user.id ?? identity?.id ?? null ;
265 const username = user.username ?? identity?.username ?? null ;
266 if (( ! userId || userId === orgId) && username) {
267 return {
268 ok: true ,
269 cliScope: username,
270 source: 'whoami-user' ,
271 required: true ,
272 userId: orgId,
273 detail: 'Resolved linked user ID to a Vercel CLI username scope.' ,
274 };
275 }
276 return {
277 ok: false ,
278 cliScope: null ,
279 source: 'whoami-user' ,
280 required: true ,
281 userId: orgId,
282 error: 'USER_SCOPE_UNRESOLVED' ,
283 detail: 'Could not resolve the linked user ID to the authenticated Vercel username.' ,
284 };
285 }
286
287 return {
288 ok: true ,
289 cliScope: orgId,
290 source: 'linked-org-scope' ,
291 required: true ,
292 detail: 'Using the linked org value as the Vercel CLI scope.' ,
293 };
294 }
295
296 async function getTeamInfo ( teamIdOrSlug ) {
297 const r = await runVercelJson ([ 'api' , `/v2/teams/${ encodeURIComponent ( teamIdOrSlug ) }` ]);
298 if ( ! r.ok) return { ok: false , error: r.code ?? 'UNKNOWN' };
299 const team = r.data?.team ?? r.data ?? {};
300 return {
301 ok: true ,
302 id: team.id ?? null ,
303 slug: team.slug ?? null ,
304 name: team.name ?? null ,
305 };
306 }
307
308 // Some commands emit `{error: {...}}` on stdout AND exit non-zero — parse stdout first; embedded `error` is the most reliable signal.
309 // 32 MiB buffer: 14d function-duration timeseries across many routes exceeds Node's 1 MiB default.
310 export async function runVercelJson ( args , opts = {}) {
311 let stdout = '' ;
312 let stderr = '' ;
313 let exitCode = 0 ;
314 try {
315 const r = await runVercel (args, { maxBuffer: 32 * 1024 * 1024 , ... opts });
316 stdout = r.stdout;
317 stderr = r.stderr;
318 } catch (err) {
319 stdout = err.stdout || '' ;
320 stderr = err.stderr || '' ;
321 exitCode = err.code ?? err.exitCode ?? 1 ;
322 }
323 const safeStderr = redactSensitiveText (stderr);
324
325 if (stdout && stdout. trim (). startsWith ( '{' )) {
326 try {
327 const data = JSON . parse (stdout);
328 if (data && typeof data === 'object' && data.error) {
329 const failure = {
330 ok: false ,
331 code: data.error.code || `EXIT_${ exitCode }` ,
332 message: redactSensitiveText (data.error.message || '' ),
333 allowedValues: data.error.allowedValues,
334 stderr: safeStderr,
335 };
336 return isDailyQuotaExceeded (failure)
337 ? { ... failure, code: 'DAILY_QUOTA_EXCEEDED' , originalCode: failure.code }
338 : failure;
339 }
340 if (exitCode === 0 ) return { ok: true , data };
341 // Exit non-zero, no `error` key, parseable stdout → still useful.
342 return { ok: true , data };
343 } catch {
344 /* fall through to stderr categorization */
345 }
346 }
347
348 // Metrics schema returns a top-level array.
349 if (stdout && stdout. trim (). startsWith ( '[' )) {
350 try {
351 const data = JSON . parse (stdout);
352 if (exitCode === 0 ) return { ok: true , data };
353 } catch { /* fall through */ }
354 }
355
356 return {
357 ok: false ,
358 code: categorizeError (exitCode, stderr),
359 stderr: safeStderr,
360 };
361 }
362
363 export function redactSensitiveText ( value ) {
364 return String (value ?? '' )
365 . replace ( / \b (Bearer) \s + [A-Za-z0-9._~+/=-] {12,} / gi , '$1 [REDACTED]' )
366 . replace ( / \b (Authorization: \s * ) [ ^ \r\n] + / gi , '$1[REDACTED]' )
367 . replace ( / \b (x-vercel-id: \s * ) [ ^ \r\n] + / gi , '$1[REDACTED]' )
368 . replace ( / \b (VERCEL_TOKEN | TURBO_TOKEN | NPM_TOKEN | NODE_AUTH_TOKEN | GITHUB_TOKEN)=(" [ ^ "] + " | ' [ ^ '] + ' | [ ^ \s"'`] + )/ g , '$1=[REDACTED]' )
369 . replace ( /(--token(?:= | \s + ))(" [ ^ "] + " | ' [ ^ '] + ' | [ ^ \s"'`] + )/ gi , '$1[REDACTED]' )
370 . replace ( / \b (prj | team | usr)_ [A-Za-z0-9] {8,}\b / g , '$1_[REDACTED]' )
371 . replace ( /("token" \s * : \s * ") [ ^ "] {8,} (")/ gi , '$1[REDACTED]$2' );
372 }
373
374 // CLI doesn't emit machine-readable error codes for these states — stderr substring is fallback only.
375 function categorizeError ( exitCode , stderr ) {
376 const lc = (stderr || '' ). toLowerCase ();
377 if ( isDailyQuotaExceeded ({ ok: false , stderr })) return 'DAILY_QUOTA_EXCEEDED' ;
378 if (lc. includes ( 'observability plus' )) return 'OPLUS_REQUIRED' ;
379 if (lc. includes ( 'costs not found' )) return 'USAGE_UNAVAILABLE' ;
380 if (lc. includes ( 'project not found' )) return 'PROJECT_NOT_FOUND' ;
381 if (lc. includes ( 'not linked' ) || lc. includes ( 'no project' )) return 'NOT_LINKED' ;
382 if (lc. includes ( 'log in' ) || lc. includes ( 'credentials' )) return 'NOT_AUTH' ;
383 if (lc. includes ( 'rate limit' ) || lc. includes ( '429' )) return 'RATE_LIMIT' ;
384 if (lc. includes ( 'permission' ) || lc. includes ( 'not authorized' ) || lc. includes ( '403' ))
385 return 'FORBIDDEN' ;
386 return `EXIT_${ exitCode }` ;
387 }
388
389 // Schema is global per team — pass scope so we hit the right team rather than user's currentTeam.
390 export async function hasObservabilityPlus ( scope ) {
391 const r = await runVercelJson ( scopedArgs ([ 'metrics' , 'schema' , '--format' , 'json' ], scope));
392 return r.ok;
393 }
394
395 export async function getMetricsSchema ( scope ) {
396 const r = await runVercelJson ( scopedArgs ([ 'metrics' , 'schema' , '--format' , 'json' ], scope));
397 return r.ok ? r.data : null ;
398 }
399
400 export async function checkObservabilityPlusConfiguration ({ orgId , projectId } = {}) {
401 if ( ! orgId) {
402 return {
403 ok: false ,
404 source: 'observability-configuration-api' ,
405 blocker: 'unknown' ,
406 detail: 'No team ID was available for the Observability Plus configuration preflight.' ,
407 };
408 }
409 if ( String (orgId). startsWith ( 'usr_' )) {
410 return {
411 ok: false ,
412 source: 'observability-configuration-api' ,
413 access: null ,
414 blocker: 'unknown' ,
415 detail: 'The Observability Plus team configuration preflight is not available for a user-owned project; falling back to the scoped metrics probe.' ,
416 };
417 }
418 const qs = `?teamId=${ encodeURIComponent ( orgId ) }` ;
419 const r = await runVercelJson ([ 'api' , `/v1/observability/manage/configuration/projects${ qs }` ]);
420 return classifyObservabilityPlusConfiguration (r, { projectId });
421 }
422
423 export function classifyObservabilityPlusConfiguration ( result , { projectId } = {}) {
424 const source = 'observability-configuration-api' ;
425 if (result?.ok) {
426 const disabledProjects = Array. isArray (result.data?.disabledProjects) ? result.data.disabledProjects : [];
427 const disabled = projectId
428 ? disabledProjects. find (( p ) => String (p?.id ?? '' ) === String (projectId))
429 : null ;
430 if (disabled) {
431 return {
432 ok: true ,
433 source,
434 access: false ,
435 blocker: 'project_disabled' ,
436 detail: 'Observability Plus is enabled for the team but disabled for this project.' ,
437 disabledProject: {
438 id: disabled.id,
439 name: disabled.name ?? null ,
440 disabledAt: disabled.disabledAt ?? null ,
441 },
442 };
443 }
444 return {
445 ok: true ,
446 source,
447 access: true ,
448 blocker: null ,
449 detail: 'Observability Plus is enabled for this team/project.' ,
450 };
451 }
452
453 const code = String (result?.code ?? 'unknown' ). toLowerCase ();
454 const text = `${ result ?. message ?? ''} \n ${ result ?. stderr ?? ''}` . toLowerCase ();
455 const mentionsObservabilityPlusNotEnabled =
456 /observability plus [\s\S] {0,160} not enabled/ . test (text) ||
457 /not enabled [\s\S] {0,160} observability plus/ . test (text) ||
458 /subscription to observability plus [\s\S] {0,160} required/ . test (text);
459 if (code === 'oplus_required' || ((code === 'not_found' || code === '404' ) && mentionsObservabilityPlusNotEnabled)) {
460 return {
461 ok: true ,
462 source,
463 access: false ,
464 blocker: 'no_oplus_probe' ,
465 detail: 'Route-level metrics are unavailable because Observability Plus is not enabled for this team.' ,
466 };
467 }
468 if ( /forbidden | not_authorized | 403/ . test (code) || /forbidden | not authorized | permission | 403/ . test (text)) {
469 return {
470 ok: false ,
471 source,
472 access: null ,
473 blocker: 'forbidden' ,
474 detail: 'Could not read Observability Plus configuration for this team. Run `vercel switch <team>` and verify access.' ,
475 };
476 }
477 if ( /not_auth | unauthorized | 401/ . test (code) || /unauthorized | log in | credentials | 401/ . test (text)) {
478 return {
479 ok: false ,
480 source,
481 access: null ,
482 blocker: 'forbidden' ,
483 detail: 'Could not read Observability Plus configuration because the Vercel CLI is not authenticated.' ,
484 };
485 }
486 return {
487 ok: false ,
488 source,
489 access: null ,
490 blocker: 'unknown' ,
491 detail: `Could not determine Observability Plus configuration before querying metrics (code=${ code }).` ,
492 };
493 }
494
495 // Returns `{ok, ...}`. CLI summary defaults to top 10 groups under --group-by; widen via opts.limit.
496 export async function queryMetric ( metricId , opts = {}) {
497 const args = [ 'metrics' , metricId, '--format' , 'json' ];
498 if (opts.aggregation) args. push ( '-a' , opts.aggregation);
499 for ( const dim of opts.groupBy ?? []) args. push ( '--group-by' , dim);
500 if (opts.filter) args. push ( '-f' , opts.filter);
501 if (opts.since) args. push ( '--since' , opts.since);
502 if (opts.until) args. push ( '--until' , opts.until);
503 if (opts.limit) args. push ( '--limit' , String (opts.limit));
504
505 // 3-layer protection: semaphore (8 concurrent) + sliding-window (80/60s) + retryOnRateLimit (3× 60-90s jitter). payment_required is terminal.
506 const throttle = getMetricThrottle ();
507 const onRetry = ( attempt , delayMs ) => {
508 console. error ( `[queryMetric] ${ metricId } hit RATE_LIMITED; retry ${ attempt }/3 after ${ ( delayMs / 1000 ). toFixed ( 0 ) }s` );
509 };
510 return await throttle. run (() =>
511 retryOnRateLimit (() => runVercelJson ( scopedArgs (args, opts.scope)), { onRetry })
512 );
513 }
514
515 // Team-owned projects need `?teamId=<orgId>` to avoid current-team drift. User-
516 // owned projects use the authenticated user context and should not pass teamId.
517 export async function getProjectConfig ( projectId , orgId ) {
518 const qs = orgId && ! String (orgId). startsWith ( 'usr_' )
519 ? `?teamId=${ encodeURIComponent ( orgId ) }`
520 : '' ;
521 const r = await runVercelJson ([ 'api' , `/v9/projects/${ projectId }${ qs }` ]);
522 return r.ok ? r.data : { error: r.code, stderr: r.stderr };
523 }
524
525 // USAGE_UNAVAILABLE distinguishes "no Costs feature" from genuine emptiness.
526 export async function getUsage ({ days = 14 , scope , groupByProject = true } = {}) {
527 const toDate = new Date ();
528 const fromDate = new Date (toDate. getTime () - days * 86400000 );
529 const fmt = ( d ) => d. toISOString (). slice ( 0 , 10 );
530 const args = [
531 'usage' ,
532 '--format' , 'json' ,
533 '--from' , fmt (fromDate),
534 '--to' , fmt (toDate),
535 ];
536 // The CLI rejects --breakdown with --group-by. Project grouping is higher
537 // value for this skill because every recommendation must be project-scoped.
538 if (groupByProject) args. push ( '--group-by' , 'project' );
539 else args. push ( '--breakdown' , 'daily' );
540 return await runVercelJson ( scopedArgs (args, scope));
541 }
542
543 // CLI `--group-by project` returns project buckets under groupBy.data. Older
544 // breakdown-shaped fixtures tag service rows with projectId; keep both paths.
545 export function filterUsageByProject ( usage , projectId , projectName = null ) {
546 if ( ! usage || ! projectId) return { filtered: null , matched: false , unattributedTotal: 0 };
547 if (usage.groupBy?.dimension === 'project' && Array. isArray (usage.groupBy.data)) {
548 const project = usage.groupBy.data. find (( entry ) => projectMatches (entry, projectId, projectName));
549 if ( ! project) return { filtered: null , matched: false , unattributedTotal: 0 };
550 return {
551 filtered: {
552 ... usage,
553 groupBy: { ... usage.groupBy, data: [project] },
554 services: Array. isArray (project.services) ? project.services : [],
555 totals: project.totals ?? null ,
556 project: { name: project.name ?? projectName ?? null , projectId: project.projectId ?? projectId },
557 },
558 matched: true ,
559 unattributedTotal: 0 ,
560 };
561 }
562 const breakdown = usage.breakdown;
563 if ( ! breakdown || ! Array. isArray (breakdown.data)) {
564 return { filtered: null , matched: false , unattributedTotal: 0 };
565 }
566 const out = {
567 ... usage,
568 breakdown: { ... breakdown, data: [] },
569 };
570 let matchedAny = false ;
571 let projectTotal = 0 ;
572 let unattributedTotal = 0 ;
573
574 for ( const day of breakdown.data) {
575 const services = Array. isArray (day.services) ? day.services : [];
576 const projectRows = services. filter (( s ) => projectMatches (s, projectId, projectName));
577 const unattributedRows = services. filter (( s ) => ! s.projectId && ! s.project);
578 for ( const r of projectRows) projectTotal += (r.billedCost ?? r.cost ?? 0 );
579 for ( const r of unattributedRows) unattributedTotal += (r.billedCost ?? r.cost ?? 0 );
580 if (projectRows. length === 0 ) continue ;
581 matchedAny = true ;
582 out.breakdown.data. push ({ ... day, services: projectRows });
583 }
584
585 if ( ! matchedAny) return { filtered: null , matched: false , unattributedTotal };
586
587 out.services = aggregateServicesByName (out.breakdown.data);
588 out.totals = { billedCost: projectTotal };
589 return { filtered: out, matched: true , unattributedTotal };
590 }
591
592 function projectMatches ( serviceRow , projectId , projectName = null ) {
593 if ( ! serviceRow) return false ;
594 if (serviceRow.projectId === projectId) return true ;
595 if (projectName && serviceRow.name === projectName) return true ;
596 if (projectName && serviceRow.project === projectName) return true ;
597 if (serviceRow.project === projectId) return true ;
598 if (serviceRow.project && (serviceRow.project.id === projectId || serviceRow.project.projectId === projectId || serviceRow.project.name === projectName)) return true ;
599 return false ;
600 }
601
602 function aggregateServicesByName ( days ) {
603 const byName = new Map ();
604 for ( const day of days) {
605 for ( const s of (day.services ?? [])) {
606 const key = s.name ?? '(unnamed)' ;
607 const prev = byName. get (key) ?? { name: key, billedCost: 0 , pricingQuantity: 0 , pricingUnit: s.pricingUnit ?? null };
608 prev.billedCost += (s.billedCost ?? s.cost ?? 0 );
609 prev.pricingQuantity += (s.pricingQuantity ?? 0 );
610 byName. set (key, prev);
611 }
612 }
613 return Array. from (byName. values ()). sort (( a , b ) => (b.billedCost ?? 0 ) - (a.billedCost ?? 0 ));
614 }
615
616 export async function getContract ( scope ) {
617 const r = await runVercelJson ( scopedArgs ([ 'contract' , '--format' , 'json' ], scope));
618 return r.ok ? r.data : null ;
619 }
620
621 export async function getAccountPlan ( scope ) {
622 const currentTeamId = scope ? null : await getCurrentTeamId ();
623 const teamScope = scope || currentTeamId;
624
625 if (teamScope && ! String (teamScope). startsWith ( 'usr_' )) {
626 const team = await getBillingPlanFromPath ( `/v2/teams/${ encodeURIComponent ( teamScope ) }` , 'team.billing.plan' );
627 if (team.plan !== 'unknown' || ! /not_found | 404/ i . test ( String (team.error ?? '' ))) {
628 return team;
629 }
630 // Older project links can carry a user/org id instead of a team id. If the
631 // team lookup misses, fall back to the authenticated user's billing record.
632 }
633
634 return await getBillingPlanFromPath ( '/v2/user' , 'user.billing.plan' );
635 }
636
637 async function getCurrentTeamId () {
638 const identity = await getCliIdentity ();
639 return identity?.team?.id ?? null ;
640 }
641
642 async function getBillingPlanFromPath ( path , source ) {
643 const r = await runVercelJson ([ 'api' , path]);
644 if ( ! r.ok) {
645 return {
646 plan: 'unknown' ,
647 reason: `${ source } unavailable (${ r . code ?? 'unknown'})` ,
648 source,
649 error: r.code ?? 'unknown' ,
650 };
651 }
652
653 const parsed = extractBillingPlan (r.data);
654 if ( ! parsed) {
655 return {
656 plan: 'unknown' ,
657 reason: `${ source } missing from Vercel API response` ,
658 source,
659 };
660 }
661
662 return {
663 ... parsed,
664 reason: `${ source }=${ parsed . plan }` ,
665 source,
666 };
667 }
668
669 export function extractBillingPlan ( data ) {
670 const raw =
671 data?.billing?.plan ??
672 data?.team?.billing?.plan ??
673 data?.user?.billing?.plan ??
674 null ;
675 const plan = normalizeBillingPlan (raw);
676 return plan ? { plan, rawPlan: raw } : null ;
677 }
678
679 function normalizeBillingPlan ( raw ) {
680 const value = String (raw ?? '' ). trim (). toLowerCase ();
681 if (value === 'hobby' || value === 'pro' || value === 'enterprise' ) return value;
682 return null ;
683 }
684
685 // Primary source: billing.plan from `/v2/teams/:team` or `/v2/user`.
686 // Fallbacks: contract category, then recent billed usage for legacy CLI/API gaps.
687 export function inferPlan ( contract , opts = {}) {
688 const accountPlan = extractPlanOption (opts?.accountPlan);
689 if (accountPlan) {
690 return {
691 plan: accountPlan.plan,
692 reason: accountPlan.reason ?? `${ accountPlan . source ?? 'billing.plan'}=${ accountPlan . plan }` ,
693 };
694 }
695
696 const commits = contract?.commitments ?? [];
697
698 if (commits. length > 0 ) {
699 const c0 = commits[ 0 ] ?? {};
700 // category field names are tentative — try several.
701 const category = c0.category ?? c0.commitmentCategory ?? c0.type ?? null ;
702 if (category === 'Spend' || category === 'spend' ) {
703 return { plan: 'pro' , reason: `commitment category=${ category }` };
704 }
705 if (category === 'Usage' || category === 'usage' ) {
706 return { plan: 'enterprise' , reason: `commitment category=${ category }` };
707 }
708 return { plan: 'uncertain' , reason: `unknown commitment category=${ category }` };
709 }
710
711 const totalCost = opts?.usageTotalCost;
712 if ( typeof totalCost === 'number' && totalCost > 0 ) {
713 return {
714 plan: 'pro' ,
715 reason: `commitments=[] but usage=$${ totalCost . toFixed ( 2 ) }/window — Pro pay-as-you-go (Hobby teams don't bill)` ,
716 };
717 }
718
719 return {
720 plan: 'uncertain' ,
721 reason: typeof totalCost === 'number' && totalCost === 0
722 ? 'no commitments and no billed usage in window (could be Hobby, or Pro with no recent billing)'
723 : 'no commitments on contract; usage unavailable' ,
724 };
725 }
726
727 function extractPlanOption ( accountPlan ) {
728 if ( ! accountPlan) return null ;
729 if ( typeof accountPlan === 'string' ) {
730 const plan = normalizeBillingPlan (accountPlan);
731 return plan ? { plan, reason: `billing.plan=${ plan }` } : null ;
732 }
733
734 const plan = normalizeBillingPlan (accountPlan.plan);
735 if ( ! plan) return null ;
736 return {
737 plan,
738 reason: accountPlan.reason ?? (
739 accountPlan.source
740 ? `${ accountPlan . source }=${ plan }`
741 : `billing.plan=${ plan }`
742 ),
743 source: accountPlan.source ?? null ,
744 };
745 }
746
747 export async function detectStack ( cwd = process. cwd ()) {
748 const pkgPath = join (cwd, 'package.json' );
749 let pkg = {};
750 try {
751 pkg = JSON . parse ( await readFile (pkgPath, 'utf-8' ));
752 } catch {
753 return baselineStack ();
754 }
755 const deps = { ... pkg.dependencies, ... pkg.devDependencies };
756
757 const framework =
758 deps.next ? 'next' :
759 deps.nuxt ? 'nuxt' :
760 deps.astro ? 'astro' :
761 deps[ '@sveltejs/kit' ] ? 'sveltekit' :
762 deps[ '@remix-run/react' ] ? 'remix' :
763 deps.hono ? 'hono' :
764 'unknown' ;
765
766 const frameworkVersion = (() => {
767 const m = { next: 'next' , nuxt: 'nuxt' , astro: 'astro' , sveltekit: '@sveltejs/kit' , remix: '@remix-run/react' , hono: 'hono' };
768 const dep = m[framework];
769 if ( ! dep) return null ;
770 return (deps[dep] || '' ). replace ( / ^ [ \^ ~] / , '' ) || null ;
771 })();
772
773 const hasAppRouter = await pathExists ( join (cwd, 'app' )) || await pathExists ( join (cwd, 'src/app' ));
774 const hasPagesRouter = await pathExists ( join (cwd, 'pages' )) || await pathExists ( join (cwd, 'src/pages' ));
775 const typescript = await pathExists ( join (cwd, 'tsconfig.json' ));
776 const cacheComponents = framework === 'next'
777 ? await detectNextCacheComponents (cwd)
778 : null ;
779
780 const orm =
781 deps.prisma || deps[ '@prisma/client' ] ? 'prisma' :
782 deps[ 'drizzle-orm' ] ? 'drizzle' :
783 deps.kysely ? 'kysely' :
784 'none' ;
785 const vercelFlagsPackages = [
786 '@vercel/flags' ,
787 '@vercel/flags/next' ,
788 '@vercel/flags/sveltekit' ,
789 '@vercel/flags/nuxt' ,
790 ]. filter (( name ) => deps[name]);
791 const workflowPackages = Object. keys (deps)
792 . filter (( name ) => name === 'workflow' || name. startsWith ( '@workflow/' ))
793 . sort ();
794
795 const isMonorepo =
796 !! pkg.workspaces ||
797 await pathExists ( join (cwd, 'pnpm-workspace.yaml' )) ||
798 await pathExists ( join (cwd, 'lerna.json' ));
799
800 return {
801 framework,
802 frameworkVersion,
803 hasAppRouter,
804 hasPagesRouter,
805 cacheComponents,
806 typescript,
807 orm,
808 isMonorepo,
809 rootDirectory: null ,
810 hasVercelFlagsPackage: vercelFlagsPackages. length > 0 ,
811 vercelFlagsPackages,
812 hasWorkflowPackage: workflowPackages. length > 0 ,
813 workflowPackages,
814 };
815 }
816
817 function baselineStack () {
818 return {
819 framework: 'unknown' , frameworkVersion: null ,
820 hasAppRouter: false , hasPagesRouter: false , cacheComponents: null , typescript: false ,
821 orm: 'none' , isMonorepo: false , rootDirectory: null ,
822 hasVercelFlagsPackage: false , vercelFlagsPackages: [],
823 hasWorkflowPackage: false , workflowPackages: [],
824 };
825 }
826
827 async function detectNextCacheComponents ( cwd ) {
828 for ( const name of [ 'next.config.js' , 'next.config.mjs' , 'next.config.ts' , 'next.config.cjs' ]) {
829 try {
830 const content = await readFile ( join (cwd, name), 'utf-8' );
831 if ( / \b cacheComponents \s * : \s * true \b / . test (content)) return true ;
832 if ( / \b cacheComponents \s * : \s * false \b / . test (content)) return false ;
833 } catch {}
834 }
835 return null ;
836 }
837
838 async function pathExists ( p ) {
839 try { await access (p); return true ; } catch { return false ; }
840 }
841
842 // `--scope <teamId>` is buggy on several subcommands (silently falls back to
843 // currentTeam). Resolve raw account IDs to slugs/usernames before scoped calls.
844 function scopedArgs ( args , scope ) {
845 if ( ! scope) return args;
846 if ( typeof scope === 'string' && / ^ (team | usr)_/ . test (scope)) {
847 throw new Error ( 'RAW_ID_SCOPE_UNRESOLVED: resolve the linked org/user ID to a CLI scope slug before running Vercel commands.' );
848 }
849 return [ ... args, '--scope' , scope];
850 }
851
852 // CLI summary field is `<metric_id_with_underscores>_<aggregation>` (e.g. `vercel_request_count_sum`).
853 export function normalizeSummary ( metricResponse , metricId , aggregation , groupBy = []) {
854 if ( ! metricResponse || metricResponse.error) return [];
855 const field = `${ metricId . replace ( / \. / g , '_' ) }_${ aggregation }` ;
856 const rows = Array. isArray (metricResponse.summary) ? metricResponse.summary : [];
857 return rows. map (( row ) => {
858 const out = { value: row[field] ?? null };
859 for ( const dim of groupBy) {
860 if (row[dim] !== undefined ) out[dim] = row[dim];
861 }
862 return out;
863 });
864 }