Guards production deploys and production data, shows branch, PR, CI and issues in a Desktop-styled pane, and keeps a live Fleet ticker of your other projects.

A Claude Code mod for the commit → PR → merge → deploy loop.
vercel --prod, vercel deploy --prod, promote, rollback, redeploy, alias set and vercel env add|rm ... production stop and ask you first (Cancel / Deploy). The question warns you if you're not on main, have uncommitted changes or the PR checks are failing. Anything other than Deploy, or dismissing the question, refuses the command.db:* scripts except generate/check/studio, tsx|node scripts/*migrate*|*seed*|…, or psql/neon with mutating SQL), it works out which database the command reaches:dotenv -e, --env-file, source, $(cat .env.x));.env.local, .env and $DATABASE_URL, in the folder the command cds into.It refuses the command when that host is marked prod, appears in a .env*prod* file, or is remote and not marked safe. Localhost always passes. Commands that mention production are refused too.
/ship db shows which database a migration here would reach, and the hosts you've marked./ship db safe <host> / /ship db prod <host> / /ship db forget <host>. Only you, typing at the prompt, can mark a database safe. Marks are kept across sessions, and a host in a prod env file can never be marked safe./ship): branch, HEAD, uncommitted/ahead/behind, the branch's PR with a checks ring, the last production deploy, and the 5 most recently updated open issues. It refreshes every minute and after any git/gh/vercel command. In the Desktop app's Code tab it draws as native cards with Refresh and Ship it buttons (Ship it asks before it starts); the terminal keeps the compact text view./ship fleet): a second tab with your other projects. Add one with /ship fleet add <path> (no path adds the current folder) and drop one with /ship fleet remove <name>; up to 12. Each project shows a status (Failing, Needs you, Healthy, Quiet), its most urgent news (main's CI failing, a PR's checks failing, running or ready to merge, uncommitted work, or the last commit), the progress of its first open GitHub milestone, and a 7-day commit sparkline./ship go: asks Claude to test, commit, open a PR, wait for checks, merge, deploy (with the guard's confirmation) and smoke-test.⛵ branch · N uncommitted · PR #n checks.git (branch, HEAD, status, ahead/behind counts) and gh (gh pr view, gh issue list) in the current folder, to fill the pane and status line. For each Fleet project, every 3 minutes: git (branch, status, the last commit, commit times for the last 7 days, origin's default branch) and gh (gh pr list, gh run list for the default branch, and gh api for the first open milestone) in that project's folder. Nothing else is started..env.local and .env in the target folder, and the DATABASE_URL and HOME environment variables. It only parses the database hostname out of them to decide whether the target is production. Credentials in those files are never stored, shown or sent anywhere.gh talks to GitHub with your own sign-in to read PRs, runs and milestones. /ship go submits a fixed prompt to Claude (the shipping checklist above) and nothing from your files or conversation is put into it.Bash tool call to classify it (deploy, data change, force-push) and either lets it through, asks you, or refuses it. It never rewrites the command.Mods are advisory, not a security boundary. For hard blocks, also add permission rules (e.g. "deny": ["Bash(vercel deploy --prod:*)"]) in settings.
At the prompt of a Claude Code terminal session:
/plugin install ship-cockpit --marketplace hansraj316/hansraj-mods
Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install ship-cockpit --marketplace hansraj316/hansraj-mods.
hooks/register.tsx 686 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FleetProject, ShipDeploy, ShipPr, ShipSnapshot, ShipTab } from '../types'
5import { cdTarget, envFilesIn, hostsIn, isDbMutation, isProdEnvFile, riskyHost } from './db'
6import type { HostMark, HostVerdict } from './db'
7import { C, T, checksColor, checksPercent, dotSvg, progressSvg, ringSvg, sparkSvg } from './draw'
8import { FLEET_KEY, LEVEL_WORD, eventOf, expandHome, levelOf, nameOf, perDay, sortFleet } from './fleet'
9import type { FleetLevel } from './fleet'
10import { classify, touchesShipState } from './rules'
11
12const PANE = 'ship'
13const DEPLOYS_KEY = 'deploys'
14const DB_MARKS_KEY = 'dbHosts'
15const snapshot = atom({ plugin: 'ship-cockpit', key: 'snapshot' } as const, {
16 updatedAt: 0,
17 dirty: 0,
18 ahead: 0,
19 behind: 0,
20 issues: [],
21} as ShipSnapshot)
22const fleet = atom({ plugin: 'ship-cockpit', key: 'fleet' } as const, [] as FleetProject[], { shape: 'fleet-1' })
23const tab = atom({ plugin: 'ship-cockpit', key: 'tab' } as const, 'ship' as ShipTab)
24const tickerIndex = atom({ plugin: 'ship-cockpit', key: 'tickerIndex' } as const, 0)
25const tickerPaused = atom({ plugin: 'ship-cockpit', key: 'tickerPaused' } as const, false)
26
27// Reads for the Fleet: git and gh in each project's folder. Kept here so `$` is only ever handed to functions declared in this file.
28async function run($: EngineInterface, cwd: string, argv: string[], timeoutMs = 15_000): Promise<string | undefined> {
29 try {
30 const ran = await $.process.run(argv, { cwd, timeoutMs })
31 return ran.exitCode === 0 ? ran.stdout.trim() : undefined
32 } catch {
33 return undefined
34 }
35}
36
37function json(text: string | undefined): unknown {
38 if (!text) return undefined
39 try {
40 return JSON.parse(text)
41 } catch {
42 return undefined
43 }
44}
45
46function fleetChecksOf(rollup: unknown): 'passing' | 'failing' | 'pending' | 'none' {
47 if (!Array.isArray(rollup) || rollup.length === 0) return 'none'
48 const states = rollup.map(c => String(c?.conclusion || c?.state || c?.status || '').toUpperCase())
49 if (states.some(s => /FAIL|ERROR|CANCEL|TIMED_OUT|ACTION_REQUIRED/.test(s))) return 'failing'
50 if (states.every(s => /SUCCESS|NEUTRAL|SKIPPED/.test(s))) return 'passing'
51 return 'pending'
52}
53
54
55/** Reads one project: branch, local changes, last commit, newest open PR, main's CI and the first open milestone. */
56async function readProject($: EngineInterface, path: string): Promise<FleetProject> {
57 const now = Date.now()
58 const base: FleetProject = { path, name: nameOf(path), dirty: 0, activity: [0, 0, 0, 0, 0, 0, 0], readAt: now }
59 const inside = await run($, path, ['git', 'rev-parse', '--is-inside-work-tree'])
60 if (inside !== 'true') return { ...base, error: 'not a git repository' }
61 const [branch, status, last, stamps, originHead] = await Promise.all([
62 run($, path, ['git', 'branch', '--show-current']),
63 run($, path, ['git', 'status', '--porcelain']),
64 run($, path, ['git', 'log', '-1', '--format=%ct%x1f%s']),
65 run($, path, ['git', 'log', '--since=7.days', '--format=%ct']),
66 run($, path, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']),
67 ])
68 const [lastAt, lastSubject] = (last ?? '').split('\x1f')
69 const mainBranch = originHead?.replace(/^origin\//, '') || 'main'
70 const [prRaw, runRaw, msRaw] = await Promise.all([
71 run($, path, ['gh', 'pr', 'list', '--state', 'open', '--limit', '1', '--json', 'number,title,statusCheckRollup,updatedAt']),
72 run($, path, ['gh', 'run', 'list', '--branch', mainBranch, '--limit', '1', '--json', 'conclusion,status,displayTitle,updatedAt']),
73 run($, path, ['gh', 'api', 'repos/{owner}/{repo}/milestones?state=open&sort=due_on&per_page=1']),
74 ])
75 const prs = json(prRaw) as { number: number; title: string; statusCheckRollup: unknown; updatedAt: string }[] | undefined
76 const runs = json(runRaw) as { conclusion: string; status: string; displayTitle: string; updatedAt: string }[] | undefined
77 const milestones = json(msRaw) as { title: string; open_issues: number; closed_issues: number }[] | undefined
78 const pr = prs?.[0]
79 const ci = runs?.[0]
80 const ms = milestones?.[0]
81 const msTotal = ms ? ms.open_issues + ms.closed_issues : 0
82 return {
83 ...base,
84 branch: branch || undefined,
85 dirty: status ? status.split('\n').length : 0,
86 lastCommit: lastAt ? { at: Number(lastAt) * 1000, subject: lastSubject ?? '' } : undefined,
87 pr: pr ? { number: pr.number, title: pr.title, checks: fleetChecksOf(pr.statusCheckRollup), updatedAt: Date.parse(pr.updatedAt) || now } : undefined,
88 mainCi: ci
89 ? {
90 branch: mainBranch,
91 state: ci.status !== 'completed' ? 'running' : /success|neutral|skipped/i.test(ci.conclusion) ? 'passing' : 'failing',
92 title: ci.displayTitle,
93 }
94 : undefined,
95 milestone: ms && msTotal > 0 ? { title: ms.title, percent: Math.round((ms.closed_issues / msTotal) * 100) } : undefined,
96 activity: perDay((stamps ?? '').split('\n').filter(Boolean).map(Number), now),
97 }
98}
99
100async function fleetPaths($: EngineInterface): Promise<string[]> {
101 const stored = (await $.store.get(FLEET_KEY)) as string[] | undefined
102 return Array.isArray(stored) ? stored.filter(p => typeof p === 'string') : []
103}
104
105const LEVEL_COLOR: Record<FleetLevel, { fill: string; text: string; term: string }> = {
106 failing: { fill: C.red, text: T.red, term: 'error' },
107 attention: { fill: C.amber, text: T.amber, term: 'warning' },
108 healthy: { fill: C.green, text: T.green, term: 'success' },
109 quiet: { fill: C.blue, text: T.blue, term: 'subtle' },
110}
111
112/** Re-reads every project on the fleet list, a few at a time. */
113async function refreshFleet($: EngineInterface): Promise<void> {
114 const paths = await fleetPaths($)
115 const out: FleetProject[] = []
116 for (let i = 0; i < paths.length; i += 3) {
117 const batch = await Promise.all(paths.slice(i, i + 3).map(p => readProject($, p)))
118 out.push(...batch)
119 }
120 await update($, fleet, () => out)
121}
122
123async function fleetCommand($: EngineInterface, args: string[], isPerson: boolean): Promise<string> {
124 const [action, ...rest] = args
125 if ((action === 'add' || action === 'remove') && !isPerson) return 'Only you, typing at the prompt, can add or remove fleet projects.'
126 const paths = await fleetPaths($)
127 const home = (await $.env.get('HOME')) ?? ''
128 if (action === 'add') {
129 const raw = rest.join(' ').trim() || (await $.session.cwd())
130 const cwd = await $.session.cwd()
131 const path = expandHome(raw.startsWith('/') || raw.startsWith('~') ? raw : `${cwd}/${raw}`, home)
132 if (paths.includes(path)) return `${nameOf(path)} is already on the fleet.`
133 const next = [...paths, path].slice(0, 12)
134 await $.store.set(FLEET_KEY, next)
135 void refreshFleet($)
136 return `Added ${nameOf(path)} to the fleet (${next.length} project${next.length === 1 ? '' : 's'}).`
137 }
138 if (action === 'remove') {
139 const target = rest.join(' ').trim()
140 const next = paths.filter(p => p !== expandHome(target, home) && nameOf(p) !== target)
141 if (next.length === paths.length) return `No fleet project matches "${target}".`
142 await $.store.set(FLEET_KEY, next)
143 await update($, fleet, list => list.filter(p => next.includes(p.path)))
144 return `Removed ${target} from the fleet.`
145 }
146 await update($, tab, () => 'fleet' as ShipTab)
147 await $.ui.open({ id: PANE, title: 'Ship' })
148 void refreshFleet($)
149 if (paths.length === 0) return 'The fleet is empty. Add a project with /ship fleet add <path> (or no path for this folder).'
150 return `Fleet: ${paths.map(nameOf).join(', ')}`
151}
152
153async function shipWithConfirm($: EngineInterface): Promise<void> {
154 let answer = 'Cancel'
155 try {
156 answer = await $.ui.ask('Ship the current work? Claude will test, commit, open a PR, merge when checks pass, and ask you before deploying to production.', {
157 header: 'Ship',
158 options: ['Cancel', 'Ship it'],
159 })
160 } catch {
161 return
162 }
163 if (answer === 'Ship it') await $.prompt.submit({ text: SHIP_PROMPT, asUser: true })
164}
165
166const SHIP_PROMPT = `Ship the current work, stopping at the first failure and telling me why:
1671. Run the project's tests and lint.
1682. Commit any changes with a clear message, push the branch and open a PR (or update the open one).
1693. Wait for the PR's checks; merge it only when they pass.
1704. Deploy to production with the Vercel CLI. ship-cockpit will ask me to confirm the deploy.
1715. Smoke-test the production URL and report what shipped.
172Never run migrations or data changes against production.`
173
174async function git($: EngineInterface, ...args: string[]): Promise<string> {
175 const ran = await $.process.run(['git', ...args], { timeoutMs: 10_000 })
176 return ran.exitCode === 0 ? ran.stdout.trim() : ''
177}
178
179async function gh($: EngineInterface, ...args: string[]): Promise<unknown> {
180 const ran = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
181 if (ran.exitCode !== 0) return undefined
182 try {
183 return JSON.parse(ran.stdout)
184 } catch {
185 return undefined
186 }
187}
188
189function checksOf(rollup: unknown): ShipPr['checks'] {
190 if (!Array.isArray(rollup) || rollup.length === 0) return 'none'
191 const states = rollup.map(c => String(c?.conclusion || c?.state || c?.status || '').toUpperCase())
192 if (states.some(s => /FAIL|ERROR|CANCEL|TIMED_OUT|ACTION_REQUIRED/.test(s))) return 'failing'
193 if (states.every(s => /SUCCESS|NEUTRAL|SKIPPED/.test(s))) return 'passing'
194 return 'pending'
195}
196
197async function lastDeploy($: EngineInterface): Promise<ShipDeploy | undefined> {
198 const list = (await $.store.get(DEPLOYS_KEY)) as ShipDeploy[] | undefined
199 return list?.[list.length - 1]
200}
201
202/** Re-reads git; with `withGitHub`, also the PR and issues (kept from the last read otherwise). */
203async function refresh($: EngineInterface, withGitHub = true): Promise<void> {
204 try {
205 const branch = await git($, 'branch', '--show-current')
206 if (!branch) {
207 await update($, snapshot, s => ({ ...s, updatedAt: Date.now(), error: 'not a git repository' }))
208 return
209 }
210 const [head, status, counts] = await Promise.all([
211 git($, 'rev-parse', '--short', 'HEAD'),
212 git($, 'status', '--porcelain'),
213 git($, 'rev-list', '--left-right', '--count', '@{upstream}...HEAD'),
214 ])
215 const [behind = 0, ahead = 0] = counts ? counts.split(/\s+/).map(Number) : [0, 0]
216 const before = await read($, snapshot)
217 const [prRaw, issuesRaw, deploy] = await Promise.all([
218 withGitHub ? gh($, 'pr', 'view', '--json', 'number,title,state,statusCheckRollup') : undefined,
219 withGitHub
220 ? gh($, 'issue', 'list', '--state', 'open', '--limit', '5', '--json', 'number,title,updatedAt', '--search', 'sort:updated-desc')
221 : undefined,
222 lastDeploy($),
223 ])
224 const raw = prRaw as { number: number; title: string; state: string; statusCheckRollup: unknown } | undefined
225 const pr = withGitHub
226 ? raw && { number: raw.number, title: raw.title, state: raw.state, checks: checksOf(raw.statusCheckRollup) }
227 : before.branch === branch ? before.pr : undefined
228 const next: ShipSnapshot = {
229 updatedAt: Date.now(),
230 branch,
231 head,
232 dirty: status ? status.split('\n').length : 0,
233 ahead,
234 behind,
235 pr,
236 issues: withGitHub ? (Array.isArray(issuesRaw) ? (issuesRaw as ShipSnapshot['issues']) : []) : before.issues,
237 lastDeploy: deploy,
238 }
239 await update($, snapshot, () => next)
240 $.ui.status(statusLine(next))
241 } catch (err) {
242 await update($, snapshot, s => ({ ...s, updatedAt: Date.now(), error: String(err).slice(0, 120) }))
243 }
244}
245
246function statusLine(s: ShipSnapshot): string {
247 const pr = s.pr ? ` · PR #${s.pr.number} ${s.pr.checks}` : ''
248 const dirty = s.dirty ? ` · ${s.dirty} uncommitted` : ''
249 return `⛵ ${s.branch}${dirty}${pr}`
250}
251
252function ago(at: number): string {
253 const min = Math.round((Date.now() - at) / 60_000)
254 if (min < 1) return 'just now'
255 if (min < 60) return `${min}m ago`
256 if (min < 48 * 60) return `${Math.round(min / 60)}h ago`
257 return `${Math.round(min / 1440)}d ago`
258}
259
260/** Asks the person to confirm a production deploy; anything but "Deploy" refuses. */
261async function confirmDeploy($: EngineInterface, segment: string): Promise<string | undefined> {
262 const s = await read($, snapshot)
263 const warnings = [
264 s.branch && !/^(main|master)$/.test(s.branch) ? `branch is ${s.branch}, not main` : '',
265 s.dirty ? `${s.dirty} uncommitted change(s)` : '',
266 s.pr?.checks === 'failing' ? 'PR checks are failing' : '',
267 ].filter(Boolean)
268 const where = s.branch ? ` from ${s.branch}@${s.head}` : ''
269 const warn = warnings.length ? ` ⚠ ${warnings.join('; ')}.` : ''
270 try {
271 const answer = await $.ui.ask(`Deploy to PRODUCTION${where}?${warn} Command: ${segment.slice(0, 160)}`, {
272 header: 'Ship',
273 options: ['Cancel', 'Deploy'],
274 })
275 return answer === 'Deploy' ? undefined : `the user chose "${answer}"`
276 } catch {
277 return 'nobody confirmed it'
278 }
279}
280
281async function dbMarks($: EngineInterface): Promise<Record<string, HostMark>> {
282 return ((await $.store.get(DB_MARKS_KEY)) as Record<string, HostMark> | undefined) ?? {}
283}
284
285async function readText($: EngineInterface, path: string): Promise<string> {
286 try {
287 return String(await $.fs.read(path))
288 } catch {
289 return ''
290 }
291}
292
293/** Where a database-writing command points, and the first target to treat as production. */
294async function judgeDb($: EngineInterface, command: string): Promise<{ dir: string; targets: string[]; risky?: HostVerdict }> {
295 const cwd = await $.session.cwd()
296 const home = (await $.env.get('HOME')) ?? ''
297 const cd = cdTarget(command)?.replace(/^~(?=\/|$)/, home)
298 const dir = !cd ? cwd : cd.startsWith('/') ? cd : `${cwd}/${cd}`
299 const inDir = (file: string) => (file.startsWith('/') ? file : `${dir}/${file}`)
300
301 const explicit = envFilesIn(command)
302 const inline = hostsIn(command)
303 let targets = [...inline]
304 for (const file of explicit) targets.push(...hostsIn(await readText($, inDir(file))))
305 if (inline.length === 0 && explicit.length === 0) {
306 for (const file of ['.env.local', '.env']) targets.push(...hostsIn(await readText($, inDir(file))))
307 targets.push(...hostsIn(`DATABASE_URL=${(await $.env.get('DATABASE_URL')) ?? ''}`))
308 }
309 targets = [...new Set(targets)]
310
311 const prodFileHosts = new Set<string>()
312 const entries = await $.fs.list(dir).catch(() => [])
313 for (const entry of entries)
314 if (entry.kind === 'file' && isProdEnvFile(entry.name))
315 for (const host of hostsIn(await readText($, inDir(entry.name)))) prodFileHosts.add(host)
316
317 return { dir, targets, risky: riskyHost(targets, await dbMarks($), prodFileHosts) }
318}
319
320async function dbCommand($: EngineInterface, args: string[], isPerson: boolean): Promise<string> {
321 const [action, host] = args
322 const marks = await dbMarks($)
323 if (!action) {
324 const { dir, targets, risky } = await judgeDb($, 'npx drizzle-kit push')
325 const listed = Object.entries(marks).map(([name, mark]) => ` ${mark.padEnd(4)} ${name}`)
326 return [
327 `Databases a migration in ${dir} would reach: ${targets.join(', ') || 'none found'}`,
328 risky ? ` → blocked: ${risky.host} (${risky.why})` : ' → allowed',
329 listed.length ? `Marked hosts:\n${listed.join('\n')}` : 'No hosts marked yet.',
330 'Mark one with /ship db safe <host> or /ship db prod <host>; /ship db forget <host> clears it.',
331 ].join('\n')
332 }
333 if (!host || !/^(safe|prod|forget)$/.test(action)) return 'Usage: /ship db [safe|prod|forget] <host>'
334 if (action !== 'prod' && !isPerson) return 'Only you, typing at the prompt, can mark a database safe or forget a mark.'
335 const key = host.toLowerCase()
336 const nextMarks = { ...marks }
337 if (action === 'forget') delete nextMarks[key]
338 else nextMarks[key] = action as HostMark
339 await $.store.set(DB_MARKS_KEY, nextMarks)
340 return action === 'forget' ? `Forgot ${key}.` : `Marked ${key} as ${action}.`
341}
342
343// True once session.start found an interactive session in a git repository.
344let isWatching = false
345
346export const register: Register = on => {
347 on('session.start', async ($, e, next) => {
348 const started = await next(e)
349 await $.command.register({
350 name: 'ship',
351 description: 'Ship cockpit: open the pane, `/ship go` to test, PR, merge and deploy, `/ship fleet` for your other projects',
352 argumentHint: '[go | fleet [add|remove] <path> | db [safe|prod|forget] <host>]',
353 })
354 // Headless runs (claude -p, the SDK) keep the guard and nothing else.
355 if (!e.isInteractive) return started
356 // Fleet: your other projects, read every 3 minutes; the ticker turns every 5 seconds.
357 void (async () => {
358 if ((await fleetPaths($)).length === 0) return
359 await refreshFleet($)
360 })()
361 $.clock.every(3 * 60_000, () => void refreshFleet($))
362 $.clock.every(5000, () => {
363 void (async () => {
364 const count = (await read($, fleet)).length
365 if (count > 1 && !(await read($, tickerPaused))) await update($, tickerIndex, i => (i + 1) % count)
366 })()
367 })
368 void (async () => {
369 if (!(await git($, 'rev-parse', '--is-inside-work-tree'))) return
370 isWatching = true
371 await refresh($)
372 $.clock.every(60_000, () => void refresh($, false))
373 $.clock.every(5 * 60_000, () => void refresh($))
374 void $.ui.open({ id: PANE, title: 'Ship' })
375 })()
376 return started
377 })
378
379 on('command.run', { command: 'ship' }, async ($, e) => {
380 const arg = e.args.trim()
381 const words = arg.split(/\s+/)
382 if (words[0] === 'db') return { text: await dbCommand($, words.slice(1), e.origin?.kind === 'composer') }
383 if (words[0] === 'fleet') return { text: await fleetCommand($, words.slice(1), e.origin?.kind === 'composer') }
384 if (arg === 'go') {
385 await $.prompt.submit({ text: SHIP_PROMPT, asUser: true })
386 return { text: 'Shipping: tests → PR → merge → deploy. You will be asked before production.' }
387 }
388 await refresh($)
389 await update($, tab, () => 'ship' as ShipTab)
390 await $.ui.open({ id: PANE, title: 'Ship' })
391 return { text: statusLine(await read($, snapshot)) }
392 })
393
394 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
395 const verdict = classify(e.command)
396 if (verdict.kind === 'prod-data')
397 return {
398 deny: `ship-cockpit: "${verdict.segment.slice(0, 120)}" would change PRODUCTION data, which is never run from Claude. Tell the user the exact command so they can run it themselves with "! <command>" if they really mean it.`,
399 }
400 if (isDbMutation(e.command)) {
401 const { risky } = await judgeDb($, e.command)
402 if (risky)
403 return {
404 deny: `ship-cockpit: this command writes to the database at ${risky.host}, which is treated as PRODUCTION (${risky.why}). Do not retry or work around it. Tell the user; if that database is a dev copy they can type "/ship db safe ${risky.host}", or run the command themselves with "! <command>".`,
405 }
406 }
407 if (verdict.kind === 'force-push-main')
408 return { deny: 'ship-cockpit: force-pushing main is blocked. Push a branch and open a PR instead.' }
409 if (verdict.kind === 'prod-deploy') {
410 const refused = await confirmDeploy($, verdict.segment)
411 if (refused)
412 return { deny: `ship-cockpit: production deploy not confirmed (${refused}). Do not retry; ask the user what they want.` }
413 const ran = await next(e)
414 if (ran.deny === undefined && ran.isError !== true) {
415 const s = await read($, snapshot)
416 const entry: ShipDeploy = { at: Date.now(), command: verdict.segment.slice(0, 200), branch: s.branch ?? '?', head: s.head ?? '?' }
417 const list = ((await $.store.get(DEPLOYS_KEY)) as ShipDeploy[] | undefined) ?? []
418 await $.store.set(DEPLOYS_KEY, [...list, entry].slice(-20))
419 $.ui.toast(`Deployed ${entry.branch}@${entry.head} to production`)
420 }
421 if (isWatching) void refresh($)
422 return ran
423 }
424 const ran = await next(e)
425 if (isWatching && touchesShipState(e.command)) void refresh($)
426 return ran
427 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'ship-cockpit: its guard failed, so the command was not run.' }))
428
429 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
430 const { Box, Text, Button } = $.ui.resolve(e)
431 const current = await read($, tab)
432 const tabs = (
433 <Box flexDirection="row" gap="xs">
434 <Button key="tab-ship" label="Ship" variant={current === 'ship' ? 'primary' : 'secondary'} onPress={() => update($, tab, () => 'ship' as ShipTab)} />
435 <Button
436 key="tab-fleet"
437 label="Fleet"
438 variant={current === 'fleet' ? 'primary' : 'secondary'}
439 onPress={async () => {
440 await update($, tab, () => 'fleet' as ShipTab)
441 void refreshFleet($)
442 }}
443 />
444 </Box>
445 )
446 const width = Math.max(20, e.props.bodyColumns)
447 const cut = (text: string) => (text.length > width ? text.slice(0, width - 1) + '…' : text)
448
449 if (current === 'fleet') {
450 const list = sortFleet(await read($, fleet))
451 const count = (l: FleetLevel) => list.filter(p => levelOf(p) === l).length
452 if (e.surface === 'terminal') {
453 return (
454 <Box flexDirection="column">
455 {tabs}
456 {list.length === 0 && <Text dimColor>No projects yet. /ship fleet add <path></Text>}
457 {list.map(p => {
458 const level = levelOf(p)
459 const ev = eventOf(p)
460 return (
461 <Box flexDirection="column">
462 <Text bold color={LEVEL_COLOR[level].term}>{cut(`● ${p.name} · ${LEVEL_WORD[level]}`)}</Text>
463 <Text dimColor>{cut(` ${ev.text}${ev.at ? ` · ${ago(ev.at)}` : ''}${p.milestone ? ` · ${p.milestone.title} ${p.milestone.percent}%` : ''}`)}</Text>
464 </Box>
465 )
466 })}
467 </Box>
468 )
469 }
470 const { Svg } = $.ui.resolve(e)
471 const barW = Math.max(120, Math.min(300, width * 7))
472 return (
473 <Box flexDirection="column" gap="md">
474 {tabs}
475 <Box flexDirection="row" gap="lg">
476 {(['failing', 'attention', 'healthy'] as FleetLevel[]).map(l => (
477 <Box flexDirection="column">
478 <Text size="heading" weight="semibold" tabular color={LEVEL_COLOR[l].text}>
479 {String(count(l))}
480 </Text>
481 <Text size="caption" dimColor>
482 {LEVEL_WORD[l]}
483 </Text>
484 </Box>
485 ))}
486 </Box>
487 {list.length === 0 && <Text dimColor>No projects yet. Type /ship fleet add followed by a folder, or no folder for this one.</Text>}
488 {list.map(p => {
489 const level = levelOf(p)
490 const ev = eventOf(p)
491 return (
492 <Box key={`p-${p.path}`} flexDirection="column" gap="xs" paddingY="xs">
493 <Box flexDirection="row" alignItems="center" gap="sm">
494 <Svg source={dotSvg(LEVEL_COLOR[level].fill)} alt="" width={8} height={8} />
495 <Text weight="semibold" wrap="truncate">{p.name}</Text>
496 <Box flexGrow={1} />
497 <Text size="footnote" weight="semibold" color={LEVEL_COLOR[level].text}>
498 {LEVEL_WORD[level]}
499 </Text>
500 </Box>
501 <Text size="footnote" wrap="truncate">{ev.text}</Text>
502 {p.milestone && (
503 <Box flexDirection="row" alignItems="center" gap="sm">
504 <Svg source={progressSvg(p.milestone.percent, LEVEL_COLOR[level].fill, barW)} alt={`${p.milestone.title} ${p.milestone.percent}% done`} width={barW} height={6} />
505 <Text size="footnote" dimColor tabular>{`${p.milestone.percent}%`}</Text>
506 </Box>
507 )}
508 <Box flexDirection="row" alignItems="center" gap="sm">
509 <Text size="footnote" dimColor wrap="truncate">{p.milestone?.title ?? p.branch ?? ''}</Text>
510 <Box flexGrow={1} />
511 <Svg source={sparkSvg(p.activity)} alt={`Commits in the last 7 days: ${p.activity.join(', ')}`} width={34} height={14} />
512 <Text size="footnote" dimColor>{ev.at ? ago(ev.at) : ''}</Text>
513 </Box>
514 </Box>
515 )
516 })}
517 <Text size="footnote" dimColor>From git and GitHub every 3 minutes · /ship fleet add or remove</Text>
518 </Box>
519 )
520 }
521
522 const s = await read($, snapshot)
523 if (!s.updatedAt) return <Box flexDirection="column">{tabs}<Text dimColor>Loading…</Text></Box>
524 if (!s.branch) return <Box flexDirection="column">{tabs}<Text dimColor>{s.error ?? 'No git repository here.'}</Text></Box>
525
526 if (e.surface === 'terminal') {
527 const checkColor = s.pr?.checks === 'passing' ? 'green' : s.pr?.checks === 'failing' ? 'red' : 'yellow'
528 return (
529 <Box flexDirection="column">
530 {tabs}
531 <Text bold>{cut(`⎇ ${s.branch} @ ${s.head}`)}</Text>
532 <Text dimColor={!s.dirty && !s.ahead && !s.behind} color={s.dirty ? 'yellow' : undefined}>
533 {cut(`${s.dirty} uncommitted · ↑${s.ahead} ↓${s.behind}`)}
534 </Text>
535 <Text> </Text>
536 <Text bold>Pull request</Text>
537 {s.pr ? (
538 <Box flexDirection="column">
539 <Text>{cut(`#${s.pr.number} ${s.pr.title}`)}</Text>
540 <Text color={checkColor}>{`${s.pr.state.toLowerCase()} · checks ${s.pr.checks}`}</Text>
541 </Box>
542 ) : (
543 <Text dimColor>none for this branch</Text>
544 )}
545 <Text> </Text>
546 <Text bold>Production</Text>
547 {s.lastDeploy ? (
548 <Text>{cut(`${s.lastDeploy.branch}@${s.lastDeploy.head} · ${ago(s.lastDeploy.at)}`)}</Text>
549 ) : (
550 <Text dimColor>no deploy recorded yet</Text>
551 )}
552 <Text dimColor>guarded: asks before deploy</Text>
553 <Text> </Text>
554 <Text bold>Open issues</Text>
555 {s.issues.length === 0 && <Text dimColor>none</Text>}
556 {s.issues.map(issue => (
557 <Text>{cut(`#${issue.number} ${issue.title} · ${ago(Date.parse(issue.updatedAt))}`)}</Text>
558 ))}
559 <Text> </Text>
560 <Text dimColor>{cut(`updated ${ago(s.updatedAt)} · /ship go to ship`)}</Text>
561 </Box>
562 )
563 }
564
565 const { Svg } = $.ui.resolve(e)
566 const checks = checksColor(s.pr?.checks)
567 return (
568 <Box flexDirection="column" gap="md">
569 {tabs}
570 <Box flexDirection="column" gap="xs">
571 <Box flexDirection="row" alignItems="center">
572 <Text size="caption" weight="semibold" dimColor>
573 BRANCH
574 </Text>
575 <Box flexGrow={1} />
576 <Text size="footnote" mono dimColor>{`HEAD ${s.head ?? ''}`}</Text>
577 </Box>
578 <Text mono weight="semibold" wrap="truncate">{s.branch}</Text>
579 <Box flexDirection="row" gap="sm" flexWrap="wrap">
580 <Text size="footnote" weight="semibold" color={s.dirty ? T.amber : T.gray}>{`${s.dirty} uncommitted`}</Text>
581 <Text size="footnote" dimColor>{`${s.ahead} ahead`}</Text>
582 <Text size="footnote" dimColor>{`${s.behind} behind`}</Text>
583 </Box>
584 </Box>
585 <Box flexDirection="row" alignItems="center" gap="sm">
586 <Svg source={ringSvg(checksPercent(s.pr?.checks), checks.fill)} alt={s.pr ? `Checks ${s.pr.checks}` : 'No pull request'} width={36} height={36} />
587 <Box flexDirection="column" flexGrow={1}>
588 <Text size="caption" weight="semibold" dimColor>
589 PULL REQUEST
590 </Text>
591 <Text weight="semibold" wrap="truncate">{s.pr ? `#${s.pr.number} ${s.pr.title}` : 'None for this branch'}</Text>
592 </Box>
593 {s.pr && (
594 <Text size="footnote" weight="semibold" color={checks.text}>
595 {s.pr.checks === 'passing' ? 'All passed' : s.pr.checks === 'failing' ? 'Failing' : s.pr.checks === 'pending' ? 'Running' : 'No checks'}
596 </Text>
597 )}
598 </Box>
599 <Box flexDirection="row" alignItems="center" gap="sm">
600 <Svg source={dotSvg(s.lastDeploy ? C.green : C.gray, 10)} alt="" width={10} height={10} />
601 <Box flexDirection="column" flexGrow={1}>
602 <Text size="caption" weight="semibold" dimColor>
603 LAST PRODUCTION DEPLOY
604 </Text>
605 <Text size="footnote" wrap="truncate">{s.lastDeploy ? `${s.lastDeploy.branch}@${s.lastDeploy.head} · ${ago(s.lastDeploy.at)}` : 'None recorded yet · asks before every deploy'}</Text>
606 </Box>
607 </Box>
608 <Box flexDirection="column" gap="xs">
609 <Text size="caption" weight="semibold" dimColor>
610 OPEN ISSUES
611 </Text>
612 {s.issues.length === 0 && <Text size="footnote" dimColor>None open</Text>}
613 {s.issues.map(issue => (
614 <Box flexDirection="row" gap="sm">
615 <Text size="footnote" dimColor tabular>{`#${issue.number}`}</Text>
616 <Text size="footnote" wrap="truncate">{issue.title}</Text>
617 <Box flexGrow={1} />
618 <Text size="footnote" dimColor>{ago(Date.parse(issue.updatedAt))}</Text>
619 </Box>
620 ))}
621 </Box>
622 <Box flexDirection="row" gap="sm" alignItems="center">
623 <Text size="footnote" dimColor>{`Updated ${ago(s.updatedAt)}`}</Text>
624 <Box flexGrow={1} />
625 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
626 <Button key="ship-it" variant="primary" label="Ship it" onPress={() => shipWithConfirm($)} />
627 </Box>
628 </Box>
629 )
630 })
631
632 // Fleet ticker: one line above the prompt that turns through your other projects. Click to pause.
633 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
634 const below = await next(e)
635 if (e.props.hasSurvey) return below
636 const list = sortFleet(await read($, fleet))
637 if (list.length === 0) return below
638 const paused = await read($, tickerPaused)
639 const p = list[(await read($, tickerIndex)) % list.length] ?? list[0]
640 if (!p) return below
641 const level = levelOf(p)
642 const ev = eventOf(p)
643 const { Box, Text, Button } = $.ui.resolve(e)
644 const toggle = () => update($, tickerPaused, x => !x)
645 const open = async () => {
646 await update($, tab, () => 'fleet' as ShipTab)
647 await $.ui.open({ id: PANE, title: 'Ship' })
648 }
649 const when = ev.at ? ` · ${ago(ev.at)}` : ''
650
651 if (e.surface === 'terminal') {
652 return (
653 <Box flexDirection="column">
654 <Box flexDirection="row" gap={1}>
655 <Text color={paused ? 'subtle' : 'success'}>{paused ? 'paused' : 'live'}</Text>
656 <Text color={LEVEL_COLOR[level].term}>●</Text>
657 <Text bold>{p.name}</Text>
658 <Text dimColor wrap="truncate">{`${ev.text}${when}`}</Text>
659 <Button key="ticker" label={paused ? 'Resume' : 'Pause'} onPress={toggle} />
660 </Box>
661 {below}
662 </Box>
663 )
664 }
665
666 const { Svg } = $.ui.resolve(e)
667 return (
668 <Box flexDirection="column" gap="xs">
669 <Box key="fleet-ticker" flexDirection="row" alignItems="center" gap="sm">
670 <Svg source={dotSvg(paused ? C.gray : C.green, 6)} alt="" width={6} height={6} />
671 <Text size="caption" weight="bold" color={paused ? T.gray : T.green}>
672 {paused ? 'PAUSED' : 'LIVE'}
673 </Text>
674 <Svg source={dotSvg(LEVEL_COLOR[level].fill)} alt={LEVEL_WORD[level]} width={8} height={8} />
675 <Button key="ticker" plain label={p.name} onPress={open} />
676 <Text dimColor wrap="truncate">{`${ev.text}${when}`}</Text>
677 <Box flexGrow={1} />
678 <Text size="caption" dimColor tabular>{`${(list.indexOf(p) + 1)}/${list.length}`}</Text>
679 <Button key="ticker-pause" plain dimColor label={paused ? 'Resume' : 'Pause'} onPress={toggle} />
680 </Box>
681 {below}
682 </Box>
683 )
684 })
685}
686hooks/db.ts 92 lines1// Pure parsing for the database guard: which commands change a database, and
2// which database hosts a command or an env file points at.
3
4export type HostMark = 'safe' | 'prod'
5
6const MUTATION = [
7 String.raw`drizzle-kit\s+(push|migrate|drop|up)\b`,
8 String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?db:(?!(generate|check|studio)\b)[\w:-]+`,
9 String.raw`prisma\s+(migrate\s+(deploy|reset|dev)|db\s+(push|execute|seed))`,
10 String.raw`\b(tsx|ts-node|node|bun)\s+\S*(migrat|seed|reset|wipe|backfill|purge|delete)\S*`,
11 String.raw`\b(psql|neon|neonctl|mysql|sqlite3|turso)\b.*\b(DELETE\s+FROM|TRUNCATE|DROP\s+(TABLE|SCHEMA|DATABASE)|ALTER\s+TABLE|UPDATE\s+\w+\s+SET|INSERT\s+INTO)\b`,
12].map(source => new RegExp(source, 'i'))
13
14/** True when the command would write to a database (migrations, pushes, seeds, mutating SQL). */
15export function isDbMutation(command: string): boolean {
16 const flat = command.replace(/\n/g, ' ')
17 return MUTATION.some(pattern => pattern.test(flat))
18}
19
20const unquote = (word: string) => word.replace(/^['"]|['"]$/g, '')
21
22/** Env files the command loads explicitly: `dotenv -e f`, `--env-file=f`, `source f`, `. f`. */
23export function envFilesIn(command: string): string[] {
24 const files: string[] = []
25 const patterns = [
26 /\bdotenv(?:-cli)?\s+(?:-\w+\s+)*?-e\s+(\S+)/g,
27 /--env-file[= ](\S+)/g,
28 /(?:^|[;&|]\s*|\s)(?:source|\.)\s+(\S*\.env\S*)/g,
29 // Any other env file the line names (`export $(cat .env.prod)`, `set -a; . ./.env.vercel.local`).
30 /(?:^|[\s'"(=<])((?:[\w~.-]*\/)*\.env(?:\.[\w-]+)*)(?=$|[\s'");|&])/g,
31 ]
32 for (const pattern of patterns)
33 for (const match of command.matchAll(pattern)) if (match[1]) files.push(unquote(match[1]))
34 return [...new Set(files)].filter(file => !/\.env\.example$/.test(file))
35}
36
37/** The last `cd <dir>` that runs before the command's first database write, if any. */
38export function cdTarget(command: string): string | undefined {
39 let dir: string | undefined
40 for (const part of command.split(/&&|;|\n/)) {
41 const cd = part.trim().match(/^cd\s+(\S+)/)
42 if (cd?.[1]) dir = unquote(cd[1])
43 else if (isDbMutation(part)) break
44 }
45 return dir
46}
47
48/** Lowercases a host and folds Neon's pooled endpoint into the direct one. */
49export function normalizeHost(host: string): string {
50 return unquote(host).toLowerCase().replace(/^(ep-[a-z0-9-]+?)-pooler\./, '$1.')
51}
52
53/** Database hosts named in a command line or an env file's text. */
54export function hostsIn(text: string): string[] {
55 const hosts = new Set<string>()
56 for (const match of text.matchAll(/\b(?:postgres(?:ql)?|mysql|mariadb|libsql|mongodb(?:\+srv)?):\/\/(?:[^@\s'"/]*@)?([^:/?\s'"]+)/gi))
57 if (match[1]) hosts.add(normalizeHost(match[1]))
58 for (const match of text.matchAll(/^\s*(?:export\s+)?(?:PGHOST|POSTGRES_HOST|DB_HOST)\s*=\s*['"]?([^'"\s#]+)/gim))
59 if (match[1]) hosts.add(normalizeHost(match[1]))
60 return [...hosts]
61}
62
63export function isLocalHost(host: string): boolean {
64 return /^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|\[::1\]|host\.docker\.internal|db|postgres)$/.test(host)
65}
66
67/** An env file whose name says production (`.env.production`, `.env.prod.vercel.local`). */
68export function isProdEnvFile(name: string): boolean {
69 return /^\.env\b.*\bprod/i.test(name)
70}
71
72export type HostVerdict = { host: string; why: 'marked prod' | 'in a production env file' | 'remote and not marked safe' }
73
74/**
75 * The first target host that must be treated as production, or undefined.
76 * A host marked safe passes, a local host passes, anything else remote is production.
77 */
78export function riskyHost(
79 targets: readonly string[],
80 marks: Readonly<Record<string, HostMark>>,
81 prodFileHosts: ReadonlySet<string>,
82): HostVerdict | undefined {
83 for (const host of targets) {
84 const mark = marks[host]
85 if (mark === 'prod') return { host, why: 'marked prod' }
86 if (prodFileHosts.has(host)) return { host, why: 'in a production env file' }
87 if (mark === 'safe' || isLocalHost(host)) continue
88 return { host, why: 'remote and not marked safe' }
89 }
90 return undefined
91}
92hooks/draw.ts 72 lines1// SVG pieces for the graphical surfaces (Desktop, VS Code, mobile), in the same palette as tokentide.
2
3export const C = {
4 green: '#30D158',
5 amber: '#FF9F0A',
6 red: '#FF453A',
7 blue: '#0A84FF',
8 gray: '#8E8E93',
9 track: '#3A3A3C',
10 bar: '#636366',
11} as const
12
13/** Text colors that read on dark and light grounds. */
14export const T = { green: '#28A745', amber: '#E08600', red: '#FF6961', blue: '#409CFF', gray: '#8E8E93' } as const
15
16const NS = 'xmlns="http://www.w3.org/2000/svg"'
17
18export function dotSvg(color: string, size = 8): string {
19 const r = size / 2
20 return `<svg ${NS} width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${r}" cy="${r}" r="${r}" fill="${color}"/></svg>`
21}
22
23export function ringSvg(percent: number, color: string, size = 36, stroke = 5): string {
24 const c = size / 2
25 const r = c - stroke / 2
26 const len = 2 * Math.PI * r
27 const on = (Math.max(0, Math.min(100, percent)) / 100) * len
28 return (
29 `<svg ${NS} width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
30 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.25" stroke-width="${stroke}"/>` +
31 (on > 0
32 ? `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${on.toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`
33 : '') +
34 `</svg>`
35 )
36}
37
38export function progressSvg(percent: number, color: string, width: number, height = 6): string {
39 const w = Math.max(0, Math.min(100, percent)) / 100 * width
40 return (
41 `<svg ${NS} width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
42 `<rect width="${width}" height="${height}" rx="${height / 2}" fill="${C.track}"/>` +
43 (w > 0 ? `<rect width="${Math.max(height, w).toFixed(2)}" height="${height}" rx="${height / 2}" fill="${color}"/>` : '') +
44 `</svg>`
45 )
46}
47
48export function sparkSvg(values: readonly number[], width = 34, height = 14): string {
49 const max = Math.max(1, ...values)
50 const n = values.length || 1
51 const w = 3
52 const gap = (width - n * w) / Math.max(1, n - 1)
53 const bars = values
54 .map((v, i) => {
55 const barH = Math.max(2, (v / max) * height)
56 return `<rect x="${(i * (w + gap)).toFixed(2)}" y="${(height - barH).toFixed(2)}" width="${w}" height="${barH.toFixed(2)}" rx="1" fill="${C.bar}"/>`
57 })
58 .join('')
59 return `<svg ${NS} width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${bars}</svg>`
60}
61
62export function checksPercent(checks: string | undefined): number {
63 return checks === 'passing' || checks === 'failing' ? 100 : checks === 'pending' ? 60 : 0
64}
65
66export function checksColor(checks: string | undefined): { fill: string; text: string } {
67 if (checks === 'passing') return { fill: C.green, text: T.green }
68 if (checks === 'failing') return { fill: C.red, text: T.red }
69 if (checks === 'pending') return { fill: C.amber, text: T.amber }
70 return { fill: C.gray, text: T.gray }
71}
72hooks/fleet.ts 54 lines1// Fleet: pure helpers for the other projects you are shipping. The git/gh reads live in register.tsx.
2import type { FleetProject } from '../types'
3
4export const FLEET_KEY = 'fleetPaths'
5
6export type FleetLevel = 'failing' | 'attention' | 'healthy' | 'quiet'
7
8export const LEVEL_ORDER: Record<FleetLevel, number> = { failing: 0, attention: 1, healthy: 2, quiet: 3 }
9
10export function expandHome(path: string, home: string): string {
11 return path.replace(/^~(?=\/|$)/, home).replace(/\/+$/, '') || '/'
12}
13
14export function nameOf(path: string): string {
15 return path.split('/').filter(Boolean).pop() ?? path
16}
17
18/** Commits per day for the last 7 days, oldest first. */
19export function perDay(stamps: number[], nowMs: number): number[] {
20 const days = [0, 0, 0, 0, 0, 0, 0]
21 for (const s of stamps) {
22 const ago = Math.floor((nowMs - s * 1000) / 86_400_000)
23 if (ago >= 0 && ago < 7) days[6 - ago] = (days[6 - ago] ?? 0) + 1
24 }
25 return days
26}
27
28export function levelOf(p: FleetProject): FleetLevel {
29 if (p.error) return 'quiet'
30 if (p.mainCi?.state === 'failing' || p.pr?.checks === 'failing') return 'failing'
31 if (p.pr?.checks === 'pending' || p.mainCi?.state === 'running' || p.dirty > 0) return 'attention'
32 if (p.mainCi || p.pr) return 'healthy'
33 return 'quiet'
34}
35
36export const LEVEL_WORD: Record<FleetLevel, string> = { failing: 'Failing', attention: 'Needs you', healthy: 'Healthy', quiet: 'Quiet' }
37
38/** The one line a project contributes to the ticker, most urgent news first. */
39export function eventOf(p: FleetProject): { text: string; at?: number } {
40 if (p.error) return { text: p.error }
41 if (p.mainCi?.state === 'failing') return { text: `${p.mainCi.branch} failing · ${p.mainCi.title}` }
42 if (p.pr?.checks === 'failing') return { text: `PR #${p.pr.number} checks failing`, at: p.pr.updatedAt }
43 if (p.pr?.checks === 'pending') return { text: `PR #${p.pr.number} checks running`, at: p.pr.updatedAt }
44 if (p.pr?.checks === 'passing') return { text: `PR #${p.pr.number} ready to merge · ${p.pr.title}`, at: p.pr.updatedAt }
45 if (p.dirty > 0) return { text: `${p.dirty} uncommitted on ${p.branch ?? 'a branch'}` }
46 if (p.mainCi?.state === 'running') return { text: `${p.mainCi.branch} CI running` }
47 if (p.lastCommit) return { text: p.lastCommit.subject, at: p.lastCommit.at }
48 return { text: 'no commits yet' }
49}
50
51export function sortFleet(list: readonly FleetProject[]): FleetProject[] {
52 return [...list].sort((a, z) => LEVEL_ORDER[levelOf(a)] - LEVEL_ORDER[levelOf(z)] || a.name.localeCompare(z.name))
53}
54hooks/rules.ts 61 lines1// Pure command classification: no `$`, so the guard judges from the text alone.
2
3export type Verdict =
4 | { kind: 'prod-deploy'; segment: string }
5 | { kind: 'prod-data'; segment: string }
6 | { kind: 'force-push-main'; segment: string }
7 | { kind: 'ok' }
8
9const PROD_MARKER = /production|(^|[\s=:/._-])prod($|[\s=:/._-])|\.env\.vercel/i
10
11const DB_MUTATION = new RegExp(
12 [
13 String.raw`drizzle-kit\s+(push|migrate|drop|up)`,
14 String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?db:(push|migrate|reset|seed|drop|wipe)`,
15 String.raw`prisma\s+(migrate\s+(deploy|reset|dev)|db\s+(push|execute|seed))`,
16 String.raw`\b(psql|neon|neonctl|mysql|sqlite3|turso)\b.*\b(DELETE\s+FROM|TRUNCATE|DROP\s+(TABLE|SCHEMA|DATABASE)|ALTER\s+TABLE|UPDATE\s+\w+\s+SET|INSERT\s+INTO)\b`,
17 ].join('|'),
18 'i',
19)
20
21/** Splits a shell line into the commands it chains, quotes ignored. */
22export function segments(command: string): string[] {
23 return command
24 .split(/&&|\|\||;|\||\n/)
25 .map(part => part.trim())
26 .filter(Boolean)
27}
28
29// Subcommands that never ship a deployment (`vercel build --prod` builds locally).
30const NON_DEPLOY = new Set(
31 'build pull ls list inspect logs link login logout whoami project projects integration dev domains dns certs teams switch git curl open help init telemetry blob cache flags metrics ai-gateway global-config edge-config microfrontends mcp'.split(' '),
32)
33
34function isVercelProdDeploy(segment: string): boolean {
35 const vercel = segment.match(/(?:^|\s|\/)vercel(?:\s+(.*))?$/)
36 if (!vercel) return false
37 const rest = vercel[1] ?? ''
38 const sub = rest.split(/\s+/).find(word => word && !word.startsWith('-')) ?? ''
39 if (/^(promote|rollback|redeploy)$/.test(sub)) return true
40 if (/^(alias)$/.test(sub) && /\bset\b/.test(rest)) return true
41 if (/^env$/.test(sub)) return /\b(add|rm|remove|update)\b/.test(rest) && /production/.test(rest)
42 if (NON_DEPLOY.has(sub)) return false
43 return /(^|\s)--prod\b|--target[= ]production\b|--environment[= ]production\b/.test(rest)
44}
45
46export function classify(command: string): Verdict {
47 if (PROD_MARKER.test(command) && DB_MUTATION.test(command.replace(/\n/g, ' ')))
48 return { kind: 'prod-data', segment: command.split('\n')[0] ?? command }
49 for (const segment of segments(command)) {
50 if (isVercelProdDeploy(segment)) return { kind: 'prod-deploy', segment }
51 if (/\bgit\s+push\b/.test(segment) && /(\s-f\b|--force)/.test(segment) && /\b(main|master)\b/.test(segment))
52 return { kind: 'force-push-main', segment }
53 }
54 return { kind: 'ok' }
55}
56
57/** True when the command is worth refreshing the cockpit after. */
58export function touchesShipState(command: string): boolean {
59 return /\b(git|gh|vercel)\b/.test(command)
60}
61types/index.d.ts 53 lines1export type ShipPr = {
2 number: number
3 title: string
4 state: string
5 checks: 'passing' | 'failing' | 'pending' | 'none'
6}
7
8export type ShipIssue = { number: number; title: string; updatedAt: string }
9
10export type ShipDeploy = { at: number; command: string; branch: string; head: string }
11
12export type ShipSnapshot = {
13 updatedAt: number
14 branch?: string
15 head?: string
16 dirty: number
17 ahead: number
18 behind: number
19 pr?: ShipPr
20 issues: ShipIssue[]
21 lastDeploy?: ShipDeploy
22 error?: string
23}
24
25export type FleetProject = {
26 path: string
27 name: string
28 branch?: string
29 dirty: number
30 lastCommit?: { at: number; subject: string }
31 pr?: { number: number; title: string; checks: ShipPr['checks']; updatedAt: number }
32 mainCi?: { branch: string; state: 'passing' | 'failing' | 'running'; title: string }
33 milestone?: { title: string; percent: number }
34 /** Commits per day over the last 7 days, oldest first. */
35 activity: number[]
36 readAt: number
37 error?: string
38}
39
40export type ShipTab = 'ship' | 'fleet'
41
42declare module 'claude-code' {
43 interface PluginState {
44 'ship-cockpit': {
45 snapshot: ShipSnapshot
46 fleet: Shaped<FleetProject[]>
47 tab: ShipTab
48 tickerIndex: number
49 tickerPaused: boolean
50 }
51 }
52}
53