playbook/antigravity-awesome-skills/plugins/antigravity-awesome-skills-.../skills/vercel-optimize/lib/vercel.mjs

785 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Vercel CLI helpers. All shell-outs use execFile (not exec) — no shell injection. Error detection: exit code + JSON-parse first; stderr grep only as fallback (CLI error strings aren't a stable contract).
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { readFile, access } from 'node:fs/promises';
import { join } from 'node:path';
import { getMetricThrottle, isDailyQuotaExceeded, retryOnRateLimit } from './throttle.mjs';
const exec = promisify(execFile);
const MIN_CLI_VERSION = [53, 0, 0];
// Pre-v53 lacks `vercel metrics` and `vercel contract`.
export async function checkCliVersion() {
let raw;
try {
const { stdout } = await exec('vercel', ['--version']);
raw = stdout.trim();
} catch (err) {
throw new Error('VERCEL_NOT_INSTALLED: `vercel` CLI not found in PATH. Install with `npm i -g vercel@latest`.');
}
const m = raw.match(/(\d+)\.(\d+)\.(\d+)/);
if (!m) throw new Error(`VERCEL_VERSION_UNPARSEABLE: ${raw}`);
const v = [Number(m[1]), Number(m[2]), Number(m[3])];
for (let i = 0; i < 3; i++) {
if (v[i] > MIN_CLI_VERSION[i]) return v;
if (v[i] < MIN_CLI_VERSION[i]) {
throw new Error(
`VERCEL_CLI_TOO_OLD: have ${v.join('.')}, need >= ${MIN_CLI_VERSION.join('.')}. Upgrade with \`npm i -g vercel@latest\`.`
);
}
}
return v;
}
export async function checkAuth() {
try {
await exec('vercel', ['whoami']);
} catch {
throw new Error('NOT_AUTH: run `vercel login`.');
}
}
export async function getCliIdentity() {
const r = await runVercelJson(['whoami', '--format', 'json']);
return r.ok ? r.data : null;
}
// Supports newer `.vercel/repo.json` (multi-project) + legacy `.vercel/project.json` (single-project).
export async function readProjectJson(cwd = process.cwd()) {
try {
const raw = await readFile(join(cwd, '.vercel', 'repo.json'), 'utf-8');
const parsed = JSON.parse(raw);
const projects = Array.isArray(parsed?.projects) ? parsed.projects.filter((p) => p?.id) : [];
if (projects.length > 1) {
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.');
}
const first = projects[0];
if (first?.id) {
return { projectId: first.id, orgId: first.orgId ?? null, source: 'repo.json' };
}
} catch (err) {
if (err?.message?.startsWith('AMBIGUOUS_PROJECT_LINK:')) throw err;
/* fall through */
}
// Legacy single-project format.
try {
const raw = await readFile(join(cwd, '.vercel', 'project.json'), 'utf-8');
const parsed = JSON.parse(raw);
if (parsed?.projectId) {
return { projectId: parsed.projectId, orgId: parsed.orgId ?? null, source: 'project.json' };
}
} catch { /* fall through */ }
return null;
}
// Does NOT auto-run `vercel link` — interactive surprises bad.
export async function resolveProjectId(explicit, cwd = process.cwd()) {
if (explicit) {
const linked = process.env.VERCEL_ORG_ID
? null
: await readLinkedOwnerForProjectId(explicit, cwd);
return {
projectId: explicit,
orgId: process.env.VERCEL_ORG_ID || linked?.orgId || null,
source: linked?.source ? `arg+${linked.source}` : 'arg',
};
}
if (process.env.VERCEL_PROJECT_ID) {
const linked = process.env.VERCEL_ORG_ID
? null
: await readLinkedOwnerForProjectId(process.env.VERCEL_PROJECT_ID, cwd);
return {
projectId: process.env.VERCEL_PROJECT_ID,
orgId: process.env.VERCEL_ORG_ID || linked?.orgId || null,
source: linked?.source ? `env+${linked.source}` : 'env',
};
}
return await readProjectJson(cwd);
}
async function readLinkedOwnerForProjectId(projectId, cwd = process.cwd()) {
try {
const raw = await readFile(join(cwd, '.vercel', 'repo.json'), 'utf-8');
const parsed = JSON.parse(raw);
const matches = (Array.isArray(parsed?.projects) ? parsed.projects : [])
.filter((p) => p?.id && String(p.id) === String(projectId));
if (matches.length > 1) {
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.');
}
const match = matches[0];
if (match?.orgId) return { orgId: match.orgId, source: 'repo.json' };
} catch (err) {
if (err?.message?.startsWith('AMBIGUOUS_PROJECT_LINK:')) throw err;
/* fall through */
}
try {
const raw = await readFile(join(cwd, '.vercel', 'project.json'), 'utf-8');
const parsed = JSON.parse(raw);
if (String(parsed?.projectId ?? '') === String(projectId) && parsed?.orgId) {
return { orgId: parsed.orgId, source: 'project.json' };
}
} catch { /* fall through */ }
return null;
}
export async function resolveCommandScope(project = {}) {
const orgId = project?.orgId ?? null;
if (!orgId) {
return {
ok: false,
cliScope: null,
source: 'missing-org-scope',
required: true,
error: 'PROJECT_SCOPE_UNRESOLVED',
detail: 'The project was resolved without an owner account, so the collector cannot prove which Vercel scope to query.',
};
}
const identity = await getCliIdentity();
const currentTeam = identity?.team ?? null;
if (String(orgId).startsWith('team_')) {
if (currentTeam?.id === orgId && currentTeam?.slug) {
return {
ok: true,
cliScope: currentTeam.slug,
source: 'whoami-current-team',
required: true,
teamId: orgId,
detail: 'Resolved linked team ID to the current CLI team slug.',
};
}
const team = await getTeamInfo(orgId);
if (team.ok && team.slug) {
return {
ok: true,
cliScope: team.slug,
source: 'team-api',
required: true,
teamId: orgId,
detail: 'Resolved linked team ID to a Vercel CLI scope slug.',
};
}
return {
ok: false,
cliScope: null,
source: 'team-api',
required: true,
teamId: orgId,
error: team.error ?? 'TEAM_SCOPE_UNRESOLVED',
detail: 'Could not resolve the linked team ID to a Vercel CLI scope slug.',
};
}
if (String(orgId).startsWith('usr_')) {
const user = identity?.user ?? identity ?? {};
const userId = user.id ?? identity?.id ?? null;
const username = user.username ?? identity?.username ?? null;
if ((!userId || userId === orgId) && username) {
return {
ok: true,
cliScope: username,
source: 'whoami-user',
required: true,
userId: orgId,
detail: 'Resolved linked user ID to a Vercel CLI username scope.',
};
}
return {
ok: false,
cliScope: null,
source: 'whoami-user',
required: true,
userId: orgId,
error: 'USER_SCOPE_UNRESOLVED',
detail: 'Could not resolve the linked user ID to the authenticated Vercel username.',
};
}
return {
ok: true,
cliScope: orgId,
source: 'linked-org-scope',
required: true,
detail: 'Using the linked org value as the Vercel CLI scope.',
};
}
async function getTeamInfo(teamIdOrSlug) {
const r = await runVercelJson(['api', `/v2/teams/${encodeURIComponent(teamIdOrSlug)}`]);
if (!r.ok) return { ok: false, error: r.code ?? 'UNKNOWN' };
const team = r.data?.team ?? r.data ?? {};
return {
ok: true,
id: team.id ?? null,
slug: team.slug ?? null,
name: team.name ?? null,
};
}
// Some commands emit `{error: {...}}` on stdout AND exit non-zero — parse stdout first; embedded `error` is the most reliable signal.
// 32 MiB buffer: 14d function-duration timeseries across many routes exceeds Node's 1 MiB default.
export async function runVercelJson(args, opts = {}) {
let stdout = '';
let stderr = '';
let exitCode = 0;
try {
const r = await exec('vercel', args, { maxBuffer: 32 * 1024 * 1024, ...opts });
stdout = r.stdout;
stderr = r.stderr;
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
exitCode = err.code ?? err.exitCode ?? 1;
}
const safeStderr = redactSensitiveText(stderr);
if (stdout && stdout.trim().startsWith('{')) {
try {
const data = JSON.parse(stdout);
if (data && typeof data === 'object' && data.error) {
const failure = {
ok: false,
code: data.error.code || `EXIT_${exitCode}`,
message: redactSensitiveText(data.error.message || ''),
allowedValues: data.error.allowedValues,
stderr: safeStderr,
};
return isDailyQuotaExceeded(failure)
? { ...failure, code: 'DAILY_QUOTA_EXCEEDED', originalCode: failure.code }
: failure;
}
if (exitCode === 0) return { ok: true, data };
// Exit non-zero, no `error` key, parseable stdout → still useful.
return { ok: true, data };
} catch {
/* fall through to stderr categorization */
}
}
// Metrics schema returns a top-level array.
if (stdout && stdout.trim().startsWith('[')) {
try {
const data = JSON.parse(stdout);
if (exitCode === 0) return { ok: true, data };
} catch { /* fall through */ }
}
return {
ok: false,
code: categorizeError(exitCode, stderr),
stderr: safeStderr,
};
}
export function redactSensitiveText(value) {
return String(value ?? '')
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{12,}/gi, '$1 [REDACTED]')
.replace(/\b(Authorization:\s*)[^\r\n]+/gi, '$1[REDACTED]')
.replace(/\b(x-vercel-id:\s*)[^\r\n]+/gi, '$1[REDACTED]')
.replace(/\b(VERCEL_TOKEN|TURBO_TOKEN|NPM_TOKEN|NODE_AUTH_TOKEN|GITHUB_TOKEN)=("[^"]+"|'[^']+'|[^\s"'`]+)/g, '$1=[REDACTED]')
.replace(/(--token(?:=|\s+))("[^"]+"|'[^']+'|[^\s"'`]+)/gi, '$1[REDACTED]')
.replace(/\b(prj|team|usr)_[A-Za-z0-9]{8,}\b/g, '$1_[REDACTED]')
.replace(/("token"\s*:\s*")[^"]{8,}(")/gi, '$1[REDACTED]$2');
}
// CLI doesn't emit machine-readable error codes for these states — stderr substring is fallback only.
function categorizeError(exitCode, stderr) {
const lc = (stderr || '').toLowerCase();
if (isDailyQuotaExceeded({ ok: false, stderr })) return 'DAILY_QUOTA_EXCEEDED';
if (lc.includes('observability plus')) return 'OPLUS_REQUIRED';
if (lc.includes('costs not found')) return 'USAGE_UNAVAILABLE';
if (lc.includes('project not found')) return 'PROJECT_NOT_FOUND';
if (lc.includes('not linked') || lc.includes('no project')) return 'NOT_LINKED';
if (lc.includes('log in') || lc.includes('credentials')) return 'NOT_AUTH';
if (lc.includes('rate limit') || lc.includes('429')) return 'RATE_LIMIT';
if (lc.includes('permission') || lc.includes('not authorized') || lc.includes('403'))
return 'FORBIDDEN';
return `EXIT_${exitCode}`;
}
// Schema is global per team — pass scope so we hit the right team rather than user's currentTeam.
export async function hasObservabilityPlus(scope) {
const r = await runVercelJson(scopedArgs(['metrics', 'schema', '--format', 'json'], scope));
return r.ok;
}
export async function getMetricsSchema(scope) {
const r = await runVercelJson(scopedArgs(['metrics', 'schema', '--format', 'json'], scope));
return r.ok ? r.data : null;
}
export async function checkObservabilityPlusConfiguration({ orgId, projectId } = {}) {
if (!orgId) {
return {
ok: false,
source: 'observability-configuration-api',
blocker: 'unknown',
detail: 'No team ID was available for the Observability Plus configuration preflight.',
};
}
if (String(orgId).startsWith('usr_')) {
return {
ok: false,
source: 'observability-configuration-api',
access: null,
blocker: 'unknown',
detail: 'The Observability Plus team configuration preflight is not available for a user-owned project; falling back to the scoped metrics probe.',
};
}
const qs = `?teamId=${encodeURIComponent(orgId)}`;
const r = await runVercelJson(['api', `/v1/observability/manage/configuration/projects${qs}`]);
return classifyObservabilityPlusConfiguration(r, { projectId });
}
export function classifyObservabilityPlusConfiguration(result, { projectId } = {}) {
const source = 'observability-configuration-api';
if (result?.ok) {
const disabledProjects = Array.isArray(result.data?.disabledProjects) ? result.data.disabledProjects : [];
const disabled = projectId
? disabledProjects.find((p) => String(p?.id ?? '') === String(projectId))
: null;
if (disabled) {
return {
ok: true,
source,
access: false,
blocker: 'project_disabled',
detail: 'Observability Plus is enabled for the team but disabled for this project.',
disabledProject: {
id: disabled.id,
name: disabled.name ?? null,
disabledAt: disabled.disabledAt ?? null,
},
};
}
return {
ok: true,
source,
access: true,
blocker: null,
detail: 'Observability Plus is enabled for this team/project.',
};
}
const code = String(result?.code ?? 'unknown').toLowerCase();
const text = `${result?.message ?? ''}\n${result?.stderr ?? ''}`.toLowerCase();
const mentionsObservabilityPlusNotEnabled =
/observability plus[\s\S]{0,160}not enabled/.test(text) ||
/not enabled[\s\S]{0,160}observability plus/.test(text) ||
/subscription to observability plus[\s\S]{0,160}required/.test(text);
if (code === 'oplus_required' || ((code === 'not_found' || code === '404') && mentionsObservabilityPlusNotEnabled)) {
return {
ok: true,
source,
access: false,
blocker: 'no_oplus_probe',
detail: 'Route-level metrics are unavailable because Observability Plus is not enabled for this team.',
};
}
if (/forbidden|not_authorized|403/.test(code) || /forbidden|not authorized|permission|403/.test(text)) {
return {
ok: false,
source,
access: null,
blocker: 'forbidden',
detail: 'Could not read Observability Plus configuration for this team. Run `vercel switch <team>` and verify access.',
};
}
if (/not_auth|unauthorized|401/.test(code) || /unauthorized|log in|credentials|401/.test(text)) {
return {
ok: false,
source,
access: null,
blocker: 'forbidden',
detail: 'Could not read Observability Plus configuration because the Vercel CLI is not authenticated.',
};
}
return {
ok: false,
source,
access: null,
blocker: 'unknown',
detail: `Could not determine Observability Plus configuration before querying metrics (code=${code}).`,
};
}
// Returns `{ok, ...}`. CLI summary defaults to top 10 groups under --group-by; widen via opts.limit.
export async function queryMetric(metricId, opts = {}) {
const args = ['metrics', metricId, '--format', 'json'];
if (opts.aggregation) args.push('-a', opts.aggregation);
for (const dim of opts.groupBy ?? []) args.push('--group-by', dim);
if (opts.filter) args.push('-f', opts.filter);
if (opts.since) args.push('--since', opts.since);
if (opts.until) args.push('--until', opts.until);
if (opts.limit) args.push('--limit', String(opts.limit));
// 3-layer protection: semaphore (8 concurrent) + sliding-window (80/60s) + retryOnRateLimit (3× 60-90s jitter). payment_required is terminal.
const throttle = getMetricThrottle();
const onRetry = (attempt, delayMs) => {
console.error(`[queryMetric] ${metricId} hit RATE_LIMITED; retry ${attempt}/3 after ${(delayMs / 1000).toFixed(0)}s`);
};
return await throttle.run(() =>
retryOnRateLimit(() => runVercelJson(scopedArgs(args, opts.scope)), { onRetry })
);
}
// Team-owned projects need `?teamId=<orgId>` to avoid current-team drift. User-
// owned projects use the authenticated user context and should not pass teamId.
export async function getProjectConfig(projectId, orgId) {
const qs = orgId && !String(orgId).startsWith('usr_')
? `?teamId=${encodeURIComponent(orgId)}`
: '';
const r = await runVercelJson(['api', `/v9/projects/${projectId}${qs}`]);
return r.ok ? r.data : { error: r.code, stderr: r.stderr };
}
// USAGE_UNAVAILABLE distinguishes "no Costs feature" from genuine emptiness.
export async function getUsage({ days = 14, scope, groupByProject = true } = {}) {
const toDate = new Date();
const fromDate = new Date(toDate.getTime() - days * 86400000);
const fmt = (d) => d.toISOString().slice(0, 10);
const args = [
'usage',
'--format', 'json',
'--from', fmt(fromDate),
'--to', fmt(toDate),
];
// The CLI rejects --breakdown with --group-by. Project grouping is higher
// value for this skill because every recommendation must be project-scoped.
if (groupByProject) args.push('--group-by', 'project');
else args.push('--breakdown', 'daily');
return await runVercelJson(scopedArgs(args, scope));
}
// CLI `--group-by project` returns project buckets under groupBy.data. Older
// breakdown-shaped fixtures tag service rows with projectId; keep both paths.
export function filterUsageByProject(usage, projectId, projectName = null) {
if (!usage || !projectId) return { filtered: null, matched: false, unattributedTotal: 0 };
if (usage.groupBy?.dimension === 'project' && Array.isArray(usage.groupBy.data)) {
const project = usage.groupBy.data.find((entry) => projectMatches(entry, projectId, projectName));
if (!project) return { filtered: null, matched: false, unattributedTotal: 0 };
return {
filtered: {
...usage,
groupBy: { ...usage.groupBy, data: [project] },
services: Array.isArray(project.services) ? project.services : [],
totals: project.totals ?? null,
project: { name: project.name ?? projectName ?? null, projectId: project.projectId ?? projectId },
},
matched: true,
unattributedTotal: 0,
};
}
const breakdown = usage.breakdown;
if (!breakdown || !Array.isArray(breakdown.data)) {
return { filtered: null, matched: false, unattributedTotal: 0 };
}
const out = {
...usage,
breakdown: { ...breakdown, data: [] },
};
let matchedAny = false;
let projectTotal = 0;
let unattributedTotal = 0;
for (const day of breakdown.data) {
const services = Array.isArray(day.services) ? day.services : [];
const projectRows = services.filter((s) => projectMatches(s, projectId, projectName));
const unattributedRows = services.filter((s) => !s.projectId && !s.project);
for (const r of projectRows) projectTotal += (r.billedCost ?? r.cost ?? 0);
for (const r of unattributedRows) unattributedTotal += (r.billedCost ?? r.cost ?? 0);
if (projectRows.length === 0) continue;
matchedAny = true;
out.breakdown.data.push({ ...day, services: projectRows });
}
if (!matchedAny) return { filtered: null, matched: false, unattributedTotal };
out.services = aggregateServicesByName(out.breakdown.data);
out.totals = { billedCost: projectTotal };
return { filtered: out, matched: true, unattributedTotal };
}
function projectMatches(serviceRow, projectId, projectName = null) {
if (!serviceRow) return false;
if (serviceRow.projectId === projectId) return true;
if (projectName && serviceRow.name === projectName) return true;
if (projectName && serviceRow.project === projectName) return true;
if (serviceRow.project === projectId) return true;
if (serviceRow.project && (serviceRow.project.id === projectId || serviceRow.project.projectId === projectId || serviceRow.project.name === projectName)) return true;
return false;
}
function aggregateServicesByName(days) {
const byName = new Map();
for (const day of days) {
for (const s of (day.services ?? [])) {
const key = s.name ?? '(unnamed)';
const prev = byName.get(key) ?? { name: key, billedCost: 0, pricingQuantity: 0, pricingUnit: s.pricingUnit ?? null };
prev.billedCost += (s.billedCost ?? s.cost ?? 0);
prev.pricingQuantity += (s.pricingQuantity ?? 0);
byName.set(key, prev);
}
}
return Array.from(byName.values()).sort((a, b) => (b.billedCost ?? 0) - (a.billedCost ?? 0));
}
export async function getContract(scope) {
const r = await runVercelJson(scopedArgs(['contract', '--format', 'json'], scope));
return r.ok ? r.data : null;
}
export async function getAccountPlan(scope) {
const currentTeamId = scope ? null : await getCurrentTeamId();
const teamScope = scope || currentTeamId;
if (teamScope && !String(teamScope).startsWith('usr_')) {
const team = await getBillingPlanFromPath(`/v2/teams/${encodeURIComponent(teamScope)}`, 'team.billing.plan');
if (team.plan !== 'unknown' || !/not_found|404/i.test(String(team.error ?? ''))) {
return team;
}
// Older project links can carry a user/org id instead of a team id. If the
// team lookup misses, fall back to the authenticated user's billing record.
}
return await getBillingPlanFromPath('/v2/user', 'user.billing.plan');
}
async function getCurrentTeamId() {
const identity = await getCliIdentity();
return identity?.team?.id ?? null;
}
async function getBillingPlanFromPath(path, source) {
const r = await runVercelJson(['api', path]);
if (!r.ok) {
return {
plan: 'unknown',
reason: `${source} unavailable (${r.code ?? 'unknown'})`,
source,
error: r.code ?? 'unknown',
};
}
const parsed = extractBillingPlan(r.data);
if (!parsed) {
return {
plan: 'unknown',
reason: `${source} missing from Vercel API response`,
source,
};
}
return {
...parsed,
reason: `${source}=${parsed.plan}`,
source,
};
}
export function extractBillingPlan(data) {
const raw =
data?.billing?.plan ??
data?.team?.billing?.plan ??
data?.user?.billing?.plan ??
null;
const plan = normalizeBillingPlan(raw);
return plan ? { plan, rawPlan: raw } : null;
}
function normalizeBillingPlan(raw) {
const value = String(raw ?? '').trim().toLowerCase();
if (value === 'hobby' || value === 'pro' || value === 'enterprise') return value;
return null;
}
// Primary source: billing.plan from `/v2/teams/:team` or `/v2/user`.
// Fallbacks: contract category, then recent billed usage for legacy CLI/API gaps.
export function inferPlan(contract, opts = {}) {
const accountPlan = extractPlanOption(opts?.accountPlan);
if (accountPlan) {
return {
plan: accountPlan.plan,
reason: accountPlan.reason ?? `${accountPlan.source ?? 'billing.plan'}=${accountPlan.plan}`,
};
}
const commits = contract?.commitments ?? [];
if (commits.length > 0) {
const c0 = commits[0] ?? {};
// category field names are tentative — try several.
const category = c0.category ?? c0.commitmentCategory ?? c0.type ?? null;
if (category === 'Spend' || category === 'spend') {
return { plan: 'pro', reason: `commitment category=${category}` };
}
if (category === 'Usage' || category === 'usage') {
return { plan: 'enterprise', reason: `commitment category=${category}` };
}
return { plan: 'uncertain', reason: `unknown commitment category=${category}` };
}
const totalCost = opts?.usageTotalCost;
if (typeof totalCost === 'number' && totalCost > 0) {
return {
plan: 'pro',
reason: `commitments=[] but usage=$${totalCost.toFixed(2)}/window — Pro pay-as-you-go (Hobby teams don't bill)`,
};
}
return {
plan: 'uncertain',
reason: typeof totalCost === 'number' && totalCost === 0
? 'no commitments and no billed usage in window (could be Hobby, or Pro with no recent billing)'
: 'no commitments on contract; usage unavailable',
};
}
function extractPlanOption(accountPlan) {
if (!accountPlan) return null;
if (typeof accountPlan === 'string') {
const plan = normalizeBillingPlan(accountPlan);
return plan ? { plan, reason: `billing.plan=${plan}` } : null;
}
const plan = normalizeBillingPlan(accountPlan.plan);
if (!plan) return null;
return {
plan,
reason: accountPlan.reason ?? (
accountPlan.source
? `${accountPlan.source}=${plan}`
: `billing.plan=${plan}`
),
source: accountPlan.source ?? null,
};
}
export async function detectStack(cwd = process.cwd()) {
const pkgPath = join(cwd, 'package.json');
let pkg = {};
try {
pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
} catch {
return baselineStack();
}
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const framework =
deps.next ? 'next' :
deps.nuxt ? 'nuxt' :
deps.astro ? 'astro' :
deps['@sveltejs/kit'] ? 'sveltekit' :
deps['@remix-run/react'] ? 'remix' :
deps.hono ? 'hono' :
'unknown';
const frameworkVersion = (() => {
const m = { next: 'next', nuxt: 'nuxt', astro: 'astro', sveltekit: '@sveltejs/kit', remix: '@remix-run/react', hono: 'hono' };
const dep = m[framework];
if (!dep) return null;
return (deps[dep] || '').replace(/^[\^~]/, '') || null;
})();
const hasAppRouter = await pathExists(join(cwd, 'app')) || await pathExists(join(cwd, 'src/app'));
const hasPagesRouter = await pathExists(join(cwd, 'pages')) || await pathExists(join(cwd, 'src/pages'));
const typescript = await pathExists(join(cwd, 'tsconfig.json'));
const cacheComponents = framework === 'next'
? await detectNextCacheComponents(cwd)
: null;
const orm =
deps.prisma || deps['@prisma/client'] ? 'prisma' :
deps['drizzle-orm'] ? 'drizzle' :
deps.kysely ? 'kysely' :
'none';
const vercelFlagsPackages = [
'@vercel/flags',
'@vercel/flags/next',
'@vercel/flags/sveltekit',
'@vercel/flags/nuxt',
].filter((name) => deps[name]);
const workflowPackages = Object.keys(deps)
.filter((name) => name === 'workflow' || name.startsWith('@workflow/'))
.sort();
const isMonorepo =
!!pkg.workspaces ||
await pathExists(join(cwd, 'pnpm-workspace.yaml')) ||
await pathExists(join(cwd, 'lerna.json'));
return {
framework,
frameworkVersion,
hasAppRouter,
hasPagesRouter,
cacheComponents,
typescript,
orm,
isMonorepo,
rootDirectory: null,
hasVercelFlagsPackage: vercelFlagsPackages.length > 0,
vercelFlagsPackages,
hasWorkflowPackage: workflowPackages.length > 0,
workflowPackages,
};
}
function baselineStack() {
return {
framework: 'unknown', frameworkVersion: null,
hasAppRouter: false, hasPagesRouter: false, cacheComponents: null, typescript: false,
orm: 'none', isMonorepo: false, rootDirectory: null,
hasVercelFlagsPackage: false, vercelFlagsPackages: [],
hasWorkflowPackage: false, workflowPackages: [],
};
}
async function detectNextCacheComponents(cwd) {
for (const name of ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs']) {
try {
const content = await readFile(join(cwd, name), 'utf-8');
if (/\bcacheComponents\s*:\s*true\b/.test(content)) return true;
if (/\bcacheComponents\s*:\s*false\b/.test(content)) return false;
} catch {}
}
return null;
}
async function pathExists(p) {
try { await access(p); return true; } catch { return false; }
}
// `--scope <teamId>` is buggy on several subcommands (silently falls back to
// currentTeam). Resolve raw account IDs to slugs/usernames before scoped calls.
function scopedArgs(args, scope) {
if (!scope) return args;
if (typeof scope === 'string' && /^(team|usr)_/.test(scope)) {
throw new Error('RAW_ID_SCOPE_UNRESOLVED: resolve the linked org/user ID to a CLI scope slug before running Vercel commands.');
}
return [...args, '--scope', scope];
}
// CLI summary field is `<metric_id_with_underscores>_<aggregation>` (e.g. `vercel_request_count_sum`).
export function normalizeSummary(metricResponse, metricId, aggregation, groupBy = []) {
if (!metricResponse || metricResponse.error) return [];
const field = `${metricId.replace(/\./g, '_')}_${aggregation}`;
const rows = Array.isArray(metricResponse.summary) ? metricResponse.summary : [];
return rows.map((row) => {
const out = { value: row[field] ?? null };
for (const dim of groupBy) {
if (row[dim] !== undefined) out[dim] = row[dim];
}
return out;
});
}