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 }