/** * T6 (v0.9) — add-machine onboarding (pairing) from the browser's side (§4.5 ISSUE). * * `start()` requests a single-use, short-TTL pairing code (P3), displays the copy-paste command * `npx web-terminal-agent pair ` and a countdown to `expiresAt`, then polls `listHosts` until * a new host flips `●online` → fires `onPaired` ONCE. KPI: first-shell-in-under-2-minutes * (EXPLORE §6). After `expiresAt` it shows "expired, generate a new code" and STOPS polling — the * client never re-displays a redeemed/expired code, and never persists it (INV5: transient state). */ import type { ApiClient } from './api-client' import { ApiError } from './errors' const DEFAULT_POLL_MS = 3000 const AGENT_PAIR_CMD = 'npx web-terminal-agent pair' export interface AddMachine { start(): Promise dispose(): void } export interface AddMachineOpts { readonly pollMs?: number readonly onPaired?: (hostId: string) => void /** Injectable clock for deterministic expiry tests; defaults to `Date.now`. */ readonly now?: () => number } export function mountAddMachine(root: HTMLElement, api: ApiClient, opts: AddMachineOpts = {}): AddMachine { const pollMs = opts.pollMs ?? DEFAULT_POLL_MS const now = opts.now ?? (() => Date.now()) const cmd = document.createElement('code') cmd.className = 'pair-command' const status = document.createElement('p') status.className = 'pair-status' status.setAttribute('role', 'status') const error = document.createElement('p') error.className = 'pair-error' error.setAttribute('role', 'alert') root.append(cmd, status, error) let timer: ReturnType | null = null let disposed = false let paired = false let knownHostIds: ReadonlySet = new Set() function stopPolling(): void { if (timer) { clearInterval(timer) timer = null } } async function poll(expiresAtMs: number): Promise { if (disposed || paired) return if (now() >= expiresAtMs) { stopPolling() status.textContent = 'Code expired — generate a new code.' cmd.textContent = '' // never re-display an expired/redeemed code return } try { const hosts = await api.listHosts() if (disposed || paired) return const fresh = hosts.find((h) => h.status === 'online' && !knownHostIds.has(h.hostId)) if (fresh) { paired = true stopPolling() status.textContent = `Paired: ${fresh.subdomain} is online.` opts.onPaired?.(fresh.hostId) } } catch (err) { // A transient poll error is non-fatal; the loop retries until expiry (no infinite hang). error.textContent = err instanceof ApiError ? `Status check failed (${err.kind}).` : 'Status check failed.' } } async function start(): Promise { error.textContent = '' // Snapshot existing hosts so we detect the NEW one that pairs in. try { knownHostIds = new Set((await api.listHosts()).map((h) => h.hostId)) } catch { knownHostIds = new Set() } let code: string let expiresAt: string try { const issued = await api.requestPairingCode() code = issued.code expiresAt = issued.expiresAt } catch (err) { error.textContent = err instanceof ApiError ? `Could not issue a code (${err.kind}).` : 'Could not issue a code.' return } cmd.textContent = `${AGENT_PAIR_CMD} ${code}` // textContent-only, no injection status.textContent = 'Waiting for the host to pair…' const expiresAtMs = Date.parse(expiresAt) stopPolling() timer = setInterval(() => void poll(expiresAtMs), pollMs) void poll(expiresAtMs) } return { start, dispose(): void { disposed = true stopPolling() }, } }