SLOPSHOPPER

deploy-deck

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

newpanebandrowscommandtoast
v0.1.0MITupdated 2026-10-06mrjk05/modemon/mods/deploy-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deploy-deck
│ ┃ Deploys ✕ › fix the failing auth test and add an audit log call │ ┃ Deploys every 120s │ ┃ ⏺ Read(src/auth.ts) │ ┃ github · acme/app gh CLI ⎿ Read 6 lines │ ┃ ⚠ github: gh did not print JSON ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ /deploys refresh · add · remove · status ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /deploys │ ⎿ deploy-deck: Deploys panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Deploys
Deploys every 120s github · acme/app gh CLI ⚠ github: gh did not print JSON /deploys refresh · add · remove · status
README

deploy-deck

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.

Install

/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.

Setup per provider

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.)

ProviderWhere to create itMinimum scope
GitHubgithub.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).
Vercelvercel.com/account/tokensScope 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.
Cloudflaredash.cloudflare.com/profile/api-tokens, then Create custom tokenAccount permissions Cloudflare Pages: Read and Workers Scripts: Read, limited to the one account. Also set cloudflareAccountId, or put account_id in wrangler.toml.
Renderdashboard.render.com, then Account settings, then API keysRender 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.

Detection

When autoDetect is on (the default), deploy-deck reads targets from the repository root, or from the session root outside git:

SourceTarget
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 linkvercel:<name>: its legacy name field, else the folder name (a guess)
wrangler.toml, wrangler.jsonc, wrangler.jsonname. With pages_build_output_dir it is cloudflare-pages:<name>, otherwise cloudflare-workers:<name>. account_id is used as the account.
render.yaml / render.ymlrender:<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.

Commands

CommandWhat it does
/deploysOpens 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 refreshClears 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 statusFor each provider: auth source (token, gh CLI, none), targets with where each came from, the last error, any backoff and the poll interval.
/deploys closeCloses the panel.

What you see

  • Band above the prompt (terminal and desktop). It shows one row per in-flight deploy: 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).
  • Toasts, once per deploy, when it goes live or fails: ✓ vercel production live · a1b2c3d, ✗ render build failed · 5f3e2d1.
  • Status line: the most important current item. That is the in-flight deploy (production first, 🚀 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.

Polling

  • There is one timer. It runs every fastSeconds (10) while any deploy is in flight and every slowSeconds (120) when idle. It stops when there are no targets.
  • Backoff per provider: on HTTP 429, 5xx or a network error, the provider rests for 30s, doubling up to 15 minutes, and for at least as long as 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.
  • The first read of each target never toasts, so old deploys stay quiet. After that, a deploy toasts when it moves from in flight to live or failed. It also toasts when it is new and finished since the previous read.
  • An in-flight record older than 6 hours (for example a GitHub deployment that never got a status) does not count as active.

Config

FieldDefault
githubTokenemptySensitive. Empty: gh api is used when useCli is on.
vercelToken, vercelTeamIdemptyToken is sensitive. Team id is team_….
cloudflareToken, cloudflareAccountIdemptyToken is sensitive.
renderTokenemptySensitive.
targetsemptyExtra targets, provider:id, comma-separated.
autoDetectonRead targets from the repo files.
useClionWithout a GitHub token, read GitHub through gh api.
fastSeconds / slowSeconds10 / 120Poll intervals (minimum 5 / 30).
band, toasts, statusLineonTurn each surface element on or off.

Surfaces

  • Terminal and desktop: band, /deploys pane (bordered cards on desktop), toasts and status line.
  • Mobile (Claude app watching a cloud or Remote Control session): the band is not raised there, so the status line carries the current deploy. /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.

Security

  • Tokens are injected as 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.
  • deploy-deck sends GET requests only, and gh api without -X/--method.
  • Tokens are sensitive userConfig fields, so Claude Code keeps them in secure storage.
  • Requests go through $.http.fetch, so an organisation's web-fetch policy applies.

Limitations

  • Vercel, Cloudflare and Render need a token. Only GitHub has a CLI fallback (gh api). vercel ls and wrangler print no stable JSON for deployment lists, so there is no fallback for them.
  • No read-only tokens on Vercel or Render: those providers do not offer read-only scopes. deploy-deck itself only reads.
  • Elapsed time moves at the poll rate (every 10s while in flight), not every second.
  • GitHub: the 8 latest workflow runs of every workflow (CI included), plus the 3 latest Deployments with one status read each. Runs are not filtered by branch. A run's "live" means the run succeeded.
  • Vercel has no separate deploying stage: a deployment goes building → live. With only 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.
  • Cloudflare Workers deployments are atomic, so they show as live when they appear (a toast when a new one is seen). Gradual rollouts show the split (90%/10% split). Workers have no deploy URL.
  • Render blueprints are matched by exact service name. Preview environments and databases are not tracked.
  • Detection reads the repo root only (no monorepo sub-folders). Add sub-projects with targets or /deploys add.
  • Detected targets are read at session start and again on /deploys refresh.
  • Built against Claude Code 2.1.290. The mods API is early access.
Source 10 files
hooks/register.tsx 760 lines
1// 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}
760
hooks/detect.ts 186 lines
1// 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}
186
hooks/lib.ts 455 lines
1// 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}
455
hooks/net.ts 232 lines
1// 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}
232
hooks/tracker.ts 82 lines
1// 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
82
hooks/providers/cloudflare.ts 183 lines
1// 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}
183
hooks/providers/github.ts 163 lines
1// 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}
163
hooks/providers/render.ts 151 lines
1// 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}
151
hooks/providers/vercel.ts 92 lines
1// 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}
92
types/index.d.ts 101 lines
1// 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