Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/vercel

src/vercel.js

355 lines · 11.2 KB · JavaScript
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 }