Your Vercel projects in a side pane: each connected site with the state of its latest deployment (building, ready, failed), the production domain, the Git…

Your Vercel projects in a side pane: each connected site with the state of its latest deployment, its production domain, the Git commit behind it and whether the site answers right now. It refreshes itself and toasts when a deployment finishes or fails.
vercel: 5 ready · 1 building · 1 failed
◐ web 1m ✓200
✕ api 5s ✕502
● docs 25h ✓200
/vercel opens it (/vercel stop closes it). A row opens that project's last five deployments (production or preview, age, commit message) and a copy url button.
$.clock.every(5000) decides when to read: every pollSeconds (60 by default) while nothing builds, every 10 seconds while a deployment builds or is queued, and nothing while the pane is closed and the status line is off.vercel: web is ready in production, vercel: api failed: …). The first read announces nothing.$.ui.status keeps vercel: 5 ready · 1 building · 1 failed under the prompt.healthCheck on, each production domain gets one GET at most once a minute, with a 5-second cap. A site "answers" with any status below 500, so a protected deployment (401) counts as up and a gateway failure does not.Read-only: it lists projects (GET /v10/projects) and deployments (GET /v7/deployments) and sends nothing else to Vercel. The access token comes from the plugin options (vercelToken, stored as sensitive), goes only to api.vercel.com in the Authorization header, and is never logged or drawn. The health probes carry no credentials and go to the hostnames Vercel reports for your projects, which are your own sites but not hosts this mod controls: turn healthCheck off to send nothing but API calls. Use a token scoped to the team you want to see (teamId takes a team_… id or a team slug).
This is separate from security/jev-vercel-sandbox, which runs Claude's work in a Vercel Sandbox.
vercelToken: string Vercel access token (required)
teamId: string team_... id or slug (default: the token's own account)
maxProjects: number projects listed, 1-50 (default 20)
pollSeconds: number idle refresh interval, 10-3600 (default 60)
healthCheck: boolean GET each production domain, at most once a minute (default true)
status: boolean status line summary; keeps polling with the pane closed (default true)
toast: boolean toast when a deployment finishes (default true)
openOnStart: boolean open the pane at session start (default false)
Vercel's response fields come from its REST reference; the parsers read what they find and leave out what is missing, so an API change degrades rows rather than breaking the pane.
npx claude-code-templates@latest --mod integrations/vercel-deploys
claude
It is written to .claude/skills/vercel-deploys/, which Claude Code auto-loads as vercel-deploys@skills-dir once the workspace trust prompt is accepted. For one session with hot reload: claude --plugin-dir .claude/skills/vercel-deploys. claude plugin validate .claude/skills/vercel-deploys prints every event it hooks and every $ call it makes; claude plugin test .claude/skills/vercel-deploys runs its tests.
Options are read from user settings (~/.claude/settings.json, never project settings), --settings <file> or managed settings, under the plugin's full id:
{ "pluginConfigs": { "vercel-deploys@skills-dir": { "options": { } } } }
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/vercel-deploys.tsx 297 lines1/**
2 * vercel-deploys — Claude Mod
3 *
4 * Your Vercel projects in a pane: one row per connected site with the state of
5 * its latest deployment, the production domain, the commit behind it and
6 * whether the site answers. It keeps itself current:
7 *
8 * - `$.clock.every(5000)` decides when to read: every `pollSeconds` while
9 * nothing builds, every 10 s while something does; nothing while the pane
10 * is closed and the status line is off
11 * - a deployment that turns ready or fails raises a toast
12 * - `$.ui.status` keeps "N ready · N building · N failed" under the prompt
13 *
14 * Read-only: it lists projects and deployments through Vercel's REST API and,
15 * if `healthCheck` is on, sends one GET to each production domain at most once
16 * a minute. The token is only ever sent to api.vercel.com in an Authorization
17 * header and is never logged or drawn. Never hardcode it in this file.
18 *
19 * Needs Claude Code >= 2.1.287 and a
20 * Vercel access token in the plugin's options.
21 *
22 * Options (pluginConfigs["vercel-deploys@skills-dir"].options):
23 * vercelToken: string required; without it the mod does nothing
24 * teamId: string team_... id or a team slug (default: the token's own account)
25 * maxProjects: number projects listed, 1-50 (default 20)
26 * pollSeconds: number idle refresh interval, 10-3600 (default 60)
27 * healthCheck: boolean GET each production domain, at most once a minute (default true)
28 * status: boolean status line summary, keeps polling with the pane closed (default true)
29 * toast: boolean toast when a deployment finishes (default true)
30 * openOnStart: boolean open the pane at session start (default false)
31 */
32import type { Register } from 'claude-code'
33import {
34 COLOR,
35 GLYPH,
36 ago,
37 answers,
38 changes,
39 clampInt,
40 fit,
41 listDeploys,
42 listProjects,
43 sortProjects,
44 summarize,
45 summaryText,
46} from './vercel-api.ts'
47import type { Change, Deploy, Fetch, Project } from './vercel-api.ts'
48
49const PANE = 'vercel'
50const COMMAND = 'vercel'
51const TICK_MS = 5000
52const BUILDING_POLL_MS = 10_000
53const HEALTH_EVERY_MS = 60_000
54const HEALTH_TIMEOUT_MS = 5000
55
56type Health = { status: number; ok: boolean; at: number }
57
58let projects: Project[] = []
59let before = new Map<string, Deploy>()
60let health = new Map<string, Health>()
61let error: string | undefined
62let isOpen = false
63let isBusy = false
64let lastReadAt = 0
65let selected: string | undefined
66let history: Deploy[] = []
67let timer: { cancel: () => void } | undefined
68
69const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
70
71export const register: Register = (on, options) => {
72 const token = str(options.vercelToken)
73 const team = str(options.teamId)
74 const maxProjects = clampInt(options.maxProjects, 20, 1, 50)
75 const idleMs = clampInt(options.pollSeconds, 60, 10, 3600) * 1000
76 const healthCheck = options.healthCheck !== false
77 const showStatus = options.status !== false
78 const wantToast = options.toast !== false
79 const openOnStart = options.openOnStart === true
80
81 const isBuilding = () => projects.some(p => p.latest?.state === 'building')
82 const statusText = () => (token && projects.length > 0 ? summaryText(summarize(projects)) : undefined)
83
84 /** One read of the projects, then the sites that are due a health check; the toasts it earned. */
85 async function read(f: Fetch, sleep: (ms: number) => Promise<void>): Promise<Change[]> {
86 isBusy = true
87 try {
88 const list = sortProjects(await listProjects(f, token, team, maxProjects))
89 const finished = changes(before, list)
90 before = new Map(list.flatMap(p => (p.latest ? [[p.id, p.latest] as const] : [])))
91 projects = list
92 error = undefined
93 lastReadAt = Date.now()
94
95 if (healthCheck) {
96 const due = list.filter(p => p.domain && Date.now() - (health.get(p.id)?.at ?? 0) >= HEALTH_EVERY_MS)
97 await Promise.all(
98 due.map(async p => {
99 const at = Date.now()
100 const hit = await Promise.race([
101 f(`https://${p.domain}`, { method: 'GET' }).then(r => ({ status: r.status })),
102 sleep(HEALTH_TIMEOUT_MS).then(() => ({ status: 0 })),
103 ]).catch(() => ({ status: 0 }))
104 health.set(p.id, { status: hit.status, ok: answers(hit.status), at })
105 }),
106 )
107 }
108 return finished
109 } catch (err) {
110 error = err instanceof Error ? err.message : String(err)
111 lastReadAt = Date.now()
112 return []
113 } finally {
114 isBusy = false
115 }
116 }
117
118 const toastFor = (c: Change) =>
119 c.to === 'ready'
120 ? `vercel: ${c.project} is ready${c.target === 'production' ? ' in production' : ''}`
121 : `vercel: ${c.project} failed${c.error ? `: ${fit(c.error, 80)}` : ''}`
122
123 on('session.start', async ($, e, next) => {
124 const r = await next(e)
125 await $.command
126 .register({
127 name: COMMAND,
128 description: 'Your Vercel projects and the state of their deployments (stop closes)',
129 argumentHint: '[stop]',
130 immediate: true,
131 })
132 .catch(err => $.ui.log(`vercel-deploys: /${COMMAND} not registered: ${err}`))
133 if (!token) {
134 $.ui.log('vercel-deploys: set vercelToken in the plugin options to list your projects', { to: 'debug' })
135 return r
136 }
137
138 timer?.cancel()
139 timer = $.clock.every(TICK_MS, () => {
140 if (isBusy || (!isOpen && !showStatus)) return
141 if (Date.now() - lastReadAt < (isBuilding() ? BUILDING_POLL_MS : idleMs) - TICK_MS / 2) return
142 void read(
143 (url, init) => $.http.fetch(url, init),
144 ms => $.clock.sleep(ms),
145 ).then(finished => {
146 if (wantToast) for (const c of finished) $.ui.toast(toastFor(c))
147 $.ui.status(showStatus ? statusText() : undefined)
148 $.ui.invalidate('ui.render')
149 })
150 })
151
152 if (openOnStart) {
153 await read(
154 (url, init) => $.http.fetch(url, init),
155 ms => $.clock.sleep(ms),
156 )
157 isOpen = true
158 await $.ui.open({ id: PANE, title: 'vercel' }).catch(() => {
159 isOpen = false
160 })
161 $.ui.status(showStatus ? statusText() : undefined)
162 }
163 return r
164 })
165
166 on('session.end', async ($, e, next) => {
167 timer?.cancel()
168 timer = undefined
169 return next(e)
170 })
171
172 on('command.run', { command: COMMAND }, async ($, e) => {
173 if (!token) return { text: 'vercel: set vercelToken in the plugin options' }
174 const arg = e.args.trim().toLowerCase()
175 if (arg === 'stop' || arg === 'close') {
176 await $.ui.close({ id: PANE }).catch(() => undefined)
177 isOpen = false
178 return { text: 'vercel closed' }
179 }
180 await read(
181 (url, init) => $.http.fetch(url, init),
182 ms => $.clock.sleep(ms),
183 )
184 isOpen = true
185 await $.ui.open({ id: PANE, title: 'vercel', focus: true })
186 $.ui.status(showStatus ? statusText() : undefined)
187 $.ui.invalidate('ui.render')
188 if (error) return { text: `vercel: ${error}` }
189 return { text: `${summaryText(summarize(projects))} · /${COMMAND} stop closes` }
190 })
191
192 on('ui.close', async ($, e, next) => {
193 if (e.id !== PANE) return next(e)
194 isOpen = false
195 return next(e)
196 })
197
198 on('ui.press', async ($, e, next) => {
199 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
200 const r = await next(e)
201 const key = e.element
202 if (key === 'close') {
203 await $.ui.close({ id: PANE }).catch(() => undefined)
204 return r
205 }
206 if (key === 'refresh') {
207 if (!isBusy) {
208 health = new Map()
209 const finished = await read(
210 (url, init) => $.http.fetch(url, init),
211 ms => $.clock.sleep(ms),
212 )
213 if (wantToast) for (const c of finished) $.ui.toast(toastFor(c))
214 if (selected) {
215 history = await listDeploys((url, init) => $.http.fetch(url, init), token, team, selected, 5).catch(() => history)
216 }
217 }
218 } else if (key.startsWith('p:')) {
219 const project = projects[Number(key.slice(2))]
220 if (project && selected === project.id) {
221 selected = undefined
222 history = []
223 } else if (project) {
224 selected = project.id
225 history = await listDeploys((url, init) => $.http.fetch(url, init), token, team, project.id, 5).catch(() => [])
226 }
227 } else if (key === 'copy') {
228 const domain = projects.find(p => p.id === selected)?.domain
229 if (domain) {
230 await $.ui.copy({ text: `https://${domain}`, surface: e.surface }).catch(() => undefined)
231 $.ui.toast(`vercel: copied https://${domain}`)
232 }
233 }
234 $.ui.status(showStatus ? statusText() : undefined)
235 $.ui.invalidate('ui.render')
236 return r
237 })
238
239 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
240 if (e.requestId !== PANE) return next(e)
241 const { Box, Text, Button } = $.ui.resolve(e)
242 const width = Math.max(30, e.props.bodyColumns - 1)
243 const now = Date.now()
244 const noop = () => {}
245 const chosen = selected ? projects.find(p => p.id === selected) : undefined
246
247 return (
248 <Box flexDirection="column">
249 <Text bold>{fit(summaryText(summarize(projects)), width)}</Text>
250 {error ? <Text color="red">{fit(error, width)}</Text> : null}
251 {projects.length === 0 && !error ? <Text dimColor>no projects</Text> : null}
252
253 {projects.map((p, i) => {
254 const state = p.latest?.state ?? 'unknown'
255 const probe = health.get(p.id)
256 const site = !healthCheck || !p.domain || !probe ? '' : probe.ok ? ` ✓${probe.status}` : probe.status === 0 ? ' ✕down' : ` ✕${probe.status}`
257 const age = ago(p.latest?.createdAt, now)
258 const label = `${p.name}${age ? ` ${age}` : ''}`
259 const room = width - 2 - site.length
260 return (
261 <Box key={`row:${i}`} flexDirection="row">
262 <Text color={COLOR[state]}>{`${GLYPH[state]} `}</Text>
263 <Button key={`p:${i}`} plain dimColor={selected !== p.id && state === 'canceled'} label={fit(label, room)} onPress={noop} />
264 {site ? <Text color={probe?.ok ? 'green' : 'red'}>{site}</Text> : null}
265 </Box>
266 )
267 })}
268
269 {chosen ? (
270 <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
271 <Text bold>{fit(chosen.domain ?? chosen.name, width - 4)}</Text>
272 <Text dimColor>
273 {fit([chosen.framework, chosen.repo].filter(Boolean).join(' · ') || 'no Git repository linked', width - 4)}
274 </Text>
275 {history.map((d, i) => (
276 <Text key={`d:${i}`} wrap="truncate-end">
277 <Text color={COLOR[d.state]}>{`${GLYPH[d.state]} `}</Text>
278 {`${d.target === 'production' ? 'prod' : 'prev'} ${ago(d.createdAt, now)} ${d.commit ?? d.readyState.toLowerCase()}`}
279 </Text>
280 ))}
281 {chosen.latest?.error ? <Text color="red">{fit(chosen.latest.error, width - 4)}</Text> : null}
282 {chosen.domain ? <Button key="copy" label="copy url" hotkey="c" onPress={noop} /> : null}
283 </Box>
284 ) : null}
285
286 <Box key="foot" marginTop={1} flexDirection="column">
287 <Box flexDirection="row" columnGap={1}>
288 <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
289 <Button key="close" label="close" onPress={noop} />
290 </Box>
291 <Text dimColor>{fit('● ready ◐ building ✕ failed · ✓ site answers', width)}</Text>
292 </Box>
293 </Box>
294 )
295 })
296}
297hooks/vercel-api.ts 284 lines1/**
2 * vercel-api.ts — the Vercel REST reads the mod makes, and what it takes out of them.
3 *
4 * Reference: https://vercel.com/docs/rest-api (base https://api.vercel.com,
5 * `Authorization: Bearer <token>`, `teamId` for a team's resources):
6 * GET /v10/projects?limit=N projects[] (a bare array on older shapes) with
7 * `latestDeployments[]` { id, readyState, url, createdAt,
8 * target, meta } and `targets.production` { alias[], url }
9 * GET /v7/deployments?projectId&limit deployments[] { uid, url, readyState, target, created, meta, errorMessage }
10 *
11 * `readyState` is one of BLOCKED, BUILDING, CANCELED, DELETED, ERROR,
12 * INITIALIZING, QUEUED, READY. Nothing here trusts a field to be there: the
13 * parsers read what they find and leave the rest out. No `$` in this file, and
14 * the token only ever leaves in an Authorization header.
15 */
16
17export const API = 'https://api.vercel.com'
18
19export type Fetch = (
20 url: string,
21 init?: { method?: string; headers?: Record<string, string>; body?: string },
22) => Promise<{ status: number; ok: boolean; text: string }>
23
24export type DeployState = 'ready' | 'building' | 'failed' | 'canceled' | 'blocked' | 'unknown'
25
26export type Deploy = {
27 id: string
28 /** the raw readyState Vercel reported */
29 readyState: string
30 state: DeployState
31 /** hostname, no scheme */
32 url?: string
33 target?: string
34 createdAt?: number
35 readyAt?: number
36 commit?: string
37 branch?: string
38 error?: string
39}
40
41export type Project = {
42 id: string
43 name: string
44 framework?: string
45 repo?: string
46 /** the domain the production site is reached at, no scheme */
47 domain?: string
48 latest?: Deploy
49 /** state of the production deployment, when Vercel names one */
50 production?: DeployState
51 updatedAt?: number
52}
53
54const obj = (v: unknown): Record<string, unknown> | undefined =>
55 v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
56const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
57const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
58
59export function toState(readyState: string | undefined): DeployState {
60 switch (readyState) {
61 case 'READY':
62 return 'ready'
63 case 'BUILDING':
64 case 'INITIALIZING':
65 case 'QUEUED':
66 return 'building'
67 case 'ERROR':
68 return 'failed'
69 case 'CANCELED':
70 case 'DELETED':
71 return 'canceled'
72 case 'BLOCKED':
73 return 'blocked'
74 default:
75 return 'unknown'
76 }
77}
78
79/** Hostname of a url or domain: no scheme, no path. */
80export function hostOf(value: string | undefined): string | undefined {
81 if (!value) return undefined
82 return value.replace(/^https?:\/\//, '').replace(/\/.*$/, '') || undefined
83}
84
85export function parseDeploy(raw: unknown): Deploy | undefined {
86 const d = obj(raw)
87 if (!d) return undefined
88 const id = str(d.uid) ?? str(d.id)
89 if (!id) return undefined
90 const readyState = str(d.readyState) ?? str(d.state) ?? 'UNKNOWN'
91 const meta = obj(d.meta)
92 const commit =
93 str(meta?.githubCommitMessage) ?? str(meta?.gitlabCommitMessage) ?? str(meta?.bitbucketCommitMessage)
94 return {
95 id,
96 readyState,
97 state: toState(readyState),
98 url: hostOf(str(d.url)),
99 target: str(d.target),
100 createdAt: num(d.createdAt) ?? num(d.created),
101 readyAt: num(d.readyAt) ?? num(d.ready),
102 commit: commit?.split('\n')[0],
103 branch: str(meta?.githubCommitRef) ?? str(meta?.gitlabCommitRef) ?? str(meta?.bitbucketCommitRef),
104 error: str(d.errorMessage),
105 }
106}
107
108/** The production domain: a custom one beats the *.vercel.app alias. */
109export function productionDomain(p: Record<string, unknown>): string | undefined {
110 const production = obj(obj(p.targets)?.production)
111 const aliases: string[] = []
112 if (Array.isArray(production?.alias)) {
113 for (const a of production.alias) if (typeof a === 'string') aliases.push(a)
114 }
115 if (Array.isArray(p.alias)) {
116 for (const a of p.alias) {
117 const domain = str(obj(a)?.domain)
118 if (domain) aliases.push(domain)
119 }
120 }
121 const hosts = aliases.map(a => hostOf(a)).filter((h): h is string => h !== undefined)
122 return hosts.find(h => !h.endsWith('.vercel.app')) ?? hosts[0] ?? hostOf(str(production?.url))
123}
124
125export function parseProject(raw: unknown): Project | undefined {
126 const p = obj(raw)
127 if (!p) return undefined
128 const id = str(p.id)
129 const name = str(p.name)
130 if (!id || !name) return undefined
131 const deploys = (Array.isArray(p.latestDeployments) ? p.latestDeployments : [])
132 .map(parseDeploy)
133 .filter((d): d is Deploy => d !== undefined)
134 .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
135 const link = obj(p.link)
136 const org = str(link?.org)
137 const repo = str(link?.repo)
138 const production = obj(obj(p.targets)?.production)
139 return {
140 id,
141 name,
142 framework: str(p.framework),
143 repo: org && repo ? `${org}/${repo}` : repo,
144 domain: productionDomain(p),
145 latest: deploys[0],
146 production: production ? toState(str(production.readyState)) : undefined,
147 updatedAt: num(p.updatedAt),
148 }
149}
150
151/** The list endpoint answers a bare array or `{ projects, pagination }`. */
152export function parseProjects(text: string): Project[] {
153 const body: unknown = JSON.parse(text)
154 const list = Array.isArray(body) ? body : obj(body)?.projects
155 return (Array.isArray(list) ? list : []).map(parseProject).filter((p): p is Project => p !== undefined)
156}
157
158export function parseDeploys(text: string): Deploy[] {
159 const list = obj(JSON.parse(text))?.deployments
160 return (Array.isArray(list) ? list : []).map(parseDeploy).filter((d): d is Deploy => d !== undefined)
161}
162
163const RANK: Record<DeployState, number> = { building: 0, failed: 1, blocked: 2, unknown: 3, ready: 4, canceled: 5 }
164
165/** Building first, then failed, then the rest by recent activity. */
166export function sortProjects(projects: readonly Project[]): Project[] {
167 const state = (p: Project) => p.latest?.state ?? 'unknown'
168 return [...projects].sort((a, b) => {
169 const r = RANK[state(a)] - RANK[state(b)]
170 if (r !== 0) return r
171 return (b.latest?.createdAt ?? b.updatedAt ?? 0) - (a.latest?.createdAt ?? a.updatedAt ?? 0)
172 })
173}
174
175export type Summary = { total: number; live: number; building: number; failed: number }
176
177export function summarize(projects: readonly Project[]): Summary {
178 const s: Summary = { total: projects.length, live: 0, building: 0, failed: 0 }
179 for (const p of projects) {
180 const state = p.latest?.state
181 if (state === 'building') s.building += 1
182 else if (state === 'failed') s.failed += 1
183 else if (state === 'ready') s.live += 1
184 }
185 return s
186}
187
188export const summaryText = (s: Summary) =>
189 `vercel: ${s.live} ready · ${s.building} building · ${s.failed} failed`
190
191export type Change = { project: string; to: 'ready' | 'failed'; target?: string; error?: string }
192
193/**
194 * What finished between two reads: a project whose latest deployment was
195 * building and is now ready or failed, or whose latest deployment is a new one
196 * that already is. The first read (nothing before) announces nothing.
197 */
198export function changes(before: ReadonlyMap<string, Deploy>, projects: readonly Project[]): Change[] {
199 const out: Change[] = []
200 if (before.size === 0) return out
201 for (const p of projects) {
202 const now = p.latest
203 if (!now || (now.state !== 'ready' && now.state !== 'failed')) continue
204 const was = before.get(p.id)
205 const settled = was === undefined ? false : was.id === now.id ? was.state === 'building' : true
206 if (settled) out.push({ project: p.name, to: now.state, target: now.target, error: now.error })
207 }
208 return out
209}
210
211/** Hours, minutes or seconds since `at`, in the shortest form. */
212export function ago(at: number | undefined, now: number): string {
213 if (at === undefined) return ''
214 const s = Math.max(0, Math.round((now - at) / 1000))
215 if (s < 60) return `${s}s`
216 if (s < 3600) return `${Math.round(s / 60)}m`
217 if (s < 86_400) return `${Math.round(s / 3600)}h`
218 return `${Math.round(s / 86_400)}d`
219}
220
221export const GLYPH: Record<DeployState, string> = {
222 ready: '●',
223 building: '◐',
224 failed: '✕',
225 canceled: '○',
226 blocked: '◌',
227 unknown: '·',
228}
229
230export const COLOR: Record<DeployState, string | undefined> = {
231 ready: 'green',
232 building: 'yellow',
233 failed: 'red',
234 canceled: undefined,
235 blocked: 'yellow',
236 unknown: undefined,
237}
238
239export function fit(text: string, width: number): string {
240 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
241}
242
243export function clampInt(v: unknown, fallback: number, min: number, max: number): number {
244 const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : fallback
245 return Math.min(max, Math.max(min, n))
246}
247
248function headers(token: string): Record<string, string> {
249 return { authorization: `Bearer ${token}`, accept: 'application/json' }
250}
251
252const withTeam = (q: URLSearchParams, team: string) => {
253 if (team) q.set(team.startsWith('team_') ? 'teamId' : 'slug', team)
254 return q
255}
256
257export async function listProjects(f: Fetch, token: string, team: string, limit: number): Promise<Project[]> {
258 const q = withTeam(new URLSearchParams({ limit: String(limit) }), team)
259 const res = await f(`${API}/v10/projects?${q}`, { headers: headers(token) })
260 if (!res.ok) throw new Error(describeFailure(res.status, res.text))
261 return parseProjects(res.text)
262}
263
264export async function listDeploys(f: Fetch, token: string, team: string, projectId: string, limit: number): Promise<Deploy[]> {
265 const q = withTeam(new URLSearchParams({ projectId, limit: String(limit) }), team)
266 const res = await f(`${API}/v7/deployments?${q}`, { headers: headers(token) })
267 if (!res.ok) throw new Error(describeFailure(res.status, res.text))
268 return parseDeploys(res.text)
269}
270
271export function describeFailure(status: number, text: string): string {
272 if (status === 401 || status === 403) return `Vercel refused the token (${status}); check vercelToken and teamId`
273 let detail = ''
274 try {
275 detail = str(obj(obj(JSON.parse(text))?.error)?.message) ?? ''
276 } catch {
277 detail = ''
278 }
279 return `Vercel answered ${status}${detail ? `: ${detail}` : ''}`
280}
281
282/** A site "answers" when the request completes with a status below 500 that is not a gateway failure. */
283export const answers = (status: number) => status > 0 && status < 500
284