SLOPSHOPPER

vercel-deploys

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…

newpanecommandtoaststatusnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vercel-deploys
│ ┃ vercel ✕ › fix the failing auth test and add an audit log call │ ┃ vercel: 0 ready · 0 building · 0 failed │ ┃ no projects ● vercel-deploys: vercel-deploys: set vercelToken in the plugin optio │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ refresh ] [ close ] ⎿ Read 6 lines │ ┃ ● ready ◐ building ✕ failed · ✓ site ⏺ Update(src/auth.ts) │ ┃ answers ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /vercel │ ⎿ vercel-deploys: vercel: set vercelToken in the plugin options │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · vercel
vercel: 0 ready · 0 building · 0 failed no projects [ refresh ] [ close ] ● ready ◐ building ✕ failed · ✓ site answers
README

vercel-deploys

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.

How it stays current

  • $.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.
  • A deployment that turns ready or failed raises a toast (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.
  • With 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.

Scope and secrets

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.

Options

  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.

Install

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

Source 2 files
hooks/vercel-deploys.tsx 297 lines
1/**
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}
297
hooks/vercel-api.ts 284 lines
1/**
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