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