import { useEffect, useRef, useState } from "react"; import { type AgentInfo, type PeerInfo, type WorkspaceInfo, CLIENT_WIRE_ROLE, isClientRole } from "../shared/signaling"; import { TOKEN_REQUIREMENTS, validateToken } from "../shared/token"; import { SignalingClient } from "../shared/signaling-client"; import type { RtcSignal } from "../shared/rtc"; import { RtcClient } from "./rtc-client"; import { getSignalUrl } from "./config"; import { registerTunnelHost } from "./tunnel-host"; import { connBroker } from "./conn-broker"; import { DEFAULT_BRANCH, GITHUB_HOST, type DeepLink, type RepoTarget, type ResolvePrefs, type RoomMatch, gitUrlToPath, parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, resolveHostTarget, resolveRepoTarget, shareableDeepLink, } from "../shared/repo"; import { getRooms, historyFor, recordConnection, setRooms } from "./history"; import { deriveTags, matchQuery, roomLabels, shortRoomLabel, tagKey } from "../shared/tags"; const TOKEN_KEY = "codehost.token"; type ConnState = "idle" | "connecting" | "pending" | "provisioning" | "connected" | "failed" | "denied"; /** What to do once `connectTo`'s RTC handshake finishes: run provisioning then * open the iframe (repo deep link), or fetch this host's provisioning files * and render the settings view (bare /host/ deep link). Undefined * just opens the iframe on the resolved/current folder. */ type PostConnect = { kind: "repo"; target: RepoTarget } | { kind: "hostSettings"; host: string } | undefined; /** `connectTo`'s post-connect action for a parsed deep link. */ function postConnectFor(dl: DeepLink): PostConnect { if (dl?.type === "repo") return { kind: "repo", target: dl.target }; if (dl?.type === "hostSettings") return { kind: "hostSettings", host: dl.host }; return undefined; } type SetupScriptName = "setup.sh" | "setup.bat" | "setup.ps1"; /** A host's fetched `.codehost/config.yaml` + setup script, and the in-progress * edit/save state for the settings view (`GET`/`PUT /__codehost/provision-config`). */ type HostSettingsState = | { status: "loading" } | { status: "error"; message: string } | { status: "ready"; configYaml: string; configYamlExists: boolean; configYamlDefault: string; setupScript: string; setupScriptName: SetupScriptName; setupScriptExists: boolean; setupScriptDefault: string; configYamlDraft: string; setupScriptDraft: string; saving: boolean; saveError: string | null; savedAt: number | null; }; /** A server discovered in a specific room (its token routes the signaling). */ type RoomedServer = { server: PeerInfo; room: string }; /** * A short "Browser · OS" label this page advertises in the room roster, so the * host and other clients can tell devices apart (and spot a stranger that * shouldn't have the token). Best-effort UA sniff; falls back to "browser". */ function clientLabel(): string { const ua = navigator.userAgent; const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Firefox\//.test(ua) ? "Firefox" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser"; const os = /Mac OS X/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Android/.test(ua) ? "Android" : /(iPhone|iPad|iPod)/.test(ua) ? "iOS" : /Linux/.test(ua) ? "Linux" : ""; return os ? `${browser} · ${os}` : browser; } /** Coarse "Ns/Nm/Nh" from a worker-clock join time and the room's clock. */ function relTime(since?: number, now?: number): string | null { if (!since || !now) return null; const secs = Math.max(0, Math.round((now - since) / 1000)); if (secs < 60) return `${secs}s`; const mins = Math.round(secs / 60); if (mins < 60) return `${mins}m`; return `${Math.round(mins / 60)}h`; } /** * Read a room token handed in the URL fragment as `#t=` (what the CLI * prints/opens after `setup`/`serve`). The page is static, so the fragment * never reaches the server — a safe place for the shared secret. Everything * after `#t=` is the token (URL-encoded by the CLI); returns "" if absent. */ function tokenFromHash(): string { const m = window.location.hash.match(/^#t=(.+)$/); if (!m) return ""; try { return decodeURIComponent(m[1]).trim(); } catch { return m[1].trim(); } } /** Short label for the "looking for…" state from a deep link. */ function deepLinkLabel(dl: DeepLink): string | null { if (!dl) return null; if (dl.type === "repo") return `${dl.target.owner}/${dl.target.name}`; if (dl.type === "hostSettings") return `${dl.host} settings`; return dl.target.host ? `${dl.target.host}:${dl.target.path}` : dl.target.path; } /** How long any WebRTC dial (foreground or background probe) may stay * unsettled before it's treated as failed. */ const DIAL_TIMEOUT_MS = 15000; function folderQuery(folder?: string): string { return folder ? `?folder=${encodeURIComponent(folder)}` : ""; } /** Human label for a connected workspace: its GitHub-style URL when the share * path is repo-shaped (/github.com/owner/repo, or a machine-scoped * /sno@Mac/github.com/owner/repo), else the deep-link path as-is. */ function shareLabel(path: string | null): string | null { if (!path) return null; const repo = repoPathOf(path); if (repo) return repo; return path; } /** The `//(/tree/)` tail of a share path, with any * `@` scope prefix dropped — null for folder mounts. Also reads * the legacy /gh/ and /git/ forms so old links still label/link out. */ function repoPathOf(path: string): string | null { const rest = path.replace(/^\/[^/]*@[^/]+/, ""); const gh = rest.match(/^\/gh\/(.+)$/); if (gh) return `${GITHUB_HOST}/${gh[1]}`; const git = rest.match(/^\/git\/(.+)$/); if (git) return git[1]; const dl = parseDeepLink(rest); if (dl?.type !== "repo") return null; return rest.replace(/^\//, ""); } /** External URL for a repo-shaped share path, so the connected-view label can * link out to the real host: /github.com/owner/repo/tree/x -> * https://github.com/owner/repo/tree/x. Null for non-repo paths (folder * mounts), which have no public URL. */ function shareHref(path: string | null): string | null { if (!path) return null; const repo = repoPathOf(path); return repo ? `https://${repo}` : null; } /** * Find which of the user's saved rooms hosts a server matching a token-less deep * link. Opens a short-lived viewer connection to each candidate room in * parallel. An *exact* match (a server that truly serves this workspace) wins * immediately; *root-fallback* matches (any room with a root daemon, which * `resolveRepoTarget` returns for ANY repo link) are only chosen at the timeout, * via `pickRoomMatch`, so an unrelated room with a root server can't steal the * link. Resolves to the winning room's token (or null on no match). All temp * clients are closed. */ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000): Promise { if (!dl || tokens.length === 0) return Promise.resolve(null); return new Promise((resolve) => { const clients: SignalingClient[] = []; const fallbacks: RoomMatch[] = []; let done = false; const finish = (tok: string | null) => { if (done) return; done = true; clearTimeout(timer); clients.forEach((c) => c.close()); resolve(tok); }; const timer = setTimeout(() => finish(pickRoomMatch(fallbacks)?.token ?? null), timeoutMs); for (const tok of tokens) { const client = new SignalingClient({ url: getSignalUrl(), token: tok, role: CLIENT_WIRE_ROLE, onPeers: (peers) => { const servers = peers.filter((p) => p.role === "server"); const res = dl.type === "repo" ? resolveRepoTarget(servers, dl.target) : dl.type === "hostSettings" ? resolveHostTarget(servers, dl.host) : resolveDevTarget(servers, dl.target); if (!res) return; if (!res.folder || res.exact) finish(tok); // exact match — take it now else if (!fallbacks.some((f) => f.token === tok)) fallbacks.push({ token: tok, resolution: res }); }, }); clients.push(client); client.connect(); } }); } /** * Headless per-room signaling client — one instance per joined room. React's * keyed reconciliation (`key={token}`) adds/removes these as the joined set * changes, so joining or leaving a room never tears down the other rooms' live * discovery (or the active WebRTC session). Renders nothing: it pushes its * room's servers/open-state up to the parent and registers a signal sender so * the parent can dial peers found in this room. */ function RoomClient(props: { token: string; label: string; onPeers: (peers: PeerInfo[]) => void; onRoster: (clients: PeerInfo[], now?: number) => void; onStatus: (open: boolean) => void; onSignal: (from: string, data: unknown) => void; registerSender: (send: ((to: string, data: unknown) => void) | null) => void; }) { // Keep the latest callbacks in a ref so the socket effect runs once per token, // not on every parent re-render (which would needlessly churn the WebSocket). const cb = useRef(props); cb.current = props; const { token, label } = props; useEffect(() => { const client = new SignalingClient({ url: getSignalUrl(), token, // Connecting role. CLIENT_WIRE_ROLE is still the legacy "viewer" during the // accept-both transition; servers match it via isClientRole either way. role: CLIENT_WIRE_ROLE, // Advertise a label so this tab shows up named in the room roster. meta: { name: label }, onOpen: () => cb.current.onStatus(true), onClose: () => cb.current.onStatus(false), onPeers: (peers, now) => { cb.current.onPeers(peers.filter((p) => p.role === "server")); // Other clients in the room (not us) — surfaced as the roster. cb.current.onRoster(peers.filter((p) => isClientRole(p.role) && p.peerId !== client.peerId), now); }, onSignal: (from, data) => cb.current.onSignal(from, data), }); cb.current.registerSender((to, data) => client.sendSignal(to, data)); client.connect(); return () => { client.close(); cb.current.registerSender(null); }; }, [token]); return null; } /** Track a `(max-width: …)` media query so inline-styled components can go * responsive without a stylesheet. SSR-safe and listener-cleaned. */ function useNarrow(maxWidth = 560): boolean { const [narrow, setNarrow] = useState( () => typeof window !== "undefined" && window.matchMedia(`(max-width:${maxWidth}px)`).matches, ); useEffect(() => { const mq = window.matchMedia(`(max-width:${maxWidth}px)`); const on = () => setNarrow(mq.matches); on(); mq.addEventListener("change", on); return () => mq.removeEventListener("change", on); }, [maxWidth]); return narrow; } /** A copy-to-clipboard command row: label, the command, and a Copy button. On * narrow screens the three stack vertically so the long command doesn't get * crushed between a fixed label and the button. */ function CopyCommand({ label, command }: { label: string; command: string }) { const [copied, setCopied] = useState(false); const narrow = useNarrow(); const copy = async () => { try { await navigator.clipboard.writeText(command); } catch { // clipboard blocked (insecure context / permission) — fall back to prompt window.prompt("Copy this command:", command); } setCopied(true); setTimeout(() => setCopied(false), 1500); }; return (
{label} {command}
); } /** * "Set up a machine" card: the one-liner that turns any machine into a codehost * server. The script bootstraps everything (Bun, the CLI, VS Code, the daemon), * so the user needs no prerequisites — not even Bun. setup.sh/.ps1 are aliases * of install.* served by Pages (see public/_redirects). */ function SetupCard() { return (
Set up a machine

Run this on a machine to serve it here. It installs everything — Bun, VS Code, and the codehost daemon — no prerequisites, and it picks a token and opens the browser for you.

); } export function Discovery() { // Joined rooms — each token *is* a room id, and we keep one live signaling // client per room (see RoomClient). Seeded from the persisted room list plus // any legacy single-token / URL-fragment token, then format-validated. const [tokens, setTokens] = useState(() => { const seed = new Set(getRooms()); const legacy = localStorage.getItem(TOKEN_KEY); if (legacy) seed.add(legacy); const fromHash = tokenFromHash(); if (fromHash) seed.add(fromHash); return [...seed].filter((t) => validateToken(t).ok); }); // Per-room discovery state, merged into one workspace list below. const [serversByRoom, setServersByRoom] = useState>({}); const [roomOpen, setRoomOpen] = useState>({}); // Other clients (browsers) per room, for the "In this room" roster, plus the // worker clock from the latest peers message for relative join times. const [clientsByRoom, setClientsByRoom] = useState>({}); const [roomNow, setRoomNow] = useState(undefined); // This tab's roster label, computed once. const labelRef = useRef(clientLabel()); // Token input = "join another room": validated, then appended to the set. // Never pre-filled with a saved token — it's a bearer secret. const [draft, setDraft] = useState(""); const [editingToken, setEditingToken] = useState(false); const [tokenError, setTokenError] = useState(null); // "Open a GitHub repo" box: paste a github.com URL -> navigate to its // /// deep link, which resolves/opens (and, once provisioning lands, materializes) // the workspace. const [ghUrl, setGhUrl] = useState(""); const [ghError, setGhError] = useState(null); // Fake-tag filter over the merged workspace list: a free-text box plus a set // of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher. const [filter, setFilter] = useState(""); const [activeTags, setActiveTags] = useState([]); // One WebRTC session at a time (you view a single VS Code), discovered across // many rooms. `activeRoomRef` is the room the active peer was found in: its // client carries the peer's signaling and it's the token Share/history record. const [activePeerId, setActivePeerId] = useState(null); const [connState, setConnState] = useState("idle"); // ICE path of the live session ("lan" | "p2p"); null when unknown or when // this tab rides another tab's connection via the broker. const [connPath, setConnPath] = useState<"lan" | "p2p" | null>(null); const [iframeSrc, setIframeSrc] = useState(null); // Streamed setup.sh output shown while connState === "provisioning", and // whether the script exited non-zero — a failed provision keeps this view // (log + diagnosis) on screen instead of opening the editor on a workspace // that was never materialized. const [provisionLog, setProvisionLog] = useState(""); const [provisionFailed, setProvisionFailed] = useState(false); // Per-host-card free-text filter over its workspace chip list, keyed by // peerId — only rendered once a card's list is long enough to need one. const [wsFilter, setWsFilter] = useState>({}); // Host-settings view: set once connectTo resolves a `hostSettings` deep // link, instead of an iframe. `hostSettings` holds the fetched/edited // .codehost/config.yaml + setup script for `settingsHost`. const [settingsHost, setSettingsHost] = useState(null); const [hostSettings, setHostSettings] = useState(null); // #provisioning is the only tab today; read once and kept in the URL (not // stripped like #t=) so the convention supports more tabs later. const [settingsTab, setSettingsTab] = useState(() => window.location.hash.slice(1) || "provisioning"); const rtcRef = useRef(null); const activePeerRef = useRef(null); const activeRoomRef = useRef(null); const sendersRef = useRef void>>(new Map()); // Background "preview" connections: one per root-kind host, opened just to // fetch its live workspace/agent list over the tunnel (see previewFor) — // shared across tabs via connBroker's SharedWorker, same as the main // session. Kept separate from rtcRef/activePeerRef so browsing the list // never disturbs the one connection the user actually opened. const previewRtcRef = useRef>(new Map()); const previewAttemptedRef = useRef>(new Set()); const [previewMeta, setPreviewMeta] = useState>({}); // Admission control: the host can hold ("pending") or reject ("denied") us. // `deniedRef` stops a trailing pc state change from overwriting the denied UI; // the timer/reject refs let a control signal extend or abort the dial attempt. const deniedRef = useRef(false); const dialTimerRef = useRef | null>(null); const dialRejectRef = useRef<((e: Error) => void) | null>(null); // Whether the live connection pushed a history entry (so Disconnect/Back can // pop it back to the list). const pushedRef = useRef(false); // A dial is in flight — a synchronous guard so the several reconnect triggers // (popstate, server-list change, retry timer, deep-link auto-connect) never // double-dial (connState updates a render too late to gate them). const dialingRef = useRef(false); // Set just before a failed-dial history.back() so the resulting popstate is // treated as a URL revert, not a user navigation — the reconciler skips it once. const revertingRef = useRef(false); // Latest merged server list + connection state, read by the URL reconciler // (invoked from the once-at-mount popstate handler) without a stale closure. const allServersRef = useRef([]); const connStateRef = useRef("idle"); // Deep-link resolution (////..., /@/...): // parse once, // auto-connect when a matching server appears, remember the opened folder. const deepLinkRef = useRef(parseDeepLink(window.location.pathname, window.location.search)); const resolvedRef = useRef(false); // A valid token in the URL fragment enables single-server auto-connect, scoped // to *that* room so unrelated joined rooms don't block it. const autoConnectRef = useRef(false); const hashRoomRef = useRef(null); const activeFolderRef = useRef(undefined); const [resolving, setResolving] = useState(() => deepLinkLabel(deepLinkRef.current)); // Shareable deep-link pathname for the live connection (drives the address bar // and the Share button); transient "copied" flag for the button. const sharePathRef = useRef(null); const [copied, setCopied] = useState(false); function adoptRoom(t: string) { setTokens((prev) => (prev.includes(t) ? prev : [...prev, t])); } // Persist the joined set so rooms survive reloads. useEffect(() => { setRooms(tokens); }, [tokens]); // Register the Service Worker + connection broker once. The broker shares one // WebRTC connection per server across tabs; on owner failover it asks us to // reload the iframe so it reconnects through the new owner. useEffect(() => { void registerTunnelHost(); connBroker.onLost((peerId) => { if (peerId !== activePeerRef.current) return; setIframeSrc(null); const folder = activeFolderRef.current; setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400); }); // Back/Forward (Cmd+Left / Cmd+Right) reconcile the connection to the URL: a // workspace deep link (re)connects to its server, the list URL drops the // connection. The browser already changed the URL; we follow it. A failed // dial's revert-back is skipped once (revertingRef) so it keeps the list. const onPopState = () => { if (revertingRef.current) { revertingRef.current = false; return; } syncToUrl(); }; window.addEventListener("popstate", onPopState); // Only #provisioning exists today, but keep settingsTab in sync with the // hash so a second tab later just needs a new render case, no new plumbing. const onHashChange = () => setSettingsTab(window.location.hash.slice(1) || "provisioning"); window.addEventListener("hashchange", onHashChange); // A valid token in the URL fragment (#t=) joins the room and turns on // single-server auto-connect for it; consume it from the address bar after, // so the secret isn't left visible or re-applied on a manual reload. const urlToken = tokenFromHash(); if (urlToken && validateToken(urlToken).ok) { autoConnectRef.current = true; hashRoomRef.current = urlToken; if (window.location.hash) { history.replaceState(null, "", window.location.pathname + window.location.search); } } // Resolve a token-less deep link to a room: first the room that last served // this repo, otherwise search all saved rooms for a live server that hosts // this workspace and adopt it. Skipped when the link already carries a token. const dl = deepLinkRef.current; if (dl && !(urlToken && validateToken(urlToken).ok)) { const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined; if (histToken) { adoptRoom(histToken); } else { const rooms = getRooms(); if (rooms.length) { void findRoomForDeepLink(dl, rooms).then((tok) => { if (tok) adoptRoom(tok); }); } } } return () => { window.removeEventListener("popstate", onPopState); window.removeEventListener("hashchange", onHashChange); }; }, []); // Auto-connect once discovery turns up a match: a deep-link target across any // room, or the lone server of a room joined via #t=. useEffect(() => { if (resolvedRef.current) return; tryAutoConnect(); // previewMeta: a repo deep link may only resolve to the exact matching // host once that host's background preview connection lands (see // withPreviewMeta) — retry when it changes, not just on roster churn. // eslint-disable-next-line react-hooks/exhaustive-deps }, [serversByRoom, tokens, previewMeta]); // Mirror the open workspace into the tab title (GitHub-style URL), so tabs // read as "github.com/owner/repo/tree/main — codehost", not all "Codehost". useEffect(() => { const label = connState === "connected" ? shareLabel(sharePathRef.current) : null; document.title = label ? `${label} — codehost` : "Codehost"; }, [connState]); // Keep the connection in sync with the URL as servers come and go: reconnect // when the workspace named by the address bar (re)appears in a room — covers a // daemon restart or a dropped channel while the tab stays open. useEffect(() => { syncToUrl(); }, [serversByRoom]); // Safety-net retry: while the URL names a workspace we're not connected to and // no dial is in flight, retry every few seconds — covers a dropped channel // whose server never left the room (so no list change fires the effect above). useEffect(() => { if (!parseDeepLink(window.location.pathname)) return; if (connState === "connected" || connState === "connecting") return; const id = setInterval(() => syncToUrl(), 5000); return () => clearInterval(id); }, [connState, serversByRoom]); function joinFromInput(e: React.FormEvent) { e.preventDefault(); const t = draft.trim(); const check = validateToken(t); if (!check.ok) { setTokenError(check.reason ?? "invalid token"); return; } setTokenError(null); adoptRoom(t); setDraft(""); setEditingToken(false); } function openGithubUrl(e: React.FormEvent) { e.preventDefault(); const path = gitUrlToPath(ghUrl); if (!path) { setGhError("not a recognizable git repo URL"); return; } setGhError(null); setGhUrl(""); // Navigate to the deep link and reconcile: connect if a daemon already // serves it (provisioning, later, will materialize it when it doesn't). history.pushState(null, "", path); setResolving(deepLinkLabel(parseDeepLink(path))); syncToUrl(); } function leaveRoom(t: string) { if (activeRoomRef.current === t) disconnect(); setTokens((prev) => prev.filter((x) => x !== t)); setServersByRoom((m) => { const n = { ...m }; delete n[t]; return n; }); setRoomOpen((m) => { const n = { ...m }; delete n[t]; return n; }); sendersRef.current.delete(t); } // Opens (or reuses, via connBroker's SharedWorker) a background connection // to a root-kind host just to fetch its live workspace/agent list — // the room roster only carries identity fields now (see worker/room.ts), // so the chip list needs an actual peek instead of reading it off `meta`. // At most one attempt per peerId per page load; errors are silent (the // list just stays empty, same as an old daemon with no `meta` route). function previewFor(peerId: string, room: string) { if (previewAttemptedRef.current.has(peerId)) return; previewAttemptedRef.current.add(peerId); const establish = () => new Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>((resolve, reject) => { const send = sendersRef.current.get(room); if (!send) { reject(new Error("room not ready")); return; } // Bound the probe like a real dial does. An establish() that never // settles is worse than one that fails: the broker keeps the peer in // `establishing` forever, and every later connect() for it then waits // on a `ready` that can't come — no timeout, no failure, a Connect // button stuck on "negotiating WebRTC…". A background probe must never // be able to wedge the foreground. let timer: ReturnType | null = null; const settle = (fn: () => void) => { if (timer) clearTimeout(timer); timer = null; fn(); }; const rtc = new RtcClient({ sendSignal: (data: RtcSignal) => send(peerId, data), onState: () => {}, onOpen: (channel) => settle(() => resolve({ channel, bulk: rtc.bulkChannel })), onClose: () => {}, }); previewRtcRef.current.set(peerId, rtc); timer = setTimeout(() => { rtc.close(); reject(new Error("preview connection timed out")); }, DIAL_TIMEOUT_MS); rtc.start().catch((err) => settle(() => reject(err))); }); connBroker .connect(peerId, establish) .then(() => connBroker.tunnelFor(peerId).fetch("GET", "/__codehost/meta", {})) .then((res) => (res.ok ? res.json() : null)) .then((data: { workspaces?: WorkspaceInfo[]; agents?: AgentInfo[] } | null) => { if (data) setPreviewMeta((m) => ({ ...m, [peerId]: { workspaces: data.workspaces, agents: data.agents } })); }) .catch(() => { // Denied, offline, or an older daemon with no /__codehost/meta route — // leave previewMeta unset; the UI falls back to whatever (if anything) // the room roster still advertised. }) .finally(() => previewRtcRef.current.delete(peerId)); } async function connectTo( server: PeerInfo, room: string, folder?: string, fromHistory = false, postConnect?: PostConnect, ) { const send = sendersRef.current.get(room); if (!send) return; dialingRef.current = true; // synchronous gate against concurrent triggers deniedRef.current = false; let didPush = false; try { // Clear any prior connection's broker state first: after an RTC drop the // broker still holds the dead channel in `locals`, so re-dialing the same // peer would otherwise resolve straight to it. Also covers switching peers. if (activePeerRef.current) connBroker.disconnect(activePeerRef.current); rtcRef.current?.close(); rtcRef.current = null; setIframeSrc(null); setSettingsHost(null); setHostSettings(null); setActivePeerId(server.peerId); activePeerRef.current = server.peerId; activeRoomRef.current = room; setConnState("connecting"); // Update the address bar the instant Connect is clicked (don't wait for the // handshake) and push a history entry, so Back returns to the list and // Forward returns here. When `fromHistory`, the browser already set the URL // (back/forward/reconnect) — don't push again, but a prior entry exists. let openFolder = folder ?? server.meta?.cwd; if (fromHistory) { pushedRef.current = true; sharePathRef.current = window.location.pathname; } else { const targetPath = shareablePathFor(server, openFolder); sharePathRef.current = targetPath ?? window.location.pathname; if (targetPath && targetPath !== window.location.pathname) { if (deepLinkRef.current) { // Arrived via a deep link — canonicalize the URL in place (e.g. add // /tree/). Same destination, so replace, don't push a // back-to-the-list entry. history.replaceState(null, "", targetPath); } else { history.pushState(null, "", targetPath); didPush = true; } } pushedRef.current = didPush; } // The broker decides whether this tab owns the connection. `establish` is // only invoked when we're the owner (or get promoted on failover); other // tabs reuse the owner's channel via a proxy, so they never open WebRTC. const establish = () => new Promise<{ channel: RTCDataChannel; bulk: RTCDataChannel | null }>((resolve, reject) => { const rtc = new RtcClient({ sendSignal: (data: RtcSignal) => send(server.peerId, data), onState: (state) => { if ((state === "failed" || state === "disconnected") && !deniedRef.current) setConnState("failed"); }, onOpen: (channel) => { if (dialTimerRef.current) clearTimeout(dialTimerRef.current); resolve({ channel, bulk: rtc.bulkChannel }); }, onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)), }); rtcRef.current = rtc; dialRejectRef.current = reject; // Don't hang forever dialing a peer that never answers (e.g. a stale // server still listed in the room): fail the attempt after // DIAL_TIMEOUT_MS. A "pending" admission signal swaps this for a // longer approval window. dialTimerRef.current = setTimeout(() => { rtc.close(); reject(new Error("connection timed out")); }, DIAL_TIMEOUT_MS); rtc.start().catch((err) => { if (dialTimerRef.current) clearTimeout(dialTimerRef.current); reject(err); }); }); await connBroker.connect(server.peerId, establish); // Show which ICE path got nominated (owner tab only — a proxied tab has // no RTCPeerConnection of its own). ICE may re-nominate just after the // channel opens, so sample again shortly. setConnPath(null); // (assertion: TS narrows the ref to null from the reset above and can't // see that `establish` re-assigned it) const rtcForPath = rtcRef.current as RtcClient | null; if (rtcForPath) { const sample = () => void rtcForPath.selectedPath().then((p) => { if (rtcRef.current === rtcForPath && p) setConnPath(p); }); sample(); setTimeout(sample, 3000); } // Switch to the connected iframe view on `folder`. Used both for the // normal post-provision open and for a setup.sh that signals its // workspace is ready early (via the ::codehost:ready sentinel) while // still running in the background (e.g. installing deps afterward). const openConnected = (folder?: string) => { activeFolderRef.current = folder; setIframeSrc(`/vs/${server.peerId}/${folderQuery(folder)}`); setConnState("connected"); setResolving(null); recordConnect(server, room, folder); }; // For a repo deep link, ask the daemon to provision (run .codehost/setup.sh // and hand back the authoritative workspace path) before opening. Streams // the log under the "provisioning" state. Daemons without the route (older // builds) return no path → fall back to the browser-computed folder. The // script may also open the editor early via `openEarly`, before it exits. if (postConnect?.kind === "repo") { setConnState("provisioning"); setProvisionLog(""); setProvisionFailed(false); let openedEarly = false; const { ws, failed } = await runProvision(server.peerId, postConnect.target, (path) => { if (openedEarly || activePeerRef.current !== server.peerId) return; openedEarly = true; openConnected(path); }); if (activePeerRef.current !== server.peerId) return; // cancelled/switched mid-provision if (openedEarly) return; // already opened via the ready sentinel if (failed) { // Keep the log (with the daemon's diagnosis) on screen — opening the // editor on a workspace that never materialized helps no one. setProvisionFailed(true); setResolving(null); return; } if (ws) openFolder = ws; } // A host-settings deep link: no iframe, just fetch the provisioning // files over the tunnel and render the settings view below. if (postConnect?.kind === "hostSettings") { setConnState("connected"); setResolving(null); setSettingsHost(postConnect.host); void loadHostSettings(server.peerId); return; } // A folder-mount open (an explicit `folder`, not a bare root Connect, // and not a repo/settings link) gets the same pre-open hook a repo open // does, IF this host opted in (config.yaml's folderProvisioning) — the // meta flag lets us skip the round-trip entirely when it isn't. if (!postConnect && folder !== undefined && server.meta?.folderProvisioning) { setConnState("provisioning"); setProvisionLog(""); setProvisionFailed(false); let openedEarly = false; const { ws, failed } = await runFolderProvision(server.peerId, folder, (path) => { if (openedEarly || activePeerRef.current !== server.peerId) return; openedEarly = true; openConnected(path); }); if (activePeerRef.current !== server.peerId) return; if (openedEarly) return; if (failed) { setProvisionFailed(true); setResolving(null); return; } if (ws) openFolder = ws; } // The daemon no longer sets a default folder (current VS Code serve-web // dropped that flag), so open the served workspace from here: the // provisioned/deep-link folder if we have one, else the server's reported cwd. openConnected(openFolder); } catch { setConnState(deniedRef.current ? "denied" : "failed"); // Undo the optimistic history entry we pushed. revertingRef makes the // resulting popstate a no-op so the "failed" card stays on the list. if (didPush) { revertingRef.current = true; history.back(); } } finally { dialingRef.current = false; } } // Ask the daemon to provision a repo workspace over the tunnel: stream // setup.sh's output into `provisionLog` and return the daemon-authoritative // path (the `x-codehost-workspace` header). Returns null when the daemon has // no provision route (older build) or the call fails — caller falls back. // Stream a provisioning response's body into `provisionLog`, watching for // an early `::codehost:ready={"path":...}` sentinel a script can emit // before it exits. `onReady` fires at most once, as soon as it's seen — // the caller can switch to the connected view immediately while this // keeps draining the rest of the log in the background. // Resolves to the script's exit code (null when the stream carried none). async function streamProvisionBody( res: Response, initialLog: string, onReady?: (path: string) => void, ): Promise { let buf = initialLog; setProvisionLog(buf); if (!res.body) return null; let readyFired = false; const reader = res.body.getReader(); const dec = new TextDecoder(); try { for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); if (!readyFired) { const readyLine = buf.match(/^::codehost:ready=(.+)$/m); if (readyLine) { readyFired = true; try { const path = (JSON.parse(readyLine[1]) as { path?: string }).path; if (path) onReady?.(path); } catch { // malformed sentinel — ignore, fall back to the header path at exit } } } // Hide the internal exit + ready sentinels from the displayed log. setProvisionLog(buf.replace(/\n::codehost:exit=\d+\n?/, "\n").replace(/^::codehost:ready=.+$\n?/m, "")); } } catch { // stream interrupted (channel closed) — caller falls back to the header path } const exit = buf.match(/::codehost:exit=(\d+)/); return exit ? Number(exit[1]) : null; } async function runProvision( peerId: string, t: RepoTarget, onReady?: (path: string) => void, ): Promise<{ ws: string | null; failed: boolean }> { const params = new URLSearchParams({ owner: t.owner, repo: t.name, branch: t.branch ?? DEFAULT_BRANCH, host: t.host, }); let res: Response; try { res = await connBroker.tunnelFor(peerId).fetch("GET", `/__codehost/provision?${params}`, {}); } catch { return { ws: null, failed: false }; } const ws = res.headers.get("x-codehost-workspace"); if (!ws) { await res.body?.cancel().catch(() => {}); return { ws: null, failed: false }; } const exit = await streamProvisionBody(res, `[codehost] provisioning ${t.owner}/${t.name}@${t.branch ?? DEFAULT_BRANCH}…\n`, onReady); return { ws, failed: exit != null && exit !== 0 }; } // Folder-mount provisioning: opt-in (server.meta?.folderProvisioning), // gives a `/host//` open the same pre-open hook a repo // open gets. Older daemons (or hosts that haven't opted in) 403 — treated // the same as "no route", falling straight back to opening the folder. async function runFolderProvision( peerId: string, path: string, onReady?: (path: string) => void, ): Promise<{ ws: string | null; failed: boolean }> { const params = new URLSearchParams({ kind: "folder", path }); let res: Response; try { res = await connBroker.tunnelFor(peerId).fetch("GET", `/__codehost/provision?${params}`, {}); } catch { return { ws: null, failed: false }; } const ws = res.headers.get("x-codehost-workspace"); if (!ws) { await res.body?.cancel().catch(() => {}); return { ws: null, failed: false }; } const exit = await streamProvisionBody(res, `[codehost] provisioning ${path}…\n`, onReady); return { ws, failed: exit != null && exit !== 0 }; } // Fetch a host's `.codehost/config.yaml` + setup script over the tunnel for // the settings view. A missing file reads back as the daemon's default // scaffold template (see provision-server.ts's handleProvisionConfig). async function loadHostSettings(peerId: string) { setHostSettings({ status: "loading" }); try { const res = await connBroker.tunnelFor(peerId).fetch("GET", "/__codehost/provision-config", {}); if (!res.ok) throw new Error(`server returned ${res.status}`); const data = (await res.json()) as { configYaml: string; configYamlExists: boolean; configYamlDefault: string; setupScript: string; setupScriptName: SetupScriptName; setupScriptExists: boolean; setupScriptDefault: string; }; setHostSettings({ status: "ready", ...data, configYamlDraft: data.configYaml, setupScriptDraft: data.setupScript, saving: false, saveError: null, savedAt: null, }); } catch (err) { setHostSettings({ status: "error", message: String(err) }); } } async function saveHostSettings() { if (hostSettings?.status !== "ready" || !activePeerId) return; setHostSettings({ ...hostSettings, saving: true, saveError: null }); try { const body = new TextEncoder().encode( JSON.stringify({ configYaml: hostSettings.configYamlDraft, setupScript: hostSettings.setupScriptDraft }), ); const res = await connBroker .tunnelFor(activePeerId) .fetch("PUT", "/__codehost/provision-config", { "content-type": "application/json" }, body); if (!res.ok) throw new Error(`server returned ${res.status}`); setHostSettings((s) => s?.status === "ready" ? { ...s, saving: false, configYaml: s.configYamlDraft, configYamlExists: true, setupScript: s.setupScriptDraft, setupScriptExists: true, savedAt: Date.now(), } : s, ); } catch (err) { setHostSettings((s) => (s?.status === "ready" ? { ...s, saving: false, saveError: String(err) } : s)); } } // Shareable deep-link pathname for a server+folder, with no side effects (no // token — Share adds that). Keeps an existing deep-link path as-is; otherwise // derives /gh|/git|/dev from the server's repo identity or opened folder. function shareablePathFor(server: PeerInfo, folder?: string): string | null { const dl = deepLinkRef.current; // A repo workspace always shows /tree/ (GitHub-style, and it pins the // worktree in snomiao's /tree/ layout). Branch source, in order: the // deep link's branch, the server's reported branch, else the layout default — // matching the worktree fillLayout actually opened. if (dl?.type === "repo") { const branch = dl.target.branch ?? server.meta?.branch ?? DEFAULT_BRANCH; return shareableDeepLink({ repo: repoKey(dl.target), branch, machine: dl.target.machine, user: dl.target.user ?? server.meta?.user, }); } if (server.meta?.repo) { return shareableDeepLink({ repo: server.meta.repo, branch: server.meta.branch ?? DEFAULT_BRANCH }); } // Folder mount: keep the deep-link path as-is, else derive the host-scoped one. return dl ? window.location.pathname : shareableDeepLink({ folder, host: server.meta?.host, user: server.meta?.user }); } async function shareLink() { const room = activeRoomRef.current; if (!room) return; const path = sharePathRef.current ?? window.location.pathname; const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`; try { await navigator.clipboard.writeText(url); } catch { // clipboard blocked (insecure context / permission) — fall back to prompt window.prompt("Copy this share link:", url); } setCopied(true); setTimeout(() => setCopied(false), 1500); } // resolveRepoTarget/resolveDevTarget's "exact match against an already- // enumerated checkout" branch reads server.meta.workspaces — but the room // roster no longer carries that (see worker/room.ts); it only shows up once // this peer's background preview connection lands (previewFor, above). // Overlay it here, at every resolver call site, rather than teach the pure // resolver in shared/repo.ts about browser-side lazy state. Replace, don't // merge: a peer with a landed preview is fully described by it. function withPreviewMeta(peers: PeerInfo[]): PeerInfo[] { return peers.map((p) => { const preview = previewMeta[p.peerId]; if (!preview || !p.meta) return p; return { ...p, meta: { ...p.meta, workspaces: preview.workspaces ?? p.meta.workspaces, agents: preview.agents ?? p.meta.agents } }; }); } // The machine history says served this repo last — break resolution ties // toward it (stable hostId when recorded, hostname for older entries). function preferFor(dl: DeepLink): ResolvePrefs | undefined { if (dl?.type !== "repo") return undefined; const h = historyFor(repoKey(dl.target)); return h ? { hostId: h.hostId, host: h.host } : undefined; } // Deep-link auto-connect: when servers arrive, pick the best match (exact repo // daemon, else a root daemon's subfolder) across all rooms and open it once. function tryAutoConnect() { if (resolvedRef.current) return; const dl = deepLinkRef.current; if (dl) { const peers = withPreviewMeta(allServers.map((x) => x.server)); const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target, preferFor(dl)) : dl.type === "hostSettings" ? resolveHostTarget(peers, dl.host) : resolveDevTarget(peers, dl.target); if (!res) return; const match = allServers.find((x) => x.server.peerId === res.peerId); if (!match) return; resolvedRef.current = true; void connectTo(match.server, match.room, res.folder, false, postConnectFor(dl)); return; } // No deep link, but a token arrived via the URL: open that room's server // straight away when it has exactly one. Scoped to the hash room so servers // in other joined rooms can't push the count past one and block it. const hashRoom = hashRoomRef.current; if (autoConnectRef.current && hashRoom) { const inRoom = allServers.filter((x) => x.room === hashRoom); if (inRoom.length === 1) { resolvedRef.current = true; void connectTo(inRoom[0].server, inRoom[0].room); } } } function recordConnect(server: PeerInfo, room: string, folder?: string) { const base = { token: room, hostId: server.meta?.hostId, kind: server.meta?.kind, name: server.meta?.name, host: server.meta?.host, lastConnected: Date.now(), }; if (server.meta?.repo) recordConnection(server.meta.repo, { ...base, folder }); const dl = deepLinkRef.current; if (dl?.type === "repo") recordConnection(repoKey(dl.target), { ...base, folder }); } // Tear down the active connection and return to the workspace list. Does NOT // touch history — the caller (Disconnect → history.back, or a popstate from // Cmd+Left) owns the URL. function teardownConn() { rtcRef.current?.close(); rtcRef.current = null; if (activePeerRef.current) connBroker.disconnect(activePeerRef.current); setIframeSrc(null); setConnPath(null); setActivePeerId(null); activePeerRef.current = null; activeRoomRef.current = null; setConnState("idle"); sharePathRef.current = null; pushedRef.current = false; setSettingsHost(null); setHostSettings(null); } // Resolve a workspace deep-link path to a live server across all joined rooms. function findServerForDeepLink(dl: DeepLink): (RoomedServer & { folder?: string }) | null { if (!dl) return null; const peers = withPreviewMeta(allServersRef.current.map((x) => x.server)); const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target, preferFor(dl)) : dl.type === "hostSettings" ? resolveHostTarget(peers, dl.host) : resolveDevTarget(peers, dl.target); if (!res) return null; const match = allServersRef.current.find((x) => x.server.peerId === res.peerId); return match ? { ...match, folder: res.folder } : null; } // Reconcile the live connection to the current URL. Drives Back/Forward nav and // auto-reconnect: a workspace deep link connects to (or reconnects to) the // server it resolves to; the list URL ("/") drops the connection. Reads only // refs/window, so it's safe to call from the once-at-mount popstate handler. function syncToUrl() { const dl = parseDeepLink(window.location.pathname, window.location.search); if (!dl) { if (activePeerRef.current) teardownConn(); return; } if (dialingRef.current) return; // a dial is already in flight const target = findServerForDeepLink(dl); if (!target) return; // its server isn't present (yet) — wait for it to appear if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return; void connectTo(target.server, target.room, target.folder, true, postConnectFor(dl)); } // Open an enumerated checkout via its deep link, reusing the URL-driven // resolution (provisioning, history, machine preference) instead of dialing // the card's peer directly. function openWorkspace(server: PeerInfo, w: WorkspaceInfo) { const path = w.repo ? shareableDeepLink({ repo: w.repo, branch: w.branch }) : shareableDeepLink({ folder: w.path, host: server.meta?.host, user: server.meta?.user }); if (!path) return; history.pushState(null, "", path); setResolving(deepLinkLabel(parseDeepLink(path))); syncToUrl(); } function disconnect() { // Mirror Cmd+Left: if connecting pushed a history entry, pop it — the // browser restores the previous URL and our popstate handler tears down. if (pushedRef.current) { history.back(); return; } teardownConn(); if (window.location.pathname !== "/") history.replaceState(null, "", "/"); } // Merge every room's servers into one list, each tagged with its room so the // Connect button knows which client to signal through. const allServers: RoomedServer[] = tokens.flatMap((t) => (serversByRoom[t] ?? []).map((server) => ({ server, room: t })), ); // Mirror the latest merged servers + connection state into refs so the URL // reconciler (called from event handlers/timers) never reads a stale closure. allServersRef.current = allServers; connStateRef.current = connState; const serverCount = allServers.length; // Root-kind peerIds seen so far, joined into a stable string so the effect // below only re-runs when the actual set changes (allServers is a fresh // array every render). const rootServerKey = allServers .filter((x) => x.server.meta?.kind === "root") .map((x) => `${x.server.peerId}:${x.room}`) .join(","); useEffect(() => { for (const { server, room } of allServersRef.current) { if (server.meta?.kind === "root") previewFor(server.peerId, room); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [rootServerKey]); const onlineRooms = tokens.filter((t) => roomOpen[t]).length; // Human room labels — the room chips read as "Hosts": named by the machines // inside, disambiguated (user@name, then ·hash) when two rooms collide, and // only the empty room falls back to the token hash. const roomLabelByToken = roomLabels( tokens.map((t) => ({ token: t, servers: (serversByRoom[t] ?? []).map((s) => ({ name: s.meta?.name ?? s.meta?.host, user: s.meta?.user, })), })), ); const roomLabel = (t: string) => roomLabelByToken.get(t) ?? shortRoomLabel(t); const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server; // Other clients (browsers) across all joined rooms, deduped by peerId — the // "In this room" roster, so you can spot a device that shouldn't hold a token. const otherClients = Object.values(clientsByRoom) .flat() .filter((c, i, all) => all.findIndex((x) => x.peerId === c.peerId) === i); // Annotate each server with its mnemonic fake-tags, then filter. The room // tag only appears with several rooms joined — in the common single-room // case it distinguishes nothing and is pure noise. (The raw token is never // rendered; the label is server names or, for an empty room, the hash.) const tagged = allServers.map(({ server: s, room }) => ({ server: s, room, name: s.meta?.name ?? s.peerId.slice(0, 8), tags: deriveTags(s.meta, tokens.length > 1 ? { roomLabel: roomLabel(room) } : {}), })); const query = [...activeTags, filter].join(" "); const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query)); // Group workspaces by machine: the stable hostId when the daemon advertises // one, else the hostname string (older daemons), else the peer stands alone. // Agents are machine-level (advertised by the host's root daemon) — just a // deduped-by-pid count here; agent-yes.com is the place to actually browse // and interact with them, so we don't duplicate that list on this page. const hostGroups: { key: string; label: string; items: typeof filtered; agentPids: Set }[] = []; for (const t of filtered) { const key = t.server.meta?.hostId ?? t.server.meta?.host ?? t.server.peerId; let group = hostGroups.find((g) => g.key === key); if (!group) { group = { key, label: t.server.meta?.host ?? t.name, items: [], agentPids: new Set() }; hostGroups.push(group); } group.items.push(t); // Room-advertised `agents` is gone (see worker/room.ts) — fall back to the // live-fetched previewMeta once its background connection lands. const agents = t.server.meta?.agents ?? previewMeta[t.server.peerId]?.agents ?? []; for (const a of agents) group.agentPids.add(a.pid); } // Same-name machines (two "Mac"s) already get distinct cards via hostId, but // their titles would read identically — qualify duplicates like the Hosts // chips: user@name first, then a stable hostId snippet if even that ties. for (let pass = 0; pass < 2; pass++) { const dup = new Map(); for (const g of hostGroups) dup.set(g.label, (dup.get(g.label) ?? 0) + 1); for (const g of hostGroups) { if ((dup.get(g.label) ?? 0) <= 1) continue; const user = g.items[0]?.server.meta?.user; g.label = pass === 0 && user ? `${user}@${g.label}` : `${g.label}·${g.key.slice(0, 4)}`; } } const toggleTag = (t: string) => setActiveTags((a) => (a.includes(t) ? a.filter((x) => x !== t) : [...a, t])); const addTag = (t: string) => setActiveTags((a) => (a.includes(t) ? a : [...a, t])); // Suggested chips: the most common identity/location tags across the list. const tagFreq = new Map(); for (const t of tagged) for (const tag of t.tags) tagFreq.set(tag, (tagFreq.get(tag) ?? 0) + 1); const suggestedTags = [...tagFreq.entries()] .sort((a, b) => b[1] - a[1]) .map(([t]) => t) .filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t))) .slice(0, 12); // Headless signaling clients, one per joined room. Kept mounted across BOTH // views so switching into the iframe never tears down discovery/session. const roomClients = tokens.map((t) => ( setServersByRoom((m) => ({ ...m, [t]: peers }))} onRoster={(clients, now) => { setClientsByRoom((m) => ({ ...m, [t]: clients })); if (now) setRoomNow(now); }} onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))} onSignal={(from, data) => { if (from !== activePeerRef.current) { // Not the active (visible) session — maybe a background preview // connection fetching a host's workspace list (see previewFor). void previewRtcRef.current.get(from)?.handleSignal(data); return; } const kind = (data as { kind?: string } | null)?.kind; if (kind === "pending") { // Host is reviewing us — show "waiting" and stop the short dial timer // from failing the attempt while a human decides. setConnState("pending"); if (dialTimerRef.current) clearTimeout(dialTimerRef.current); dialTimerRef.current = setTimeout(() => { rtcRef.current?.close(); dialRejectRef.current?.(new Error("approval timed out")); }, 120000); return; } if (kind === "denied") { // Denied while dialing, or kicked after connecting — set state directly // so it covers both (the dial promise may already be settled). deniedRef.current = true; if (dialTimerRef.current) clearTimeout(dialTimerRef.current); rtcRef.current?.close(); setIframeSrc(null); setConnState("denied"); dialRejectRef.current?.(new Error("host denied the connection")); return; } void rtcRef.current?.handleSignal(data); }} registerSender={(send) => { if (send) sendersRef.current.set(t, send); else sendersRef.current.delete(t); }} /> )); // Provisioning view: the daemon's setup.sh is running; stream its log. if (connState === "provisioning") { return ( <> {roomClients}
codehost · {provisionFailed ? ( provisioning failed — see the log below ) : ( provisioning… )}
{provisionLog || "starting…"}
); } // Host settings view: view/edit .codehost/config.yaml + the setup script // over the tunnel, instead of an iframe. `#provisioning` is the only tab. // The host's advertised meta (if it's still online) carries provisioning // readiness — e.g. whether private clones can authenticate. const settingsMeta = settingsHost ? allServers.find((x) => x.server.meta?.host === settingsHost || x.server.meta?.name === settingsHost)?.server .meta : undefined; if (settingsHost && connState === "connected") { return ( <> {roomClients}
codehost [{settingsHost}] · settings
{settingsMeta?.githubAuth && (settingsMeta.githubAuth.ok ? (

✓ GitHub auth: ready — private repos will clone on this host

) : (

⚠ GitHub auth: not set up — private repos won't clone. On this host, run:{" "} gh auth login && gh auth setup-git

))} {hostSettings?.status === "loading" &&

loading…

} {hostSettings?.status === "error" &&

{hostSettings.message}

} {hostSettings?.status === "ready" && settingsTab === "provisioning" && ( <>