From 68ecdfe1e7ff2c4360210482b188bbc9e5a4273e Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 11 Jul 2026 10:25:27 -0400 Subject: [PATCH] feat(host): manage multiple Ellipsis instances (add/use/list/set/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `agent host` command group so the CLI can target Ellipsis Cloud, a preview environment, or a self-hosted deployment, and switch between them without re-exporting env vars. Config becomes a named set of hosts + an active pointer; each host keeps its own token (switching doesn't re-auth) and its own dashboard/app URL. The app/dashboard URL is derived from the API URL (api. -> app.) or set explicitly per host (--app-base) for self-hosted instances whose dashboard isn't a mechanical swap. `login` builds the device-code approval URL from the active host's app base, not the server's verification_uri_complete (which the backend defaults to prod) — this fixes beta/dev/self-hosted login and supersedes #36. Precedence unchanged (arg -> env -> active host -> prod default), so sandbox env injection still works. A pre-hosts v1 config is migrated in place on first load. Closes #36. --- README.md | 47 +++++++-- src/cli.tsx | 2 + src/commands/host.ts | 125 +++++++++++++++++++++++ src/commands/login.ts | 51 ++++++---- src/lib/auth.ts | 10 +- src/lib/config.ts | 231 ++++++++++++++++++++++++++++++++++++++---- src/lib/laptop.ts | 15 +-- src/lib/urls.ts | 11 ++ test/config.test.ts | 119 +++++++++++++++++++++- test/urls.test.ts | 16 ++- 10 files changed, 562 insertions(+), 65 deletions(-) create mode 100644 src/commands/host.ts diff --git a/README.md b/README.md index eed991f..aab211a 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,17 @@ skills: ## Usage ```sh -agent login # device-code auth (use --no-browser for SSH) -agent logout # remove stored credentials +agent login # device-code auth against the active host +agent logout # remove stored credentials (--all for every host) agent me # show the current credential's identity +agent host list # list configured hosts (the active one is marked *) +agent host add beta https://beta-api.ellipsis.dev # add a host and switch to it +agent host use prod # switch the active host +agent host current # show the active host and how it resolves +agent host set beta --rename staging # rename / re-point a host (--api-base / --app-base) +agent host delete beta # remove a host and its stored token + agent session start --config # start a session from a saved config agent session start --config-file f.json # ...or from an inline config agent session start --template welcome-to-ellipsis # ...or from a maintained template @@ -91,8 +98,9 @@ agent ping # check authenticated /v1 connectivity ``` Most commands accept `--json` to print the raw API response. The CLI talks to -the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL` -(or the legacy `ELLIPSIS_API_BASE`). +the public `/v1` REST API. Point it at a different instance durably with +`agent host` (below), or per-invocation with `ELLIPSIS_API_BASE_URL` (or the +legacy `ELLIPSIS_API_BASE`). `--watch` (on both `session start` and `session get`) streams the session's output live over WebSocket until it reaches a terminal status, falling back to @@ -109,12 +117,31 @@ approve the request in the dashboard. The issued user token is stored under **Credentials resolve in this order (highest wins):** explicit argument → environment (`ELLIPSIS_API_TOKEN` / `ELLIPSIS_API_BASE_URL`, with the legacy -`ELLIPSIS_API_BASE` accepted as a fallback) → config file → default. This lets -the CLI run headlessly — e.g. inside an Ellipsis cloud sandbox where a -per-sandbox token and base URL are injected into the environment — with no -`agent login` and no config file on disk. `agent logout` only clears the -on-disk token; a token supplied via `ELLIPSIS_API_TOKEN` lives in the -environment and keeps working until you unset it. +`ELLIPSIS_API_BASE` accepted as a fallback) → the **active host** in the config +file → default (prod). This lets the CLI run headlessly — e.g. inside an +Ellipsis cloud sandbox where a per-sandbox token and base URL are injected into +the environment — with no `agent login` and no config file on disk. `agent +logout` only clears the on-disk token (`--all` for every host); a token supplied +via `ELLIPSIS_API_TOKEN` lives in the environment and keeps working until you +unset it. + +### Hosts + +`agent host` selects which Ellipsis instance the CLI targets — Ellipsis Cloud, +a preview environment, or a self-hosted deployment — so you can switch without +re-exporting env vars. `agent host add ` registers an instance +and makes it active; `agent host use ` switches; `agent host list` shows +them all (the active one marked `*`). Each host keeps its own token (so +switching doesn't re-authenticate) and its own dashboard/app URL. The app URL +is derived from the API URL by default (`api.` → `app.`); a self-hosted instance +whose dashboard host isn't a mechanical swap sets it explicitly with `agent host +add … --app-base ` (or `agent host set --app-base `). `agent +login` then authenticates the active host, and every link the CLI prints points +at that host's dashboard. + +Hosts and tokens live in `~/.config/ellipsis/config.json` (mode 0600). A config +file from before hosts existed is migrated in place on first use — your existing +login becomes a host named for its API base — so nothing needs re-doing. ## Develop diff --git a/src/cli.tsx b/src/cli.tsx index fefc47e..d91cc45 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,5 +1,6 @@ import { Command } from 'commander' import { registerLogin } from './commands/login' +import { registerHost } from './commands/host' import { registerMe } from './commands/me' import { registerSession } from './commands/session' import { registerConfig } from './commands/config' @@ -25,6 +26,7 @@ program .version(VERSION) registerLogin(program) +registerHost(program) registerMe(program) registerSession(program) registerConfig(program) diff --git a/src/commands/host.ts b/src/commands/host.ts new file mode 100644 index 0000000..0b4438c --- /dev/null +++ b/src/commands/host.ts @@ -0,0 +1,125 @@ +import { InvalidArgumentError, type Command } from 'commander' +import { + activeHostName, + addHost, + deleteHost, + deriveAppBase, + listHosts, + resolveApiBase, + resolveAppBase, + updateHost, + useHost, +} from '../lib/config' +import { printTable } from '../lib/output' + +// `agent host …` manages the Ellipsis instances the CLI can target — prod, +// beta, or a self-hosted deployment — and which one is active. It does NOT +// authenticate: `agent host add` / `use` set WHERE the CLI points; `agent +// login` sets the credential for wherever it's pointing. Every other command +// resolves against the active host (unless ELLIPSIS_API_BASE_URL / +// ELLIPSIS_API_TOKEN override it, e.g. inside a sandbox). +export function registerHost(program: Command): void { + const host = program + .command('host') + .description('Manage the Ellipsis instances the CLI targets (prod / beta / self-hosted)') + + host + .command('list', { isDefault: false }) + .alias('ls') + .description('List configured hosts (the active one is marked *)') + .action(() => { + const hosts = listHosts() + if (hosts.length === 0) { + console.log('No hosts configured. Add one with `agent host add `.') + return + } + printTable( + ['', 'NAME', 'API', 'APP', 'LOGGED IN'], + hosts.map((h) => [ + h.active ? '*' : '', + h.name, + h.host.apiBase, + h.host.appBase ?? deriveAppBase(h.host.apiBase), + h.host.token ? 'yes' : 'no', + ]), + ) + }) + + host + .command('add ') + .description('Add a host and switch to it (then `agent login` to authenticate)') + .option( + '--app-base ', + 'dashboard URL for building links / login (default: derived from the API URL)', + ) + .action((name: string, apiUrl: string, opts: { appBase?: string }) => { + addHost(name, requireUrl(apiUrl, 'api-url'), opts.appBase && requireUrl(opts.appBase, '--app-base')) + console.log(`✓ added host "${name}" · now active`) + console.log('Run `agent login` to authenticate against it.') + }) + + host + .command('use ') + .description('Switch the active host') + .action((name: string) => { + useHost(name) + console.log(`✓ active host: ${name}`) + }) + + host + .command('set ') + .description('Change an existing host (API URL, app URL, or rename it)') + .option('--api-base ', 'change the API URL') + .option('--app-base ', 'change the dashboard URL') + .option('--rename ', 'rename the host') + .action( + (name: string, opts: { apiBase?: string; appBase?: string; rename?: string }) => { + if (!opts.apiBase && !opts.appBase && !opts.rename) { + throw new Error('nothing to change: pass --api-base, --app-base, and/or --rename') + } + updateHost(name, { + apiBase: opts.apiBase && requireUrl(opts.apiBase, '--api-base'), + appBase: opts.appBase && requireUrl(opts.appBase, '--app-base'), + rename: opts.rename, + }) + console.log(`✓ updated host "${opts.rename ?? name}"`) + }, + ) + + host + .command('delete ') + .alias('rm') + .description('Remove a host and its stored token') + .action((name: string) => { + const wasActive = activeHostName() === name + deleteHost(name) + console.log(`✓ removed host "${name}"`) + if (wasActive) { + console.log('That was the active host — set a new one with `agent host use `.') + } + }) + + host + .command('current') + .alias('show') + .description('Show the active host and how it resolves') + .action(() => { + const name = activeHostName() + const envApi = process.env.ELLIPSIS_API_BASE_URL || process.env.ELLIPSIS_API_BASE + // resolveApiBase/resolveAppBase apply the env-override precedence, so this + // reflects what commands will actually hit — not just the stored host. + console.log(`host: ${name ?? '(none — using defaults)'}`) + console.log(`api: ${resolveApiBase()}`) + console.log(`app: ${resolveAppBase()}`) + if (envApi) { + console.log('note: ELLIPSIS_API_BASE_URL is set and overrides the active host.') + } + }) +} + +function requireUrl(value: string, label: string): string { + if (!/^https?:\/\//i.test(value)) { + throw new InvalidArgumentError(`${label} must be an http(s) URL (got "${value}")`) + } + return value.replace(/\/+$/, '') +} diff --git a/src/commands/login.ts b/src/commands/login.ts index ce84a0a..e50a0b0 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,23 +1,35 @@ import type { Command } from 'commander' import { ApiClient } from '../lib/api' -import { loadConfig, saveConfig } from '../lib/config' +import { + activeHostName, + clearActiveHostToken, + clearAllTokens, + resolveAppBase, +} from '../lib/config' import { deviceLogin, openBrowser, persistToken } from '../lib/auth' +import { cliAuthUrl } from '../lib/urls' export function registerLogin(program: Command): void { program .command('login') - .description('Authenticate with Ellipsis via the device-code flow') + .description('Authenticate with Ellipsis via the device-code flow (against the active host)') .option('--no-browser', 'do not auto-open the verification URL (for headless / SSH)') .action(async (opts: { browser?: boolean }) => { const api = new ApiClient() try { const { token } = await deviceLogin(api, { onPrompt: (start) => { + // Build the approval URL from the app base of the host the CLI is + // pointed at, NOT the server's verification_uri_complete — the + // backend defaults that to prod, so a beta / self-hosted login would + // otherwise be sent to the prod dashboard (where the code can't be + // approved). See cliAuthUrl / resolveAppBase. + const verificationUrl = cliAuthUrl(resolveAppBase(), start.user_code) console.log('To authenticate, open this URL and approve the request:') - console.log(` ${start.verification_uri_complete}`) + console.log(` ${verificationUrl}`) console.log(`Verification code: ${start.user_code}`) if (opts.browser !== false) { - openBrowser(start.verification_uri_complete) + openBrowser(verificationUrl) } console.log('Waiting for approval…') }, @@ -32,20 +44,25 @@ export function registerLogin(program: Command): void { program .command('logout') - .description('Remove stored credentials') - .action(() => { - // Drop the token but keep apiBase so the next login targets the same API. - const { apiBase } = loadConfig() - saveConfig(apiBase ? { apiBase } : {}) - // logout only clears the on-disk token. A token supplied via - // ELLIPSIS_API_TOKEN (e.g. inside a sandbox) lives in the environment and - // keeps working — don't claim to have cleared what we can't. - if (process.env.ELLIPSIS_API_TOKEN) { - console.log( - 'Removed stored credentials. ELLIPSIS_API_TOKEN is still set in the environment, so that session stays active until it is unset.', - ) + .description('Remove stored credentials (the active host, or --all hosts)') + .option('--all', 'clear the stored token for every host, not just the active one') + .action((opts: { all?: boolean }) => { + // Clear only the on-disk token(s); the host entries (api/app base) stay so + // the next `agent login` targets the same instance. + if (opts.all) { + clearAllTokens() } else { - console.log('Logged out.') + clearActiveHostToken() } + // A token supplied via ELLIPSIS_API_TOKEN (e.g. inside a sandbox) lives in + // the environment and keeps working — don't claim to have cleared what we + // can't. + const envNote = process.env.ELLIPSIS_API_TOKEN + ? ' ELLIPSIS_API_TOKEN is still set in the environment, so that session stays active until it is unset.' + : '' + const scope = opts.all + ? 'all hosts' + : (activeHostName() ?? 'the active host') + console.log(`Removed stored credentials for ${scope}.${envNote}`) }) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a3ea9ec..50ea446 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import { ApiClient } from './api' -import { saveConfig, loadConfig } from './config' +import { setActiveHostToken } from './config' import type { CliAuthStart } from './types' export interface DeviceLoginHandlers { @@ -52,11 +52,11 @@ export async function deviceLogin( throw new Error('Timed out waiting for approval.') } -// Persists the token alongside the apiBase it was minted against, so a token -// obtained from a local/staging backend isn't silently reused against prod. +// Persists the token on the active host (seeding one at the resolved base if +// the user logged in before adding a host), so a token minted against one +// instance is never silently reused against another. export function persistToken(token: string): void { - const config = loadConfig() - saveConfig({ ...config, token }) + setActiveHostToken(token) } // Best-effort browser open; never throws (headless/SSH sessions just won't have diff --git a/src/lib/config.ts b/src/lib/config.ts index 1c23eef..1e218fc 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,8 +3,9 @@ import { join } from 'node:path' import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' import { DEFAULT_API_BASE } from './constants' -// TODO(security): move the token to the OS keychain (e.g. keytar) before GA. -// A 0600 file under ~/.config is fine for the skeleton, not for shipping. +// TODO(security): move host tokens to the OS keychain (e.g. keytar) before GA. +// A 0600 file under ~/.config is fine for the skeleton, not for shipping — and +// multi-host means several tokens on disk, so this matters more now. // Resolved lazily (not at import) so ELLIPSIS_CONFIG_DIR is honored at runtime. function configDir(): string { return process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis') @@ -14,19 +15,82 @@ function configFile(): string { return join(configDir(), 'config.json') } +// One Ellipsis instance the CLI can target (prod, beta, or a self-hosted +// deployment). `apiBase` is the /v1 host; `appBase` is the dashboard host used +// to build clickable links and the login verification URL — derived from +// `apiBase` by default (api. -> app.), but stored explicitly so a self-hosted +// instance whose dashboard host isn't a mechanical swap can set it directly +// (`agent host add … --app-base`). `token` is the credential minted against +// THIS instance; `enrolledRepos` is this instance's laptop-sync consent set. +export interface Host { + apiBase: string + appBase?: string + token?: string + enrolledRepos?: string[] +} + +// The config file (v2): a named set of hosts plus which one is active. Commands +// resolve against the active host unless an env var / explicit arg overrides. export interface CliConfig { + version: 2 + activeHost?: string + hosts: Record +} + +// The pre-hosts (v1) file shape — a single flat instance. Kept only so +// loadConfig can migrate an existing install in place (a shipped CLI must not +// silently drop a user's stored token when the schema changes). +interface CliConfigV1 { token?: string apiBase?: string - // Repositories ("owner/name") enrolled for laptop transcript sync — the - // per-repo opt-in consent gate for `agent session sync`. A sync whose cwd - // resolves to a repo outside this set is a silent no-op. enrolledRepos?: string[] } +// Swap the `api` host label for `app` (api.ellipsis.dev -> app.ellipsis.dev, +// beta-api.ellipsis.dev -> beta-app.ellipsis.dev). An unrecognized host (a +// self-hosted deployment whose dashboard isn't a mechanical swap) is returned +// unchanged — set the app base explicitly via `agent host … --app-base`. +export function deriveAppBase(apiBase: string): string { + const base = apiBase.replace(/\/+$/, '') + return base.replace('://api.', '://app.').replace('-api.', '-app.') +} + +// A friendly default name for a host seeded from a bare API base: the prod URL +// is "prod", a `