Beobachtet nach jedem git push den Cloudflare-Build (Workers Builds), zeigt den Stand unter dem Prompt, gibt Claude bei einem roten Build das Log und meldet…

Claude-Code-Mod für Projekte mit Cloudflare Workers Builds (Auto-Deploy bei jedem Push). Nach einem git push muss niemand mehr warten und nachsehen: die Mod beobachtet den Build und sagt Bescheid.
git -C <pfad> push oder cd … && git push aus einem Worktree): Sie vergleicht die Remote-Branches vor und nach dem Befehl. Ein Repo mit wrangler.jsonc/.json/.toml im Hauptordner gilt als Cloudflare-Projekt; der Worker-Name kommt von dort.curl-und-sleep-Schleifen mehr.🚀 website baut 1:20 → ✅ website live (2:41) oder ❌ website: Build fehlgeschlagen; das Ergebnis bleibt 5 min stehen./deploy fehler melden gibt es nur eine Notiz.main: Hinweis (Repo nicht verbunden, Branch nicht im Trigger, Pfad ausgeschlossen). Bei anderen Branches ohne Vorschau-Builds bleibt sie still.~/.claude/telegram.env von telegram-draht; ohne die Datei passiert hier nichts.bash echo 'CLOUDFLARE_API_TOKEN=…' > ~/.claude/cloudflare.env chmod 600 ~/.claude/cloudflare.env ``/deploy einrichten. Die Mod prüft den Token, sucht die Konto-ID, trägt sie in die Datei ein und testet, ob Builds lesbar sind.| Befehl | Wirkung | |
|---|---|---|
/deploy | Zustand, laufende Builds und der letzte Build des aktuellen Projekts (rot: mit Log-Ende) | |
/deploy einrichten | Token prüfen, Konto eintragen | |
| `/deploy fehler beheben\ | melden` | Roter Build: Claude behebt Kleines selbst (Standard) oder bekommt nur eine Notiz |
| `/deploy telegram an\ | aus` | Ergebnis aufs Handy, wenn du weg bist (Standard an) |
claude plugin validate deploy-wacht und claude plugin test deploy-wacht. Die Plugin-API für Mods ist Early Access und kann sich ändern.
hooks/register.ts 743 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const SEK = 1000
4const MIN = 60 * SEK
5// Ein Build von veerka.mp dauert 1–3 min: alle 10 s nachsehen reicht, die
6// Statuszeile tickt trotzdem jede Sekunde.
7const POLL_MS = 10 * SEK
8// Taucht so lange kein Build zum Commit auf, hat Cloudflare den Push nicht gebaut
9// (kein Trigger für den Branch, Pfad ausgeschlossen, Repo nicht verbunden).
10export const KEIN_BUILD_NACH_MS = 3 * MIN
11const AUFGEBEN_NACH_MS = 25 * MIN
12// So lange bleibt das Ergebnis unter dem Prompt stehen.
13const ERGEBNIS_ZEIGEN_MS = 5 * MIN
14const LOG_ZEILEN = 60
15// Höchstens so oft hintereinander bekommt Claude einen roten Build zum Beheben:
16// danach nur noch melden, damit keine Schleife aus Fix, Push, Fehler entsteht.
17export const BEHEBEN_MAX = 2
18// Wer so lange weder Maus noch Tastatur angefasst hat, ist weg (wie telegram-draht).
19const WEG_AB_MS = MIN
20const API = 'https://api.cloudflare.com/client/v4'
21// git push, auch als git -C <pfad> push, irgendwo in einer Befehlskette.
22export const PUSH = /(^|[\s;&|(])git\s+(?:-[Cc]\s+("[^"]+"|'[^']+'|\S+)\s+)*push\b/
23const PRODUKTION = new Set(['main', 'master'])
24
25type CfZugang = { token: string; konto?: string }
26type TgZugang = { token: string; chat: string }
27export type Build = {
28 build_uuid: string
29 status?: string
30 build_outcome?: string | null
31 created_on?: string
32 stopped_on?: string
33 build_trigger_metadata?: { commit_hash?: string; branch?: string; commit_message?: string }
34}
35export type Stand = 'wartet' | 'baut' | 'live' | 'fehler' | 'abgebrochen' | 'kein-build' | 'aufgegeben'
36export type Konfig = { name?: string; konto?: string; domain?: string }
37export type Geschoben = { branch: string; sha: string }
38
39type Wache = {
40 repo: string
41 projekt: string
42 worker: string
43 konto?: string
44 domain?: string
45 branch: string
46 sha: string
47 start: number
48 stand: Stand
49 build?: string
50 nachricht?: string
51 dauer?: number
52 ende?: number
53 hinweis?: string
54}
55
56type Zustand = {
57 cf: CfZugang | null | undefined
58 tg: TgZugang | null | undefined
59 wachen: Wache[]
60 /** beheben: Claude bekommt einen roten Build als Nachricht; melden: nur Hinweis. */
61 fehlerArt: 'beheben' | 'melden'
62 telegram: boolean
63 /** Wie oft hintereinander Claude pro Repo schon beheben sollte. */
64 behoben: Map<string, number>
65 /** Für Claude, sobald kein Zug mehr läuft. */
66 notizen: string[]
67 zugLaeuft: boolean
68 ticker: { cancel(): void } | null
69 zuletztGeholt: number
70 holt: boolean
71 status: string | undefined
72 ohneTokenGesagt: boolean
73 fehler: string
74}
75
76function neuerZustand(): Zustand {
77 return {
78 cf: undefined, tg: undefined, wachen: [], fehlerArt: 'beheben', telegram: true, behoben: new Map(),
79 notizen: [], zugLaeuft: false, ticker: null, zuletztGeholt: 0, holt: false, status: undefined,
80 ohneTokenGesagt: false, fehler: '',
81 }
82}
83
84// ---------- Reine Hilfen (getestet) ----------
85
86function leseWerte(text: string): Record<string, string> {
87 const werte: Record<string, string> = {}
88 for (const zeile of text.split('\n')) {
89 const m = /^\s*(?:export\s+)?([A-Z_]+)=(.*)$/.exec(zeile)
90 if (m?.[1] && m[2] !== undefined) werte[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2')
91 }
92 return werte
93}
94
95/** Liest ~/.claude/cloudflare.env. */
96export function leseCf(text: string): CfZugang | null {
97 const w = leseWerte(text)
98 return w.CLOUDFLARE_API_TOKEN ? { token: w.CLOUDFLARE_API_TOKEN, konto: w.CLOUDFLARE_ACCOUNT_ID || undefined } : null
99}
100
101/** Liest ~/.claude/telegram.env (dieselbe Datei wie telegram-draht). */
102export function leseTg(text: string): TgZugang | null {
103 const w = leseWerte(text)
104 return w.TELEGRAM_BOT_TOKEN && w.TELEGRAM_CHAT_ID ? { token: w.TELEGRAM_BOT_TOKEN, chat: w.TELEGRAM_CHAT_ID } : null
105}
106
107// Entfernt Zeilen- und Blockkommentare aus JSONC; Zeichenketten bleiben unberührt (https://…).
108export function ohneKommentare(text: string): string {
109 let aus = ''
110 let inText = false
111 for (let i = 0; i < text.length; i++) {
112 const c = text[i]!
113 if (inText) {
114 aus += c
115 if (c === '\\') aus += text[++i] ?? ''
116 else if (c === '"') inText = false
117 } else if (c === '"') {
118 inText = true
119 aus += c
120 } else if (c === '/' && text[i + 1] === '/') {
121 while (i < text.length && text[i] !== '\n') i++
122 aus += '\n'
123 } else if (c === '/' && text[i + 1] === '*') {
124 i = text.indexOf('*/', i + 2)
125 if (i < 0) break
126 i++
127 } else aus += c
128 }
129 return aus
130}
131
132function domainAus(muster: unknown): string | undefined {
133 const host = typeof muster === 'string' ? muster.split('/')[0] : undefined
134 return host && !host.includes('*') ? host : undefined
135}
136
137/** Name, Konto und (erste feste) Domain aus wrangler.jsonc/.json/.toml. */
138export function leseKonfig(datei: string, text: string): Konfig {
139 if (datei.endsWith('.toml')) {
140 const kopf = text.split(/^\s*\[/m)[0] ?? ''
141 const wert = (s: string, k: string) => new RegExp(`^\\s*${k}\\s*=\\s*["']([^"']+)["']`, 'm').exec(s)?.[1]
142 const route = /pattern\s*=\s*["']([^"']+)["']/.exec(text)?.[1] ?? wert(kopf, 'route')
143 return { name: wert(kopf, 'name'), konto: wert(kopf, 'account_id'), domain: domainAus(route) }
144 }
145 try {
146 const j = JSON.parse(ohneKommentare(text).replace(/,(\s*[}\]])/g, '$1')) as {
147 name?: string; account_id?: string; route?: string | { pattern?: string }; routes?: (string | { pattern?: string })[]
148 }
149 const r = j.routes?.[0] ?? j.route
150 return { name: j.name, konto: j.account_id, domain: domainAus(typeof r === 'string' ? r : r?.pattern) }
151 } catch {
152 return { name: /"name"\s*:\s*"([^"]+)"/.exec(text)?.[1] }
153 }
154}
155
156/** Ausgabe von git for-each-ref refs/remotes: Remote-Branch → Commit. */
157export function leseRefs(ausgabe: string): Map<string, string> {
158 const m = new Map<string, string>()
159 for (const zeile of ausgabe.split('\n')) {
160 const [name, sha] = zeile.trim().split(/\s+/)
161 if (name && sha && name.includes('/') && !name.endsWith('/HEAD')) m.set(name, sha)
162 }
163 return m
164}
165
166/** Was ein Push bewegt hat: Remote-Branches mit neuem Commit. */
167export function geschoben(vorher: Map<string, string>, nachher: Map<string, string>): Geschoben[] {
168 const aus: Geschoben[] = []
169 for (const [name, sha] of nachher) {
170 if (vorher.get(name) !== sha) aus.push({ branch: name.slice(name.indexOf('/') + 1), sha })
171 }
172 return aus
173}
174
175function pfad(p: string, cwd: string, home: string): string {
176 const q = p.replace(/^(['"])(.*)\1$/, '$2')
177 if (q === '~' || q.startsWith('~/')) return home + q.slice(1)
178 return q.startsWith('/') ? q : `${cwd.replace(/\/+$/, '')}/${q}`
179}
180
181/** In welchem Ordner der Push lief: git -C <pfad>, sonst das letzte cd davor, sonst cwd. */
182export function repoOrt(befehl: string, cwd: string, home: string): string {
183 const m = PUSH.exec(befehl)
184 if (!m) return cwd
185 const c = /-C\s+("[^"]+"|'[^']+'|\S+)/.exec(m[0])
186 if (c?.[1]) return pfad(c[1], cwd, home)
187 const davor = befehl.slice(0, m.index + m[1]!.length)
188 let ort = cwd
189 for (const cd of davor.matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) ort = pfad(cd[1]!, ort, home)
190 return ort
191}
192
193export function findeBuild(builds: readonly Build[], sha: string): Build | undefined {
194 return builds.find(b => {
195 const h = b.build_trigger_metadata?.commit_hash
196 return !!h && h.length >= 7 && (sha.startsWith(h) || h.startsWith(sha))
197 })
198}
199
200export function standVon(b: Build): Stand {
201 if (b.status === 'stopped') return b.build_outcome === 'success' ? 'live' : b.build_outcome === 'fail' ? 'fehler' : 'abgebrochen'
202 return b.status === 'queued' ? 'wartet' : 'baut'
203}
204
205/** Die letzten Zeilen des Build-Logs, ohne Farbcodes; nimmt [zeit, text], {message} oder Text. */
206export function logZeilen(result: unknown, n = LOG_ZEILEN): string[] {
207 const roh = (result as { lines?: unknown } | null)?.lines ?? result
208 if (!Array.isArray(roh)) return []
209 return roh
210 .map(z => Array.isArray(z) ? String(z.at(-1) ?? '') : typeof z === 'string' ? z : String((z as { message?: unknown })?.message ?? ''))
211 // eslint-disable-next-line no-control-regex
212 .map(z => z.replace(/\u001b\[[0-9;]*m/g, '').trimEnd())
213 .filter(Boolean)
214 .slice(-n)
215}
216
217/** Ob der Branch live geht (main/master); andere bauen höchstens eine Vorschau. */
218export function produktion(w: { branch: string }): boolean {
219 return PRODUKTION.has(w.branch)
220}
221
222export function mmss(ms: number): string {
223 const s = Math.max(0, Math.round(ms / SEK))
224 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
225}
226
227/** Was unter dem Prompt steht: laufende Builds mit Zeit, frische Ergebnisse. */
228export function statusText(wachen: readonly Wache[], now: number): string | undefined {
229 const teile: string[] = []
230 for (const w of wachen) {
231 const wer = produktion(w) ? w.worker : `${w.worker} (${w.branch})`
232 if (w.stand === 'wartet') teile.push(`🚀 ${wer}: wartet auf Build ${mmss(now - w.start)}`)
233 else if (w.stand === 'baut') teile.push(`🚀 ${wer} baut ${mmss(now - w.start)}`)
234 else if (w.ende && now - w.ende < ERGEBNIS_ZEIGEN_MS) {
235 if (w.stand === 'live') teile.push(`✅ ${wer} live${w.dauer ? ` (${mmss(w.dauer)})` : ''}${w.hinweis ? ` · ⚠️ ${w.hinweis}` : ''}`)
236 else if (w.stand === 'fehler') teile.push(`❌ ${wer}: Build fehlgeschlagen`)
237 else if (w.stand === 'kein-build') teile.push(`⚠️ ${wer}: kein Build zu ${w.sha.slice(0, 7)}`)
238 else if (w.stand === 'abgebrochen') teile.push(`⏹ ${wer}: Build abgebrochen`)
239 else teile.push(`⏱ ${wer}: nach ${Math.round(AUFGEBEN_NACH_MS / MIN)} min kein Ergebnis`)
240 }
241 }
242 return teile.length ? `${teile.join(' · ')}` : undefined
243}
244
245
246// ---------- Host: Dateien, git, Netz ----------
247
248async function home($: EngineInterface): Promise<string> {
249 return (await $.env.get('HOME')) ?? ''
250}
251
252async function lies($: EngineInterface, p: string): Promise<string | null> {
253 try { return await $.fs.read(p) } catch { return null }
254}
255
256async function cfZugang($: EngineInterface, z: Zustand): Promise<CfZugang | null> {
257 if (z.cf !== undefined) return z.cf
258 const t = await lies($, `${await home($)}/.claude/cloudflare.env`)
259 z.cf = t ? leseCf(t) : null
260 return z.cf
261}
262
263async function tgZugang($: EngineInterface, z: Zustand): Promise<TgZugang | null> {
264 if (z.tg !== undefined) return z.tg
265 const t = await lies($, `${await home($)}/.claude/telegram.env`)
266 z.tg = t ? leseTg(t) : null
267 return z.tg
268}
269
270async function git($: EngineInterface, ort: string, args: string[]): Promise<string | null> {
271 try {
272 const r = await $.process.run(['git', '-C', ort, ...args], { timeoutMs: 10 * SEK })
273 return r.exitCode === 0 ? r.stdout : null
274 } catch {
275 return null
276 }
277}
278
279async function refs($: EngineInterface, top: string): Promise<Map<string, string> | null> {
280 const aus = await git($, top, ['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/remotes'])
281 return aus === null ? null : leseRefs(aus)
282}
283
284async function konfig($: EngineInterface, top: string): Promise<Konfig | null> {
285 for (const datei of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
286 const t = await lies($, `${top}/${datei}`)
287 if (t !== null) return leseKonfig(datei, t)
288 }
289 return null
290}
291
292// Die Fehlermeldung nennt nie den Token: er steht nur im Header.
293async function cf<T>($: EngineInterface, z: Zustand, weg: string): Promise<T> {
294 const zg = await cfZugang($, z)
295 if (!zg) throw new Error('kein Token in ~/.claude/cloudflare.env')
296 let r
297 try {
298 r = await $.http.fetch(`${API}${weg}`, { headers: { authorization: `Bearer ${zg.token}` } })
299 } catch (err) {
300 throw new Error(`Cloudflare nicht erreichbar (${err instanceof Error ? err.message : String(err)})`)
301 }
302 let d: { success?: boolean; result?: unknown; errors?: { message?: string }[] } | null = null
303 try { d = JSON.parse(r.text) } catch { /* bleibt null */ }
304 if (!d?.success) {
305 const was = d?.errors?.map(e => e.message).filter(Boolean).join('; ') ?? ''
306 throw new Error(`Cloudflare ${weg.split('?')[0]?.replace(/[0-9a-f]{32}/g, '…')}: ${r.status} ${was}`.trim())
307 }
308 return d.result as T
309}
310
311async function konto($: EngineInterface, z: Zustand, ausKonfig?: string): Promise<string> {
312 const zg = await cfZugang($, z)
313 if (zg?.konto) return zg.konto
314 if (ausKonfig) return ausKonfig
315 const gemerkt = await $.store.get('konto')
316 if (typeof gemerkt === 'string') return gemerkt
317 const liste = await cf<{ id: string; name: string }[]>($, z, '/accounts')
318 if (liste.length !== 1) throw new Error(`Der Token sieht ${liste.length} Konten: CLOUDFLARE_ACCOUNT_ID in ~/.claude/cloudflare.env eintragen`)
319 await $.store.set('konto', liste[0]!.id)
320 return liste[0]!.id
321}
322
323/** Der Tag des Workers (die Builds-API will ihn statt des Namens), gemerkt im Store. */
324async function tagVon($: EngineInterface, z: Zustand, k: string, worker: string): Promise<string> {
325 const key = `tag:${k}:${worker}`
326 const gemerkt = await $.store.get(key)
327 if (typeof gemerkt === 'string') return gemerkt
328 const skripte = await cf<{ id: string; tag: string }[]>($, z, `/accounts/${k}/workers/scripts`)
329 const tag = skripte.find(s => s.id === worker)?.tag
330 if (!tag) throw new Error(`Worker „${worker}“ gibt es im Konto nicht`)
331 await $.store.set(key, tag)
332 return tag
333}
334
335async function builds($: EngineInterface, z: Zustand, w: Wache): Promise<Build[]> {
336 const k = await konto($, z, w.konto)
337 const tag = await tagVon($, z, k, w.worker)
338 return cf<Build[]>($, z, `/accounts/${k}/builds/workers/${tag}/builds?per_page=10`)
339}
340
341async function logVon($: EngineInterface, z: Zustand, w: Wache): Promise<string[]> {
342 if (!w.build) return []
343 try {
344 const k = await konto($, z, w.konto)
345 // Lange Logs (npm install …) kommen seitenweise; der Fehler steht am Ende.
346 let zeilen: string[] = []
347 let cursor = ''
348 for (let seite = 0; seite < 20; seite++) {
349 const r = await cf<{ lines?: unknown; truncated?: boolean; cursor?: string }>($, z,
350 `/accounts/${k}/builds/builds/${w.build}/logs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`)
351 zeilen = [...zeilen, ...logZeilen(r, Infinity)].slice(-LOG_ZEILEN)
352 if (!r.truncated || !r.cursor || r.cursor === cursor) break
353 cursor = r.cursor
354 }
355 return zeilen
356 } catch (err) {
357 return [`(Log nicht lesbar: ${err instanceof Error ? err.message : String(err)})`]
358 }
359}
360
361/** Leerlauf aus `ioreg -k HIDIdleTime` (Nanosekunden) in ms; null = nicht lesbar. */
362export function leseLeerlauf(ausgabe: string): number | null {
363 const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ausgabe)
364 return m?.[1] ? Number(m[1]) / 1e6 : null
365}
366
367async function istWeg($: EngineInterface): Promise<boolean> {
368 try {
369 const r = await $.process.run(['/usr/sbin/ioreg', '-r', '-k', 'HIDIdleTime', '-d', '1'], { timeoutMs: 5 * SEK })
370 const leer = r.exitCode === 0 ? leseLeerlauf(r.stdout) : null
371 return leer !== null && leer >= WEG_AB_MS
372 } catch {
373 return false
374 }
375}
376
377async function telegram($: EngineInterface, z: Zustand, text: string) {
378 if (!z.telegram || !(await istWeg($))) return
379 const zg = await tgZugang($, z)
380 if (!zg) return
381 try {
382 const r = await $.http.fetch(`https://api.telegram.org/bot${zg.token}/sendMessage`, {
383 method: 'POST',
384 headers: { 'content-type': 'application/json' },
385 body: JSON.stringify({ chat_id: zg.chat, text, disable_web_page_preview: true }),
386 })
387 if (!r.ok) merkeFehler($, z, `Telegram: ${r.status}`)
388 } catch {
389 merkeFehler($, z, 'Telegram nicht erreichbar')
390 }
391}
392
393function merkeFehler($: EngineInterface, z: Zustand, text: string) {
394 if (text !== z.fehler) $.ui.log(`deploy-wacht: ${text}`, { to: 'debug' })
395 z.fehler = text
396}
397
398// ---------- Beobachten ----------
399
400function zeichne($: EngineInterface, z: Zustand, now: number) {
401 const text = statusText(z.wachen, now)
402 if (text !== z.status) {
403 z.status = text
404 $.ui.status(text)
405 }
406}
407
408function aktiv(w: Wache): boolean {
409 return w.stand === 'wartet' || w.stand === 'baut'
410}
411
412function wecken($: EngineInterface, z: Zustand) {
413 if (z.ticker) return
414 z.ticker = $.clock.every(SEK, () => void tick($, z))
415}
416
417async function tick($: EngineInterface, z: Zustand) {
418 const now = await $.clock.now()
419 // Abgelaufene Ergebnisse fallen weg; ohne etwas zu zeigen schläft die Uhr.
420 z.wachen = z.wachen.filter(w => aktiv(w) || (w.ende ?? now) + ERGEBNIS_ZEIGEN_MS > now)
421 zeichne($, z, now)
422 if (!z.wachen.length) {
423 z.ticker?.cancel()
424 z.ticker = null
425 return
426 }
427 if (z.wachen.some(aktiv) && !z.holt && now - z.zuletztGeholt >= POLL_MS) await pruefe($, z)
428}
429
430async function pruefe($: EngineInterface, z: Zustand) {
431 z.holt = true
432 try {
433 const now = await $.clock.now()
434 z.zuletztGeholt = now
435 const jeWorker = new Map<string, Build[]>()
436 for (const w of z.wachen.filter(aktiv)) {
437 let liste = jeWorker.get(w.worker)
438 if (!liste) {
439 try {
440 liste = await builds($, z, w)
441 z.fehler = ''
442 } catch (err) {
443 merkeFehler($, z, err instanceof Error ? err.message : String(err))
444 liste = []
445 }
446 jeWorker.set(w.worker, liste)
447 }
448 const b = findeBuild(liste, w.sha)
449 if (b) {
450 w.build = b.build_uuid
451 w.nachricht ??= b.build_trigger_metadata?.commit_message?.split('\n')[0]
452 const stand = standVon(b)
453 if (stand === 'wartet' || stand === 'baut') w.stand = stand
454 else {
455 const a = Date.parse(b.created_on ?? ''), e = Date.parse(b.stopped_on ?? '')
456 w.dauer = Number.isFinite(a) && Number.isFinite(e) ? e - a : now - w.start
457 await abschliessen($, z, w, stand, now)
458 }
459 } else if (w.stand === 'wartet' && now - w.start >= KEIN_BUILD_NACH_MS) {
460 // Ein Nebenbranch ohne Vorschau-Builds ist normal: dann still aufhören.
461 if (produktion(w)) await abschliessen($, z, w, 'kein-build', now)
462 else z.wachen = z.wachen.filter(x => x !== w)
463 }
464 if (aktiv(w) && now - w.start >= AUFGEBEN_NACH_MS) await abschliessen($, z, w, 'aufgegeben', now)
465 }
466 zeichne($, z, now)
467 } finally {
468 z.holt = false
469 }
470}
471
472function beschreibung(w: Wache): string {
473 return `„${w.worker}“ (Commit ${w.sha.slice(0, 7)}${w.nachricht ? ` „${w.nachricht}“` : ''}, Branch ${w.branch})`
474}
475
476async function abschliessen($: EngineInterface, z: Zustand, w: Wache, stand: Stand, now: number) {
477 w.stand = stand
478 w.ende = now
479 const wer = produktion(w) ? w.worker : `${w.worker} (${w.branch})`
480 const ort = `📁 ${w.projekt}`
481 if (stand === 'live') {
482 z.behoben.delete(w.repo)
483 if (w.domain && produktion(w)) {
484 try {
485 const r = await $.http.fetch(`https://${w.domain}/`)
486 if (r.status >= 400) w.hinweis = `${w.domain} antwortet ${r.status}`
487 } catch {
488 w.hinweis = `${w.domain} nicht erreichbar`
489 }
490 }
491 const wo = w.domain && produktion(w) ? ` auf ${w.domain}` : ''
492 const satz = `✅ ${wer} ist live${wo} (Build ${mmss(w.dauer ?? 0)}, Commit ${w.sha.slice(0, 7)})${w.hinweis ? `, aber ⚠️ ${w.hinweis}` : ''}`
493 $.ui.toast(satz, { timeoutMs: 8 * SEK })
494 await notiz($, z, `deploy-wacht: Der Cloudflare-Build ${beschreibung(w)} ist durch, die Änderung ist live${wo}.${w.hinweis ? ` Achtung: ${w.hinweis}.` : ''}`)
495 await telegram($, z, `${satz}\n${w.nachricht ?? ''}\n${ort}`.trim())
496 return
497 }
498 if (stand === 'fehler') {
499 const log = await logVon($, z, w)
500 $.ui.toast(`❌ ${wer}: Build fehlgeschlagen`, { timeoutMs: 10 * SEK })
501 const versuche = z.behoben.get(w.repo) ?? 0
502 const beheben = z.fehlerArt === 'beheben' && produktion(w) && versuche < BEHEBEN_MAX
503 const text = [
504 `deploy-wacht: Der Cloudflare-Build ${beschreibung(w)} ist fehlgeschlagen, live ist weiter der alte Stand.`,
505 '',
506 'Ende des Build-Logs:',
507 '```',
508 ...(log.length ? log : ['(kein Log)']),
509 '```',
510 '',
511 beheben
512 ? 'Finde die Ursache. Ist die Behebung klein und eindeutig, behebe sie und bring sie wie gewohnt auf main; sonst erkläre mir kurz, was los ist.'
513 : `Erklär mir kurz, woran es liegt${versuche >= BEHEBEN_MAX ? ` (schon ${versuche} Behebungen hintereinander, daher nicht selbst weiterpushen)` : ''}.`,
514 ].join('\n')
515 if (beheben) {
516 z.behoben.set(w.repo, versuche + 1)
517 void $.prompt.submit({ text })
518 } else await notiz($, z, text)
519 const letzte = log.slice(-6).join('\n')
520 await telegram($, z, `❌ Build fehlgeschlagen · ${wer}\n${w.sha.slice(0, 7)} ${w.nachricht ?? ''}\n${ort}${beheben ? '\nClaude schaut nach.' : ''}${letzte ? `\n\n${letzte}` : ''}`)
521 return
522 }
523 if (stand === 'kein-build') {
524 $.ui.toast(`⚠️ ${wer}: Cloudflare hat zu ${w.sha.slice(0, 7)} keinen Build gestartet`, { timeoutMs: 10 * SEK })
525 await notiz($, z, `deploy-wacht: Zum Push ${beschreibung(w)} hat Cloudflare nach ${Math.round(KEIN_BUILD_NACH_MS / MIN)} min keinen Build gestartet. Ist das Repo mit Workers Builds verbunden und der Branch im Trigger?`)
526 await telegram($, z, `⚠️ Kein Build zu ${w.sha.slice(0, 7)} · ${wer}\n${ort}`)
527 return
528 }
529 if (stand === 'aufgegeben') {
530 await notiz($, z, `deploy-wacht: Der Cloudflare-Build ${beschreibung(w)} hatte nach ${Math.round(AUFGEBEN_NACH_MS / MIN)} min kein Ergebnis; die Mod schaut nicht mehr nach.`)
531 return
532 }
533 // abgebrochen: meist überholt von einem neueren Push, also nur die Statuszeile.
534}
535
536/** Für Claude: sofort, wenn die Sitzung ruht, sonst nach dem laufenden Zug. */
537async function notiz($: EngineInterface, z: Zustand, text: string) {
538 z.notizen.push(text)
539 if (!z.zugLaeuft) await notizenAbgeben($, z)
540}
541
542async function notizenAbgeben($: EngineInterface, z: Zustand) {
543 if (!z.notizen.length) return
544 const text = z.notizen.join('\n\n')
545 z.notizen = []
546 try {
547 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
548 } catch (err) {
549 merkeFehler($, z, `Notiz an Claude ging nicht: ${err instanceof Error ? err.message : String(err)}`)
550 }
551}
552
553/** Nach einem Push: was hat er bewegt, und ist das ein Cloudflare-Projekt? */
554async function nachPush($: EngineInterface, z: Zustand, top: string, neu: Geschoben[]): Promise<string | null> {
555 const k = await konfig($, top)
556 if (!k?.name) return null
557 if (!(await cfZugang($, z))) {
558 if (!z.ohneTokenGesagt) $.ui.toast('deploy-wacht: kein Cloudflare-Token, /deploy einrichten', { timeoutMs: 8 * SEK })
559 z.ohneTokenGesagt = true
560 return null
561 }
562 const now = await $.clock.now()
563 const projekt = top.replace(/\/+$/, '').split('/').pop() ?? top
564 const neue: Wache[] = []
565 for (const g of neu) {
566 // Ein neuerer Push auf denselben Branch überholt den alten.
567 z.wachen = z.wachen.filter(w => !(w.worker === k.name && w.branch === g.branch && aktiv(w)))
568 const w: Wache = ({ repo: top, projekt, worker: k.name, konto: k.konto, domain: k.domain, branch: g.branch, sha: g.sha, start: now, stand: 'wartet' })
569 z.wachen.push(w)
570 neue.push(w)
571 }
572 // Die erste Abfrage kommt nach 10 s: so früh gibt es den Build meist noch nicht.
573 z.zuletztGeholt = now
574 zeichne($, z, now)
575 wecken($, z)
576 const was = neue.map(w => `${w.branch} (${w.sha.slice(0, 7)})`).join(', ')
577 return `deploy-wacht: Push auf ${was} erkannt. Die Mod beobachtet den Cloudflare-Build von „${k.name}“ und meldet das Ergebnis selbst: unter dem Prompt, und bei einem Fehler als neue Nachricht mit dem Build-Log. Warte also nicht selbst auf den Live-Stand und frag ihn nicht per Schleife ab.`
578}
579
580// ---------- /deploy ----------
581
582// Die Builds-API nimmt nur Benutzer-Token (Profil → API-Tokens); ein Konto-Token
583// (Konto verwalten → Konto-API-Tokens) liest Workers, bekommt bei Builds aber
584// „Invalid token“ (so in der Cloudflare-Doku, 09.10. selbst erlebt).
585const VORLAGE = 'https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22workers_scripts%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=deploy-wacht'
586const ANLEITUNG = [
587 'Einrichten (einmalig):',
588 '1. Einen Benutzer-Token anlegen, kein Konto-Token (den nimmt die Builds-API nicht):',
589 ` ${VORLAGE}`,
590 ' Die Vorlage setzt „Workers-Skripte · Lesen“; dazu „Workers Builds Configuration · Lesen“',
591 ' (auch „Workers CI“ genannt) hinzufügen. Kontoressourcen: dein Konto. Nur Leserechte.',
592 '2. Den Token in die Datei ~/.claude/cloudflare.env schreiben, nicht hier in den Chat:',
593 ' open -e ~/.claude/cloudflare.env (Zeile: CLOUDFLARE_API_TOKEN=…)',
594 '3. /deploy einrichten',
595].join('\n')
596
597const KONTO_TOKEN = 'Das ist offenbar ein Konto-Token: Workers liest er, die Builds-API nimmt aber nur Benutzer-Token (Profil → API-Tokens).'
598
599async function einrichten($: EngineInterface, z: Zustand): Promise<string> {
600 z.cf = undefined
601 const zg = await cfZugang($, z)
602 if (!zg) return `In ~/.claude/cloudflare.env steht noch kein CLOUDFLARE_API_TOKEN.\n\n${ANLEITUNG}`
603 try {
604 let k = zg.konto
605 let zusatz = ''
606 if (!k) {
607 k = await konto($, z)
608 const p = `${await home($)}/.claude/cloudflare.env`
609 const roh = (await lies($, p)) ?? ''
610 const zeilen = roh.split('\n').filter(l => l.trim() && !/^\s*(?:export\s+)?CLOUDFLARE_ACCOUNT_ID=/.test(l))
611 await $.fs.write(p, [...zeilen, `CLOUDFLARE_ACCOUNT_ID=${k}`, ''].join('\n'))
612 z.cf = { ...zg, konto: k }
613 zusatz = ' Konto-ID eingetragen.'
614 }
615 const skripte = await cf<{ id: string; tag: string }[]>($, z, `/accounts/${k}/workers/scripts`)
616 const probe = skripte[0]
617 if (probe) {
618 try {
619 await cf<Build[]>($, z, `/accounts/${k}/builds/workers/${probe.tag}/builds?per_page=1`)
620 } catch (err) {
621 const text = err instanceof Error ? err.message : String(err)
622 const warum = /invalid token/i.test(text) ? KONTO_TOKEN : 'Fehlt „Workers Builds Configuration · Lesen“?'
623 return `Workers sieht der Token (${skripte.length}), aber keine Builds: ${text}\n${warum}${zusatz}\n\n${ANLEITUNG}`
624 }
625 }
626 return `Verbunden: ${skripte.length} Workers sichtbar, Builds lesbar.${zusatz} Ab dem nächsten git push wird beobachtet.`
627 } catch (err) {
628 return `Einrichten ging nicht: ${err instanceof Error ? err.message : String(err)}\n\n${ANLEITUNG}`
629 }
630}
631
632async function karte($: EngineInterface, z: Zustand): Promise<string> {
633 const now = await $.clock.now()
634 const zg = await cfZugang($, z)
635 const zeilen = ['deploy-wacht']
636 zeilen.push(`Zugang ${zg ? 'ok' : '⚠️ noch nicht eingerichtet: /deploy einrichten'}`)
637 zeilen.push(`Roter Build ${z.fehlerArt === 'beheben' ? `Claude bekommt das Log und behebt Kleines selbst (höchstens ${BEHEBEN_MAX}× hintereinander)` : 'nur melden'}`)
638 zeilen.push(`Telegram ${z.telegram ? 'wenn du weg bist' : 'aus'}`)
639 for (const w of z.wachen) zeilen.push(`Gerade ${statusText([w], now) ?? w.stand}`)
640 // Der letzte Build des Projekts, in dem die Sitzung steht.
641 const top = (await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel']))?.trim()
642 const k = top ? await konfig($, top) : null
643 if (zg && k?.name) {
644 try {
645 const w: Wache = ({ repo: top!, projekt: '', worker: k.name, konto: k.konto, branch: 'main', sha: '', start: now, stand: 'wartet' })
646 const b = (await builds($, z, w))[0]
647 if (b) {
648 const m = b.build_trigger_metadata
649 const stand = standVon(b)
650 const wann = Date.parse(b.created_on ?? '')
651 zeilen.push(`Letzter Build ${k.name}: ${stand} · ${m?.branch ?? '?'} ${m?.commit_hash?.slice(0, 7) ?? ''} ${m?.commit_message?.split('\n')[0] ?? ''}${Number.isFinite(wann) ? ` · vor ${Math.round((now - wann) / MIN)} min` : ''}`)
652 if (stand === 'fehler') {
653 w.build = b.build_uuid
654 zeilen.push('', ...(await logVon($, z, w)).slice(-15))
655 }
656 }
657 } catch (err) {
658 zeilen.push(`Letzter Build ${k.name}: ${err instanceof Error ? err.message : String(err)}`)
659 }
660 }
661 if (z.fehler) zeilen.push(`Letzter Fehler ${z.fehler}`)
662 zeilen.push('', '/deploy einrichten · fehler beheben|melden · telegram an|aus')
663 return zeilen.join('\n')
664}
665
666// ---------- Hooks ----------
667
668export const register: Register = on => {
669 const z = neuerZustand()
670
671 on('session.start', async ($, e, next) => {
672 const r = await next(e)
673 const art = await $.store.get('fehlerArt')
674 if (art === 'beheben' || art === 'melden') z.fehlerArt = art
675 const tg = await $.store.get('telegram')
676 if (typeof tg === 'boolean') z.telegram = tg
677 await $.command.register({
678 name: 'deploy',
679 description: 'Cloudflare-Builds nach git push beobachten (deploy-wacht)',
680 argumentHint: '[einrichten | fehler beheben|melden | telegram an|aus]',
681 immediate: true,
682 })
683 return r
684 })
685
686 on('turn.start', async ($, e, next) => {
687 z.zugLaeuft = true
688 return next(e)
689 })
690
691 on('turn.complete', async ($, e, next) => {
692 const r = await next(e)
693 if (e.agentId) return r
694 z.zugLaeuft = false
695 await notizenAbgeben($, z)
696 return r
697 })
698
699 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
700 if (e.tool !== 'Bash' || e.run_in_background || !PUSH.test(e.command)) return next(e)
701 // Der Push selbst darf an der Mod nie scheitern: alles Eigene ist abgefangen.
702 let top: string | undefined
703 let vorher: Map<string, string> | null = null
704 try {
705 const ort = repoOrt(e.command, await $.session.cwd(), await home($))
706 top = (await git($, ort, ['rev-parse', '--show-toplevel']))?.trim()
707 vorher = top ? await refs($, top) : null
708 } catch (err) {
709 merkeFehler($, z, `vor dem Push: ${err instanceof Error ? err.message : String(err)}`)
710 }
711 const r = await next(e)
712 if (!top || !vorher || r.deny !== undefined || r.isError) return r
713 try {
714 const nachher = await refs($, top)
715 const neu = nachher ? geschoben(vorher, nachher) : []
716 const hinweis = neu.length ? await nachPush($, z, top, neu) : null
717 return hinweis ? { ...r, context: [...(r.context ?? []), hinweis] } : r
718 } catch (err) {
719 merkeFehler($, z, `nach dem Push: ${err instanceof Error ? err.message : String(err)}`)
720 return r
721 }
722 })
723
724 on('command.run', { command: 'deploy' }, async ($, e) => {
725 const [was = '', wert = ''] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
726 if (was === 'einrichten') return { text: await einrichten($, z) }
727 if (was === 'fehler') {
728 if (wert !== 'beheben' && wert !== 'melden') return { text: 'Roter Build: beheben oder melden?' }
729 z.fehlerArt = wert
730 await $.store.set('fehlerArt', wert)
731 return { text: wert === 'beheben' ? 'Bei einem roten Build bekommt Claude das Log und behebt Kleines selbst.' : 'Rote Builds werden nur gemeldet.' }
732 }
733 if (was === 'telegram') {
734 if (wert !== 'an' && wert !== 'aus') return { text: 'Telegram: an oder aus?' }
735 z.telegram = wert === 'an'
736 await $.store.set('telegram', z.telegram)
737 return { text: z.telegram ? 'Ergebnisse kommen aufs Handy, wenn du weg bist.' : 'Keine Telegram-Meldungen mehr.' }
738 }
739 const unbekannt = was && was !== 'status' ? `„${String(e.args).trim()}“ kenne ich nicht.\n\n` : ''
740 return { text: unbekannt + await karte($, z) }
741 })
742}
743