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<unknown>} 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 }