Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M5FXFQ3K4PKHS0PB2W3HQWNJ
anchor: '@file'
created: 2026-10-21T19:02:24Z
norm: '1'
sig: 061da171c15167fe
body_hash: 25ad1835bce04814
raw_hash: 8a4a43bed92aa15a
vouched: 2026-10-21T19:33:27Z
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
14 changes: 13 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ 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 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 <cloudflare|fly> [--hosted]
```

By hand, on Cloudflare:

```sh
SECRET=$(openssl rand -hex 32)
TAILNET=$(tailscale status --json | jq -r .MagicDNSSuffix)
Expand Down Expand Up @@ -114,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-<team>`
and `ace-channel-<team>` below), then create the apps and buckets. `fly storage create` prints the
and `ace-channel-<tailnet>` below), then create the apps and buckets. `fly storage create` prints the
bucket's keys once; keep them to deploy from a checkout.

```sh
Expand Down
114 changes: 114 additions & 0 deletions services/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const USAGE = "bun services/deploy.ts <cloudflare|fly> [--hosted]";
const MODEL_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];

const [provider, ...options] = process.argv.slice(2);
const withHosted = options.includes("--hosted");
if (provider !== "cloudflare" && provider !== "fly") throw new Error(USAGE);

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;
// 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}.

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.`);

function models(): Record<string, string> {
const found: Record<string, string> = {};
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<string, string>): Promise<string> {
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<string, string>): Promise<string> {
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<string> {
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<string, string>; quiet?: boolean };

/** Runs in `services/<dir>`, echoing output unless `quiet` so progress stays visible, and returns it for parsing. */
async function run(cmd: string[], { dir, input, env, quiet }: Options = {}): Promise<string> {
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;
}