From 0590c75f13c6e393209ab364d9e34e3af14cb801 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 23 Jun 2026 14:21:33 -0700 Subject: [PATCH] slab: deploy-status menubar feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Deploy submenu showing whether a repo's latest commit on each watched branch has finished deploying, read from Cloudflare 'Workers Builds' GitHub check-runs — deployed / building / failing per worker, per environment. bin/deploy-status(.mjs) is the poller (mirrors the asana bridge); the Swift menubar renders it (mirrors buildAsana). Watched repos and an optional GitHub PAT live in untracked ~/.config/slab/deploy-status.json — nothing repo-specific in tracked code. Per-repo via a targets array (one repo today, extensible). --- slab/bin/deploy-status | 38 +++ slab/bin/deploy-status.mjs | 275 ++++++++++++++++++ .../Sources/SlabMenubar/AppDelegate.swift | 90 ++++++ .../Sources/SlabMenubar/MenuBuilder.swift | 100 +++++++ .../Sources/SlabMenubar/Paths.swift | 8 + .../Sources/SlabMenubar/StateSnapshot.swift | 30 ++ 6 files changed, 541 insertions(+) create mode 100755 slab/bin/deploy-status create mode 100755 slab/bin/deploy-status.mjs diff --git a/slab/bin/deploy-status b/slab/bin/deploy-status new file mode 100755 index 0000000000..5a1ca619c8 --- /dev/null +++ b/slab/bin/deploy-status @@ -0,0 +1,38 @@ +#!/bin/bash +# deploy-status — node-resolving wrapper around deploy-status.mjs. +# +# The menubar runs under launchd with a minimal PATH that does NOT include +# fnm's per-shell node shim, so the Swift side calls THIS absolute path and +# we resolve a usable `node` ourselves (mirrors Tools.resolve in Swift). +set -u +# Resolve through symlinks (this is symlinked into ~/.local/bin like the +# other slab bins) so SCRIPT points at the real .mjs next to the wrapper. +SRC="${BASH_SOURCE[0]}" +while [ -L "$SRC" ]; do + DIR="$(cd -P "$(dirname "$SRC")" && pwd)" + SRC="$(readlink "$SRC")" + [[ "$SRC" != /* ]] && SRC="$DIR/$SRC" +done +HERE="$(cd -P "$(dirname "$SRC")" && pwd)" +SCRIPT="$HERE/deploy-status.mjs" + +find_node() { + if command -v node >/dev/null 2>&1; then command -v node; return; fi + local c + for c in \ + "$HOME/.local/share/fnm/aliases/default/bin/node" \ + "$HOME/.fnm/aliases/default/bin/node" \ + /opt/homebrew/bin/node \ + /usr/local/bin/node \ + "$HOME/.volta/bin/node"; do + [ -x "$c" ] && { echo "$c"; return; } + done + # Newest fnm-managed version, if any. + c=$(ls -td "$HOME"/.local/share/fnm/node-versions/*/installation/bin/node \ + 2>/dev/null | head -1) + [ -n "$c" ] && [ -x "$c" ] && { echo "$c"; return; } + return 1 +} + +NODE="$(find_node)" || { echo '{"configured":false,"label":"Deploy: no node"}'; exit 0; } +exec "$NODE" "$SCRIPT" "$@" diff --git a/slab/bin/deploy-status.mjs b/slab/bin/deploy-status.mjs new file mode 100755 index 0000000000..74fc66bd8c --- /dev/null +++ b/slab/bin/deploy-status.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node +// deploy-status.mjs — a tiny deploy-status bridge for the slab menubar. +// +// Reports whether a repo's latest commit on each watched branch has finished +// deploying, as JSON the Swift menubar renders into a `Deploy` submenu +// (mirrors `asana`). The signal is Cloudflare's "Workers Builds" GitHub +// check-runs — one per worker per environment (e.g. `Workers Builds: +// api-staging`) — so "deployed or not" is just whether those checks have all +// gone green on the branch tip. No Cloudflare API token needed; the same +// GitHub read access slab already uses is enough. +// +// This file ships in the PUBLIC aesthetic.computer repo, so it carries NO +// repo names or tokens: the watched targets and the GitHub PAT live in an +// UNTRACKED config at ~/.config/slab/deploy-status.json (see +// `deploy-status config`). If no token is set there, we fall back to +// `gh auth token` for machines where the GitHub CLI is signed in. +// +// Subcommands: +// deploy-status status [--target ] [--env ] JSON summary (default) +// deploy-status config print/create config stub +// deploy-status open [--target ] [--env ] open the deploy in a browser +// +// Per-repo: `targets` is a list, so this watches one repo (fuser) today and +// extends to more by adding entries — `--target ` selects one. + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; + +const HOME = homedir(); +const CONFIG_PATH = + process.env.SLAB_DEPLOY_STATUS_CONFIG || + join(HOME, ".config", "slab", "deploy-status.json"); +const API = "https://api.github.com"; + +// ─── config ────────────────────────────────────────────────────────────── + +const CONFIG_STUB = { + _README: + "slab deploy-status config — UNTRACKED, never committed. List the repos " + + "to watch under `targets`; each maps an environment name to the branch " + + "whose tip deploys it. The deploy signal is Cloudflare 'Workers Builds' " + + "check-runs named ` -`.", + token: "REPLACE_ME", + tokenComment: + "GitHub PAT with read access to the repos' commit statuses/checks. " + + "Leave as REPLACE_ME (or blank) to fall back to `gh auth token`.", + targets: [ + { + name: "example", + repo: "owner/repo", + environments: { staging: "staging", production: "main" }, + buildsPrefix: "Workers Builds:", + }, + ], + default: "example", +}; + +function loadConfig() { + if (!existsSync(CONFIG_PATH)) return null; + try { + const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); + if (!cfg || !Array.isArray(cfg.targets) || cfg.targets.length === 0) + return null; + const real = cfg.targets.filter((t) => t.repo && !/owner\/repo/.test(t.repo)); + if (real.length === 0) return null; + return { ...cfg, targets: real }; + } catch { + return null; + } +} + +function writeStub() { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + if (!existsSync(CONFIG_PATH)) { + writeFileSync(CONFIG_PATH, JSON.stringify(CONFIG_STUB, null, 2) + "\n"); + } +} + +// Token precedence: config → env → `gh auth token` (CLI sign-in). +function resolveToken(cfg) { + if (cfg?.token && !/REPLACE_ME/.test(cfg.token)) return cfg.token; + if (process.env.GH_TOKEN) return process.env.GH_TOKEN; + if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; + const r = spawnSync("gh", ["auth", "token"], { encoding: "utf8" }); + if (r.status === 0) return r.stdout.trim(); + return null; +} + +// ─── GitHub REST ─────────────────────────────────────────────────────────── + +async function gh(token, path) { + const res = await fetch(`${API}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "slab-deploy-status", + }, + }); + if (!res.ok) { + const err = new Error(`github ${res.status}`); + err.status = res.status; + throw err; + } + return res.json(); +} + +// ─── deploy state ──────────────────────────────────────────────────────── + +// Roll the per-worker check-runs for one branch tip into a single state. +// deployed → every Workers Build succeeded +// building → at least one still queued/in-progress (none failing) +// failing → at least one concluded unsuccessfully +// none → no Workers Builds checks on this commit +function rollUp(apps) { + if (apps.length === 0) return "none"; + if (apps.some((a) => a.state === "failing")) return "failing"; + if (apps.some((a) => a.state === "building")) return "building"; + return "deployed"; +} + +const GLYPH = { deployed: "✓", building: "⏳", failing: "✗", none: "·" }; + +function appState(run) { + if (run.status !== "completed") return "building"; + return run.conclusion === "success" || run.conclusion === "neutral" + ? "deployed" + : "failing"; +} + +async function envStatus(token, repo, env, branch, prefix) { + const head = await gh(token, `/repos/${repo}/commits/${branch}`); + const sha = head.sha; + const checks = await gh( + token, + `/repos/${repo}/commits/${sha}/check-runs?per_page=100`, + ); + const wantSuffix = `-${env}`; + const apps = (checks.check_runs || []) + .filter((r) => r.name.startsWith(prefix)) + .map((r) => ({ + name: r.name + .slice(prefix.length) + .trim() + .replace(new RegExp(`${wantSuffix}$`), ""), + raw: r.name.slice(prefix.length).trim(), + state: appState(r), + conclusion: r.conclusion, + url: r.details_url || r.html_url, + completedAt: r.completed_at, + })) + // Only the workers for THIS environment (suffix match), when discernible. + .filter((a) => a.raw.endsWith(wantSuffix) || a.raw === a.name); + const state = rollUp(apps); + const completed = apps.map((a) => a.completedAt).filter(Boolean).sort(); + return { + env, + branch, + sha: sha.slice(0, 7), + message: (head.commit?.message || "").split("\n")[0], + author: head.commit?.author?.name || head.author?.login || "", + committedAt: head.commit?.author?.date || head.commit?.committer?.date || "", + state, + deployedAt: state === "deployed" ? completed[completed.length - 1] : "", + apps: apps.map(({ raw, ...a }) => a), + url: `https://github.com/${repo}/commit/${sha}`, + }; +} + +function pickTarget(cfg, name) { + const want = name || cfg.default; + return cfg.targets.find((t) => t.name === want) || cfg.targets[0]; +} + +async function status(cfg, opts) { + const token = resolveToken(cfg); + if (!token) + return { + configured: false, + label: "Deploy: no GitHub token", + }; + const target = pickTarget(cfg, opts.target); + const prefix = target.buildsPrefix || "Workers Builds:"; + const envMap = target.environments || { staging: "staging" }; + const wantEnvs = + opts.env && opts.env !== "all" + ? { [opts.env]: envMap[opts.env] } + : envMap; + + const envs = []; + for (const [env, branch] of Object.entries(wantEnvs)) { + if (!branch) continue; + try { + envs.push(await envStatus(token, target.repo, env, branch, prefix)); + } catch (e) { + envs.push({ env, branch, state: "error", error: String(e.message || e) }); + } + } + + // Menubar parent label: " …" newest-env-first. + const summary = envs + .map((e) => `${GLYPH[e.state] || "?"} ${e.env}`) + .join(" "); + return { + configured: true, + target: target.name, + label: `${target.name} ${summary}`.trim(), + state: rollUp( + envs.map((e) => ({ state: e.state === "deployed" ? "deployed" : e.state })), + ), + envs, + url: envs[0]?.url || `https://github.com/${target.repo}`, + }; +} + +// ─── CLI ──────────────────────────────────────────────────────────────── + +function parseFlags(argv) { + const opts = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--target") opts.target = argv[++i]; + else if (argv[i] === "--env") opts.env = argv[++i]; + } + return opts; +} + +async function main() { + const [cmd = "status", ...rest] = process.argv.slice(2); + const opts = parseFlags(rest); + + if (cmd === "config") { + writeStub(); + console.log(CONFIG_PATH); + return; + } + + const cfg = loadConfig(); + + if (cmd === "open") { + if (!cfg) return console.log(CONFIG_PATH); + const target = pickTarget(cfg, opts.target); + const url = `https://github.com/${target.repo}/commits/${ + (target.environments || {})[opts.env || "staging"] || "staging" + }`; + spawnSync("open", [url]); + return; + } + + // status (default) + if (!cfg) { + console.log( + JSON.stringify({ + configured: false, + label: "Deploy: not set up", + configPath: CONFIG_PATH, + }), + ); + return; + } + try { + console.log(JSON.stringify(await status(cfg, opts))); + } catch (e) { + console.log( + JSON.stringify({ + configured: true, + label: `Deploy: ${e.status || ""} error`.trim(), + error: String(e.message || e), + }), + ); + } +} + +main(); diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 779033b7f4..2f42602cde 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -73,6 +73,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var asanaTickCount = 29 // primed so the first tick loads tasks private var asanaPending = false private var asanaState = AsanaState() + /// Deploy status. Polled off-main on a slow cadence via the helper, which + /// reads Cloudflare "Workers Builds" GitHub check-runs. Repos + token live + /// only in the untracked deploy-status config (see slab-public-repo PII). + private var deployTickCount = 28 // offset from asana so polls don't collide + private var deployPending = false + private var deployState = DeployStatusState() private var state = StateSnapshot() private let passphraseServer = PassphraseServer() /// System-wide ⌘⌥T → re-tile claude terminals. Kept alive for the app's @@ -246,6 +252,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self.refreshAsana() } + // Deploy status — changes on a merge cadence; poll ~every 30 + // ticks via the helper (GitHub check-runs round-trip). + self.deployTickCount += 1 + if self.deployTickCount >= 30 && !self.deployPending { + self.deployTickCount = 0 + self.refreshDeploy() + } + // No menu rebuild here — it's lazy via menuNeedsUpdate(_:). // Consume any "open this PDF / video" asks (tiny main-thread // stats; see PdfViewer.swift / VideoViewer.swift contracts). @@ -296,6 +310,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { imsgStatus: imsgStatus, imsgConfigured: imsgConfigured, asana: asanaState, + deploy: deployState, target: self ) } @@ -570,6 +585,81 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Pull deploy status off-main via `slab/bin/deploy-status status`. The + /// helper exits 0 with one JSON line: `{configured,label,target,envs:[…]}` + /// where each env carries its rolled-up state + per-worker apps. We decode + /// it into `deployState` for the lazy menu rebuild. + private func refreshDeploy() { + let helper = Paths.deployStatusHelper + guard FileManager.default.isExecutableFile(atPath: helper) else { + deployState = DeployStatusState(configured: false, label: "Deploy: helper missing") + return + } + deployPending = true + DispatchQueue.global(qos: .utility).async { [weak self] in + let out = ShellRunner.run(helper, args: ["status"], timeout: 15).output + let line = out.split(separator: "\n").last.map(String.init) ?? "" + let parsed = Self.parseDeploy(line) + DispatchQueue.main.async { + self?.deployState = parsed + self?.deployPending = false + } + } + } + + /// Decode one JSON status line into a DeployStatusState. Defensive: any + /// missing field collapses to an empty/unconfigured state, never throws. + private static func parseDeploy(_ line: String) -> DeployStatusState { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return DeployStatusState(configured: false, label: "Deploy: —") } + var s = DeployStatusState() + s.configured = (obj["configured"] as? Bool) ?? false + s.label = (obj["label"] as? String) ?? s.label + s.target = (obj["target"] as? String) ?? "" + s.url = (obj["url"] as? String) ?? "" + let envs = (obj["envs"] as? [[String: Any]]) ?? [] + s.envs = envs.map { e in + let apps = (e["apps"] as? [[String: Any]]) ?? [] + return DeployEnv( + env: (e["env"] as? String) ?? "?", + state: (e["state"] as? String) ?? "none", + sha: (e["sha"] as? String) ?? "", + message: (e["message"] as? String) ?? "", + apps: apps.map { a in + DeployApp( + name: (a["name"] as? String) ?? "?", + state: (a["state"] as? String) ?? "none", + url: (a["url"] as? String) ?? "") + }, + url: (e["url"] as? String) ?? "") + } + return s + } + + /// Force an immediate deploy re-poll from the submenu's "Refresh now". + @objc func refreshDeployNow() { + deployTickCount = 0 + if !deployPending { refreshDeploy() } + } + + /// Open the commit / build log carried on the item's representedObject + /// (falls back to the repo URL). + @objc func openDeploy(_ sender: NSMenuItem) { + let url = (sender.representedObject as? String) ?? deployState.url + guard !url.isEmpty, let u = URL(string: url) else { return } + NSWorkspace.shared.open(u) + } + + /// Ensure the (untracked) deploy-status config exists, then open it. + @objc func openDeployConfig() { + ShellRunner.runAsync(Paths.deployStatusHelper, args: ["config"]) + let path = Paths.deployStatusConfig + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + } + @objc func openImsg() { ShellRunner.runAsync(Paths.imsgHelper, args: ["open"]) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 07d0e22b77..83dfd75939 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -7,6 +7,7 @@ enum MenuBuilder { imsgStatus: String, imsgConfigured: Bool, asana: AsanaState, + deploy: DeployStatusState, target: AppDelegate ) -> NSMenu { let menu = NSMenu() @@ -17,6 +18,7 @@ enum MenuBuilder { imsgStatus: imsgStatus, imsgConfigured: imsgConfigured, asana: asana, + deploy: deploy, target: target ) return menu @@ -33,6 +35,7 @@ enum MenuBuilder { imsgStatus: String, imsgConfigured: Bool, asana: AsanaState, + deploy: DeployStatusState, target: AppDelegate ) { menu.removeAllItems() @@ -51,6 +54,7 @@ enum MenuBuilder { menu.addItem(buildMail(status: mailStatus, target: target)) menu.addItem(buildImsg(status: imsgStatus, configured: imsgConfigured, target: target)) menu.addItem(buildAsana(state: asana, target: target)) + menu.addItem(buildDeploy(state: deploy, target: target)) appendOvertime(to: menu, target: target) menu.addItem(buildPdf(target: target)) menu.addItem(buildVideo(target: target)) @@ -719,6 +723,102 @@ enum MenuBuilder { return parent } + /// Deploy submenu — a per-environment rollup of Cloudflare Workers Builds. + /// The parent title is the helper's glyph summary (e.g. "fuser ✓ staging + /// ✗ production"); each environment expands to its branch tip + per-worker + /// build states. Repo identity stays in the untracked config; an + /// unconfigured machine shows a one-line set-up hint. + private static func buildDeploy(state: DeployStatusState, target: AppDelegate) -> NSMenuItem { + let parent = NSMenuItem(title: state.label, action: nil, keyEquivalent: "") + let sub = NSMenu() + sub.autoenablesItems = false + + if !state.configured { + sub.addItem(info("Not set up — add a repo + GitHub token:")) + sub.addItem(item("Edit deploy-status config", + selector: #selector(AppDelegate.openDeployConfig), target: target)) + parent.submenu = sub + return parent + } + + if state.envs.isEmpty { + sub.addItem(info("No environments configured")) + } else { + for (i, env) in state.envs.enumerated() { + if i > 0 { sub.addItem(.separator()) } + sub.addItem(deployEnvItem(env, target: target)) + for app in env.apps { + sub.addItem(deployAppItem(app, target: target)) + } + } + } + + sub.addItem(.separator()) + sub.addItem(item("Refresh now", selector: #selector(AppDelegate.refreshDeployNow), target: target)) + sub.addItem(item("Edit deploy-status config", + selector: #selector(AppDelegate.openDeployConfig), target: target)) + parent.submenu = sub + return parent + } + + /// Environment header: " — ", colored by state, + /// opening the branch-tip commit on click. + private static func deployEnvItem(_ env: DeployEnv, target: AppDelegate) -> NSMenuItem { + let sha = env.sha.isEmpty ? "" : " \(env.sha)" + let msg = env.message.isEmpty ? "" : " — \(env.message)" + let title = "\(deployGlyph(env.state)) \(env.env)\(sha)\(msg)" + let mi = NSMenuItem(title: title, + action: #selector(AppDelegate.openDeploy(_:)), + keyEquivalent: "") + mi.target = target + mi.isEnabled = !env.url.isEmpty + mi.representedObject = env.url + mi.attributedTitle = deployColored(title, state: env.state) + return mi + } + + /// One worker row, indented and colored by state; opens its build log. + private static func deployAppItem(_ app: DeployApp, target: AppDelegate) -> NSMenuItem { + let title = " \(deployGlyph(app.state)) \(app.name)" + let mi = NSMenuItem(title: title, + action: #selector(AppDelegate.openDeploy(_:)), + keyEquivalent: "") + mi.target = target + mi.isEnabled = !app.url.isEmpty + mi.representedObject = app.url + mi.attributedTitle = deployColored(title, state: app.state) + return mi + } + + private static func deployGlyph(_ state: String) -> String { + switch state { + case "deployed": return "✓" + case "building": return "⏳" + case "failing": return "✗" + case "error": return "⚠" + default: return "·" + } + } + + /// Tint a whole row green/amber/red/orange by deploy state; unknown stays + /// default so failing workers jump out of the list. + private static func deployColored(_ title: String, state: String) -> NSAttributedString { + let attr = NSMutableAttributedString(string: title) + let color: NSColor? + switch state { + case "deployed": color = .systemGreen + case "building": color = .systemYellow + case "failing": color = .systemRed + case "error": color = .systemOrange + default: color = nil + } + if let c = color { + attr.addAttribute(.foregroundColor, value: c, + range: NSRange(location: 0, length: attr.length)) + } + return attr + } + /// OVERTIME toggle — only on machines armed as overtime workers (the /// untracked config exists). Mirrors the badge's right-click toggle: /// same flag file, same worker kickstart. While on, the worker's current diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift index a0f4a55082..b36991ed28 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -39,6 +39,14 @@ enum Paths { static var asanaHelper: String { "\(slabBin)/asana" } static var asanaConfig: String { "\(home)/.config/slab/asana.json" } + /// Deploy-status bridge for the Deploy submenu. The watched repos and an + /// optional GitHub PAT live in the untracked config below (never in tracked + /// code) — same convention as asanaConfig. The helper reads Cloudflare + /// "Workers Builds" GitHub check-runs and prints a per-environment JSON + /// deploy summary (deployed / building / failing). + static var deployStatusHelper: String { "\(slabBin)/deploy-status" } + static var deployStatusConfig: String { "\(home)/.config/slab/deploy-status.json" } + /// OVERTIME — autonomous task execution on machines armed as overtime /// workers. The flag file is shared with the desktop badge (right-click /// the avatar) and the worker LaunchAgent; the menubar is a third toggle diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index 147b4455a5..4ef4d61cde 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -94,6 +94,36 @@ struct AsanaState { var projects: [AsanaProject] = [] } +/// One deployed worker within an environment (e.g. `api` in staging). +struct DeployApp { + var name: String + var state: String // "deployed" | "building" | "failing" + var url: String = "" +} + +/// One watched environment (e.g. staging→staging, production→main), rolled up +/// from its Workers Builds check-runs on the branch tip. +struct DeployEnv { + var env: String + var state: String = "none" // deployed | building | failing | none | error + var sha: String = "" + var message: String = "" + var apps: [DeployApp] = [] + var url: String = "" +} + +/// Deploy status for the menubar, polled off-main via `slab/bin/deploy-status` +/// (mirrors the Asana bridge). Watched repos + token come from the untracked +/// config — nothing repo-specific lives in tracked code. Unconfigured machines +/// show a one-line "set up" hint instead of the environment tree. +struct DeployStatusState { + var configured: Bool = false + var label: String = "Deploy: —" // menu parent title from the helper + var target: String = "" + var envs: [DeployEnv] = [] + var url: String = "" +} + struct StateSnapshot { var lidClosed: Bool = false var sleepDisabled: Bool = false -- 2.51.2