From 5496a3a36a65870d756d1da16f7ce1688edf0a73 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:02:24 -0400 Subject: [PATCH 1/6] feat: deploy the team's services to Cloudflare or Fly.io with one command Why: services/deploy.ts | the secret is generated here and printed once; the services and the joining host must get the same value, and nothing else stores it --- docs/setup.md | 11 +++++ services/deploy.ts | 111 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 services/deploy.ts diff --git a/docs/setup.md b/docs/setup.md index c6ed967f..9f88ea18 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -61,6 +61,17 @@ team runs or on [Fly.io](#on-flyio). To deploy them to your Cloudflare account, same secret and your tailnet's MagicDNS suffix. The services then serve only that tailnet; see [the team](architecture.md#the-team) for what that scope does and doesn't protect. +One command does every step below for Cloudflare or Fly.io, signed in to `wrangler` or `fly`, +with Tailscale on. It names the services `ace-directory-` and `ace-channel-`, makes a +new team secret, gives the hosted channels `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from your +environment or the checkout's `.env`, and prints the directory's URL and the secret to join with. + +```sh +bun services/deploy.ts +``` + +By hand, on Cloudflare: + ```sh SECRET=$(openssl rand -hex 32) TAILNET=$(tailscale status --json | jq -r .MagicDNSSuffix) diff --git a/services/deploy.ts b/services/deploy.ts new file mode 100644 index 00000000..7ed2ea38 --- /dev/null +++ b/services/deploy.ts @@ -0,0 +1,111 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const USAGE = "bun services/deploy.ts "; +const MODEL_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]; + +const [provider, team] = process.argv.slice(2); +if (provider !== "cloudflare" && provider !== "fly") throw new Error(USAGE); +// App and Worker names: ace-directory- and ace-channel-. +if (!team || !/^[a-z0-9][a-z0-9-]{0,30}$/.test(team)) { + throw new Error(`${USAGE}\n is lowercase letters, digits, and dashes`); +} + +const tailnet = await magicDNS(); +const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); +const models: Record = {}; +for (const name of MODEL_KEYS) if (process.env[name]) models[name] = process.env[name]!; +if (!Object.keys(models).length) { + const names = MODEL_KEYS.join(" or "); + console.warn(`No ${names} in the environment: hosted channels can't run agents without one.`); +} + +const shared = { ACE_SECRET: secret, TAILNET: tailnet }; +const deploy = provider === "cloudflare" ? cloudflare : fly; +const hosted = await deploy("channel", { ...shared, ...models }); +const directory = await deploy("directory", { ...shared, HOSTED: hosted }); + +console.log(` +Deployed for ${tailnet}. + + Directory ${directory} + Team secret ${secret} + +Join in Ace with both, then send them to teammates privately. This is the only time the secret is shown. +The services can take a minute to accept the new secret.`); + +async function cloudflare(service: string, vars: Record): Promise { + const name = `ace-${service}-${team}`; + const output = await run(["bunx", "wrangler", "deploy", "--name", name], { dir: service }); + const url = /https:\/\/\S+\.workers\.dev/.exec(output)?.[0]; + if (!url) throw new Error(`wrangler deploy printed no workers.dev URL for ${name}`); + await run(["bunx", "wrangler", "secret", "bulk", "--name", name], { + dir: service, + input: JSON.stringify(vars), + }); + return url; +} + +async function fly(service: string, vars: Record): Promise { + const app = `ace-${service}-${team}`; + await run(["fly", "apps", "create", app]); + // Tigris prints the bucket's keys once, and deploying the service's code to the bucket needs them. + const storage = await run(["fly", "storage", "create", "-a", app, "-n", app, "--yes"], { + quiet: true, + }); + const key = (name: string) => { + const value = new RegExp(`${name}:\\s*(\\S+)`).exec(storage)?.[1]; + if (!value) throw new Error(`fly storage create printed no ${name} for ${app}`); + return value; + }; + const bucket = `s3://${app}`; + await run(["fly", "secrets", "set", "-a", app, "--stage", `CELLD_BUCKET=${bucket}`]); + // celld has no secret store, so these vars sit in the bucket as plain text either way. + const dir = await mkdtemp(join(tmpdir(), "ace-deploy-")); + const file = join(dir, `${service}.env`); + try { + await Bun.write(file, Object.entries(vars).map(([k, v]) => `${k}=${v}`).join("\n")); + await run(["bun", "celld.ts", service, "deploy", file, "--bucket", bucket], { + env: { + S3_ENDPOINT: "https://fly.storage.tigris.dev", + AWS_REGION: "auto", + AWS_ACCESS_KEY_ID: key("AWS_ACCESS_KEY_ID"), + AWS_SECRET_ACCESS_KEY: key("AWS_SECRET_ACCESS_KEY"), + }, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + await run(["fly", "deploy", "-c", "fly.toml", "-a", app, "--ha=false"]); + return `https://${app}.fly.dev`; +} + +async function magicDNS(): Promise { + const cli = Bun.which("tailscale") ?? "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + const status = JSON.parse(await run([cli, "status", "--json"], { quiet: true })) as { + MagicDNSSuffix?: string; + }; + if (!status.MagicDNSSuffix) throw new Error("Tailscale reports no tailnet. Connect to it first."); + return status.MagicDNSSuffix; +} + +type Options = { dir?: string; input?: string; env?: Record; quiet?: boolean }; + +/** Runs in `services/`, echoing output unless `quiet` so progress stays visible, and returns it for parsing. */ +async function run(cmd: string[], { dir, input, env, quiet }: Options = {}): Promise { + const child = Bun.spawn(cmd, { + cwd: join(import.meta.dir, dir || "."), + env: { ...process.env, ...env }, + stdin: input === undefined ? "inherit" : new TextEncoder().encode(input), + stdout: "pipe", + stderr: "inherit", + }); + let output = ""; + for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { + output += chunk; + if (!quiet) process.stdout.write(chunk); + } + if (await child.exited) throw new Error(`${cmd.slice(0, 3).join(" ")} failed`); + return output; +} From d799493e9a73323d520173a17b9d19156c92ab14 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:02:33 -0400 Subject: [PATCH 2/6] lane: sync team-setup memory --- ...K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md diff --git a/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md b/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md new file mode 100644 index 00000000..3b44d058 --- /dev/null +++ b/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md @@ -0,0 +1,12 @@ +--- +id: 01M5FXFQ3K4PKHS0PB2W3HQWNJ +anchor: '@file' +created: 2026-10-21T19:02:24Z +norm: '1' +sig: 061da171c15167fe +body_hash: 27f1e1117bcd6010 +raw_hash: 5eac7fddc8963526 +lines: 1-111 +--- + +the secret is generated here and printed once; the services and the joining host must get the same value, and nothing else stores it From 0fc4d2c850cec7ccaa6d2b1dda15241eee1e563e Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:04:10 -0400 Subject: [PATCH 3/6] feat: deploy hosted channels only with --hosted --- docs/setup.md | 9 +++++---- services/deploy.ts | 27 ++++++++++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 9f88ea18..359459a5 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -62,12 +62,13 @@ same secret and your tailnet's MagicDNS suffix. The services then serve only tha [the team](architecture.md#the-team) for what that scope does and doesn't protect. One command does every step below for Cloudflare or Fly.io, signed in to `wrangler` or `fly`, -with Tailscale on. It names the services `ace-directory-` and `ace-channel-`, makes a -new team secret, gives the hosted channels `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from your -environment or the checkout's `.env`, and prints the directory's URL and the secret to join with. +with Tailscale on. It deploys `ace-directory-` with a new team secret and prints the +directory's URL and the secret to join with. `--hosted` adds hosted channels as +`ace-channel-`, with `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from your environment or the +checkout's `.env`. ```sh -bun services/deploy.ts +bun services/deploy.ts [--hosted] ``` By hand, on Cloudflare: diff --git a/services/deploy.ts b/services/deploy.ts index 7ed2ea38..fcaced36 100644 --- a/services/deploy.ts +++ b/services/deploy.ts @@ -2,10 +2,11 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const USAGE = "bun services/deploy.ts "; +const USAGE = "bun services/deploy.ts [--hosted]"; const MODEL_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]; -const [provider, team] = process.argv.slice(2); +const [provider, team, ...options] = process.argv.slice(2); +const withHosted = options.includes("--hosted"); if (provider !== "cloudflare" && provider !== "fly") throw new Error(USAGE); // App and Worker names: ace-directory- and ace-channel-. if (!team || !/^[a-z0-9][a-z0-9-]{0,30}$/.test(team)) { @@ -14,17 +15,11 @@ if (!team || !/^[a-z0-9][a-z0-9-]{0,30}$/.test(team)) { const tailnet = await magicDNS(); const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); -const models: Record = {}; -for (const name of MODEL_KEYS) if (process.env[name]) models[name] = process.env[name]!; -if (!Object.keys(models).length) { - const names = MODEL_KEYS.join(" or "); - console.warn(`No ${names} in the environment: hosted channels can't run agents without one.`); -} - const shared = { ACE_SECRET: secret, TAILNET: tailnet }; const deploy = provider === "cloudflare" ? cloudflare : fly; -const hosted = await deploy("channel", { ...shared, ...models }); -const directory = await deploy("directory", { ...shared, HOSTED: hosted }); +// Hosted channels are opt-in: without the channel service, the directory offers no Move to cloud. +const hosted = withHosted ? { HOSTED: await deploy("channel", { ...shared, ...models() }) } : {}; +const directory = await deploy("directory", { ...shared, ...hosted }); console.log(` Deployed for ${tailnet}. @@ -35,6 +30,16 @@ Deployed for ${tailnet}. Join in Ace with both, then send them to teammates privately. This is the only time the secret is shown. The services can take a minute to accept the new secret.`); +function models(): Record { + const found: Record = {}; + for (const name of MODEL_KEYS) if (process.env[name]) found[name] = process.env[name]!; + if (!Object.keys(found).length) { + const names = MODEL_KEYS.join(" or "); + console.warn(`No ${names} in the environment: hosted channels can't run agents without one.`); + } + return found; +} + async function cloudflare(service: string, vars: Record): Promise { const name = `ace-${service}-${team}`; const output = await run(["bunx", "wrangler", "deploy", "--name", name], { dir: service }); From 08c5a91a3bd2dde1e13cfbfe984a25ae340bad91 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:04:29 -0400 Subject: [PATCH 4/6] chore: confirm the deploy secret note --- ...1M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md b/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md index 3b44d058..f6c3f1b6 100644 --- a/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md +++ b/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md @@ -4,8 +4,9 @@ anchor: '@file' created: 2026-10-21T19:02:24Z norm: '1' sig: 061da171c15167fe -body_hash: 27f1e1117bcd6010 -raw_hash: 5eac7fddc8963526 +body_hash: afb7abadb2418641 +raw_hash: bf501d960e4a6ba6 +vouched: 2026-10-21T19:04:26Z lines: 1-111 --- From be7059b63cf358bf129139da7a7ec2b233cd37f6 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:31:18 -0400 Subject: [PATCH 5/6] fix: name the team's services after the tailnet instead of asking for a team name --- docs/setup.md | 12 ++++++------ services/deploy.ts | 10 ++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 359459a5..4d44b46d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -62,13 +62,13 @@ same secret and your tailnet's MagicDNS suffix. The services then serve only tha [the team](architecture.md#the-team) for what that scope does and doesn't protect. One command does every step below for Cloudflare or Fly.io, signed in to `wrangler` or `fly`, -with Tailscale on. It deploys `ace-directory-` with a new team secret and prints the -directory's URL and the secret to join with. `--hosted` adds hosted channels as -`ace-channel-`, with `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from your environment or the -checkout's `.env`. +with Tailscale on. It names the services for your tailnet (`ace-directory-tail1234` for +`tail1234.ts.net`), deploys the directory with a new team secret, and prints the directory's URL +and the secret to join with. `--hosted` adds hosted channels, with `ANTHROPIC_API_KEY` and +`OPENAI_API_KEY` from your environment or the checkout's `.env`. ```sh -bun services/deploy.ts [--hosted] +bun services/deploy.ts [--hosted] ``` By hand, on Cloudflare: @@ -126,7 +126,7 @@ restart. Compute still needs Cloudflare. [`services/fly.toml`](../services/fly.toml) runs one celld node per service on Fly.io, with a Tigris bucket each. Tigris needs a card on the Fly account. Pick app names (`ace-directory-` -and `ace-channel-` below), then create the apps and buckets. `fly storage create` prints the +and `ace-channel-` below), then create the apps and buckets. `fly storage create` prints the bucket's keys once; keep them to deploy from a checkout. ```sh diff --git a/services/deploy.ts b/services/deploy.ts index fcaced36..6014ac59 100644 --- a/services/deploy.ts +++ b/services/deploy.ts @@ -2,18 +2,16 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const USAGE = "bun services/deploy.ts [--hosted]"; +const USAGE = "bun services/deploy.ts [--hosted]"; const MODEL_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]; -const [provider, team, ...options] = process.argv.slice(2); +const [provider, ...options] = process.argv.slice(2); const withHosted = options.includes("--hosted"); if (provider !== "cloudflare" && provider !== "fly") throw new Error(USAGE); -// App and Worker names: ace-directory- and ace-channel-. -if (!team || !/^[a-z0-9][a-z0-9-]{0,30}$/.test(team)) { - throw new Error(`${USAGE}\n is lowercase letters, digits, and dashes`); -} const tailnet = await magicDNS(); +// The services serve one tailnet, so its name names them: ace-directory-tail1234 for tail1234.ts.net. +const team = tailnet.split(".")[0]!.toLowerCase(); const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); const shared = { ACE_SECRET: secret, TAILNET: tailnet }; const deploy = provider === "cloudflare" ? cloudflare : fly; From 58fd6adadf7ed7926c912a62a4f06fd0e6b143d6 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:33:27 -0400 Subject: [PATCH 6/6] chore: confirm deploy.ts note after naming services for the tailnet --- ...M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md b/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md index f6c3f1b6..2d46303b 100644 --- a/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md +++ b/.lane/memory/services/deploy.ts/01M5FXFQ3K4PKHS0PB2W3HQWNJ-the-secret-is-generated-here.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-21T19:02:24Z norm: '1' sig: 061da171c15167fe -body_hash: afb7abadb2418641 -raw_hash: bf501d960e4a6ba6 -vouched: 2026-10-21T19:04:26Z +body_hash: 25ad1835bce04814 +raw_hash: 8a4a43bed92aa15a +vouched: 2026-10-21T19:33:27Z lines: 1-111 ---