Live deploy tracker for GitHub Actions and Deployments, Vercel, Cloudflare Pages and Workers, and Render: a stage pipeline above the prompt, a /deploys panel…

Live deploy tracking inside Claude Code. deploy-deck watches GitHub (Actions workflow runs and Deployments), Vercel (preview and production deployments), Cloudflare (Pages deployments with their stages, Workers deployments and versions) and Render (service deploys). It shows each deploy on one normalised pipeline:
queued → building → deploying → live ✓ / failed ✗ / canceled ⊘
It is read-only: it only sends GET requests.
vercel · shop-web · production ● queued ━ ◉ building ━ ○ deploying ━ ○ live 1:12
render · shop-api ● queued ━ ● building ━ ◉ deploying ━ ○ live 3:40
github · acme/shop · Deploy ● queued ━ ● building ━ ● deploying ━ ✓ live 2:05
▌ modemon ▐ ← other band mods, below
╭──────────────────────────────────────────────────────────────────────────────╮
│ > │
╰──────────────────────────────────────────────────────────────────────────────╯
🚀 vercel building 1:12 +1
The band only appears while something is in flight, and keeps a finished deploy for 60 seconds. /deploys opens the panel:
┌ Deploys ──────────────────────────────────────── every 10s ┐
│ vercel · prj_Qm8kd7Vf2LpXr0aNc3TyEw9H token │
│ ◉ building production · a1b2c3d · main · 1:12 │
│ open · logs Fix checkout rounding │
│ ✗ build failed preview · ffeeddc · feature/cart · 1h ago │
│ open · logs Cart drawer │
│ ✓ live production · 0123456 · main · 1d ago │
│ open · logs │
│ │
│ render · shop-api token │
│ ◉ building a5f3e2d · main · 3:40 │
│ ✗ deploy failed 1111111 · main · 3h ago │
│ ✓ live abcdefa · main · 1d ago │
│ │
│ cloudflare · docs-site (pages) token │
│ ⚠ cloudflare: set cloudflareAccountId (or account_id …) │
│ │
│ /deploys refresh · add · remove · status │
└────────────────────────────────────────────────────────────┘
Each row shows the state glyph and stage, the environment, the version (a tag or version number when the provider has one, else the short sha), the branch, and the age (or elapsed time while in flight). open links to the deployed URL and logs links to the provider's build or run page.
/plugin install deploy-deck --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope. The install screen asks for the options below. You can change them later with /plugin, then deploy-deck, then configure.
Each provider takes one read-only token. The tokens are sensitive fields, so Claude Code keeps them in secure storage and they do not appear as /config rows. You can set them on the plugin's configure screen (/plugin, then deploy-deck), or under pluginConfigs in ~/.claude/settings.json:
{
"pluginConfigs": {
"deploy-deck": {
"options": {
"vercelToken": "…",
"vercelTeamId": "team_…",
"cloudflareToken": "…",
"cloudflareAccountId": "023e105f4ecef8ad9ca31a8372d0c353",
"renderToken": "rnd_…"
}
}
}
}
(Use deploy-deck@inline as the key for a --plugin-dir load.)
| Provider | Where to create it | Minimum scope |
|---|---|---|
| GitHub | github.com/settings/personal-access-tokens (fine-grained) | Repository access: the repo. Permissions: Actions: Read, Deployments: Read, Metadata: Read (implied). Or leave githubToken empty and run gh auth login: deploy-deck then reads through gh api (GET only). |
| Vercel | vercel.com/account/tokens | Scope the token to the team that owns the project. Vercel has no read-only token scope, but deploy-deck only sends GET /v7/deployments. Set vercelTeamId unless .vercel/project.json names the team. |
| Cloudflare | dash.cloudflare.com/profile/api-tokens, then Create custom token | Account permissions Cloudflare Pages: Read and Workers Scripts: Read, limited to the one account. Also set cloudflareAccountId, or put account_id in wrangler.toml. |
| Render | dashboard.render.com, then Account settings, then API keys | Render API keys cover the whole account and have no scopes. deploy-deck only sends GETs with the key. |
/deploys status shows, for each provider, whether a token is set, the gh CLI is used, or nothing is configured. It never shows a token.
When autoDetect is on (the default), deploy-deck reads targets from the repository root, or from the session root outside git:
| Source | Target |
|---|---|
origin remote on github.com (git@github.com:o/r.git, https://github.com/o/r) | github:o/r |
.vercel/project.json (vercel link) | vercel:<projectId>. An orgId starting with team_ becomes the team. |
vercel.json without a link | vercel:<name>: its legacy name field, else the folder name (a guess) |
wrangler.toml, wrangler.jsonc, wrangler.json | name. With pages_build_output_dir it is cloudflare-pages:<name>, otherwise cloudflare-workers:<name>. account_id is used as the account. |
render.yaml / render.yml | render:<name> for each services[].name. The name is resolved to its srv-… id with GET /v1/services?name=. |
To add more targets, list them in targets (comma-separated, e.g. vercel:my-app, cloudflare-pages:site, cloudflare-workers:api, render:srv-123, github:owner/repo) or use /deploys add.
| Command | What it does |
|---|---|
/deploys | Opens the panel on terminal and desktop. On the phone, or where no pane can be placed, it answers inline as a card. With nothing configured, it explains how to set up each provider. |
/deploys refresh | Clears any backoff and reads every target now. |
/deploys add <provider:id> | Tracks another target, saved per repo. Providers: github, vercel, cloudflare-pages (or cloudflare, pages), cloudflare-workers (or workers), render. |
/deploys remove <provider:id> | Stops tracking a target, a detected one included. This is saved per repo. |
/deploys status | For each provider: auth source (token, gh CLI, none), targets with where each came from, the last error, any backoff and the poll interval. |
/deploys close | Closes the panel. |
provider · project · env, a coloured stage pipeline, and elapsed time. Done stages are green, the current one is in the accent colour, a failed one is red. Finished deploys stay for 60 seconds. The row fits bodyColumns: the full pipeline when it fits, then glyphs with the stage word (●━◉━○━○ building), then just ◉ building. Other band mods are stacked below it through next(e).✓ vercel production live · a1b2c3d, ✗ render build failed · 5f3e2d1.🚀 vercel building 1:12 +1), else a failure or go-live from the last 10 minutes (✗ render build failed, ✓ prod live v1.4.2). Otherwise the status line is empty.fastSeconds (10) while any deploy is in flight and every slowSeconds (120) when idle. It stops when there are no targets.Retry-After, x-ratelimit-reset or ratelimit-reset asks. A GitHub 403 with x-ratelimit-remaining: 0 counts as a rate limit. On 401, 403 or 404 the provider rests for 10 minutes. /deploys refresh clears the rest.| Field | Default | |
|---|---|---|
githubToken | empty | Sensitive. Empty: gh api is used when useCli is on. |
vercelToken, vercelTeamId | empty | Token is sensitive. Team id is team_…. |
cloudflareToken, cloudflareAccountId | empty | Token is sensitive. |
renderToken | empty | Sensitive. |
targets | empty | Extra targets, provider:id, comma-separated. |
autoDetect | on | Read targets from the repo files. |
useCli | on | Without a GitHub token, read GitHub through gh api. |
fastSeconds / slowSeconds | 10 / 120 | Poll intervals (minimum 5 / 30). |
band, toasts, statusLine | on | Turn each surface element on or off. |
/deploys pane (bordered cards on desktop), toasts and status line./deploys sent from the phone, or anywhere $.ui.open answers isPlaced: false, comes back as an inline card (CommandOutput) drawn with Box, Text and Link only. It redraws as deploys change. The card's text is also plain markdown, so it reads well wherever the tree is not drawn. The set-up help is drawn as Markdown.Authorization: Bearer … by a single network helper (hooks/net.ts). They are never put in URLs, errors, logs, the status line, toasts, the panel or /deploys status. Every error message is built by deploy-deck from the HTTP status and the provider's own error words. Known tokens and anything shaped like a bearer credential (ghp_…, github_pat_…, Bearer …) are scrubbed from those words. A test shows that a 401 whose body echoes the token does not leak it.GET requests only, and gh api without -X/--method.sensitive userConfig fields, so Claude Code keeps them in secure storage.$.http.fetch, so an organisation's web-fetch policy applies.gh api). vercel ls and wrangler print no stable JSON for deployment lists, so there is no fallback for them.vercel.json (no .vercel/project.json) the project is guessed from the folder name. Run vercel link or /deploys add vercel:<name> to fix it.90%/10% split). Workers have no deploy URL.targets or /deploys add./deploys refresh.hooks/register.tsx 760 lines1// deploy-deck: live deploys from GitHub, Vercel, Cloudflare and Render.
2//
3// - A band above the prompt while anything is in flight (and a minute after
4// it finishes): one row per deploy, a coloured stage pipeline and elapsed
5// time, stacked over the other band mods' rows via next(e).
6// - `/deploys`: a pane (terminal, desktop) or an inline card (mobile, or
7// wherever the pane cannot be placed) with each target's latest deploys.
8// - A toast when a deploy goes live or fails, once per deploy, and a status
9// line naming the most important current item (the phone's fallback).
10//
11// The polling lives in ./tracker, the providers in ./providers, the pure
12// helpers in ./lib. No token is ever drawn, logged or put in a message.
13
14import { atom, read, update } from 'claude-code'
15import type {
16 CommandRunInput,
17 CommandRunResult,
18 EngineInterface,
19 PluginOptions,
20 Register,
21 RenderElement,
22 RenderNode,
23 RenderSurface,
24 Timer,
25} from 'claude-code'
26
27import type {
28 DeployDeckBackoff,
29 DeployDeckDeploy,
30 DeployDeckFeed,
31 DeployDeckProvider,
32 DeployDeckTarget,
33} from '../types'
34import { detectTargets } from './detect'
35import type { Run } from './lib'
36import {
37 INLINE_HEAD,
38 PROVIDERS,
39 PROVIDER_OF,
40 SETUP_HELP,
41 STATUS_LINGER_MS,
42 allDeploys,
43 authLabel,
44 backoffDelay,
45 bandDeploys,
46 deployLabel,
47 elapsedOf,
48 finishedSince,
49 formatAge,
50 formatElapsed,
51 inlineText,
52 isActive,
53 isTerminal,
54 mergeTargets,
55 parseCommand,
56 parseTargetSpec,
57 parseTargets,
58 pipeline,
59 pollInterval,
60 runsText,
61 setupText,
62 stageColor,
63 stageGlyph,
64 stageWord,
65 statusText,
66 targetKey,
67 targetLabel,
68 toastText,
69 truncate,
70 versionLabel,
71} from './lib'
72import type { Client } from './net'
73import { ProviderError, redact } from './net'
74import type { Config } from './tracker'
75import { MISSING, authOf, quietly, readTarget, secretsOf } from './tracker'
76
77type Engine = EngineInterface
78
79const PANE = 'deploy-deck'
80const TITLE = 'Deploys'
81const COMMAND = 'deploys'
82
83// --- The tracker: targets, polling, backoff, toasts ---------------------------
84//
85// Everything a drawing reads lives in `$.state`. The timer's handle, its
86// period and the in-flight flag are module-level on purpose: a hot reload
87// drops the timers, and `ensureRunning` re-arms from the next event.
88
89const targetsAtom = atom({ plugin: 'deploy-deck', key: 'targets' } as const, [] as DeployDeckTarget[])
90const feedsAtom = atom({ plugin: 'deploy-deck', key: 'feeds' } as const, {} as Record<string, DeployDeckFeed>)
91const nowAtom = atom({ plugin: 'deploy-deck', key: 'now' } as const, 0)
92const announcedAtom = atom({ plugin: 'deploy-deck', key: 'announced' } as const, [] as string[])
93const finishedAtom = atom({ plugin: 'deploy-deck', key: 'finished' } as const, {} as Record<string, number>)
94const backoffAtom = atom(
95 { plugin: 'deploy-deck', key: 'backoff' } as const,
96 {} as Partial<Record<DeployDeckProvider, DeployDeckBackoff>>,
97)
98const intervalAtom = atom({ plugin: 'deploy-deck', key: 'intervalMs' } as const, 0)
99const readyAtom = atom({ plugin: 'deploy-deck', key: 'isReady' } as const, false)
100
101/** The polling timer and its period (runtime handles, see the header). */
102let timer: Timer | undefined
103let timerMs = 0
104let isPolling = false
105
106const STORE_PREFIX = 'targets:'
107
108type Edits = { added: string[]; removed: string[] }
109
110async function rootOf($: Engine): Promise<{ root: string; remote: string | null }> {
111 const repo = await $.session.repo().catch(() => null)
112 if (repo !== null) return { root: repo.root, remote: repo.remote }
113 return { root: await $.session.root(), remote: null }
114}
115
116async function readEdits($: Engine): Promise<Edits> {
117 const { root } = await rootOf($)
118 const stored = (await $.store.get(STORE_PREFIX + root).catch(() => undefined)) as Partial<Edits> | undefined
119 return {
120 added: Array.isArray(stored?.added) ? stored.added.filter((s): s is string => typeof s === 'string') : [],
121 removed: Array.isArray(stored?.removed) ? stored.removed.filter((s): s is string => typeof s === 'string') : [],
122 }
123}
124
125async function writeEdits($: Engine, edits: Edits): Promise<void> {
126 const { root } = await rootOf($)
127 await $.store.set(STORE_PREFIX + root, edits)
128}
129
130/** Detected + pinned + added, minus removed; keeps meta learned earlier. */
131async function resolveTargets($: Engine, cfg: Config): Promise<DeployDeckTarget[]> {
132 const { root, remote } = await rootOf($)
133 const detected = cfg.autoDetect
134 ? await detectTargets(root, remote, async path => {
135 try {
136 if (!(await $.fs.exists(path))) return undefined
137 return await $.fs.read(path)
138 } catch {
139 return undefined
140 }
141 })
142 : []
143 const pinned = parseTargets(cfg.targets, 'pinned').targets
144 const edits = await readEdits($)
145 const added = edits.added
146 .map(spec => parseTargetSpec(spec, 'added'))
147 .filter((t): t is DeployDeckTarget => typeof t !== 'string')
148 const before = new Map((await read($, targetsAtom)).map(t => [targetKey(t), t]))
149 const merged = mergeTargets([added, pinned, detected], edits.removed).map(target => {
150 const known = before.get(targetKey(target))
151 return known?.meta !== undefined ? { ...target, meta: { ...target.meta, ...known.meta } } : target
152 })
153 await update($, targetsAtom, () => merged)
154 await update($, feedsAtom, feeds => {
155 const next: Record<string, DeployDeckFeed> = {}
156 for (const target of merged) {
157 const key = targetKey(target)
158 const auth = authOf(PROVIDER_OF[target.kind], cfg)
159 const old = feeds[key]
160 next[key] = old !== undefined ? { ...old, target, auth } : { target, deploys: [], auth, primed: false }
161 }
162 return next
163 })
164 await update($, readyAtom, () => true)
165 return merged
166}
167
168/**
169 * Reads every target due (all of them with `force`), records what changed,
170 * toasts finished deploys once, then re-arms the timer at the right speed.
171 */
172async function pollOnce($: Engine, cfg: Config, force = false): Promise<void> {
173 if (isPolling) return
174 isPolling = true
175 try {
176 if (!(await read($, readyAtom))) await resolveTargets($, cfg)
177 const now = await $.clock.now()
178 const targets = await read($, targetsAtom)
179 const secrets = secretsOf(cfg)
180 const toToast: DeployDeckDeploy[] = []
181 const finishedNow: string[] = []
182 for (const target of targets) {
183 const provider = PROVIDER_OF[target.kind]
184 const key = targetKey(target)
185 const auth = authOf(provider, cfg)
186 if (auth === 'none') {
187 await update($, feedsAtom, feeds => ({
188 ...feeds,
189 [key]: { ...(feeds[key] ?? { target, deploys: [], primed: false }), target, auth, error: MISSING[provider] },
190 }))
191 continue
192 }
193 const rest = (await read($, backoffAtom))[provider]
194 if (!force && rest !== undefined && rest.until > now) continue
195 const client: Client = {
196 provider,
197 fetch: (url, init) => $.http.fetch(url, init),
198 run: argv => $.process.run(argv, { timeoutMs: 20_000 }),
199 secrets,
200 now,
201 }
202 const token = cfg.tokens[provider]
203 if (auth === 'token' && token !== undefined) client.token = token
204 try {
205 const got = await readTarget(client, target, cfg)
206 const previous = (await read($, feedsAtom))[key]
207 for (const deploy of finishedSince(previous, got.deploys)) {
208 finishedNow.push(deploy.key)
209 if (deploy.stage === 'live' || deploy.stage === 'failed') toToast.push(deploy)
210 }
211 const learned = got.meta
212 const nextTarget = learned !== undefined ? { ...target, meta: { ...target.meta, ...learned } } : target
213 if (learned !== undefined) {
214 await update($, targetsAtom, list => list.map(t => (targetKey(t) === key ? nextTarget : t)))
215 }
216 await update($, feedsAtom, feeds => ({
217 ...feeds,
218 [key]: { target: nextTarget, deploys: got.deploys.slice(0, 10), auth, checkedAt: now, primed: true },
219 }))
220 await update($, backoffAtom, all => {
221 const copy = { ...all }
222 delete copy[provider]
223 return copy
224 })
225 } catch (error) {
226 const failure =
227 error instanceof ProviderError
228 ? error
229 : new ProviderError(provider, 'parse', `${provider}: ${redact(error instanceof Error ? error.message : String(error), secrets).slice(0, 120)}`)
230 const failures = (rest?.failures ?? 0) + 1
231 const until = now + backoffDelay(failure.kind, failures, failure.retryAfterMs)
232 await update($, backoffAtom, all => ({ ...all, [provider]: { until, failures, reason: failure.kind } }))
233 await update($, feedsAtom, feeds => ({
234 ...feeds,
235 [key]: { ...(feeds[key] ?? { target, deploys: [], primed: false }), target, auth, error: failure.message, checkedAt: now },
236 }))
237 }
238 }
239 await update($, nowAtom, () => now)
240 await update($, finishedAtom, seen => {
241 const kept: Record<string, number> = {}
242 for (const [key, at] of Object.entries(seen)) if (now - at < STATUS_LINGER_MS) kept[key] = at
243 for (const key of finishedNow) kept[key] ??= now
244 return kept
245 })
246 const announced = new Set(await read($, announcedAtom))
247 const fresh = toToast.filter(deploy => !announced.has(deploy.key))
248 if (fresh.length > 0) {
249 await update($, announcedAtom, list => [...list, ...fresh.map(d => d.key)].slice(-500))
250 if (cfg.toasts) for (const deploy of fresh) $.ui.toast(toastText(deploy), { timeoutMs: 8000 })
251 }
252 } finally {
253 isPolling = false
254 }
255 await settle($, cfg)
256}
257
258/** Status line and timer follow the state. */
259async function settle($: Engine, cfg: Config): Promise<void> {
260 const feeds = await read($, feedsAtom)
261 const now = await read($, nowAtom)
262 if (cfg.statusLine) $.ui.status(statusText(feeds, await read($, finishedAtom), now))
263 const targets = await read($, targetsAtom)
264 const isAnyActive = allDeploys(feeds).some(deploy => isActive(deploy, now))
265 const want = targets.length === 0 ? 0 : pollInterval(isAnyActive, cfg.fastMs, cfg.slowMs)
266 if (want !== timerMs || (want > 0 && timer === undefined)) {
267 timer?.cancel()
268 timer = undefined
269 timerMs = want
270 if (want > 0) timer = $.clock.every(want, () => void quietly(() => pollOnce($, cfg)))
271 }
272 if ((await read($, intervalAtom)) !== want) await update($, intervalAtom, () => want)
273}
274
275/** Re-arms after a hot reload (the timers went with the old module). */
276async function ensureRunning($: Engine, cfg: Config): Promise<void> {
277 if (timer !== undefined) return
278 if (!(await read($, readyAtom))) {
279 await resolveTargets($, cfg)
280 void quietly(() => pollOnce($, cfg))
281 return
282 }
283 await settle($, cfg)
284}
285
286/** Stops polling (tests, and a session's end). */
287function stop(): void {
288 timer?.cancel()
289 timer = undefined
290 timerMs = 0
291}
292
293
294function configOf(options: PluginOptions): Config {
295 const text = (key: string): string => {
296 const value = options[key]
297 return typeof value === 'string' ? value.trim() : ''
298 }
299 const seconds = (key: string, fallback: number, min: number): number => {
300 const value = options[key]
301 return (typeof value === 'number' && Number.isFinite(value) ? Math.max(min, value) : fallback) * 1000
302 }
303 const tokens: Partial<Record<DeployDeckProvider, string>> = {}
304 const pairs: [DeployDeckProvider, string][] = [
305 ['github', 'githubToken'],
306 ['vercel', 'vercelToken'],
307 ['cloudflare', 'cloudflareToken'],
308 ['render', 'renderToken'],
309 ]
310 for (const [provider, key] of pairs) {
311 const token = text(key)
312 if (token !== '') tokens[provider] = token
313 }
314 return {
315 tokens,
316 vercelTeamId: text('vercelTeamId'),
317 cloudflareAccountId: text('cloudflareAccountId'),
318 targets: text('targets'),
319 autoDetect: options['autoDetect'] !== false,
320 useCli: options['useCli'] !== false,
321 fastMs: seconds('fastSeconds', 10, 5),
322 slowMs: seconds('slowSeconds', 120, 30),
323 band: options['band'] !== false,
324 toasts: options['toasts'] !== false,
325 statusLine: options['statusLine'] !== false,
326 }
327}
328
329/** Links separated by ` · `, as children of one Text. */
330function joinLinks(links: readonly RenderElement[]): RenderNode[] {
331 return links.flatMap((link, index) => (index === 0 ? [link] : [' · ', link]))
332}
333
334/** True when `node` would draw nothing (the engine's own empty band included). */
335function drawsNothing(node: RenderNode | undefined | null): boolean {
336 if (node === undefined || node === null) return true
337 if (typeof node === 'string') return node === ''
338 if (node.type === 'engine') return true
339 if (node.type === 'Box' || node.type === 'Text') return (node.children ?? []).every(drawsNothing)
340 return false
341}
342
343async function surfacesOf($: Engine): Promise<readonly RenderSurface[]> {
344 try {
345 return await $.session.surfaces()
346 } catch {
347 return []
348 }
349}
350
351function isUnconfigured(targets: readonly DeployDeckTarget[], cfg: Config): boolean {
352 return targets.length === 0 || targets.every(target => authOf(PROVIDER_OF[target.kind], cfg) === 'none')
353}
354
355async function inline($: Engine, cfg: Config): Promise<CommandRunResult> {
356 const targets = await read($, targetsAtom)
357 const feeds = await read($, feedsAtom)
358 const now = await $.clock.now()
359 const text = inlineText(targets, feeds, now)
360 return { text: isUnconfigured(targets, cfg) && targets.length > 0 ? `${text}\n\n${setupText()}` : text }
361}
362
363function intervalWords(ms: number): string {
364 return ms === 0 ? 'stopped (no targets)' : `every ${Math.round(ms / 1000)}s`
365}
366
367async function statusReport($: Engine, cfg: Config): Promise<string> {
368 const targets = await read($, targetsAtom)
369 const feeds = await read($, feedsAtom)
370 const backoff = await read($, backoffAtom)
371 const now = await $.clock.now()
372 const lines = ['**Deploy deck status**', '']
373 for (const provider of PROVIDERS) {
374 const auth = authOf(provider, cfg)
375 const mine = targets.filter(target => PROVIDER_OF[target.kind] === provider)
376 const source =
377 auth === 'token'
378 ? `${provider}Token set`
379 : auth === 'cli'
380 ? 'gh CLI (no githubToken)'
381 : 'not configured'
382 const rest = backoff[provider]
383 const resting = rest !== undefined && rest.until > now ? ` · backing off ${formatElapsed(rest.until - now)} (${rest.reason})` : ''
384 const list =
385 mine.length === 0
386 ? 'no targets'
387 : mine.map(target => `${targetLabel(target)} (${target.source})`).join(', ')
388 lines.push(`- **${provider}**: ${authLabel(auth, provider)} · ${source}${resting} · ${list}`)
389 for (const target of mine) {
390 const error = feeds[targetKey(target)]?.error
391 if (error !== undefined) lines.push(` - ⚠ ${error}`)
392 }
393 }
394 lines.push('', `Polling ${intervalWords(await read($, intervalAtom))}.`)
395 if (isUnconfigured(targets, cfg)) lines.push('', setupText())
396 return lines.join('\n')
397}
398
399async function runCommand($: Engine, cfg: Config, e: CommandRunInput): Promise<CommandRunResult> {
400 const command = parseCommand(e.args)
401 await ensureRunning($, cfg)
402 switch (command.verb) {
403 case 'help':
404 return {
405 text: `Usage: /${COMMAND} opens the deploys panel; /${COMMAND} refresh | add <provider:id> | remove <provider:id> | status | close.`,
406 }
407 case 'status':
408 return { text: await statusReport($, cfg) }
409 case 'refresh': {
410 await update($, backoffAtom, () => ({}))
411 await resolveTargets($, cfg)
412 await pollOnce($, cfg, true)
413 const feeds = await read($, feedsAtom)
414 const now = await read($, nowAtom)
415 const list = Object.values(feeds)
416 const active = list.flatMap(feed => feed.deploys).filter(deploy => isActive(deploy, now)).length
417 const errors = list.filter(feed => feed.error !== undefined).length
418 return {
419 text:
420 list.length === 0
421 ? `Nothing to refresh: no targets.\n\n${setupText()}`
422 : `Refreshed ${list.length} target${list.length === 1 ? '' : 's'}: ${active} in flight${errors > 0 ? `, ${errors} with errors (see /${COMMAND} status)` : ''}.`,
423 }
424 }
425 case 'add':
426 case 'remove': {
427 const parsed = parseTargetSpec(command.spec, 'added')
428 if (typeof parsed === 'string') return { text: `deploy-deck: ${parsed}.` }
429 const spec = targetLabel(parsed)
430 const edits = await readEdits($)
431 if (command.verb === 'add') {
432 await writeEdits($, {
433 added: [...edits.added.filter(s => s !== spec), spec],
434 removed: edits.removed.filter(s => s !== spec),
435 })
436 } else {
437 const isKnown = (await read($, targetsAtom)).some(target => targetKey(target) === spec)
438 if (!isKnown) return { text: `Not tracking ${spec}.` }
439 await writeEdits($, {
440 added: edits.added.filter(s => s !== spec),
441 removed: [...edits.removed.filter(s => s !== spec), spec],
442 })
443 }
444 await resolveTargets($, cfg)
445 if (command.verb === 'add') await pollOnce($, cfg, true)
446 else await settle($, cfg)
447 const provider = PROVIDER_OF[parsed.kind]
448 const hint = command.verb === 'add' && authOf(provider, cfg) === 'none' ? ` It needs credentials: ${SETUP_HELP[provider]}` : ''
449 return { text: command.verb === 'add' ? `Tracking ${spec}.${hint}` : `Stopped tracking ${spec}.` }
450 }
451 case 'close': {
452 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
453 if (isUp) await $.ui.close({ id: PANE })
454 return { text: 'Deploys panel closed.' }
455 }
456 case 'open': {
457 const targets = await read($, targetsAtom)
458 if (isUnconfigured(targets, cfg)) return inline($, cfg)
459 const surfaces = await surfacesOf($)
460 const isPhoneOnly = surfaces.length > 0 && surfaces.every(surface => surface === 'mobile')
461 const isFromPhone = isPhoneOnly || (e.origin.kind === 'bridge' && surfaces.includes('mobile'))
462 if (isFromPhone) return inline($, cfg)
463 const opened = await $.ui.open({ id: PANE, title: TITLE })
464 if (opened.isPlaced) return { text: 'Deploys panel opened.' }
465 return inline($, cfg)
466 }
467 }
468}
469
470export const register: Register = (on, options: PluginOptions) => {
471 const cfg = configOf(options)
472
473 on('session.start', async ($, e, next) => {
474 await quietly(() =>
475 $.command.register({
476 name: COMMAND,
477 description: 'Deploys panel: latest deploys per target; refresh | add <provider:id> | remove <provider:id> | status',
478 argumentHint: '[refresh|add <provider:id>|remove <provider:id>|status|close]',
479 }),
480 )
481 stop()
482 await quietly(() => resolveTargets($, cfg))
483 // The first read runs in the background: the session never waits on a provider.
484 void quietly(() => pollOnce($, cfg))
485 return next(e)
486 })
487
488 on('session.end', async ($, e, next) => {
489 stop()
490 return next(e)
491 })
492
493 // Timers die with a hot reload: any turn re-arms them.
494 on('prompt.submit', async ($, e, next) => {
495 await quietly(() => ensureRunning($, cfg))
496 return next(e)
497 }).catch(($, e, next) => next(e))
498
499 on('command.run', { command: COMMAND }, ($, e) => runCommand($, cfg, e)).catch(($, e, next) =>
500 next.called ? next(e) : { text: 'deploy-deck: the command failed.' },
501 )
502
503 // --- The band ---------------------------------------------------------------
504
505 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
506 const feeds = await read($, feedsAtom)
507 const finished = await read($, finishedAtom)
508 const now = await read($, nowAtom)
509 const shown = cfg.band && !e.props.hasSurvey ? bandDeploys(feeds, finished, now) : []
510 const below = await next(e)
511 if (shown.length === 0) return below
512
513 const { Box, Text } = $.ui.resolve(e)
514 const width = Math.max(10, e.props.bodyColumns)
515 const room = Math.max(1, e.props.maxRows - (drawsNothing(below) ? 0 : 1))
516 const visible = shown.length > room ? shown.slice(0, Math.max(0, room - 1)) : shown
517 const hidden = shown.length - visible.length
518 const styled = (run: Run) => (
519 <Text color={run.color} dimColor={run.dim} bold={run.bold}>
520 {run.text}
521 </Text>
522 )
523
524 const rows = visible.map(deploy => {
525 const elapsed = formatElapsed(elapsedOf(deploy, now))
526 const label = deployLabel(deploy)
527 const full = pipeline(deploy, false)
528 const compact = pipeline(deploy, true)
529 const fullLen = runsText(full).length
530 const word = stageWord(deploy)
531 let runs: Run[]
532 if (width >= fullLen + elapsed.length + 14) runs = full
533 else if (width >= runsText(compact).length + word.length + elapsed.length + 12) {
534 runs = [...compact, { text: ` ${word}`, color: stageColor(deploy.stage), bold: true }]
535 } else runs = [{ text: `${stageGlyph(deploy.stage)} ${word}`, color: stageColor(deploy.stage), bold: true }]
536 const used = runsText(runs).length + elapsed.length + 2
537 const labelRoom = Math.max(0, width - used - 1)
538 return (
539 <Box key={`band:${deploy.key}`} flexDirection="row">
540 <Text bold={isActive(deploy, now)} dimColor={!isActive(deploy, now)} wrap="truncate">
541 {truncate(label, labelRoom).padEnd(labelRoom)}{' '}
542 </Text>
543 {runs.map(styled)}
544 <Text dimColor>
545 {' '}
546 {elapsed}
547 </Text>
548 </Box>
549 )
550 })
551 if (hidden > 0) {
552 rows.push(
553 <Text key="band:more" dimColor>
554 {truncate(`+${hidden} more · /${COMMAND}`, width)}
555 </Text>,
556 )
557 }
558 const mine = (
559 <Box key="deploy-deck" flexDirection="column">
560 {rows}
561 </Box>
562 )
563 if (drawsNothing(below)) return mine
564 return (
565 <Box flexDirection="column">
566 {mine}
567 {below}
568 </Box>
569 )
570 })
571
572 // --- The pane ---------------------------------------------------------------
573
574 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
575 const { Box, Text, Link } = $.ui.resolve(e)
576 const targets = await read($, targetsAtom)
577 const feeds = await read($, feedsAtom)
578 const now = await read($, nowAtom)
579 const interval = await read($, intervalAtom)
580 const width = Math.max(20, e.props.bodyColumns)
581 const isDesktop = e.surface === 'desktop'
582 const inner = isDesktop ? width - 4 : width
583
584 const header = (
585 <Box flexDirection="row" justifyContent="space-between">
586 <Text bold>Deploys</Text>
587 <Text dimColor>{truncate(`${interval === 0 ? 'stopped' : `every ${Math.round(interval / 1000)}s`}`, Math.max(0, width - 9))}</Text>
588 </Box>
589 )
590 if (isUnconfigured(targets, cfg)) {
591 return (
592 <Box flexDirection="column" gap={1}>
593 {header}
594 <Text wrap="wrap">{targets.length === 0 ? 'No deploy targets found in this repo.' : 'No credentials for the detected targets.'}</Text>
595 <Text wrap="wrap" dimColor>
596 {setupText().replace(/`/g, '')}
597 </Text>
598 </Box>
599 )
600 }
601 const groups = targets.map(target =>
602 group({ key: `group:${targetKey(target)}`, target, feed: feeds[targetKey(target)], now, inner, isDesktop }),
603 )
604 return (
605 <Box flexDirection="column" gap={1}>
606 {header}
607 {groups}
608 <Text dimColor wrap="truncate">
609 {truncate(`/${COMMAND} refresh · add · remove · status`, width)}
610 </Text>
611 </Box>
612 )
613
614 function group(props: { key: string; target: DeployDeckTarget; feed: DeployDeckFeed | undefined; now: number; inner: number; isDesktop: boolean }) {
615 const { target, feed } = props
616 const provider = PROVIDER_OF[target.kind]
617 const kind = target.kind.startsWith('cloudflare-') ? ` (${target.kind.slice(11)})` : ''
618 const auth = feed === undefined ? '' : authLabel(feed.auth, provider)
619 const title = `${provider} · ${target.id}${kind}`
620 const body = (
621 <Box flexDirection="column">
622 <Box flexDirection="row" justifyContent="space-between">
623 <Text bold wrap="truncate">
624 {truncate(title, Math.max(4, props.inner - auth.length - 1))}
625 </Text>
626 <Text dimColor>{auth}</Text>
627 </Box>
628 {feed?.error !== undefined && (
629 <Text color="warning" wrap="wrap">
630 ⚠ {feed.error}
631 </Text>
632 )}
633 {(feed === undefined || feed.deploys.length === 0) && feed?.error === undefined && (
634 <Text dimColor>{feed?.checkedAt === undefined ? 'not read yet' : 'no deploys'}</Text>
635 )}
636 {(feed?.deploys ?? []).slice(0, 3).map(deploy => deployRow(deploy, props.now, props.inner))}
637 </Box>
638 )
639 return props.isDesktop ? (
640 <Box key={props.key} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
641 {body}
642 </Box>
643 ) : (
644 <Box key={props.key} flexDirection="column">
645 {body}
646 </Box>
647 )
648 }
649
650 function deployRow(deploy: DeployDeckDeploy, at: number, room: number): RenderElement {
651 const age = isTerminal(deploy.stage)
652 ? formatAge(at - (deploy.finishedAt ?? deploy.startedAt))
653 : formatElapsed(elapsedOf(deploy, at))
654 const head = `${stageGlyph(deploy.stage)} ${stageWord(deploy)}`
655 const facts = [deploy.env, versionLabel(deploy), deploy.branch, age].filter(
656 (part): part is string => part !== undefined && part !== '',
657 )
658 const rest = truncate(` ${facts.join(' · ')}`, Math.max(0, room - head.length))
659 const links: RenderElement[] = []
660 if (deploy.url !== undefined) links.push(<Link key={`url:${deploy.key}`} href={deploy.url} label="open" />)
661 if (deploy.logsUrl !== undefined) links.push(<Link key={`logs:${deploy.key}`} href={deploy.logsUrl} label="logs" />)
662 return (
663 <Box key={`row:${deploy.key}`} flexDirection="column">
664 <Text wrap="truncate">
665 <Text color={stageColor(deploy.stage)} bold={!isTerminal(deploy.stage)}>
666 {head}
667 </Text>
668 <Text dimColor={isTerminal(deploy.stage)}>{rest}</Text>
669 </Text>
670 {(deploy.message !== undefined || links.length > 0) && (
671 <Text wrap="truncate">
672 {' '}
673 {joinLinks(links)}
674 {deploy.message !== undefined && (
675 <Text dimColor>
676 {links.length > 0 ? ' ' : ''}
677 {truncate(deploy.message, Math.max(0, room - 4 - links.length * 7))}
678 </Text>
679 )}
680 </Text>
681 )}
682 </Box>
683 )
684 }
685 })
686
687 // --- The inline card (mobile, or no pane placed) ---------------------------
688
689 on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
690 if (e.props.isErrored || !e.props.text.startsWith(INLINE_HEAD)) return next(e)
691 const { Box, Text, Link, Markdown } = $.ui.resolve(e)
692 const targets = await read($, targetsAtom)
693 const feeds = await read($, feedsAtom)
694 const now = await read($, nowAtom)
695 const width = Math.max(20, Math.min(e.viewport?.columns ?? 48, 80))
696 if (isUnconfigured(targets, cfg)) {
697 return (
698 <Box flexDirection="column" gap={1}>
699 <Text bold>{targets.length === 0 ? 'Deploys · nothing to track yet' : 'Deploys · credentials needed'}</Text>
700 <Markdown text={setupText()} />
701 </Box>
702 )
703 }
704 const groups = targets.map(target => {
705 const feed = feeds[targetKey(target)]
706 const provider = PROVIDER_OF[target.kind]
707 return (
708 <Box key={`inline:${targetKey(target)}`} flexDirection="column">
709 <Text bold wrap="truncate">
710 {truncate(`${provider} · ${target.id}`, width)}
711 </Text>
712 {feed?.error !== undefined && (
713 <Text color="warning" wrap="wrap">
714 ⚠ {feed.error}
715 </Text>
716 )}
717 {(feed === undefined || feed.deploys.length === 0) && feed?.error === undefined && (
718 <Text dimColor>{feed?.checkedAt === undefined ? 'not read yet' : 'no deploys'}</Text>
719 )}
720 {(feed?.deploys ?? []).slice(0, 3).map(deploy => {
721 const age = isTerminal(deploy.stage)
722 ? formatAge(now - (deploy.finishedAt ?? deploy.startedAt))
723 : formatElapsed(elapsedOf(deploy, now))
724 const facts = [deploy.env, versionLabel(deploy), deploy.branch, age].filter(
725 (part): part is string => part !== undefined && part !== '',
726 )
727 return (
728 <Box key={`inline-row:${deploy.key}`} flexDirection="column">
729 <Text wrap="truncate">
730 <Text color={stageColor(deploy.stage)}>
731 {stageGlyph(deploy.stage)} {stageWord(deploy)}
732 </Text>
733 <Text dimColor>{truncate(` · ${facts.join(' · ')}`, Math.max(0, width - stageWord(deploy).length - 2))}</Text>
734 </Text>
735 {(deploy.url !== undefined || deploy.logsUrl !== undefined) && (
736 <Text>
737 {' '}
738 {joinLinks([
739 ...(deploy.url !== undefined ? [<Link key={`url:${deploy.key}`} href={deploy.url} label="open" />] : []),
740 ...(deploy.logsUrl !== undefined ? [<Link key={`logs:${deploy.key}`} href={deploy.logsUrl} label="logs" />] : []),
741 ])}
742 </Text>
743 )}
744 </Box>
745 )
746 })}
747 </Box>
748 )
749 })
750 return (
751 <Box flexDirection="column" gap={1}>
752 <Text bold wrap="truncate">
753 {truncate(`Deploys · ${targets.length} target${targets.length === 1 ? '' : 's'}`, width)}
754 </Text>
755 {groups}
756 </Box>
757 )
758 })
759}
760hooks/detect.ts 186 lines1// Detection: which targets a repo deploys to, read from its files.
2//
3// - git remote on github.com → github:owner/repo
4// - .vercel/project.json {projectId, orgId} → vercel:<projectId> (orgId team_… → team)
5// else vercel.json (with `name`, or the folder's name) → vercel:<name>
6// - wrangler.toml / wrangler.jsonc / wrangler.json: `name`; with
7// `pages_build_output_dir` it is a Pages project, else a Worker;
8// `account_id` is kept for the Cloudflare account
9// - render.yaml: each `services[].name` → render:<name> (resolved to srv-… by API)
10//
11// The parsers are pure; `detectTargets` reads the files through a reader.
12
13import type { DeployDeckTarget } from '../types'
14
15/** `git@github.com:o/r.git`, `https://github.com/o/r`, `ssh://git@github.com/o/r.git` → `o/r`. */
16export function parseGitRemote(remote: string | null | undefined): string | undefined {
17 if (remote === null || remote === undefined) return undefined
18 const match = /github\.com[:/]+([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(remote.trim())
19 if (match === null) return undefined
20 return `${match[1]}/${match[2]}`
21}
22
23/** `.vercel/project.json` → its project and org ids. */
24export function parseVercelProject(text: string): { projectId: string; orgId?: string; name?: string } | undefined {
25 try {
26 const body = JSON.parse(text) as Record<string, unknown>
27 const projectId = body['projectId']
28 if (typeof projectId !== 'string' || projectId === '') return undefined
29 const out: { projectId: string; orgId?: string; name?: string } = { projectId }
30 if (typeof body['orgId'] === 'string') out.orgId = body['orgId']
31 if (typeof body['projectName'] === 'string') out.name = body['projectName']
32 return out
33 } catch {
34 return undefined
35 }
36}
37
38/** `vercel.json`'s (legacy) `name`, when it has one. */
39export function parseVercelJsonName(text: string): string | undefined {
40 try {
41 const name = (JSON.parse(stripJsonc(text)) as Record<string, unknown>)['name']
42 return typeof name === 'string' && name !== '' ? name : undefined
43 } catch {
44 return undefined
45 }
46}
47
48/** JSONC → JSON: drops comments (outside strings) and trailing commas. */
49export function stripJsonc(text: string): string {
50 let out = ''
51 let inString = false
52 for (let i = 0; i < text.length; i += 1) {
53 const c = text[i]
54 const next = text[i + 1]
55 if (inString) {
56 out += c
57 if (c === '\\') {
58 out += next ?? ''
59 i += 1
60 } else if (c === '"') inString = false
61 continue
62 }
63 if (c === '"') {
64 inString = true
65 out += c
66 } else if (c === '/' && next === '/') {
67 while (i < text.length && text[i] !== '\n') i += 1
68 out += '\n'
69 } else if (c === '/' && next === '*') {
70 i += 2
71 while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1
72 i += 1
73 } else out += c
74 }
75 return out.replace(/,(\s*[}\]])/g, '$1')
76}
77
78export type WranglerInfo = { name: string; kind: 'cloudflare-pages' | 'cloudflare-workers'; accountId?: string }
79
80function wranglerFrom(name: unknown, pagesDir: unknown, accountId: unknown): WranglerInfo | undefined {
81 if (typeof name !== 'string' || name === '') return undefined
82 const info: WranglerInfo = { name, kind: typeof pagesDir === 'string' && pagesDir !== '' ? 'cloudflare-pages' : 'cloudflare-workers' }
83 if (typeof accountId === 'string' && accountId !== '') info.accountId = accountId
84 return info
85}
86
87/** The top-level keys of a `wrangler.toml` (before its first [table]). */
88export function parseWranglerToml(text: string): WranglerInfo | undefined {
89 const top: Record<string, string> = {}
90 for (const raw of text.split(/\r?\n/)) {
91 const line = raw.trim()
92 if (line.startsWith('[')) break
93 const match = /^([A-Za-z_][\w-]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([^#\s][^#]*?))\s*(?:#.*)?$/.exec(line)
94 if (match === null) continue
95 const key = match[1] ?? ''
96 top[key] = match[2] ?? match[3] ?? match[4] ?? ''
97 }
98 return wranglerFrom(top['name'], top['pages_build_output_dir'], top['account_id'])
99}
100
101/** `wrangler.jsonc` / `wrangler.json`. */
102export function parseWranglerJsonc(text: string): WranglerInfo | undefined {
103 try {
104 const body = JSON.parse(stripJsonc(text)) as Record<string, unknown>
105 return wranglerFrom(body['name'], body['pages_build_output_dir'], body['account_id'])
106 } catch {
107 return undefined
108 }
109}
110
111/** The service names in a `render.yaml` blueprint (`services:` → `- name:`). */
112export function parseRenderYaml(text: string): string[] {
113 const names: string[] = []
114 let inServices = false
115 let itemIndent = -1
116 for (const raw of text.split(/\r?\n/)) {
117 if (/^\s*(#.*)?$/.test(raw)) continue
118 const indent = raw.length - raw.trimStart().length
119 const line = raw.trim()
120 if (indent === 0) {
121 inServices = /^services\s*:\s*$/.test(line)
122 itemIndent = -1
123 continue
124 }
125 if (!inServices) continue
126 if (line.startsWith('- ')) {
127 if (itemIndent === -1) itemIndent = indent
128 if (indent !== itemIndent) continue
129 const first = /^-\s+name\s*:\s*["']?([^"'#]+?)["']?\s*(?:#.*)?$/.exec(line)
130 if (first?.[1] !== undefined) names.push(first[1])
131 continue
132 }
133 if (itemIndent !== -1 && indent === itemIndent + 2) {
134 const key = /^name\s*:\s*["']?([^"'#]+?)["']?\s*(?:#.*)?$/.exec(line)
135 if (key?.[1] !== undefined) names.push(key[1])
136 }
137 }
138 return [...new Set(names)]
139}
140
141/** Reads a file, or undefined when it is not there. */
142export type Reader = (path: string) => Promise<string | undefined>
143
144/** Every target the repo at `root` declares. */
145export async function detectTargets(root: string, remote: string | null | undefined, read: Reader): Promise<DeployDeckTarget[]> {
146 const found: DeployDeckTarget[] = []
147 const repo = parseGitRemote(remote)
148 if (repo !== undefined) found.push({ kind: 'github', id: repo, source: 'detected' })
149
150 const project = await read(`${root}/.vercel/project.json`)
151 const vercel = project === undefined ? undefined : parseVercelProject(project)
152 if (vercel !== undefined) {
153 const meta: Record<string, string> = {}
154 if (vercel.orgId?.startsWith('team_') === true) meta['teamId'] = vercel.orgId
155 if (vercel.name !== undefined) meta['name'] = vercel.name
156 found.push({ kind: 'vercel', id: vercel.projectId, source: 'detected', meta })
157 } else {
158 const config = await read(`${root}/vercel.json`)
159 if (config !== undefined) {
160 const folder = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
161 const name = parseVercelJsonName(config) ?? folder
162 if (name !== '') found.push({ kind: 'vercel', id: name, source: 'detected', meta: { guessed: 'name' } })
163 }
164 }
165
166 let wrangler: WranglerInfo | undefined
167 const toml = await read(`${root}/wrangler.toml`)
168 if (toml !== undefined) wrangler = parseWranglerToml(toml)
169 for (const file of ['wrangler.jsonc', 'wrangler.json']) {
170 if (wrangler !== undefined) break
171 const json = await read(`${root}/${file}`)
172 if (json !== undefined) wrangler = parseWranglerJsonc(json)
173 }
174 if (wrangler !== undefined) {
175 const target: DeployDeckTarget = { kind: wrangler.kind, id: wrangler.name, source: 'detected' }
176 if (wrangler.accountId !== undefined) target.meta = { accountId: wrangler.accountId }
177 found.push(target)
178 }
179
180 const blueprint = (await read(`${root}/render.yaml`)) ?? (await read(`${root}/render.yml`))
181 if (blueprint !== undefined) {
182 for (const name of parseRenderYaml(blueprint)) found.push({ kind: 'render', id: name, source: 'detected' })
183 }
184 return found
185}
186hooks/lib.ts 455 lines1// deploy-deck's pure helpers: target specs, the stage pipeline, formatting,
2// what the band, status line and toasts say, transitions and backoff.
3
4import type {
5 DeployDeckAuth,
6 DeployDeckDeploy,
7 DeployDeckFeed,
8 DeployDeckProvider,
9 DeployDeckStage,
10 DeployDeckStep,
11 DeployDeckTarget,
12 DeployDeckTargetKind,
13} from '../types'
14
15export const PROVIDER_OF: Record<DeployDeckTargetKind, DeployDeckProvider> = {
16 github: 'github',
17 vercel: 'vercel',
18 'cloudflare-pages': 'cloudflare',
19 'cloudflare-workers': 'cloudflare',
20 render: 'render',
21}
22
23export const PROVIDERS: readonly DeployDeckProvider[] = ['github', 'vercel', 'cloudflare', 'render']
24
25/** How long the band keeps a finished deploy. */
26export const BAND_LINGER_MS = 60_000
27/** How long the status line keeps a finished deploy. */
28export const STATUS_LINGER_MS = 10 * 60_000
29/** An in-flight deploy older than this is stale (a stuck provider record), not active. */
30export const STALE_MS = 6 * 60 * 60_000
31/** The inline card's first words, which tell its CommandOutput row apart. */
32export const INLINE_HEAD = 'Deploys ·'
33
34export function targetKey(target: Pick<DeployDeckTarget, 'kind' | 'id'>): string {
35 return `${target.kind}:${target.id}`
36}
37
38export function deployKey(target: Pick<DeployDeckTarget, 'kind' | 'id'>, id: string): string {
39 return `${target.kind}:${target.id}:${id}`
40}
41
42export function shortSha(sha: string | undefined): string | undefined {
43 return sha === undefined ? undefined : sha.slice(0, 7)
44}
45
46/** A tag-looking ref (`v1.4.2`, `refs/tags/1.2`) as a version; branches and shas are not. */
47export function versionFromRef(ref: string | undefined): string | undefined {
48 if (ref === undefined) return undefined
49 const bare = ref.replace(/^refs\/tags\//, '')
50 return /^v?\d+(\.\d+)+([-+.][\w.-]+)?$/.test(bare) || ref.startsWith('refs/tags/') ? bare : undefined
51}
52
53/** What the version column shows: the tag or version, else the short sha. */
54export function versionLabel(deploy: DeployDeckDeploy): string {
55 return deploy.version ?? shortSha(deploy.commit) ?? '—'
56}
57
58// --- Target specs -------------------------------------------------------------
59
60const KIND_ALIASES: Record<string, DeployDeckTargetKind> = {
61 github: 'github',
62 gh: 'github',
63 vercel: 'vercel',
64 cloudflare: 'cloudflare-pages',
65 'cloudflare-pages': 'cloudflare-pages',
66 pages: 'cloudflare-pages',
67 'cf-pages': 'cloudflare-pages',
68 'cloudflare-workers': 'cloudflare-workers',
69 workers: 'cloudflare-workers',
70 worker: 'cloudflare-workers',
71 'cf-workers': 'cloudflare-workers',
72 render: 'render',
73}
74
75/** `vercel:my-app` → a target; a string says what is wrong. */
76export function parseTargetSpec(spec: string, source: DeployDeckTarget['source']): DeployDeckTarget | string {
77 const text = spec.trim()
78 const colon = text.indexOf(':')
79 if (colon <= 0 || colon === text.length - 1) return `"${text}" is not provider:id (e.g. vercel:my-app)`
80 const kind = KIND_ALIASES[text.slice(0, colon).toLowerCase()]
81 const id = text.slice(colon + 1).trim()
82 if (kind === undefined) return `unknown provider "${text.slice(0, colon)}" (github, vercel, cloudflare-pages, cloudflare-workers, render)`
83 if (!/^[\w.\-/@]+$/.test(id)) return `"${id}" is not a valid id`
84 if (kind === 'github' && !/^[\w.-]+\/[\w.-]+$/.test(id)) return `github targets are owner/repo, not "${id}"`
85 return { kind, id, source }
86}
87
88/** The settings' comma- or space-separated list. */
89export function parseTargets(list: string, source: DeployDeckTarget['source']): { targets: DeployDeckTarget[]; errors: string[] } {
90 const targets: DeployDeckTarget[] = []
91 const errors: string[] = []
92 for (const spec of list.split(/[,\s]+/).filter(part => part.length > 0)) {
93 const parsed = parseTargetSpec(spec, source)
94 if (typeof parsed === 'string') errors.push(parsed)
95 else targets.push(parsed)
96 }
97 return { targets, errors }
98}
99
100/** Unites target lists, the first one listed winning, minus the removed keys. */
101export function mergeTargets(lists: readonly (readonly DeployDeckTarget[])[], removed: readonly string[]): DeployDeckTarget[] {
102 const seen = new Set<string>(removed)
103 const out: DeployDeckTarget[] = []
104 for (const list of lists) {
105 for (const target of list) {
106 const key = targetKey(target)
107 if (seen.has(key)) continue
108 seen.add(key)
109 out.push(target)
110 }
111 }
112 return out
113}
114
115// --- Commands -----------------------------------------------------------------
116
117export type DeckCommand =
118 | { verb: 'open' | 'close' | 'refresh' | 'status' | 'help' }
119 | { verb: 'add' | 'remove'; spec: string }
120
121export function parseCommand(args: string): DeckCommand {
122 const [first = '', ...rest] = args.trim().split(/\s+/)
123 const verb = first.toLowerCase()
124 const spec = rest.join(' ').trim()
125 if (verb === '' || verb === 'open' || verb === 'show') return { verb: 'open' }
126 if (verb === 'close' || verb === 'hide') return { verb: 'close' }
127 if (verb === 'refresh' || verb === 'poll') return { verb: 'refresh' }
128 if (verb === 'status') return { verb: 'status' }
129 if ((verb === 'add' || verb === 'remove' || verb === 'rm') && spec.length > 0) {
130 return { verb: verb === 'add' ? 'add' : 'remove', spec }
131 }
132 return { verb: 'help' }
133}
134
135// --- Stages -------------------------------------------------------------------
136
137export const STEPS: readonly DeployDeckStep[] = ['queued', 'building', 'deploying']
138
139export function isTerminal(stage: DeployDeckStage): boolean {
140 return stage === 'live' || stage === 'failed' || stage === 'canceled'
141}
142
143/** In flight and not stale. */
144export function isActive(deploy: DeployDeckDeploy, now: number): boolean {
145 return !isTerminal(deploy.stage) && now - deploy.startedAt < STALE_MS
146}
147
148/** `building`, `build failed`, `live`. */
149export function stageWord(deploy: Pick<DeployDeckDeploy, 'stage' | 'failedAt'>): string {
150 if (deploy.stage !== 'failed') return deploy.stage
151 if (deploy.failedAt === 'building') return 'build failed'
152 if (deploy.failedAt === 'deploying') return 'deploy failed'
153 return 'failed'
154}
155
156export function stageGlyph(stage: DeployDeckStage): string {
157 switch (stage) {
158 case 'live':
159 return '✓'
160 case 'failed':
161 return '✗'
162 case 'canceled':
163 return '⊘'
164 case 'queued':
165 return '◌'
166 default:
167 return '◉'
168 }
169}
170
171export function stageColor(stage: DeployDeckStage): string {
172 switch (stage) {
173 case 'live':
174 return 'success'
175 case 'failed':
176 return 'error'
177 case 'canceled':
178 return 'inactive'
179 case 'queued':
180 return 'warning'
181 default:
182 return 'claude'
183 }
184}
185
186/** A styled piece of text. */
187export type Run = { text: string; color?: string; dim?: boolean; bold?: boolean }
188
189/**
190 * The pipeline `● queued ━ ◉ building ━ ○ deploying ━ ○ live` as styled runs;
191 * `compact` drops the labels (`●━◉━○━○`).
192 */
193export function pipeline(deploy: Pick<DeployDeckDeploy, 'stage' | 'failedAt'>, compact: boolean): Run[] {
194 const names = [...STEPS, 'live'] as const
195 const sep: Run = { text: compact ? '━' : ' ━ ', dim: true }
196 const at =
197 deploy.stage === 'failed'
198 ? STEPS.indexOf(deploy.failedAt ?? 'building')
199 : deploy.stage === 'canceled'
200 ? -1
201 : names.indexOf(deploy.stage as (typeof names)[number])
202 const runs: Run[] = []
203 names.forEach((name, index) => {
204 if (index > 0) runs.push(sep)
205 let glyph = '○'
206 let color: string | undefined
207 let dim = true
208 let bold = false
209 let label: string = name
210 if (deploy.stage === 'canceled') {
211 if (name === 'live') {
212 glyph = '⊘'
213 label = 'canceled'
214 color = 'inactive'
215 dim = false
216 }
217 } else if (index < at || (deploy.stage === 'live' && index <= at)) {
218 glyph = name === 'live' ? '✓' : '●'
219 color = 'success'
220 dim = false
221 bold = name === 'live'
222 } else if (index === at) {
223 if (deploy.stage === 'failed') {
224 glyph = '✗'
225 color = 'error'
226 } else {
227 glyph = '◉'
228 color = 'claude'
229 }
230 dim = false
231 bold = true
232 }
233 const run: Run = { text: compact ? glyph : `${glyph} ${label}` }
234 if (color !== undefined) run.color = color
235 if (dim) run.dim = true
236 if (bold) run.bold = true
237 runs.push(run)
238 })
239 return runs
240}
241
242export function runsText(runs: readonly Run[]): string {
243 return runs.map(run => run.text).join('')
244}
245
246// --- Time ---------------------------------------------------------------------
247
248/** `m:ss`, or `h:mm:ss` past an hour. */
249export function formatElapsed(ms: number): string {
250 const total = Math.max(0, Math.floor(ms / 1000))
251 const h = Math.floor(total / 3600)
252 const m = Math.floor((total % 3600) / 60)
253 const s = total % 60
254 const ss = String(s).padStart(2, '0')
255 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
256}
257
258/** `just now`, `5m ago`, `3h ago`, `2d ago`. */
259export function formatAge(ms: number): string {
260 const s = Math.max(0, Math.floor(ms / 1000))
261 if (s < 45) return 'just now'
262 const m = Math.round(s / 60)
263 if (m < 60) return `${m}m ago`
264 const h = Math.round(m / 60)
265 if (h < 48) return `${h}h ago`
266 return `${Math.round(h / 24)}d ago`
267}
268
269/** How long a deploy ran (finished) or has been running. */
270export function elapsedOf(deploy: DeployDeckDeploy, now: number): number {
271 const end = isTerminal(deploy.stage) ? (deploy.finishedAt ?? now) : now
272 return Math.max(0, end - deploy.startedAt)
273}
274
275export function truncate(text: string, max: number): string {
276 if (max <= 0) return ''
277 if (text.length <= max) return text
278 return max === 1 ? '…' : `${text.slice(0, max - 1)}…`
279}
280
281// --- What the band, the status line and toasts say ------------------------------
282
283export function allDeploys(feeds: Readonly<Record<string, DeployDeckFeed>>): DeployDeckDeploy[] {
284 return Object.values(feeds).flatMap(feed => feed.deploys)
285}
286
287/** The band's deploys: everything in flight, plus what finished within the last minute. */
288export function bandDeploys(
289 feeds: Readonly<Record<string, DeployDeckFeed>>,
290 finished: Readonly<Record<string, number>>,
291 now: number,
292): DeployDeckDeploy[] {
293 const shown = allDeploys(feeds).filter(deploy => {
294 if (isActive(deploy, now)) return true
295 const at = finished[deploy.key]
296 return isTerminal(deploy.stage) && at !== undefined && now - at < BAND_LINGER_MS
297 })
298 return shown.sort((a, b) => {
299 const activeFirst = Number(isActive(b, now)) - Number(isActive(a, now))
300 return activeFirst !== 0 ? activeFirst : b.startedAt - a.startedAt
301 })
302}
303
304/** `vercel · my-app · production` */
305export function deployLabel(deploy: DeployDeckDeploy): string {
306 return [deploy.provider, deploy.project, deploy.env].filter((part): part is string => part !== undefined && part !== '').join(' · ')
307}
308
309function envShort(env: string | undefined): string | undefined {
310 if (env === undefined) return undefined
311 return env === 'production' ? 'prod' : env
312}
313
314function priority(deploy: DeployDeckDeploy): number {
315 return deploy.env === 'production' || deploy.env === 'prod' ? 1 : 0
316}
317
318/** The status line: the most important current item, or undefined. */
319export function statusText(
320 feeds: Readonly<Record<string, DeployDeckFeed>>,
321 finished: Readonly<Record<string, number>>,
322 now: number,
323): string | undefined {
324 const all = allDeploys(feeds)
325 const active = all
326 .filter(deploy => isActive(deploy, now))
327 .sort((a, b) => priority(b) - priority(a) || b.startedAt - a.startedAt)
328 const top = active[0]
329 if (top !== undefined) {
330 const more = active.length > 1 ? ` +${active.length - 1}` : ''
331 return `🚀 ${top.provider} ${stageWord(top)} ${formatElapsed(elapsedOf(top, now))}${more}`
332 }
333 const recent = all
334 .filter(deploy => (deploy.stage === 'live' || deploy.stage === 'failed') && finished[deploy.key] !== undefined)
335 .filter(deploy => now - (finished[deploy.key] ?? 0) < STATUS_LINGER_MS)
336 .sort((a, b) => Number(b.stage === 'failed') - Number(a.stage === 'failed') || (finished[b.key] ?? 0) - (finished[a.key] ?? 0))
337 const last = recent[0]
338 if (last === undefined) return undefined
339 if (last.stage === 'failed') return `✗ ${last.provider} ${stageWord(last)}`
340 const env = envShort(last.env)
341 return `✓ ${env ?? last.provider} live ${versionLabel(last)}`
342}
343
344/** `✓ vercel production live · abc1234` / `✗ render build failed · abc1234` */
345export function toastText(deploy: DeployDeckDeploy): string {
346 const glyph = deploy.stage === 'live' ? '✓' : '✗'
347 const env = deploy.env !== undefined ? ` ${deploy.env}` : ''
348 const version = versionLabel(deploy)
349 return `${glyph} ${deploy.provider}${env} ${stageWord(deploy)}${version === '—' ? '' : ` · ${version}`}`
350}
351
352// --- Transitions --------------------------------------------------------------
353
354/** Grace for a deploy that started and finished between two reads. */
355const BETWEEN_READS_SLACK_MS = 60_000
356
357/**
358 * The deploys of a new read that finished since the previous one: those seen
359 * in flight before, and (once the feed is primed) new ones that finished
360 * after the previous read. The first read of a target announces nothing.
361 */
362export function finishedSince(previous: DeployDeckFeed | undefined, deploys: readonly DeployDeckDeploy[]): DeployDeckDeploy[] {
363 if (previous === undefined || !previous.primed) return []
364 const before = new Map(previous.deploys.map(deploy => [deploy.key, deploy]))
365 const since = (previous.checkedAt ?? 0) - BETWEEN_READS_SLACK_MS
366 return deploys.filter(deploy => {
367 if (!isTerminal(deploy.stage)) return false
368 const was = before.get(deploy.key)
369 if (was !== undefined) return !isTerminal(was.stage)
370 return (deploy.finishedAt ?? deploy.startedAt) >= since
371 })
372}
373
374// --- Polling and backoff ------------------------------------------------------
375
376export function pollInterval(isAnyActive: boolean, fastMs: number, slowMs: number): number {
377 return isAnyActive ? fastMs : slowMs
378}
379
380const BACKOFF_BASE_MS = 30_000
381const BACKOFF_MAX_MS = 15 * 60_000
382const AUTH_PAUSE_MS = 10 * 60_000
383
384/** How long a provider rests after a failure of `kind`, its `failures`th in a row. */
385export function backoffDelay(kind: string, failures: number, retryAfterMs: number | undefined): number {
386 if (kind === 'auth' || kind === 'notfound' || kind === 'config') return Math.max(AUTH_PAUSE_MS, retryAfterMs ?? 0)
387 const exp = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1))
388 return Math.max(exp, retryAfterMs ?? 0)
389}
390
391// --- Help and reports ---------------------------------------------------------
392
393export const SETUP_HELP: Record<DeployDeckProvider, string> = {
394 github:
395 'GitHub: `githubToken`, a fine-grained token (github.com/settings/personal-access-tokens) with read-only Actions, Deployments and Metadata on the repo. Or leave it empty and log in with `gh auth login`.',
396 vercel:
397 '`vercelToken` from vercel.com/account/tokens, scoped to the team that owns the project (Vercel has no read-only scope; deploy-deck only sends GETs). Set `vercelTeamId` unless `.vercel/project.json` names the team.',
398 cloudflare:
399 '`cloudflareToken`, an account API token (dash.cloudflare.com/profile/api-tokens) with Account › Cloudflare Pages: Read and Workers Scripts: Read, plus `cloudflareAccountId` (or `account_id` in wrangler.toml).',
400 render:
401 '`renderToken`, an API key from dashboard.render.com › Account settings › API keys (Render keys are account-wide; deploy-deck only sends GETs).',
402}
403
404export function setupText(): string {
405 return [
406 'Set tokens with `/plugin` › deploy-deck › configure (tokens are kept in secure storage), or in settings.json under `pluginConfigs["deploy-deck"].options`. Each needs read access only:',
407 ...PROVIDERS.map(provider => `- ${SETUP_HELP[provider]}`),
408 'Targets are detected from the repo (git remote, .vercel/project.json, wrangler.toml/jsonc, render.yaml) or added with `/deploys add vercel:my-app`.',
409 ].join('\n')
410}
411
412export function authLabel(auth: DeployDeckAuth, provider: DeployDeckProvider): string {
413 if (auth === 'token') return 'token'
414 if (auth === 'cli') return provider === 'github' ? 'gh CLI' : 'CLI'
415 return 'no credentials'
416}
417
418/** One target as `vercel:my-app`. */
419export function targetLabel(target: DeployDeckTarget): string {
420 return `${target.kind}:${target.id}`
421}
422
423/** One deploy as a markdown line for the inline card and the model. */
424export function deployLine(deploy: DeployDeckDeploy, now: number): string {
425 const parts = [
426 `${stageGlyph(deploy.stage)} **${stageWord(deploy)}**`,
427 deploy.env,
428 `\`${versionLabel(deploy)}\``,
429 deploy.branch,
430 isTerminal(deploy.stage) ? formatAge(now - (deploy.finishedAt ?? deploy.startedAt)) : formatElapsed(elapsedOf(deploy, now)),
431 ].filter((part): part is string => part !== undefined && part !== '')
432 const links = [
433 deploy.url !== undefined ? `[open](${deploy.url})` : undefined,
434 deploy.logsUrl !== undefined ? `[logs](${deploy.logsUrl})` : undefined,
435 ].filter((part): part is string => part !== undefined)
436 return parts.join(' · ') + (links.length > 0 ? ` · ${links.join(' ')}` : '')
437}
438
439/** The deck as markdown: the inline card's text, and what the model reads. */
440export function inlineText(targets: readonly DeployDeckTarget[], feeds: Readonly<Record<string, DeployDeckFeed>>, now: number): string {
441 if (targets.length === 0) return `${INLINE_HEAD} nothing to track yet\n\n${setupText()}`
442 const lines: string[] = [`${INLINE_HEAD} ${targets.length} target${targets.length === 1 ? '' : 's'}`]
443 for (const target of targets) {
444 const feed = feeds[targetKey(target)]
445 lines.push('', `**${PROVIDER_OF[target.kind]} · ${target.id}**${target.kind.startsWith('cloudflare') ? ` (${target.kind.slice(11)})` : ''}`)
446 if (feed?.error !== undefined) lines.push(`- ⚠ ${feed.error}`)
447 if (feed === undefined || (feed.deploys.length === 0 && feed.error === undefined)) {
448 lines.push(feed?.checkedAt === undefined ? '- not read yet' : '- no deploys')
449 continue
450 }
451 for (const deploy of feed.deploys.slice(0, 3)) lines.push(`- ${deployLine(deploy, now)}`)
452 }
453 return lines.join('\n')
454}
455hooks/net.ts 232 lines1// The one network door every adapter goes through. It injects the
2// provider's credential into the request headers, sends GET only, and turns
3// every failure into a ProviderError whose message is built here, scrubbed
4// of every secret the client knows: no token ever reaches an error, a log,
5// the status line or the UI.
6
7import type { HttpInit, HttpResponse, ProcessRunResult } from 'claude-code'
8
9import type { DeployDeckProvider } from '../types'
10
11export type Fetch = (url: string, init?: HttpInit) => Promise<HttpResponse>
12export type Run = (argv: readonly string[]) => Promise<ProcessRunResult>
13
14/** How one provider is reached for one poll. */
15export type Client = {
16 provider: DeployDeckProvider
17 fetch: Fetch
18 /** A host command runner, for the CLI fallback. */
19 run?: Run
20 /** The provider's token; absent when the CLI (or nothing) is used. */
21 token?: string
22 /** Every secret the error text must never hold (all tokens). */
23 secrets: readonly string[]
24 /** The clock at the poll, for Retry-After / reset headers. */
25 now: number
26}
27
28export type ProviderErrorKind = 'auth' | 'notfound' | 'rate' | 'server' | 'network' | 'parse' | 'cli' | 'config'
29
30/** A failed read, its message safe to show. */
31export class ProviderError extends Error {
32 readonly provider: DeployDeckProvider
33 readonly kind: ProviderErrorKind
34 readonly status: number | undefined
35 /** How long the provider asked us to wait, when it said so. */
36 readonly retryAfterMs: number | undefined
37
38 constructor(
39 provider: DeployDeckProvider,
40 kind: ProviderErrorKind,
41 message: string,
42 status?: number,
43 retryAfterMs?: number,
44 ) {
45 super(message)
46 this.name = 'ProviderError'
47 this.provider = provider
48 this.kind = kind
49 this.status = status
50 this.retryAfterMs = retryAfterMs
51 }
52}
53
54const USER_AGENT = 'deploy-deck (Claude Code mod; +https://github.com/mrjk05/modemon)'
55
56/** Removes every known secret, and anything shaped like a bearer credential, from `text`. */
57export function redact(text: string, secrets: readonly string[]): string {
58 let out = text
59 for (const secret of secrets) {
60 if (secret.length >= 4) out = out.split(secret).join('•••')
61 }
62 return out
63 .replace(/(bearer|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 •••')
64 .replace(/\b(gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|rnd_[A-Za-z0-9]{8,})\b/g, '•••')
65}
66
67/** One line, at most `max` characters. */
68function oneLine(text: string, max = 160): string {
69 const line = text.replace(/\s+/g, ' ').trim()
70 return line.length > max ? `${line.slice(0, max - 1)}…` : line
71}
72
73/**
74 * How long to wait, from `Retry-After` (seconds or an HTTP date) or a reset
75 * header (`x-ratelimit-reset`, `ratelimit-reset`: epoch seconds, or seconds
76 * from now when small). Headers are lower-cased, as `$.http.fetch` gives them.
77 */
78export function retryAfterMs(headers: Readonly<Record<string, string>>, now: number): number | undefined {
79 const after = headers['retry-after']
80 if (after !== undefined && after.trim() !== '') {
81 const seconds = Number(after)
82 if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
83 const at = Date.parse(after)
84 if (Number.isFinite(at)) return Math.max(0, at - now)
85 }
86 const reset = headers['x-ratelimit-reset'] ?? headers['ratelimit-reset']
87 if (reset !== undefined) {
88 const value = Number(reset)
89 if (Number.isFinite(value)) {
90 // Epoch seconds (GitHub, Vercel) or seconds until reset (Render).
91 return value > 1e9 ? Math.max(0, value * 1000 - now) : Math.max(0, value * 1000)
92 }
93 }
94 return undefined
95}
96
97/** The provider's own error words from a JSON body, when it has some. */
98function bodyMessage(text: string): string | undefined {
99 try {
100 const body = JSON.parse(text) as Record<string, unknown>
101 const error = body['error']
102 if (typeof error === 'object' && error !== null) {
103 const message = (error as Record<string, unknown>)['message']
104 if (typeof message === 'string') return message
105 }
106 if (typeof error === 'string') return error
107 const errors = body['errors']
108 if (Array.isArray(errors) && errors.length > 0) {
109 const first = errors[0] as Record<string, unknown> | undefined
110 if (first !== undefined && typeof first['message'] === 'string') return first['message']
111 }
112 if (typeof body['message'] === 'string') return body['message']
113 } catch {
114 // not JSON
115 }
116 return undefined
117}
118
119const TOKEN_FIELD: Record<DeployDeckProvider, string> = {
120 github: 'githubToken',
121 vercel: 'vercelToken',
122 cloudflare: 'cloudflareToken',
123 render: 'renderToken',
124}
125
126/** Builds the safe error for a non-2xx answer. */
127export function httpError(client: Client, response: HttpResponse): ProviderError {
128 const { provider } = client
129 const status = response.status
130 const said = bodyMessage(response.text)
131 const detail = said === undefined ? '' : `: ${oneLine(redact(said, client.secrets), 120)}`
132 const wait = retryAfterMs(response.headers, client.now)
133 const isRateLimited =
134 status === 429 || (status === 403 && (response.headers['x-ratelimit-remaining'] === '0' || wait !== undefined))
135 if (isRateLimited) return new ProviderError(provider, 'rate', `${provider}: rate limited (HTTP ${status})`, status, wait)
136 if (status >= 500) return new ProviderError(provider, 'server', `${provider}: server error (HTTP ${status})${detail}`, status, wait)
137 if (status === 401 || status === 403) {
138 const hint = client.token !== undefined ? `check ${TOKEN_FIELD[provider]} and its read scopes` : 'set a read-only token'
139 return new ProviderError(provider, 'auth', `${provider}: not authorized (HTTP ${status})${detail}; ${hint}`, status)
140 }
141 if (status === 404) return new ProviderError(provider, 'notfound', `${provider}: not found (HTTP 404)${detail}`, status)
142 return new ProviderError(provider, 'server', `${provider}: HTTP ${status}${detail}`, status)
143}
144
145/**
146 * GETs `url` with the provider's auth header and answers its JSON body.
147 * Throws ProviderError only, whose message never holds a secret.
148 */
149export async function getJson(client: Client, url: string, headers: Record<string, string> = {}): Promise<unknown> {
150 const sent: Record<string, string> = { Accept: 'application/json', 'User-Agent': USER_AGENT, ...headers }
151 if (client.token !== undefined && client.token !== '') sent['Authorization'] = `Bearer ${client.token}`
152 let response: HttpResponse
153 try {
154 response = await client.fetch(url, { method: 'GET', headers: sent })
155 } catch (error) {
156 const said = error instanceof Error ? error.message : String(error)
157 throw new ProviderError(client.provider, 'network', `${client.provider}: network error: ${oneLine(redact(said, client.secrets), 120)}`)
158 }
159 if (!response.ok) throw httpError(client, response)
160 try {
161 return JSON.parse(response.text) as unknown
162 } catch {
163 throw new ProviderError(client.provider, 'parse', `${client.provider}: answer was not JSON (HTTP ${response.status})`)
164 }
165}
166
167/** Runs a read-only CLI command that prints JSON (`gh api <path>`) and parses it. */
168export async function cliJson(client: Client, argv: readonly string[]): Promise<unknown> {
169 const tool = argv[0] ?? 'cli'
170 if (client.run === undefined) throw new ProviderError(client.provider, 'cli', `${client.provider}: no CLI runner`)
171 let ran: ProcessRunResult
172 try {
173 ran = await client.run(argv)
174 } catch (error) {
175 const said = error instanceof Error ? error.message : String(error)
176 throw new ProviderError(client.provider, 'cli', `${client.provider}: ${tool} could not run: ${oneLine(redact(said, client.secrets), 100)}`)
177 }
178 if (ran.exitCode !== 0) {
179 const said = oneLine(redact(ran.stderr || ran.stdout, client.secrets), 120)
180 const status = /HTTP (\d{3})/.exec(said)?.[1]
181 const code = status === undefined ? undefined : Number(status)
182 const kind: ProviderErrorKind =
183 code === 401 || code === 403 || /auth login|not logged/i.test(said)
184 ? 'auth'
185 : code === 404
186 ? 'notfound'
187 : code === 429
188 ? 'rate'
189 : code !== undefined && code >= 500
190 ? 'server'
191 : 'cli'
192 throw new ProviderError(client.provider, kind, `${client.provider}: ${tool} failed: ${said}`, code)
193 }
194 try {
195 return JSON.parse(ran.stdout) as unknown
196 } catch {
197 throw new ProviderError(client.provider, 'parse', `${client.provider}: ${tool} did not print JSON`)
198 }
199}
200
201// --- Tolerant readers over unknown JSON ---------------------------------------
202
203export type Json = Record<string, unknown>
204
205export function obj(value: unknown): Json | undefined {
206 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : undefined
207}
208
209export function arr(value: unknown): unknown[] {
210 return Array.isArray(value) ? value : []
211}
212
213export function str(record: Json | undefined, key: string): string | undefined {
214 const value = record?.[key]
215 return typeof value === 'string' && value.length > 0 ? value : undefined
216}
217
218export function num(record: Json | undefined, key: string): number | undefined {
219 const value = record?.[key]
220 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
221}
222
223/** An ISO date string or epoch ms, as epoch ms. */
224export function time(value: unknown): number | undefined {
225 if (typeof value === 'number' && Number.isFinite(value)) return value
226 if (typeof value === 'string' && value.length > 0) {
227 const at = Date.parse(value)
228 return Number.isFinite(at) ? at : undefined
229 }
230 return undefined
231}
232hooks/tracker.ts 82 lines1// The tracker's pure half: the config, how each provider is reached, and one
2// target's read through its adapter. The polling itself (timer, state,
3// toasts) is in register.tsx, where `$` lives.
4
5import type { DeployDeckAuth, DeployDeckDeploy, DeployDeckProvider, DeployDeckTarget } from '../types'
6import type { Client } from './net'
7import { ProviderError } from './net'
8import { listCloudflare } from './providers/cloudflare'
9import { listGithub } from './providers/github'
10import { listRender } from './providers/render'
11import { listVercel } from './providers/vercel'
12
13
14export type Config = {
15 tokens: Partial<Record<DeployDeckProvider, string>>
16 vercelTeamId: string
17 cloudflareAccountId: string
18 targets: string
19 autoDetect: boolean
20 useCli: boolean
21 fastMs: number
22 slowMs: number
23 band: boolean
24 toasts: boolean
25 statusLine: boolean
26}
27
28/** Runs work that must never break the caller. */
29export async function quietly(work: () => Promise<unknown>): Promise<void> {
30 try {
31 await work()
32 } catch {
33 // A missed read costs one refresh, never the user's session.
34 }
35}
36
37/** How a provider is reached under this config. */
38export function authOf(provider: DeployDeckProvider, cfg: Config): DeployDeckAuth {
39 const token = cfg.tokens[provider]
40 if (token !== undefined && token !== '') return 'token'
41 if (provider === 'github' && cfg.useCli) return 'cli'
42 return 'none'
43}
44
45export function secretsOf(cfg: Config): string[] {
46 return Object.values(cfg.tokens).filter((token): token is string => token !== undefined && token !== '')
47}
48
49/** One target's read, through its adapter. */
50export async function readTarget(
51 client: Client,
52 target: DeployDeckTarget,
53 cfg: Config,
54): Promise<{ deploys: DeployDeckDeploy[]; meta?: Record<string, string> }> {
55 switch (target.kind) {
56 case 'github':
57 return { deploys: await listGithub(client, target) }
58 case 'vercel': {
59 const team = cfg.vercelTeamId !== '' ? cfg.vercelTeamId : target.meta?.['teamId']
60 return { deploys: await listVercel(client, target, team) }
61 }
62 case 'cloudflare-pages':
63 case 'cloudflare-workers': {
64 const account = cfg.cloudflareAccountId !== '' ? cfg.cloudflareAccountId : (target.meta?.['accountId'] ?? '')
65 if (account === '') {
66 throw new ProviderError('cloudflare', 'config', 'cloudflare: set cloudflareAccountId (or account_id in wrangler.toml)')
67 }
68 return { deploys: await listCloudflare(client, target, account) }
69 }
70 case 'render':
71 return listRender(client, target)
72 }
73}
74
75export const MISSING: Record<DeployDeckProvider, string> = {
76 github: 'github: no githubToken and the gh CLI fallback is off; see /deploys status',
77 vercel: 'vercel: set vercelToken to read deployments; see /deploys',
78 cloudflare: 'cloudflare: set cloudflareToken and cloudflareAccountId; see /deploys',
79 render: 'render: set renderToken to read deploys; see /deploys',
80}
81
82hooks/providers/cloudflare.ts 183 lines1// Cloudflare: Pages deployments (with their stages) and Workers deployments
2// (with the versions they serve).
3//
4// REST (Authorization: Bearer <API token>):
5// GET /client/v4/accounts/{acct}/pages/projects/{name}/deployments
6// result[]: id, short_id, url, environment, created_on, latest_stage
7// { name: queued|initialize|clone_repo|build|deploy,
8// status: idle|active|success|failure|canceled|skipped, started_on, ended_on },
9// deployment_trigger.metadata { branch, commit_hash, commit_message }
10// GET /client/v4/accounts/{acct}/workers/scripts/{name}/deployments
11// result.deployments[]: id, created_on, source, strategy, author_email,
12// annotations { workers/message, workers/triggered_by },
13// versions[] { version_id, percentage }
14// GET /client/v4/accounts/{acct}/workers/scripts/{name}/versions
15// result.items[]: id, number, metadata { created_on, source },
16// annotations { workers/tag, workers/message }
17// No CLI fallback: wrangler prints no stable JSON for these, so a token is needed.
18
19import type { DeployDeckDeploy, DeployDeckStage, DeployDeckStep, DeployDeckTarget } from '../../types'
20import { deployKey } from '../lib'
21import type { Client, Json } from '../net'
22import { arr, getJson, num, obj, str, time } from '../net'
23
24const API = 'https://api.cloudflare.com/client/v4'
25const DASH = 'https://dash.cloudflare.com'
26
27/** The step a Pages stage name belongs to. */
28function stepOf(name: string | undefined): DeployDeckStep {
29 if (name === 'deploy') return 'deploying'
30 if (name === 'queued' || name === undefined) return 'queued'
31 return 'building' // initialize, clone_repo, build
32}
33
34/** A Pages `latest_stage` on the one pipeline. */
35export function pagesStage(name: string | undefined, status: string | undefined): { stage: DeployDeckStage; failedAt?: DeployDeckStep; note?: string } {
36 const step = stepOf(name)
37 switch (status) {
38 case 'failure':
39 return { stage: 'failed', failedAt: step }
40 case 'canceled':
41 return { stage: 'canceled' }
42 case 'skipped':
43 return { stage: 'canceled', note: 'skipped' }
44 case 'success':
45 // The stage finished; until the next one reports, the deploy stands at the next step.
46 if (name === 'deploy') return { stage: 'live' }
47 if (name === 'build') return { stage: 'deploying' }
48 if (name === 'queued') return { stage: 'building' }
49 return { stage: 'building' }
50 default:
51 // idle, active
52 return { stage: step }
53 }
54}
55
56export function normalizePages(item: unknown, target: DeployDeckTarget, accountId: string): DeployDeckDeploy | undefined {
57 const d = obj(item)
58 const id = str(d, 'id')
59 if (d === undefined || id === undefined) return undefined
60 const latest = obj(d['latest_stage'])
61 const { stage, failedAt, note } = pagesStage(str(latest, 'name'), str(latest, 'status'))
62 const meta = obj(obj(d['deployment_trigger'])?.['metadata'])
63 const deploy: DeployDeckDeploy = {
64 key: deployKey(target, id),
65 provider: 'cloudflare',
66 kind: 'cloudflare-pages',
67 id,
68 project: str(d, 'project_name') ?? target.id,
69 env: str(d, 'environment') ?? 'preview',
70 stage,
71 startedAt: time(d['created_on']) ?? 0,
72 }
73 const sha = str(meta, 'commit_hash')
74 if (sha !== undefined) deploy.commit = sha
75 const branch = str(meta, 'branch')
76 if (branch !== undefined) deploy.branch = branch
77 const message = str(meta, 'commit_message')
78 if (message !== undefined) deploy.message = message.split('\n')[0] ?? message
79 if (failedAt !== undefined) deploy.failedAt = failedAt
80 if (note !== undefined) deploy.note = note
81 if (stage === 'live' || stage === 'failed' || stage === 'canceled') {
82 const done = time(latest?.['ended_on']) ?? time(d['modified_on'])
83 if (done !== undefined) deploy.finishedAt = done
84 }
85 const url = str(d, 'url')
86 if (url !== undefined) deploy.url = url
87 if (accountId !== '') deploy.logsUrl = `${DASH}/${accountId}/pages/view/${target.id}/${id}`
88 return deploy
89}
90
91/** `GET …/pages/projects/{name}/deployments` → Deploys, newest first. */
92export function parsePages(body: unknown, target: DeployDeckTarget, accountId: string): DeployDeckDeploy[] {
93 return arr(obj(body)?.['result'])
94 .map(item => normalizePages(item, target, accountId))
95 .filter((d): d is DeployDeckDeploy => d !== undefined)
96 .sort((a, b) => b.startedAt - a.startedAt)
97}
98
99type VersionInfo = { number?: number; tag?: string; message?: string }
100
101/** `GET …/workers/scripts/{name}/versions` → version id → number and tag. */
102export function parseWorkerVersions(body: unknown): Record<string, VersionInfo> {
103 const result = obj(body)?.['result']
104 const items = Array.isArray(result) ? result : arr(obj(result)?.['items'])
105 const out: Record<string, VersionInfo> = {}
106 for (const item of items) {
107 const v = obj(item)
108 const id = str(v, 'id')
109 if (v === undefined || id === undefined) continue
110 const annotations = obj(v['annotations']) ?? obj(obj(v['metadata'])?.['annotations'])
111 const info: VersionInfo = {}
112 const n = num(v, 'number')
113 if (n !== undefined) info.number = n
114 const tag = str(annotations, 'workers/tag')
115 if (tag !== undefined) info.tag = tag
116 const message = str(annotations, 'workers/message')
117 if (message !== undefined) info.message = message
118 out[id] = info
119 }
120 return out
121}
122
123/** One Workers deployment: atomic, so it is live from the moment it exists. */
124export function normalizeWorker(
125 item: unknown,
126 target: DeployDeckTarget,
127 accountId: string,
128 versions: Record<string, VersionInfo>,
129 isCurrent: boolean,
130): DeployDeckDeploy | undefined {
131 const d = obj(item)
132 const id = str(d, 'id')
133 if (d === undefined || id === undefined) return undefined
134 const served = arr(d['versions']).map(obj).filter((v): v is Json => v !== undefined)
135 const main = [...served].sort((a, b) => (num(b, 'percentage') ?? 0) - (num(a, 'percentage') ?? 0))[0]
136 const versionId = str(main, 'version_id')
137 const info = versionId === undefined ? undefined : versions[versionId]
138 const created = time(d['created_on']) ?? 0
139 const annotations = obj(d['annotations'])
140 const deploy: DeployDeckDeploy = {
141 key: deployKey(target, id),
142 provider: 'cloudflare',
143 kind: 'cloudflare-workers',
144 id,
145 project: target.id,
146 env: 'production',
147 stage: 'live',
148 startedAt: created,
149 finishedAt: created,
150 }
151 const version = info?.tag ?? (info?.number !== undefined ? `#${info.number}` : versionId?.slice(0, 8))
152 if (version !== undefined) deploy.version = version
153 const message = str(annotations, 'workers/message') ?? info?.message
154 if (message !== undefined) deploy.message = message
155 const notes: string[] = []
156 if (served.length > 1) notes.push(served.map(v => `${num(v, 'percentage') ?? 0}%`).join('/') + ' split')
157 if (!isCurrent) notes.push('superseded')
158 if (notes.length > 0) deploy.note = notes.join(', ')
159 if (accountId !== '') deploy.logsUrl = `${DASH}/${accountId}/workers/services/view/${target.id}/production/deployments`
160 return deploy
161}
162
163/** `GET …/workers/scripts/{name}/deployments` (+ versions) → Deploys, newest first. */
164export function parseWorkers(body: unknown, target: DeployDeckTarget, accountId: string, versions: Record<string, VersionInfo>): DeployDeckDeploy[] {
165 const result = obj(obj(body)?.['result'])
166 const list = arr(result?.['deployments'])
167 const sorted = [...list].sort((a, b) => (time(obj(b)?.['created_on']) ?? 0) - (time(obj(a)?.['created_on']) ?? 0))
168 return sorted
169 .map((item, index) => normalizeWorker(item, target, accountId, versions, index === 0))
170 .filter((d): d is DeployDeckDeploy => d !== undefined)
171}
172
173export async function listCloudflare(client: Client, target: DeployDeckTarget, accountId: string): Promise<DeployDeckDeploy[]> {
174 const acct = encodeURIComponent(accountId)
175 const name = encodeURIComponent(target.id)
176 if (target.kind === 'cloudflare-pages') {
177 return parsePages(await getJson(client, `${API}/accounts/${acct}/pages/projects/${name}/deployments?per_page=10`), target, accountId)
178 }
179 const deployments = await getJson(client, `${API}/accounts/${acct}/workers/scripts/${name}/deployments`)
180 const versions = parseWorkerVersions(await getJson(client, `${API}/accounts/${acct}/workers/scripts/${name}/versions?per_page=20`))
181 return parseWorkers(deployments, target, accountId, versions).slice(0, 10)
182}
183hooks/providers/github.ts 163 lines1// GitHub: Actions workflow runs and Deployments (with their latest status).
2//
3// REST: GET /repos/{owner}/{repo}/actions/runs?per_page=
4// GET /repos/{owner}/{repo}/deployments?per_page=
5// GET /repos/{owner}/{repo}/deployments/{id}/statuses?per_page=1
6// Headers: Authorization: Bearer <token>, Accept: application/vnd.github+json,
7// X-GitHub-Api-Version: 2022-11-28. Without a token: `gh api <path>`.
8
9import type { DeployDeckDeploy, DeployDeckStage, DeployDeckStep, DeployDeckTarget } from '../../types'
10import { deployKey, versionFromRef } from '../lib'
11import type { Client } from '../net'
12import { arr, cliJson, getJson, num, obj, str, time } from '../net'
13
14const API = 'https://api.github.com'
15const HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }
16const RUNS = 8
17const DEPLOYMENTS = 3
18
19/** A workflow run's `status` / `conclusion` on the one pipeline. */
20export function runStage(status: string | undefined, conclusion: string | undefined): { stage: DeployDeckStage; failedAt?: DeployDeckStep; note?: string } {
21 if (status !== 'completed') {
22 if (status === 'in_progress') return { stage: 'building' }
23 // queued, requested, waiting, pending
24 return status === 'waiting' ? { stage: 'queued', note: 'waiting for approval' } : { stage: 'queued' }
25 }
26 switch (conclusion) {
27 case 'success':
28 case 'neutral':
29 return { stage: 'live' }
30 case 'cancelled':
31 case 'skipped':
32 case 'stale':
33 return { stage: 'canceled', note: conclusion === 'cancelled' ? undefined : conclusion }
34 case 'action_required':
35 return { stage: 'failed', failedAt: 'queued', note: 'action required' }
36 case 'startup_failure':
37 return { stage: 'failed', failedAt: 'queued', note: 'startup failure' }
38 case 'timed_out':
39 return { stage: 'failed', failedAt: 'building', note: 'timed out' }
40 default:
41 return { stage: 'failed', failedAt: 'building' }
42 }
43}
44
45/** One `workflow_runs[]` entry as a Deploy. */
46export function normalizeRun(run: unknown, target: DeployDeckTarget): DeployDeckDeploy | undefined {
47 const r = obj(run)
48 const id = num(r, 'id')
49 if (r === undefined || id === undefined) return undefined
50 const { stage, failedAt, note } = runStage(str(r, 'status'), str(r, 'conclusion') ?? undefined)
51 const startedAt = time(r['run_started_at']) ?? time(r['created_at']) ?? 0
52 const sha = str(r, 'head_sha')
53 const commit = obj(r['head_commit'])
54 const deploy: DeployDeckDeploy = {
55 key: deployKey(target, `run-${id}`),
56 provider: 'github',
57 kind: 'github',
58 id: String(id),
59 project: target.id,
60 env: str(r, 'name') ?? 'workflow',
61 stage,
62 startedAt,
63 }
64 if (sha !== undefined) deploy.commit = sha
65 const branch = str(r, 'head_branch')
66 if (branch !== undefined) deploy.branch = branch
67 const message = str(commit, 'message')
68 if (message !== undefined) deploy.message = message.split('\n')[0] ?? message
69 if (failedAt !== undefined) deploy.failedAt = failedAt
70 if (note !== undefined) deploy.note = note
71 if (stage === 'live' || stage === 'failed' || stage === 'canceled') {
72 const done = time(r['updated_at'])
73 if (done !== undefined) deploy.finishedAt = done
74 }
75 const html = str(r, 'html_url')
76 if (html !== undefined) deploy.logsUrl = html
77 return deploy
78}
79
80/** `GET …/actions/runs` → Deploys, newest first. */
81export function parseRuns(body: unknown, target: DeployDeckTarget): DeployDeckDeploy[] {
82 return arr(obj(body)?.['workflow_runs'])
83 .map(run => normalizeRun(run, target))
84 .filter((d): d is DeployDeckDeploy => d !== undefined)
85}
86
87/** A deployment status `state` on the one pipeline. */
88export function deploymentStage(state: string | undefined): { stage: DeployDeckStage; failedAt?: DeployDeckStep; note?: string } {
89 switch (state) {
90 case 'success':
91 return { stage: 'live' }
92 case 'inactive':
93 return { stage: 'live', note: 'superseded' }
94 case 'in_progress':
95 return { stage: 'deploying' }
96 case 'failure':
97 case 'error':
98 return { stage: 'failed', failedAt: 'deploying' }
99 default:
100 // queued, pending, or no status yet
101 return { stage: 'queued' }
102 }
103}
104
105/** One deployment plus its latest status (statuses come newest first). */
106export function normalizeDeployment(deployment: unknown, statuses: unknown, target: DeployDeckTarget): DeployDeckDeploy | undefined {
107 const d = obj(deployment)
108 const id = num(d, 'id')
109 if (d === undefined || id === undefined) return undefined
110 const latest = obj(arr(statuses)[0])
111 const { stage, failedAt, note } = deploymentStage(str(latest, 'state'))
112 const ref = str(d, 'ref')
113 const sha = str(d, 'sha')
114 const deploy: DeployDeckDeploy = {
115 key: deployKey(target, `deployment-${id}`),
116 provider: 'github',
117 kind: 'github',
118 id: String(id),
119 project: target.id,
120 env: str(d, 'environment') ?? 'deployment',
121 stage,
122 startedAt: time(d['created_at']) ?? 0,
123 }
124 if (sha !== undefined) deploy.commit = sha
125 const version = versionFromRef(ref)
126 if (version !== undefined) deploy.version = version
127 else if (ref !== undefined && !/^[0-9a-f]{40}$/.test(ref)) deploy.branch = ref
128 const description = str(d, 'description')
129 if (description !== undefined) deploy.message = description
130 if (failedAt !== undefined) deploy.failedAt = failedAt
131 if (note !== undefined) deploy.note = note
132 if (stage === 'live' || stage === 'failed' || stage === 'canceled') {
133 const done = time(latest?.['created_at'])
134 if (done !== undefined) deploy.finishedAt = done
135 }
136 const url = str(latest, 'environment_url')
137 if (url !== undefined) deploy.url = url
138 const logs = str(latest, 'log_url') ?? str(latest, 'target_url')
139 if (logs !== undefined) deploy.logsUrl = logs
140 return deploy
141}
142
143async function get(client: Client, path: string): Promise<unknown> {
144 if (client.token !== undefined) return getJson(client, `${API}${path}`, HEADERS)
145 return cliJson(client, ['gh', 'api', '-H', `Accept: ${HEADERS.Accept}`, '-H', `X-GitHub-Api-Version: ${HEADERS['X-GitHub-Api-Version']}`, path])
146}
147
148/** Reads a repo's latest workflow runs and deployments, merged newest first. */
149export async function listGithub(client: Client, target: DeployDeckTarget): Promise<DeployDeckDeploy[]> {
150 const repo = target.id
151 const runs = parseRuns(await get(client, `/repos/${repo}/actions/runs?per_page=${RUNS}`), target)
152 const deployments = arr(await get(client, `/repos/${repo}/deployments?per_page=${DEPLOYMENTS}`)).slice(0, DEPLOYMENTS)
153 const read: DeployDeckDeploy[] = []
154 for (const one of deployments) {
155 const id = num(obj(one), 'id')
156 if (id === undefined) continue
157 const statuses = await get(client, `/repos/${repo}/deployments/${id}/statuses?per_page=1`)
158 const deploy = normalizeDeployment(one, statuses, target)
159 if (deploy !== undefined) read.push(deploy)
160 }
161 return [...runs, ...read].sort((a, b) => b.startedAt - a.startedAt)
162}
163hooks/providers/render.ts 151 lines1// Render: a service's deploys.
2//
3// REST (Authorization: Bearer <API key>):
4// GET https://api.render.com/v1/services/{id}/deploys?limit=
5// [{ deploy: { id, status, commit { id, message }, trigger, createdAt,
6// startedAt, finishedAt }, cursor }]
7// GET https://api.render.com/v1/services?name=&limit= (name → id)
8// [{ service: { id, name, type, branch, dashboardUrl, serviceDetails { url } }, cursor }]
9// GET https://api.render.com/v1/services/{id} (one service)
10// Statuses: created, queued, build_in_progress, update_in_progress, live,
11// deactivated, build_failed, update_failed, canceled, pre_deploy_in_progress,
12// pre_deploy_failed. No CLI fallback: Render needs an API key.
13
14import type { DeployDeckDeploy, DeployDeckStage, DeployDeckStep, DeployDeckTarget } from '../../types'
15import { deployKey } from '../lib'
16import type { Client } from '../net'
17import { ProviderError, arr, getJson, obj, str, time } from '../net'
18
19const API = 'https://api.render.com/v1'
20const LIMIT = 10
21
22export function renderStage(status: string | undefined): { stage: DeployDeckStage; failedAt?: DeployDeckStep; note?: string } {
23 switch (status) {
24 case 'build_in_progress':
25 return { stage: 'building' }
26 case 'pre_deploy_in_progress':
27 case 'update_in_progress':
28 return { stage: 'deploying' }
29 case 'live':
30 return { stage: 'live' }
31 case 'deactivated':
32 return { stage: 'live', note: 'superseded' }
33 case 'build_failed':
34 return { stage: 'failed', failedAt: 'building' }
35 case 'pre_deploy_failed':
36 return { stage: 'failed', failedAt: 'deploying', note: 'pre-deploy failed' }
37 case 'update_failed':
38 return { stage: 'failed', failedAt: 'deploying' }
39 case 'canceled':
40 return { stage: 'canceled' }
41 default:
42 // created, queued
43 return { stage: 'queued' }
44 }
45}
46
47/** Facts about the service a deploy row carries. */
48export type RenderService = { id: string; name?: string; branch?: string; dashboardUrl?: string; url?: string }
49
50export function normalizeRender(item: unknown, target: DeployDeckTarget, service: RenderService): DeployDeckDeploy | undefined {
51 const wrapped = obj(item)
52 const d = obj(wrapped?.['deploy']) ?? wrapped
53 const id = str(d, 'id')
54 if (d === undefined || id === undefined) return undefined
55 const { stage, failedAt, note } = renderStage(str(d, 'status'))
56 const commit = obj(d['commit'])
57 const image = obj(d['image'])
58 const deploy: DeployDeckDeploy = {
59 key: deployKey(target, id),
60 provider: 'render',
61 kind: 'render',
62 id,
63 project: service.name ?? target.id,
64 stage,
65 startedAt: time(d['startedAt']) ?? time(d['createdAt']) ?? 0,
66 }
67 const sha = str(commit, 'id') ?? str(image, 'sha')
68 if (sha !== undefined) deploy.commit = sha
69 if (service.branch !== undefined) deploy.branch = service.branch
70 const message = str(commit, 'message')
71 if (message !== undefined) deploy.message = message.split('\n')[0] ?? message
72 if (failedAt !== undefined) deploy.failedAt = failedAt
73 if (note !== undefined) deploy.note = note
74 if (stage === 'live' || stage === 'failed' || stage === 'canceled') {
75 const done = time(d['finishedAt']) ?? time(d['updatedAt'])
76 if (done !== undefined) deploy.finishedAt = done
77 }
78 if (service.url !== undefined) deploy.url = service.url
79 if (service.dashboardUrl !== undefined) deploy.logsUrl = `${service.dashboardUrl}/deploys/${id}`
80 return deploy
81}
82
83/** `GET /v1/services/{id}/deploys` → Deploys, newest first. */
84export function parseRender(body: unknown, target: DeployDeckTarget, service: RenderService): DeployDeckDeploy[] {
85 return arr(body)
86 .map(item => normalizeRender(item, target, service))
87 .filter((d): d is DeployDeckDeploy => d !== undefined)
88 .sort((a, b) => b.startedAt - a.startedAt)
89}
90
91/** One service object (bare, or wrapped as `{ service }`). */
92export function parseRenderService(body: unknown): RenderService | undefined {
93 const wrapped = obj(body)
94 const s = obj(wrapped?.['service']) ?? wrapped
95 const id = str(s, 'id')
96 if (s === undefined || id === undefined) return undefined
97 const service: RenderService = { id }
98 const name = str(s, 'name')
99 if (name !== undefined) service.name = name
100 const branch = str(s, 'branch')
101 if (branch !== undefined) service.branch = branch
102 const dashboardUrl = str(s, 'dashboardUrl')
103 if (dashboardUrl !== undefined) service.dashboardUrl = dashboardUrl
104 const url = str(obj(s['serviceDetails']), 'url')
105 if (url !== undefined) service.url = url
106 return service
107}
108
109/** `GET /v1/services?name=` → the service of exactly that name. */
110export function pickRenderService(body: unknown, name: string): RenderService | undefined {
111 return arr(body)
112 .map(parseRenderService)
113 .find((s): s is RenderService => s !== undefined && s.name === name)
114}
115
116/** A target's service: by id (`srv-…`) or looked up by name; meta caches what was learned. */
117export async function resolveRender(client: Client, target: DeployDeckTarget): Promise<RenderService> {
118 const meta = target.meta ?? {}
119 const known = meta['serviceId']
120 if (known !== undefined) {
121 const service: RenderService = { id: known }
122 if (meta['name'] !== undefined) service.name = meta['name']
123 if (meta['branch'] !== undefined) service.branch = meta['branch']
124 if (meta['dashboardUrl'] !== undefined) service.dashboardUrl = meta['dashboardUrl']
125 if (meta['url'] !== undefined) service.url = meta['url']
126 return service
127 }
128 if (/^srv-[a-z0-9]+$/i.test(target.id)) {
129 return parseRenderService(await getJson(client, `${API}/services/${encodeURIComponent(target.id)}`)) ?? { id: target.id }
130 }
131 const found = pickRenderService(await getJson(client, `${API}/services?name=${encodeURIComponent(target.id)}&limit=20`), target.id)
132 if (found === undefined) throw new ProviderError('render', 'notfound', `render: no service named "${target.id}" for this API key`)
133 return found
134}
135
136/** Meta to cache on the target once its service is known. */
137export function renderMeta(service: RenderService): Record<string, string> {
138 const meta: Record<string, string> = { serviceId: service.id }
139 if (service.name !== undefined) meta['name'] = service.name
140 if (service.branch !== undefined) meta['branch'] = service.branch
141 if (service.dashboardUrl !== undefined) meta['dashboardUrl'] = service.dashboardUrl
142 if (service.url !== undefined) meta['url'] = service.url
143 return meta
144}
145
146export async function listRender(client: Client, target: DeployDeckTarget): Promise<{ deploys: DeployDeckDeploy[]; meta: Record<string, string> }> {
147 const service = await resolveRender(client, target)
148 const body = await getJson(client, `${API}/services/${encodeURIComponent(service.id)}/deploys?limit=${LIMIT}`)
149 return { deploys: parseRender(body, target, service), meta: renderMeta(service) }
150}
151hooks/providers/vercel.ts 92 lines1// Vercel: a project's deployments, preview and production.
2//
3// REST: GET https://api.vercel.com/v7/deployments?projectId=&limit=&teamId=
4// (v6 answers the same shape). Header: Authorization: Bearer <token>.
5// States (readyState / state): QUEUED, INITIALIZING, BUILDING, READY, ERROR,
6// CANCELED, BLOCKED, DELETED. meta.githubCommitSha / Ref / Message.
7// No CLI fallback: `vercel ls` prints no JSON, so Vercel needs a token.
8
9import type { DeployDeckDeploy, DeployDeckStage, DeployDeckStep, DeployDeckTarget } from '../../types'
10import { deployKey } from '../lib'
11import type { Client } from '../net'
12import { arr, getJson, obj, str, time } from '../net'
13
14const API = 'https://api.vercel.com'
15const LIMIT = 10
16
17export function vercelStage(state: string | undefined): { stage: DeployDeckStage; failedAt?: DeployDeckStep; note?: string } {
18 switch (state) {
19 case 'QUEUED':
20 return { stage: 'queued' }
21 case 'INITIALIZING':
22 case 'BUILDING':
23 return { stage: 'building' }
24 case 'READY':
25 return { stage: 'live' }
26 case 'ERROR':
27 return { stage: 'failed', failedAt: 'building' }
28 case 'BLOCKED':
29 return { stage: 'failed', failedAt: 'queued', note: 'blocked' }
30 case 'CANCELED':
31 return { stage: 'canceled' }
32 case 'DELETED':
33 return { stage: 'canceled', note: 'deleted' }
34 default:
35 return { stage: 'queued' }
36 }
37}
38
39export function normalizeVercel(item: unknown, target: DeployDeckTarget): DeployDeckDeploy | undefined {
40 const d = obj(item)
41 const uid = str(d, 'uid') ?? str(d, 'id')
42 if (d === undefined || uid === undefined) return undefined
43 const { stage, failedAt, note } = vercelStage(str(d, 'readyState') ?? str(d, 'state'))
44 const meta = obj(d['meta'])
45 const deploy: DeployDeckDeploy = {
46 key: deployKey(target, uid),
47 provider: 'vercel',
48 kind: 'vercel',
49 id: uid,
50 project: str(d, 'name') ?? target.id,
51 env: str(d, 'target') ?? 'preview',
52 stage,
53 startedAt: time(d['buildingAt']) ?? time(d['createdAt']) ?? time(d['created']) ?? 0,
54 }
55 const sha = str(meta, 'githubCommitSha') ?? str(meta, 'gitlabCommitSha') ?? str(meta, 'bitbucketCommitSha')
56 if (sha !== undefined) deploy.commit = sha
57 const branch = str(meta, 'githubCommitRef') ?? str(meta, 'gitlabCommitRef') ?? str(meta, 'bitbucketCommitRef')
58 if (branch !== undefined) deploy.branch = branch
59 const message = str(meta, 'githubCommitMessage') ?? str(meta, 'gitlabCommitMessage') ?? str(meta, 'bitbucketCommitMessage')
60 if (message !== undefined) deploy.message = message.split('\n')[0] ?? message
61 if (failedAt !== undefined) deploy.failedAt = failedAt
62 const error = str(d, 'errorCode')
63 if (note !== undefined || error !== undefined) deploy.note = note ?? error
64 if (stage === 'live' || stage === 'failed' || stage === 'canceled') {
65 const done = time(d['ready'])
66 if (done !== undefined) deploy.finishedAt = done
67 }
68 const url = str(d, 'url')
69 if (url !== undefined) deploy.url = url.startsWith('http') ? url : `https://${url}`
70 const logs = str(d, 'inspectorUrl')
71 if (logs !== undefined) deploy.logsUrl = logs
72 return deploy
73}
74
75/** `GET /v7/deployments` → Deploys, newest first. */
76export function parseVercel(body: unknown, target: DeployDeckTarget): DeployDeckDeploy[] {
77 return arr(obj(body)?.['deployments'])
78 .map(item => normalizeVercel(item, target))
79 .filter((d): d is DeployDeckDeploy => d !== undefined)
80 .sort((a, b) => b.startedAt - a.startedAt)
81}
82
83/** The request URL for a project (id or name) and optional team. */
84export function vercelUrl(projectId: string, teamId: string | undefined): string {
85 const team = teamId !== undefined && teamId !== '' ? `&teamId=${encodeURIComponent(teamId)}` : ''
86 return `${API}/v7/deployments?projectId=${encodeURIComponent(projectId)}&limit=${LIMIT}${team}`
87}
88
89export async function listVercel(client: Client, target: DeployDeckTarget, teamId: string | undefined): Promise<DeployDeckDeploy[]> {
90 return parseVercel(await getJson(client, vercelUrl(target.id, teamId)), target)
91}
92types/index.d.ts 101 lines1// deploy-deck's state contract: what it keeps in `$.state` for the session,
2// so a hot reload of the module keeps every deploy it has seen.
3
4/** The providers deploy-deck reads. */
5export type DeployDeckProvider = 'github' | 'vercel' | 'cloudflare' | 'render'
6
7/** What a target is on its provider. */
8export type DeployDeckTargetKind = 'github' | 'vercel' | 'cloudflare-pages' | 'cloudflare-workers' | 'render'
9
10/** The one normalised stage pipeline: queued → building → deploying → live / failed / canceled. */
11export type DeployDeckStage = 'queued' | 'building' | 'deploying' | 'live' | 'failed' | 'canceled'
12
13/** The in-flight stages, in pipeline order. */
14export type DeployDeckStep = 'queued' | 'building' | 'deploying'
15
16/** One thing deploys are read for: a repo, a project, a script, a service. */
17export type DeployDeckTarget = {
18 kind: DeployDeckTargetKind
19 /** owner/repo, a Vercel project id or name, a Pages project, a Worker script, a Render service id or name. */
20 id: string
21 /** Where it came from. */
22 source: 'detected' | 'pinned' | 'added'
23 /** Provider facts learned along the way (account id, team id, resolved service id, dashboard URL). */
24 meta?: Record<string, string>
25}
26
27/** One deploy, normalised across providers. */
28export type DeployDeckDeploy = {
29 /** `<kind>:<target id>:<provider id>`: unique in the session. */
30 key: string
31 provider: DeployDeckProvider
32 kind: DeployDeckTargetKind
33 /** The provider's id for it. */
34 id: string
35 /** The project, repo, script or service it belongs to. */
36 project: string
37 /** production, preview, a GitHub environment or workflow name. */
38 env?: string
39 /** A tag or version number, when the provider has one. */
40 version?: string
41 /** The full commit sha, when known. */
42 commit?: string
43 branch?: string
44 message?: string
45 stage: DeployDeckStage
46 /** For a failed deploy: the step it failed in. */
47 failedAt?: DeployDeckStep
48 /** A short provider note (`blocked`, `superseded`, `90% of traffic`). */
49 note?: string
50 /** Epoch milliseconds. */
51 startedAt: number
52 finishedAt?: number
53 /** The deployed site, when there is one. */
54 url?: string
55 /** Where its build or run logs are. */
56 logsUrl?: string
57}
58
59/** How a provider is reached. */
60export type DeployDeckAuth = 'token' | 'cli' | 'none'
61
62/** One target's latest read. */
63export type DeployDeckFeed = {
64 target: DeployDeckTarget
65 /** Newest first. */
66 deploys: DeployDeckDeploy[]
67 auth: DeployDeckAuth
68 /** The last read's error, already free of any credential. */
69 error?: string
70 /** When it was last read (epoch ms). */
71 checkedAt?: number
72 /** True once a read succeeded: later reads may toast new deploys. */
73 primed: boolean
74}
75
76/** Per-provider backoff after rate limits, server errors and auth failures. */
77export type DeployDeckBackoff = { until: number; failures: number; reason: string }
78
79declare module 'claude-code' {
80 interface PluginState {
81 'deploy-deck': {
82 /** The targets in force: detected, pinned in settings, added by command. */
83 targets: DeployDeckTarget[]
84 /** Each target's latest read, by target key (`<kind>:<id>`). */
85 feeds: Record<string, DeployDeckFeed>
86 /** The clock at the last tick, which elapsed times read. */
87 now: number
88 /** Deploy keys a finish toast went out for. */
89 announced: string[]
90 /** When a deploy was seen to finish (deploy key → epoch ms): the band keeps it 60s. */
91 finished: Record<string, number>
92 /** Per-provider backoff. */
93 backoff: Partial<Record<DeployDeckProvider, DeployDeckBackoff>>
94 /** The poll interval in force (ms); 0 while stopped. */
95 intervalMs: number
96 /** True once targets were resolved this session. */
97 isReady: boolean
98 }
99 }
100}
101