Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/host/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,15 @@ const root = join(home, "channels");
* The local human, named by their Tailscale login so they are the same participant on every
* host. Without Tailscale the host is single-user and the OS account name stands in.
*/
const current = identity();
const current: { login?: string; tailnet?: string } = config.demo
? { tailnet: "demo" }
: identity();
export const user = config.user || current.login || userInfo().username;

/**
* Channels belong to the tailnet they were created on, and a host shows only its own tailnet's.
* Like `user`, it is fixed when the process starts; without Tailscale the host has no channels.
* ace demo's throwaway host has a tailnet of its own, so it needs no Tailscale.
*/
export const tailnet = current.tailnet;

Expand Down
45 changes: 43 additions & 2 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { existsSync, realpathSync } from "node:fs";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { parseArgs } from "node:util";

Expand All @@ -13,6 +14,8 @@ import { Connection, request } from "./client";
import { config } from "./config";
import * as directory from "./directory";
import * as web from "./web";
import { OWNER, SCRIPT } from "./demo-data";
import { DEMO_MODEL } from "./demo-model";
import { serve } from "./gateway";
import type { GatewayClient, Watch } from "./gateway-client";
import { forget, store } from "./keys";
Expand Down Expand Up @@ -52,6 +55,8 @@ import {
} from "./manage";
import { move } from "./move";

const DEMO_PORT = 4150;

const USAGE = `ace — channels on this host

ace new [--project <dir>] [--name <name>] [--model <provider/id>]
Expand Down Expand Up @@ -96,6 +101,8 @@ const USAGE = `ace — channels on this host
serve the app and its WebSocket gateway;
--loopback stays off the tailnet and the directory;
--demo runs scripted synthetic teammates (docs/demo.md)
ace demo [--port ${DEMO_PORT}] a throwaway host of made-up teammates, channels and
agent replies; uses no keys, models or Tailscale
ace logs [--channel <ref>] [--trace <id>] [--level warn] [--since 10m] [--grep <text>]
[-n 200] [--follow] [--json] every process's logs, merged in time order
ace open [--port ${config.port}] open this host's authenticated app
Expand Down Expand Up @@ -412,6 +419,38 @@ function advice(conflict: HostConflict): string {
return "Leave it running. Use an ace from the same version, or give this ace its own host: set ACE_HOME and ACE_PORT to unused values and run ace serve --loopback.";
}

/**
* Runs `serve` again as a host of its own: catalog and keys read their home and identity when they
* load, and the demo must never reach the person's channels, keys or tailnet. Its home goes when it
* stops.
*/
async function runDemo(port: string): Promise<number> {
const home = mkdtempSync(resolve(tmpdir(), "ace-demo-"));
const script = resolve(home, "demo.json");
writeFileSync(script, `${JSON.stringify(SCRIPT, null, "\t")}\n`);
const [executable, ...args] = config.cli;
const child = Bun.spawn([executable, ...args, "serve", "--loopback", "--demo", script], {
// The app's command runs as the CLI only under its own name.
argv0: process.argv0,
stdio: ["inherit", "inherit", "inherit"],
env: {
...process.env,
ACE_HOME: home,
ACE_CONFIG_HOME: resolve(home, "config"),
ACE_KEYCHAIN_SERVICE: "ace-demo",
ACE_PORT: port,
ACE_USER: OWNER,
ACE_MODEL: DEMO_MODEL,
ACE_DEMO: script,
},
});
// Ctrl-C reaches the host too; this waits for it to stop its workers before removing the home.
process.on("SIGINT", () => {});
const code = await child.exited;
rmSync(home, { recursive: true, force: true });
return code;
}

async function main() {
if (flags.version) {
return console.log(
Expand All @@ -423,7 +462,7 @@ async function main() {
if (flags.help || !command || command === "help") return console.log(HELP);
// Shells start background jobs with SIGINT ignored and Bun keeps that, so `kill -INT` would not
// end a backgrounded watch. A listener takes SIGINT back; serve shuts down with its own.
if (command !== "serve") process.on("SIGINT", () => process.exit(130));
if (command !== "serve" && command !== "demo") process.on("SIGINT", () => process.exit(130));
if (command !== "serve" && command !== "logs") {
openLog("cli");
log("info", "cli.command", { command, ...(ref ? { ref } : {}) });
Expand Down Expand Up @@ -607,6 +646,8 @@ async function main() {
}
case "serve":
return serve(Number(flags.port || config.port), !!flags.loopback, flags.demo);
case "demo":
return process.exit(await runDemo(flags.port || String(DEMO_PORT)));
case "key": {
const name = rest[0];
if (!name || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
Expand Down
8 changes: 8 additions & 0 deletions apps/host/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,19 @@ export const config = {
version: undefined as string | undefined,
/** Set by ace serve --loopback; the host stays off the tailnet and the team directory. */
loopback: false,
/** ace demo's script, set only in its throwaway host, whose agents answer from the script. */
demo: process.env.ACE_DEMO,
worker: [
process.execPath,
"--no-env-file",
fileURLToPath(new URL("./worker.ts", import.meta.url)),
] as [string, ...string[]],
/** This command line, which ace demo runs again as its own host. */
cli: [
process.execPath,
"--no-env-file",
fileURLToPath(new URL("./cli.ts", import.meta.url)),
] as [string, ...string[]],
};

const ports: Record<string, number> = { dev: 4141, canary: 4142 };
Expand Down
Loading