diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index 918286d1..ba2cd533 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-15T03:38:04Z norm: '1' sig: 063d0f00fea02511 -body_hash: 59c41863ebaa7c91 -raw_hash: 6b011c6b27c22f32 -vouched: 2026-10-19T21:10:15Z +body_hash: 247d8e002cbe5eea +raw_hash: ff8ae1733758ca88 +vouched: 2026-10-22T00:00:58Z lines: 235-824 --- diff --git a/.lane/memory/apps/app/src/invite.ts/01M5G7R116DA7GH3PEGHY3GHDG-an-invite-link-only-fills-th.md b/.lane/memory/apps/app/src/invite.ts/01M5G7R116DA7GH3PEGHY3GHDG-an-invite-link-only-fills-th.md new file mode 100644 index 00000000..e7ba8924 --- /dev/null +++ b/.lane/memory/apps/app/src/invite.ts/01M5G7R116DA7GH3PEGHY3GHDG-an-invite-link-only-fills-th.md @@ -0,0 +1,13 @@ +--- +id: 01M5G7R116DA7GH3PEGHY3GHDG +anchor: function parse +created: 2026-10-21T22:01:42Z +norm: '1' +sig: dbfd6fc52114c42f +body_hash: 5e6861eabfa9b10e +raw_hash: 263fdf4182735ac0 +vouched: 2026-10-21T23:56:16Z +lines: 13-24 +--- + +an invite link only fills the join form; joining must stay an explicit Check and join, since a link can name any directory. diff --git a/.lane/memory/apps/app/src/invite.ts/01M5GE9M9PFM6TQP78EH33REEJ-the-invite-must-stay-in-the.md b/.lane/memory/apps/app/src/invite.ts/01M5GE9M9PFM6TQP78EH33REEJ-the-invite-must-stay-in-the.md new file mode 100644 index 00000000..47c09324 --- /dev/null +++ b/.lane/memory/apps/app/src/invite.ts/01M5GE9M9PFM6TQP78EH33REEJ-the-invite-must-stay-in-the.md @@ -0,0 +1,12 @@ +--- +id: 01M5GE9M9PFM6TQP78EH33REEJ +anchor: function link +created: 2026-10-21T23:56:10Z +norm: '1' +sig: e3ff675f8b8f97f4 +body_hash: d44684f05493d6ed +raw_hash: 5d27cd630e96e30a +lines: 13-15 +--- + +the invite must stay in the URL fragment so the secret never reaches the site's server diff --git a/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md b/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md index a3bdec70..7df6efc8 100644 --- a/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md +++ b/.lane/memory/apps/desktop/electrobun.config.ts/01M53CZ2S58V1DTJKB64V1SVRD-wkwebview-dictation-needs-ns.md @@ -4,8 +4,9 @@ anchor: '@file' created: 2026-10-16T22:22:49Z norm: '1' sig: eb0482a0ddee93a6 -body_hash: de216f5680cfb1c1 -raw_hash: 6c0b5799010be95c +body_hash: 6737492378c42f80 +raw_hash: da3e9e067339f0bf +vouched: 2026-10-21T22:03:14Z lines: 1-42 --- diff --git a/.lane/memory/apps/desktop/electrobun.config.ts/01M5G7R10ME8H8G95ECA7MKHBE-each-build-channel-registers.md b/.lane/memory/apps/desktop/electrobun.config.ts/01M5G7R10ME8H8G95ECA7MKHBE-each-build-channel-registers.md new file mode 100644 index 00000000..f9a12c85 --- /dev/null +++ b/.lane/memory/apps/desktop/electrobun.config.ts/01M5G7R10ME8H8G95ECA7MKHBE-each-build-channel-registers.md @@ -0,0 +1,12 @@ +--- +id: 01M5G7R10ME8H8G95ECA7MKHBE +anchor: '@file' +created: 2026-10-21T22:01:42Z +norm: '1' +sig: eb0482a0ddee93a6 +body_hash: 6737492378c42f80 +raw_hash: da3e9e067339f0bf +lines: 1-45 +--- + +each build channel registers its own scheme (ace, ace-canary, ace-dev) so a dev or Canary build never answers stable's invite links; apps/app/src/invite.ts reads all of them. diff --git a/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md b/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md index b7d44fa4..072712ea 100644 --- a/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md +++ b/.lane/memory/apps/host/src/gateway-client.ts/01M4YQRMKVKT45GACS27J2TXWY-channel-listings-must-outliv.md @@ -4,9 +4,9 @@ anchor: class GatewayClient created: 2026-10-15T02:55:20Z norm: '1' sig: 41366b9e88f0e890 -body_hash: 127e52dea129b080 -raw_hash: 557fa94c93b713ff -vouched: 2026-10-15T12:04:25Z +body_hash: 0ea1b5a225ceb8b0 +raw_hash: 99b659f96ee9516e +vouched: 2026-10-22T00:00:58Z lines: 33-240 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index c4901f86..49171b86 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 1b1e7b788ab2e1af -raw_hash: 581d53ea3cc3de56 -vouched: 2026-10-20T17:05:11Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index c1997c10..c843f55c 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 1b1e7b788ab2e1af -raw_hash: 581d53ea3cc3de56 -vouched: 2026-10-20T17:05:11Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md index 5d9417a6..59596158 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md @@ -4,9 +4,9 @@ anchor: function websocket created: 2026-10-15T20:16:37Z norm: '1' sig: 1b462e5d3bc33728 -body_hash: 39bbd535e37f47a0 -raw_hash: c1a585dfb617245a -vouched: 2026-10-20T17:10:51Z +body_hash: e4839023c586ff4f +raw_hash: cda22d4980cf5a80 +vouched: 2026-10-22T00:00:58Z lines: 673-737 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md index bbceedef..8cef3bb9 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZZV2CRMNFXZKM0B3AWRVX-a-move-to-a-cloud-workspace.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-19T21:09:57Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 1b1e7b788ab2e1af -raw_hash: 581d53ea3cc3de56 -vouched: 2026-10-20T17:05:11Z +body_hash: 14495715208898b4 +raw_hash: 2426d3718700367c +vouched: 2026-10-22T00:00:58Z lines: 384-700 supersedes: 01M5APK9YCQH467YNMKAC8BT5B --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index fcef8035..72e645a1 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: c1aca6bd21460ec9 -raw_hash: 0c4c21e9c44f3962 -vouched: 2026-10-21T23:57:08Z +body_hash: 55717cb5cf7a6c4f +raw_hash: 3c39fe566762d0d1 +vouched: 2026-10-22T00:09:44Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md b/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md new file mode 100644 index 00000000..bce083dd --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5GEJ78HX2MSSVN17V2PTKAS-only-the-local-app-can-answe.md @@ -0,0 +1,12 @@ +--- +id: 01M5GEJ78HX2MSSVN17V2PTKAS +anchor: function asksChanged +created: 2026-10-22T00:00:52Z +norm: '1' +sig: 89d3d187f4a8cef0 +body_hash: 10d18a209d9cd21c +raw_hash: a2a5da6ab853d6c9 +lines: 391-394 +--- + +only the local app can answer an ask (team-answer is a keychain op), so asks reach local sockets only diff --git a/.lane/memory/apps/host/src/helper.ts/01M4V5N7FNEDME70ADSQF5H00A-helpers-must-stay-first-on-t.md b/.lane/memory/apps/host/src/helper.ts/01M4V5N7FNEDME70ADSQF5H00A-helpers-must-stay-first-on-t.md index 4f62c773..9ff035cd 100644 --- a/.lane/memory/apps/host/src/helper.ts/01M4V5N7FNEDME70ADSQF5H00A-helpers-must-stay-first-on-t.md +++ b/.lane/memory/apps/host/src/helper.ts/01M4V5N7FNEDME70ADSQF5H00A-helpers-must-stay-first-on-t.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-13T17:41:10Z norm: '1' sig: 4b03c8eb05d682d4 -body_hash: c6a92431f39aeeb2 -raw_hash: ab581bbaf4052fed -vouched: 2026-10-20T18:47:51Z +body_hash: d49430c648b04352 +raw_hash: f4153907c9e313c2 +vouched: 2026-10-21T19:33:15Z lines: 1-39 --- diff --git a/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md b/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md new file mode 100644 index 00000000..81affd80 --- /dev/null +++ b/.lane/memory/apps/host/src/protocol.ts/01M5GEJ77XBRPQN89DW0CKNAWP-hello-answers-any-tailnet-pe.md @@ -0,0 +1,12 @@ +--- +id: 01M5GEJ77XBRPQN89DW0CKNAWP +anchor: type Hello +created: 2026-10-22T00:00:52Z +norm: '1' +sig: a2275d05c454362d +body_hash: e3b0c44298fc1c14 +raw_hash: 7276efa4e6f4c00c +lines: 157-157 +--- + +hello answers any tailnet peer, so team carries only the directory's host, never the secret diff --git a/.lane/memory/apps/host/src/team.ts/01M5FZ7YE9SPKEVN8YC2WGRKEM-ace-helper-runs-wrangler-as.md b/.lane/memory/apps/host/src/team.ts/01M5FZ7YE9SPKEVN8YC2WGRKEM-ace-helper-runs-wrangler-as.md new file mode 100644 index 00000000..c9b4710b --- /dev/null +++ b/.lane/memory/apps/host/src/team.ts/01M5FZ7YE9SPKEVN8YC2WGRKEM-ace-helper-runs-wrangler-as.md @@ -0,0 +1,12 @@ +--- +id: 01M5FZ7YE9SPKEVN8YC2WGRKEM +anchor: '@file' +created: 2026-10-21T19:33:07Z +norm: '1' +sig: e3b0c44298fc1c14 +body_hash: a4c52bd75b46f573 +raw_hash: 1915cbbdad9adc90 +lines: 1-98 +--- + +Ace Helper runs wrangler as plain Bun via BUN_BE_BUN, and deploys the bundles apps/desktop/scripts/helper.ts writes to Resources/services; both layouts must stay in step. diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index f56682dc..90b46dc7 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -44,6 +44,7 @@ import { Github, GithubSidebar } from "./github"; import { useGithubRefresh } from "./github-cache"; import { desktop, here, titlebar } from "./desktop"; import { host } from "./host"; +import { type Invite, parse } from "./invite"; import { Rename } from "./layout/rename"; import { MoveChannel } from "./move-channel"; import { Navigation, type Page, WindowControls } from "./navigation"; @@ -55,6 +56,8 @@ import { type AppProject, label, projects, title } from "./projects"; import { type Section, Settings } from "./settings"; import { SetupProject } from "./setup-project"; import { ShareProject, useCanShare } from "./share-project"; +import { TeamAsks } from "./team-asks"; +import { TeamSetup } from "./team-setup"; import { UpdateNotice } from "./updates"; import { useWindowStorage } from "./window-storage"; @@ -298,6 +301,9 @@ export function App() { const [left, setLeft] = useLocalStorage("panel:left", true); const [width, setWidth] = useLocalStorage("panel:left:width", 200); const [pins, setPins] = useLocalStorage("ace:pinned-channels", []); + // Offered once on first run; Settings → Team opens it again. + const [offered, setOffered] = useLocalStorage("ace:team-setup", false); + const [teaming, setTeaming] = useState(false); const [collapsed, setCollapsed] = useState>({ pinned: false, mine: false, @@ -317,6 +323,38 @@ export function App() { } return () => window.removeEventListener("ace:updates", open); }, []); + useEffect(() => { + const open = () => { + setSettings(false); + setTeaming(true); + }; + const invite = () => { + setSettings(false); + setTeaming("inviting"); + }; + // The desktop app hands over an invite link it was opened with, as an event or, for a new + // window, in the hash. + const join = (link: string) => { + const invite = parse(link); + if (!invite) return; + setSettings(false); + setTeaming(invite); + }; + const joining = (event: Event) => join((event as CustomEvent).detail); + window.addEventListener("ace:team-setup", open); + window.addEventListener("ace:team-invite", invite); + window.addEventListener("ace:join", joining); + const fragment = new URLSearchParams(location.hash.slice(1)).get("join"); + if (fragment) { + history.replaceState(null, "", location.pathname + location.search); + join(fragment); + } + return () => { + window.removeEventListener("ace:team-setup", open); + window.removeEventListener("ace:team-invite", invite); + window.removeEventListener("ace:join", joining); + }; + }, []); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); @@ -1028,6 +1066,19 @@ export function App() { onClose={() => setSettings(false)} /> )} + {(teaming || (!offered && connected && !current)) && ( + { + setOffered(true); + setTeaming(false); + }} + /> + )} + {desktop && window.dispatchEvent(new Event("ace:updates"))} />} diff --git a/apps/app/src/directory-settings.tsx b/apps/app/src/directory-settings.tsx index 0dd44837..806af838 100644 --- a/apps/app/src/directory-settings.tsx +++ b/apps/app/src/directory-settings.tsx @@ -6,6 +6,7 @@ import type { Diagnostics } from "@ace/host/protocol"; import { desktop } from "./desktop"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; +import { parse } from "./invite"; import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout"; const SETUP = "https://github.com/githubnext/ace/blob/main/docs/setup.md#team-directory"; @@ -204,7 +205,14 @@ function TeamDirectory( label="Directory" description={directory.url || ( <> - Not joined. Set up a directory + Not joined.{" "} + )} footer={ @@ -237,11 +245,15 @@ function TeamDirectory( autoFocus type="url" spellCheck={false} - aria-label="Directory URL" - placeholder="https://ace-directory.example.workers.dev" + aria-label="Invite link or directory URL" + placeholder="Invite link or https://ace-directory.example.workers.dev" value={url} disabled={!!action.busy} - onChange={(event) => setUrl(event.target.value)} + onChange={(event) => { + const invite = parse(event.target.value); + setUrl(invite?.url || event.target.value); + if (invite) setSecret(invite.secret); + }} /> )} + {directory.url && saved && !editing && ( + + )} {!editing && ( + + + + + ); +} diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx new file mode 100644 index 00000000..91e9aefe --- /dev/null +++ b/apps/app/src/team-setup.tsx @@ -0,0 +1,759 @@ +import { type ReactNode, useEffect, useId, useState } from "react"; + +import { + Button, + buttonVariants, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Switch, +} from "@ace/ui"; +import { IconArrowUpRight, IconCheck, IconCopy, IconLoader } from "@ace/ui/icons"; +import type { TeamPeer } from "@ace/host/protocol"; + +import { host } from "./host"; +import { useDiagnostics } from "./host-settings"; +import { type Invite as Team, link, page, parse } from "./invite"; + +const DOCS = "https://github.com/githubnext/ace/blob/main/docs/setup.md"; +const PROVIDERS = { + cloudflare: { label: "Cloudflare" }, + fly: { label: "Fly.io" }, +}; + +// Names, URLs, and secrets are typed exactly; macOS would otherwise capitalize and correct them. +const RAW = { autoCorrect: "off", autoCapitalize: "off", spellCheck: false } as const; + +type Provider = keyof typeof PROVIDERS; +type Step = "fork" | "join" | "directory" | "remote" | "setup" | "invite" | "solo"; +const STEPS: Partial> = { + directory: "Directory", + remote: "Remote channels", + setup: "Set up", + invite: "Invite", +}; + +/** + * First-run setup for the first person on a tailnet, or a way into the team for everyone after. + * Membership stays the tailnet's: this only decides where the team's channels are listed. + * `invite` opens on joining with an invite link's values; `inviting` opens on this team's invite. + */ +export function TeamSetup({ github, invite, inviting, onClose }: { + github?: string; + invite?: Team; + inviting?: boolean; + onClose: () => void; +}) { + const [step, setStep] = useState(invite ? "join" : inviting ? "invite" : "fork"); + const [starting, setStarting] = useState(true); + const [listed, setListed] = useState<"hosted" | "peers">("hosted"); + const [provider, setProvider] = useState("cloudflare"); + const [hosted, setHosted] = useState(false); + const [joined, setJoined] = useState(); + const [error, setError] = useState(""); + + useEffect(() => { + if (!inviting) return; + host.request({ op: "team-invite" }).then( + setJoined, + (error: Error) => setError(error.message), + ); + }, [inviting]); + + return ( + next || onClose()}> + + {STEPS[step] && !inviting && } + {step === "fork" && ( + <> + +
+ +
+ setStarting(true)} + title="Get your team started" + > + You're the first on this tailnet. Pick where the team's channels are listed. You can + change it later. + + setStarting(false)} + title="Join your team" + > + Someone already set Ace up. Use the invite link they sent you. + +
+
Just me for now} + next={ + + } + /> + + )} + {step === "join" && ( + <> +
+ {!invite && } + setStep("fork")} /> + + )} + {step === "directory" && ( + <> +
+
+ setListed("hosted")} + title={ + <> + Hosted directory Recommended + + } + > + Runs in your own cloud account. +
+ {(Object.keys(PROVIDERS) as Provider[]).map(id => ( + + ))} +
+
+ setListed("peers")} + title="Peer to peer only" + > + Nothing to deploy. Teammates on the tailnet see your channels only while your Mac is + awake, with no shared projects, presence, or remote channels. + +
+
+ + + Host myself + + + } + next={ + + } + /> + + )} + {step === "remote" && ( + <> +
+
+ + + Cloud workspaces Soon + + } + description="Chat and tools run in containers from a fresh clone, billed per container minute." + /> +
+
setStep("directory")}>Back} + next={} + /> + + )} + {step === "setup" && ( + setStep("remote")} + onJoined={(url, secret) => { + setJoined({ url, secret }); + setStep("invite"); + }} + /> + )} + {step === "invite" && ( + <> +
+ {joined + ? + : error + ?

{error}

+ :

Reading the invite…

} +
Done} /> + + )} + {step === "solo" && ( + <> +
+ +
  • + Download Ace from ace.githubnext.com +
  • +
  • + Get on your tailnet:{" "} + invite them in Tailscale +
  • +
    +
    setStep("directory")}> + Add a directory + + } + next={} + /> + + )} + +
    + ); +} + +function Setup({ provider, hosted, back, onJoined }: { + provider: Provider; + hosted: boolean; + back: () => void; + onJoined: (url: string, secret: string) => void; +}) { + const command = `bun services/deploy.ts ${provider}${hosted ? " --hosted" : ""}`; + // Ace deploys Cloudflare's bundled services itself; Fly.io still needs a checkout and flyctl. + if (provider === "cloudflare") { + return ; + } + return ( + <> +
    + Ace deploys to Cloudflare for you. For Fly.io, run this from an Ace checkout signed in + to fly, then enter what it prints. + + } + /> + + + + ); +} + +function Deploy({ hosted, command, back, onJoined }: { + hosted: boolean; + command: string; + back: () => void; + onJoined: (url: string, secret: string) => void; +}) { + const [waiting, setWaiting] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [commands, setCommands] = useState(false); + const stages = [ + { label: "Sign in to Cloudflare", from: "Checking your Cloudflare" }, + ...hosted ? [{ label: "Deploy hosted channels", from: "Deploying hosted" }] : [], + { label: "Deploy the directory", from: "Deploying the directory" }, + { label: "Join this Mac", from: "Joining" }, + ]; + const [stage, setStage] = useState(-1); + + async function deploy() { + setBusy(true); + setError(""); + setStage(0); + try { + const { url, secret } = await host.deployTeam(hosted, (text) => { + setWaiting(text); + const next = stages.findIndex(({ from }) => text?.startsWith(from)); + if (next >= 0) setStage(next); + }); + onJoined(url, secret); + } catch (error) { + setError((error as Error).message); + } finally { + setBusy(false); + setWaiting(null); + } + } + + return ( + <> +
    + Ace deploys your tailnet's services to your Cloudflare account, makes a new team secret, + and joins this Mac. You'll sign in to Cloudflare in your browser if you haven't before. + {" "} + + What this creates + + + } + /> +
      + {stages.map(({ label }, index) => ( +
    1. + {index < stage + ? + : index === stage && busy + ? + : ·} + stage || (index === stage && !busy) + ? "text-muted-foreground" + : ""} + > + {index === stage && waiting || label} + +
    2. + ))} +
    + {error &&

    {error}

    } + {commands && } +