import { rawVercelRequest } from './client.js' import { authFromInput, authSetupMessage, bodyFromInput, isRecord, paginationFrom, queryFromInput, redactEnvValue, requireConfirm, setupUrls, slimDeployment, slimDomain, slimEnvVar, slimProject, slimTeam, slimUser, teamQuery, } from './setup.js' function requiredString(input, names, label) { for (const name of names) { const value = input?.[name] ?? input?.params?.[name] if (typeof value === 'string' && value.trim()) return value.trim() if (typeof value === 'number' && Number.isFinite(value)) return String(value) } throw new Error(`Vercel helper requires ${label}.`) } async function parseBody(response) { const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) return await response.json() const text = await response.text() return text ? { text } : null } async function parseOk(response, pathHint) { const data = await parseBody(response) if (!response.ok) { const error = new Error(`Vercel API request failed with ${response.status} ${response.statusText}`) error.status = response.status error.statusText = response.statusText error.path = pathHint error.data = data throw error } return data } function isAuthSetupFailure(error) { const status = error?.status if (status === 401 || status === 403) return true const message = String(error?.message ?? error) return /secret|host approval|not connected|not approved|placeholder|vercelToken|unresolved/i.test( message, ) } /** * Escape-hatch Vercel REST request. Prefer named helpers when available. * Path is relative to https://api.vercel.com. * Mutating methods require `dryRun: true` or `confirm: true`. */ export async function vercelRequest(input = {}) { if (!isRecord(input)) throw new Error('Vercel request input must be an object.') let path = input.path if (typeof path !== 'string' || path.trim() === '') { throw new Error('Vercel request requires a non-empty path string.') } if (/^https?:\/\//i.test(path)) { throw new Error('Vercel request path must be relative, for example /v2/user.') } path = path.startsWith('/') ? path : `/${path}` const method = String(input.method ?? (input.body === undefined ? 'GET' : 'POST')).toUpperCase() const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method) const query = { ...teamQuery(input), ...(input.query ?? {}) } if (mutating) { const preview = requireConfirm(input, 'vercelRequest', method, path, input.body) if (preview) return { ...preview, query } } else if (input.dryRun) { return { dryRun: true, method, path, query, body: input.body ?? null } } const response = await rawVercelRequest(path, { method, query, headers: input.headers, body: input.body, ...authFromInput(input), }) const data = await parseOk(response, path) return { status: response.status, data } } async function getJson(path, input, queryIgnoredKeys = []) { const query = queryFromInput(input, queryIgnoredKeys) const response = await rawVercelRequest(path, { method: 'GET', query, headers: input?.headers, ...authFromInput(input), }) return parseOk(response, path) } export async function getUser(input = {}) { const body = await getJson('/v2/user', input) const user = isRecord(body?.user) ? body.user : body return slimUser(user) } /** * Verify Vercel credentials with a trimmed `/v2/user` read. * Without credentials, returns setup URLs instead of throwing. */ export async function smokeTest(input = {}) { const setup = setupUrls() if (input?.dryRun) { return { ok: true, live: false, dryRun: true, method: 'GET', path: '/v2/user', setup } } try { const user = await getUser(input) return { ok: true, live: true, user, setup } } catch (error) { if (isAuthSetupFailure(error)) { return { ok: true, live: false, setup, reason: error instanceof Error ? error.message : String(error), status: error?.status ?? null, } } throw error } } export async function listTeams(input = {}) { const body = await getJson('/v2/teams', input) const teams = Array.isArray(body?.teams) ? body.teams.map(slimTeam) : [] return { items: teams, pagination: paginationFrom(body) } } export async function getTeam(input = {}) { const teamId = requiredString(input, ['teamId', 'id'], 'teamId') const body = await getJson(`/v2/teams/${encodeURIComponent(teamId)}`, input, ['teamId', 'id']) return slimTeam(body) } export async function listProjects(input = {}) { const body = await getJson('/v9/projects', input) const projects = Array.isArray(body?.projects) ? body.projects.map(slimProject) : [] return { items: projects, pagination: paginationFrom(body) } } export async function getProject(input = {}) { const idOrName = requiredString(input, ['project', 'idOrName', 'id', 'name'], 'project') const body = await getJson(`/v9/projects/${encodeURIComponent(idOrName)}`, input, [ 'project', 'idOrName', 'id', 'name', ]) return slimProject(body) } export async function listDeployments(input = {}) { const body = await getJson('/v6/deployments', input) const deployments = Array.isArray(body?.deployments) ? body.deployments.map(slimDeployment) : [] return { items: deployments, pagination: paginationFrom(body) } } export async function getDeployment(input = {}) { const idOrUrl = requiredString(input, ['deployment', 'idOrUrl', 'id', 'url'], 'deployment') const body = await getJson(`/v13/deployments/${encodeURIComponent(idOrUrl)}`, input, [ 'deployment', 'idOrUrl', 'id', 'url', ]) return slimDeployment(body) } /** * Create a Vercel deployment. Requires `dryRun: true` or `confirm: true`. * Does not upload files; pass a gitSource or already-uploaded files in the body. */ export async function createDeployment(input = {}) { const body = bodyFromInput(input) if (!body.name && !body.project && !body.projectId) { throw new Error('createDeployment requires name or project.') } const preview = requireConfirm(input, 'createDeployment', 'POST', '/v13/deployments', body) if (preview) return preview const response = await rawVercelRequest('/v13/deployments', { method: 'POST', query: teamQuery(input), headers: input.headers, body, ...authFromInput(input), }) return slimDeployment(await parseOk(response, '/v13/deployments')) } export async function listDomains(input = {}) { const project = input?.project ?? input?.idOrName ?? input?.projectId if (typeof project === 'string' && project.trim()) { const path = `/v9/projects/${encodeURIComponent(project.trim())}/domains` const body = await getJson(path, input, ['project', 'idOrName', 'projectId']) const domains = Array.isArray(body?.domains) ? body.domains.map(slimDomain) : [] return { items: domains, pagination: paginationFrom(body), project: project.trim() } } const body = await getJson('/v5/domains', input) const domains = Array.isArray(body?.domains) ? body.domains.map(slimDomain) : [] return { items: domains, pagination: paginationFrom(body) } } /** * Add a domain to a project, or to the account when `project` is omitted. * Requires `dryRun: true` or `confirm: true`. */ export async function addDomain(input = {}) { const name = requiredString(input, ['name', 'domain'], 'name') const project = input?.project ?? input?.idOrName ?? input?.projectId const body = { ...bodyFromInput(input, ['project', 'idOrName', 'projectId', 'domain']), name } if (typeof project === 'string' && project.trim()) { const path = `/v10/projects/${encodeURIComponent(project.trim())}/domains` const preview = requireConfirm(input, 'addDomain', 'POST', path, body) if (preview) return preview const response = await rawVercelRequest(path, { method: 'POST', query: teamQuery(input), headers: input.headers, body, ...authFromInput(input), }) return slimDomain(await parseOk(response, path)) } const path = '/v7/domains' const preview = requireConfirm(input, 'addDomain', 'POST', path, body) if (preview) return preview const response = await rawVercelRequest(path, { method: 'POST', query: teamQuery(input), headers: input.headers, body, ...authFromInput(input), }) return slimDomain(await parseOk(response, path)) } /** * List project environment variable metadata. Values are never returned. */ export async function listEnvVars(input = {}) { const project = requiredString(input, ['project', 'idOrName', 'projectId'], 'project') const path = `/v10/projects/${encodeURIComponent(project)}/env` const query = queryFromInput(input, ['project', 'idOrName', 'projectId', 'decrypt']) delete query.decrypt const response = await rawVercelRequest(path, { method: 'GET', query, headers: input.headers, ...authFromInput(input), }) const body = await parseOk(response, path) const envs = Array.isArray(body?.envs) ? body.envs.map(slimEnvVar) : [] return { items: envs, pagination: paginationFrom(body), project, hiddenProductionEnvCount: body?.hiddenProductionEnvCount ?? null, } } /** * Create a project environment variable. Requires `dryRun: true` or `confirm: true`. * Dry-run previews redact the value. */ export async function createEnvVar(input = {}) { const project = requiredString(input, ['project', 'idOrName', 'projectId'], 'project') const body = bodyFromInput(input, ['project', 'idOrName', 'projectId']) if (typeof body.key !== 'string' || body.key.trim() === '') { throw new Error('createEnvVar requires key.') } if (body.value === undefined || body.value === null || body.value === '') { throw new Error('createEnvVar requires value.') } const path = `/v10/projects/${encodeURIComponent(project)}/env` const preview = requireConfirm(input, 'createEnvVar', 'POST', path, redactEnvValue(body)) if (preview) return preview const response = await rawVercelRequest(path, { method: 'POST', query: teamQuery(input), headers: input.headers, body, ...authFromInput(input), }) return slimEnvVar(await parseOk(response, path)) } const actions = { request: vercelRequest, setup: () => setupUrls(), 'smoke-test': smokeTest, smoke: smokeTest, 'get-user': getUser, user: getUser, 'list-teams': listTeams, teams: listTeams, 'get-team': getTeam, 'list-projects': listProjects, projects: listProjects, 'get-project': getProject, 'list-deployments': listDeployments, deployments: listDeployments, 'get-deployment': getDeployment, 'create-deployment': createDeployment, 'list-domains': listDomains, domains: listDomains, 'add-domain': addDomain, 'list-env-vars': listEnvVars, env: listEnvVars, 'create-env-var': createEnvVar, } /** * Dispatch Vercel helper actions such as `list-projects` or `smoke-test`. * @param {Object} [input] * @param {string} [input.action] Action name. Defaults to `smoke-test`. * @param {boolean} [input.dryRun] Preview mutating calls without writing. * @returns {Promise} Action-specific Vercel API payload. * @example * import vercel from 'kody:@kody/vercel' * const result = await vercel({ action: 'list-teams' }) * // => { items: [{ id: 'team_…', name: 'Acme', slug: 'acme' }], pagination: { … } } */ export default async function vercel(input = {}) { const action = input.action ?? 'smoke-test' const handler = actions[action] if (!handler) { throw new Error(`Unsupported Vercel action: ${action}`) } return await handler(input) } export { authSetupMessage, setupUrls }