Skip to content

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

Package listing

@kody/netlify

src/netlify.js

409 lines · 13.0 KB · JavaScript
import { rawNetlifyRequest } from './client.js'
import {
	authFromInput,
	authSetupMessage,
	bodyFromInput,
	isRecord,
	paginationFromInput,
	queryFromInput,
	redactEnvValue,
	requireConfirm,
	setupUrls,
	slimAccount,
	slimDeploy,
	slimEnvVar,
	slimForm,
	slimSite,
	slimSubmission,
	slimUser,
} 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(`Netlify 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(`Netlify 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|netlifyToken|unresolved/i.test(
		message,
	)
}

function asList(data) {
	if (Array.isArray(data)) return data
	if (isRecord(data) && Array.isArray(data.items)) return data.items
	return []
}

/**
 * Escape-hatch Netlify REST request. Prefer named helpers when available.
 * Path is relative to https://api.netlify.com/api/v1.
 * Mutating methods require `dryRun: true` or `confirm: true`.
 */
export async function netlifyRequest(input = {}) {
	if (!isRecord(input)) throw new Error('Netlify request input must be an object.')

	let path = input.path
	if (typeof path !== 'string' || path.trim() === '') {
		throw new Error('Netlify request requires a non-empty path string.')
	}
	if (/^https?:\/\//i.test(path)) {
		throw new Error('Netlify request path must be relative, for example /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 = input.query ?? {}
	if (mutating) {
		const preview = requireConfirm(input, 'netlifyRequest', method, path, redactEnvValue(input.body))
		if (preview) return { ...preview, query }
	} else if (input.dryRun) {
		return { dryRun: true, method, path, query, body: input.body ?? null }
	}

	const response = await rawNetlifyRequest(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 rawNetlifyRequest(path, {
		method: 'GET',
		query,
		headers: input?.headers,
		...authFromInput(input),
	})
	return parseOk(response, path)
}

export async function getUser(input = {}) {
	const body = await getJson('/user', input)
	return slimUser(body)
}

/**
 * Verify Netlify credentials with a trimmed `/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: '/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 listAccounts(input = {}) {
	const body = await getJson('/accounts', input)
	return { items: asList(body).map(slimAccount), ...paginationFromInput(input) }
}

export async function listSites(input = {}) {
	const account = input?.accountSlug ?? input?.account_slug
	const path =
		typeof account === 'string' && account.trim()
			? `/${encodeURIComponent(account.trim())}/sites`
			: '/sites'
	const body = await getJson(path, input, ['accountSlug', 'account_slug'])
	return {
		items: asList(body).map(slimSite),
		...paginationFromInput(input),
		account_slug: typeof account === 'string' && account.trim() ? account.trim() : null,
	}
}

export async function getSite(input = {}) {
	const site = requiredString(input, ['site', 'siteId', 'site_id', 'id'], 'site')
	const body = await getJson(`/sites/${encodeURIComponent(site)}`, input, [
		'site',
		'siteId',
		'site_id',
		'id',
	])
	return slimSite(body)
}

export async function listDeploys(input = {}) {
	const site = requiredString(input, ['site', 'siteId', 'site_id'], 'site')
	const path = `/sites/${encodeURIComponent(site)}/deploys`
	const body = await getJson(path, input, ['site', 'siteId', 'site_id'])
	return { items: asList(body).map(slimDeploy), site, ...paginationFromInput(input) }
}

export async function getDeploy(input = {}) {
	const deploy = requiredString(input, ['deploy', 'deployId', 'deploy_id', 'id'], 'deploy')
	const site = input?.site ?? input?.siteId ?? input?.site_id
	const path =
		typeof site === 'string' && site.trim()
			? `/sites/${encodeURIComponent(site.trim())}/deploys/${encodeURIComponent(deploy)}`
			: `/deploys/${encodeURIComponent(deploy)}`
	const body = await getJson(path, input, ['deploy', 'deployId', 'deploy_id', 'id', 'site', 'siteId', 'site_id'])
	return slimDeploy(body)
}

/**
 * Create a site deploy. Requires `dryRun: true` or `confirm: true`.
 * Does not upload files; pass a file digest or zip in the body.
 * Production deploys (`production: true`) stay preview-only unless `confirm: true`.
 */
export async function createDeploy(input = {}) {
	const site = requiredString(input, ['site', 'siteId', 'site_id'], 'site')
	const body = bodyFromInput(input, ['site', 'siteId', 'site_id', 'production'])
	const query = {}
	if (input.production === true) query.production = true
	if (typeof input.branch === 'string' && input.branch.trim() && body.branch === undefined) {
		body.branch = input.branch.trim()
	}
	const path = `/sites/${encodeURIComponent(site)}/deploys`
	const preview = requireConfirm(input, 'createDeploy', 'POST', path, redactEnvValue(body))
	if (preview) return { ...preview, query, site }

	const response = await rawNetlifyRequest(path, {
		method: 'POST',
		query,
		headers: input.headers,
		body,
		...authFromInput(input),
	})
	return slimDeploy(await parseOk(response, path))
}

/**
 * Restore a previous deploy as the published site. Requires `dryRun: true` or `confirm: true`.
 * This publishes to the site's production URL.
 */
export async function restoreDeploy(input = {}) {
	const site = requiredString(input, ['site', 'siteId', 'site_id'], 'site')
	const deploy = requiredString(input, ['deploy', 'deployId', 'deploy_id', 'id'], 'deploy')
	const path = `/sites/${encodeURIComponent(site)}/deploys/${encodeURIComponent(deploy)}/restore`
	const preview = requireConfirm(input, 'restoreDeploy', 'POST', path, null)
	if (preview) return { ...preview, site, deploy }

	const response = await rawNetlifyRequest(path, {
		method: 'POST',
		headers: input.headers,
		...authFromInput(input),
	})
	return slimDeploy(await parseOk(response, path))
}

/**
 * List site environment-variable metadata. Values are never returned.
 */
export async function listEnvVars(input = {}) {
	const site = requiredString(input, ['site', 'siteId', 'site_id'], 'site')
	const path = `/sites/${encodeURIComponent(site)}/env`
	const query = queryFromInput(input, ['site', 'siteId', 'site_id'])
	const response = await rawNetlifyRequest(path, {
		method: 'GET',
		query,
		headers: input.headers,
		...authFromInput(input),
	})
	const body = await parseOk(response, path)
	return { items: asList(body).map(slimEnvVar), site }
}

function envCreatePayload(input) {
	if (Array.isArray(input?.body)) return input.body
	if (Array.isArray(input?.envVars)) return input.envVars
	const key = typeof input?.key === 'string' ? input.key.trim() : ''
	if (!key) throw new Error('createEnvVar requires key, or an envVars / body array.')
	const context = typeof input?.context === 'string' && input.context.trim() ? input.context.trim() : 'all'
	if (input?.value === undefined || input?.value === null || input?.value === '') {
		throw new Error('createEnvVar requires value.')
	}
	const scopes = input.scopes ?? ['builds', 'functions', 'runtime', 'post_processing']
	return [
		{
			key,
			scopes,
			values: [{ value: input.value, context }],
		},
	]
}

/**
 * Create site environment variables. Requires `dryRun: true` or `confirm: true`.
 * Dry-run previews redact values. Needs the team `account` slug or id.
 */
export async function createEnvVar(input = {}) {
	const account = requiredString(input, ['accountId', 'account_id', 'accountSlug', 'account_slug'], 'account')
	const site = requiredString(input, ['site', 'siteId', 'site_id'], 'site')
	const payload = envCreatePayload(input)
	const path = `/accounts/${encodeURIComponent(account)}/env`
	const preview = requireConfirm(input, 'createEnvVar', 'POST', path, redactEnvValue(payload))
	if (preview) return { ...preview, query: { site_id: site }, site, account }

	const response = await rawNetlifyRequest(path, {
		method: 'POST',
		query: { site_id: site },
		headers: input.headers,
		body: payload,
		...authFromInput(input),
	})
	const body = await parseOk(response, path)
	return { items: asList(body).map(slimEnvVar), site, account }
}

export async function listForms(input = {}) {
	const site = requiredString(input, ['site', 'siteId', 'site_id'], 'site')
	const path = `/sites/${encodeURIComponent(site)}/forms`
	const body = await getJson(path, input, ['site', 'siteId', 'site_id'])
	return { items: asList(body).map(slimForm), site }
}

export async function listFormSubmissions(input = {}) {
	const form = input?.form ?? input?.formId ?? input?.form_id
	const site = input?.site ?? input?.siteId ?? input?.site_id
	let path
	if (typeof form === 'string' && form.trim()) {
		path = `/forms/${encodeURIComponent(form.trim())}/submissions`
	} else if (typeof site === 'string' && site.trim()) {
		path = `/sites/${encodeURIComponent(site.trim())}/submissions`
	} else {
		throw new Error('listFormSubmissions requires form or site.')
	}
	const body = await getJson(path, input, ['form', 'formId', 'form_id', 'site', 'siteId', 'site_id'])
	return {
		items: asList(body).map(slimSubmission),
		form: typeof form === 'string' && form.trim() ? form.trim() : null,
		site: typeof site === 'string' && site.trim() ? site.trim() : null,
		...paginationFromInput(input),
	}
}

/**
 * Read-only site list for the scheduled job. Uses the caller's token — no site ids.
 */
export async function runSiteStatus(input = {}) {
	const setup = setupUrls()
	if (input?.dryRun) {
		return { ok: true, live: false, dryRun: true, method: 'GET', path: '/sites', setup }
	}
	try {
		const listed = await listSites({ ...input, per_page: input.per_page ?? 20 })
		return {
			ok: true,
			live: true,
			count: listed.items.length,
			sites: listed.items.map((site) => ({
				id: site.id,
				name: site.name,
				url: site.ssl_url ?? site.url,
				state: site.state,
			})),
			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
	}
}

const actions = {
	request: netlifyRequest,
	setup: () => setupUrls(),
	'smoke-test': smokeTest,
	smoke: smokeTest,
	'get-user': getUser,
	user: getUser,
	'list-accounts': listAccounts,
	accounts: listAccounts,
	'list-sites': listSites,
	sites: listSites,
	'get-site': getSite,
	'list-deploys': listDeploys,
	deploys: listDeploys,
	'get-deploy': getDeploy,
	'create-deploy': createDeploy,
	'restore-deploy': restoreDeploy,
	'list-env-vars': listEnvVars,
	env: listEnvVars,
	'create-env-var': createEnvVar,
	'list-forms': listForms,
	forms: listForms,
	'list-form-submissions': listFormSubmissions,
	submissions: listFormSubmissions,
	'scheduled-status': runSiteStatus,
	'list-sites-status': runSiteStatus,
}

/**
 * Dispatch Netlify helper actions such as `list-sites` 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 Netlify API payload.
 * @example
 * import netlify from 'kody:@kody/netlify'
 * const result = await netlify({ action: 'list-sites' })
 */
export default async function netlify(input = {}) {
	const action = input.action ?? 'smoke-test'
	const handler = actions[action]
	if (!handler) {
		throw new Error(`Unsupported Netlify action: ${action}`)
	}
	return await handler(input)
}

export { authSetupMessage, setupUrls }