From ad79b1dcbf524f2a75aa9aee3780c4c6c3b95fe2 Mon Sep 17 00:00:00 2001 From: Lewis Marshall Date: Wed, 29 Jul 2026 13:01:12 +0100 Subject: [PATCH 1/4] feat(accounts): add local server login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ably accounts login --local` for pointing the CLI at a locally-running Ably server. A local server is stored as an ordinary account profile, so `accounts list`/`current`/`switch` and every data plane command work unchanged. A local profile skips OAuth entirely: there is no authorization server and no account directory to query. The app ID is read out of the API key, making the key the only data plane credential needed. The alias becomes the account name, since a local server has no server-side identity to name it by. `--url` is decomposed into the SDK's endpoint/port/tls client options, which getClientOptions now resolves from account config as well as flags and env. Previously the port could only be supplied per-command via hidden dev flags, as ABLY_ENDPOINT sets the host alone. `--control-url` optionally configures a locally-run control plane. ControlApi gained a controlUrl option because it inferred its scheme and path prefix from the host string, which a localhost:port value does not match. Without a control plane, control commands now fail fast with a pointer rather than silently querying control.ably.net while a local profile is selected. The "Using:" banner gains an Endpoint field, shown only when an environment variable redirects a command away from its profile — a profile's own routing is a deliberate choice the account name already identifies. Empty account IDs and redundant name lines are suppressed in `accounts list`/`current` accordingly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Local-Server.md | 145 ++++++++ docs/Project-Structure.md | 3 + src/base-command.ts | 84 ++++- src/commands/accounts/current.ts | 2 +- src/commands/accounts/list.ts | 14 +- src/commands/accounts/login.ts | 323 +++++++++++++++++- src/commands/accounts/switch.ts | 19 ++ src/control-base-command.ts | 27 ++ src/data/env-vars.ts | 13 +- src/flags.ts | 26 ++ src/services/config-manager.ts | 113 +++++- src/services/control-api.ts | 43 ++- src/types/cli.ts | 3 + src/utils/api-key.ts | 11 + src/utils/prompt-value.ts | 33 ++ src/utils/server-url.ts | 87 +++++ test/helpers/mock-config-manager.ts | 68 +++- test/unit/base/auth-info-display.test.ts | 95 ++++++ test/unit/base/base-command.test.ts | 101 ++++++ .../base/local-server-control-guard.test.ts | 87 +++++ test/unit/commands/accounts/login.test.ts | 200 +++++++++++ test/unit/data/env-vars.test.ts | 2 +- test/unit/services/config-manager.test.ts | 95 ++++++ test/unit/utils/server-url.test.ts | 125 +++++++ 24 files changed, 1687 insertions(+), 32 deletions(-) create mode 100644 docs/Local-Server.md create mode 100644 src/utils/prompt-value.ts create mode 100644 src/utils/server-url.ts create mode 100644 test/unit/base/local-server-control-guard.test.ts create mode 100644 test/unit/utils/server-url.test.ts diff --git a/docs/Local-Server.md b/docs/Local-Server.md new file mode 100644 index 000000000..0d945ddf8 --- /dev/null +++ b/docs/Local-Server.md @@ -0,0 +1,145 @@ +# Local Servers + +The CLI can target a locally-running Ably server instead of the managed service. A local server is stored as an ordinary account profile, so `ably accounts list`, `ably accounts current` and `ably accounts switch` all work as usual and every data plane command runs unchanged. + +--- + +## Getting started + +The guided flow prompts for everything it needs: + +```bash +ably accounts login --local +``` + +It asks for: + +1. **Data plane URL** — defaults to `http://localhost:8081` +2. **API key** — for a key you already hold on the server (input is masked) +3. **Whether the control plane is also running locally** — and if so, its URL and a Control API access token + +The explicit form skips the prompts and is what you want in scripts: + +```bash +# Data plane only +ably accounts login --local --url http://localhost:8081 + +# Data plane and control plane +ably accounts login --local \ + --url http://localhost:8081 \ + --control-url http://localhost:8082 +``` + +Passing `--url` disables the prompts for the data plane URL and the control plane question; the API key and Control API token still prompt unless supplied via the environment (see [Non-interactive use](#non-interactive-use)). + +Once logged in, everything works as it does against the managed service: + +```bash +ably channels publish my-channel "hello" +ably channels subscribe my-channel +ably accounts switch production # back to a managed account +ably accounts switch local # and back again +``` + +--- + +## URLs + +Both `--url` and `--control-url` take a full URL. A missing scheme defaults to `http` for loopback hosts (`localhost`, `127.0.0.1`, `::1`) and `https` for everything else, so `--url localhost:8081` and `--url http://localhost:8081` are equivalent. + +The data plane URL must not include a path: Ably routes by hostname, so a path would be silently discarded. The control plane URL may include one — if it does, it is used verbatim as the API prefix; if it does not, `/v1` is appended (matching the dedicated Control API service). + +Internally the data plane URL is decomposed into the SDK's `endpoint`, `port`/`tlsPort` and `tls` client options and stored on the account, which is why you do not need to repeat host or port flags on individual commands. + +--- + +## The API key is the only data plane credential + +There is no OAuth flow and no account directory on a local server. The app ID is read from the API key itself (`.:`), so the key is the only thing you need to supply for the data plane to work. + +The key is validated for shape at login. A key without the `.:` structure is rejected with a message rather than being stored and failing later. + +--- + +## Running only the data plane + +Running the control plane locally is optional. When you log in without `--control-url`, control plane commands (`ably apps`, `ably auth keys`, `ably integrations`, `ably queues`, `ably stats`) fail immediately with: + +``` +Control API commands aren't available for local server "local" because it was configured without a control plane URL. +Re-run "ably accounts login --local" with --control-url if you are running the control plane locally, or switch to a managed account with "ably accounts switch". +``` + +This is deliberate: without the guard those commands would silently query the managed Control API at `control.ably.net` while a local profile is selected. + +To add a control plane later, re-run `ably accounts login --local` with `--control-url`. + +--- + +## Knowing which server you are on + +The account name in the "Using:" banner is the profile's alias, so the banner names the server you are on the same way it names a managed account: + +``` +Using: Account=dev • App=Local App (localapp) • Key=Local Key (localapp.keyid) +``` + +The endpoint itself is not repeated on every command — it is part of the profile you deliberately configured. Run `ably accounts current` or `ably accounts list` to see the stored URL. + +An `Endpoint=` field appears only when an environment variable redirects a command away from its profile, since that is ambient state you may have forgotten you exported: + +``` +$ export ABLY_ENDPOINT=other.example.com +$ ably channels publish my-channel "hello" +Using: Account=dev • Endpoint=http://other.example.com:8081 • App=… +``` + +Note that `ABLY_ENDPOINT` replaces only the host — the port and TLS setting still come from the profile, which is why the displayed URL is the combination that traffic will actually use. On control plane commands the equivalent override is `ABLY_CONTROL_HOST`. + +--- + +## Aliases + +Local logins default to the alias `local`, and the alias becomes the profile's account name — a local server has no server-side identity to name it by. Re-running against a different URL updates that profile in place and warns you first. Use `--alias` to keep several servers side by side: + +```bash +ably accounts login --local --url http://localhost:8081 --alias dev +ably accounts login --local --url http://localhost:9091 --alias staging-local +ably accounts switch dev +``` + +--- + +## Non-interactive use + +With `--json` the CLI cannot prompt, so credentials must come from the environment and `--url` is required: + +```bash +export ABLY_API_KEY="localapp.keyid:keysecret" +export ABLY_ACCESS_TOKEN="local-control-token" # only if using --control-url + +ably accounts login --local --url http://localhost:8081 --json +``` + +`ABLY_API_KEY` and `ABLY_ACCESS_TOKEN` are also used in place of the prompts in interactive mode when they are set. + +--- + +## One-off overrides without a profile + +If you do not want to store a profile, the hidden dev flags still work and take precedence over stored values on the command they are passed to: + +```bash +export ABLY_API_KEY="localapp.keyid:keysecret" +export ABLY_ENDPOINT=localhost +ably channels publish my-channel "hello" --tls=false --port 8081 +``` + +Set `ABLY_SHOW_DEV_FLAGS=true` to un-hide `--port`, `--tls-port`, `--tls`, `--control-host` and `--endpoint` in `--help` output. Note that `ABLY_ENDPOINT` only sets the host — there is no environment variable for the port, which is the main reason to store a profile instead. + +--- + +## Related + +- [Environment Variables — General Usage](Environment-Variables/General-Usage.md) +- [Project Structure](Project-Structure.md) diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 56d11c984..07c5fa8c6 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -73,6 +73,7 @@ This document outlines the directory structure of the Ably CLI project. │ └── utils/ │ ├── channel-rule-display.ts # Channel rule human-readable display │ ├── chat-constants.ts # Shared Chat SDK constants (REACTION_TYPE_MAP) +│ ├── server-url.ts # Local server URL parsing (parseServerUrl, formatServerUrl) │ ├── errors.ts # Error utilities (errorMessage) │ ├── interrupt-feedback.ts # Ctrl+C feedback messages │ ├── json-formatter.ts # JSON output formatting (formatJson, formatMessageData) @@ -86,6 +87,7 @@ This document outlines the directory structure of the Ably CLI project. │ ├── output.ts # Output helpers (progress, success, resource, etc.) │ ├── pagination.ts # Generic pagination utilities (collectPaginatedResults, collectFilteredPaginatedResults) │ ├── prompt-confirmation.ts # Y/N confirmation prompts +│ ├── prompt-value.ts # Free-text and masked value prompts │ ├── readline-helper.ts # Readline utilities for interactive mode │ ├── sigint-exit.ts # SIGINT/Ctrl+C handling (exit code 130) │ ├── string-distance.ts # Levenshtein distance for fuzzy matching @@ -175,3 +177,4 @@ This document outlines the directory structure of the Ably CLI project. - [Debugging Guide](Debugging.md) — Debugging tips for CLI development - [Interactive REPL](Interactive-REPL.md) — Architecture of `src/commands/interactive.ts` - [Exit Codes](Exit-Codes.md) — Exit codes handled in `src/commands/interactive.ts` and `src/utils/sigint-exit.ts` +- [Local Servers](Local-Server.md) — Pointing the CLI at a locally-running Ably server via `ably accounts login --local` diff --git a/src/base-command.ts b/src/base-command.ts index 917579947..ad5a16652 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -14,6 +14,7 @@ import { extractKeyNameFromApiKey, } from "./utils/api-key.js"; import { CommandError } from "./errors/command-error.js"; +import { formatServerUrl } from "./utils/server-url.js"; import { getFriendlyAblyErrorHint } from "./utils/errors.js"; import { coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; @@ -619,6 +620,17 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { ); } + // Surface routing only when an environment variable overrides the profile. + // A profile's own endpoint is a deliberate, persistent choice that the + // account name already identifies — repeating it on every command is noise. + // An env var, by contrast, is ambient and easy to forget you exported. + const routingOverride = this.formatRoutingOverride(showAppInfo); + if (routingOverride) { + displayParts.push( + `${chalk.blue("Endpoint=")}${chalk.blue.bold(routingOverride)}`, + ); + } + // For data plane commands, show app and auth info if (showAppInfo) { // Get app info @@ -626,8 +638,15 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { if (appId) { let appName = this.configManager.getAppName(appId); - // If app name is missing, try to fetch it from the API and update config - if (!appName) { + // If app name is missing, try to fetch it from the API and update config. + // Local server accounts with no control plane configured have nowhere + // to look it up, so skip straight to the fallback label. + const hasControlPlane = + this.configManager.getAuthMethod() !== "apiKey" || + Boolean(this.configManager.getControlUrl()); + if (!appName && !hasControlPlane) { + appName = "Unknown App"; + } else if (!appName) { try { // Get access token for control API const accessToken = @@ -648,6 +667,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { flags["control-host"] ?? process.env.ABLY_CONTROL_HOST ?? account?.controlHost, + controlUrl: this.configManager.getControlUrl(), }); const app = await controlApi.getApp(appId); appName = app.name; @@ -719,6 +739,34 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } } + /** + * Render the effective endpoint when an environment variable redirects the + * command away from what the current profile is configured with, or + * undefined when the profile's own routing is in force. + */ + private formatRoutingOverride(showAppInfo: boolean): string | undefined { + if (!showAppInfo) { + // Control plane: ABLY_CONTROL_HOST is the only ambient override. + return process.env.ABLY_CONTROL_HOST; + } + + const override = process.env.ABLY_ENDPOINT; + if (!override) return undefined; + + const dataPlane = this.configManager.getDataPlane(); + // Setting the env var to what the profile already says is not a redirect. + if (override === dataPlane?.endpoint) return undefined; + + // ABLY_ENDPOINT only replaces the host; port and TLS still come from the + // profile, so show the combination that traffic will actually use. + return formatServerUrl({ + host: override, + path: "", + port: dataPlane?.port, + tls: dataPlane?.tls ?? true, + }); + } + /** * Display information for control plane commands * Shows only account information @@ -1067,23 +1115,37 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } } - // Endpoint resolution: ABLY_ENDPOINT env → account config - const endpoint = - process.env.ABLY_ENDPOINT || this.configManager.getEndpoint(); + // Data plane routing: flags → env → account config. Local server accounts + // persist host, port and TLS together (see `ably accounts login --local`), + // so a stored profile targets localhost without repeating dev flags on + // every command. Explicit flags still win, for one-off overrides. + const dataPlane = this.configManager.getDataPlane(); + + const endpoint = process.env.ABLY_ENDPOINT || dataPlane?.endpoint; if (endpoint) { options.endpoint = endpoint; } - if (flags.port) { - options.port = flags.port; + if (flags.tls !== undefined) { + options.tls = flags.tls === "true"; + } else if (dataPlane?.tls !== undefined) { + options.tls = dataPlane.tls; } - if (flags["tls-port"]) { - options.tlsPort = flags["tls-port"]; + // The SDK reads `port` when TLS is off and `tlsPort` when it is on, so the + // single stored port has to be routed to whichever one is live. + const tlsEnabled = options.tls !== false; + + if (flags.port !== undefined) { + options.port = flags.port; + } else if (dataPlane?.port !== undefined && !tlsEnabled) { + options.port = dataPlane.port; } - if (flags.tls) { - options.tls = flags.tls === "true"; + if (flags["tls-port"] !== undefined) { + options.tlsPort = flags["tls-port"]; + } else if (dataPlane?.port !== undefined && tlsEnabled) { + options.tlsPort = dataPlane.port; } // Always add a log handler to control SDK output formatting and destination diff --git a/src/commands/accounts/current.ts b/src/commands/accounts/current.ts index 0d33c98e8..b7cc9cc23 100644 --- a/src/commands/accounts/current.ts +++ b/src/commands/accounts/current.ts @@ -145,7 +145,7 @@ export default class AccountsCurrent extends ControlBaseCommand { // Show cached information this.log( - `${formatLabel("Account (cached)")} ${chalk.cyan.bold(currentAccount.accountName)} ${chalk.gray(`(${currentAccount.accountId})`)}`, + `${formatLabel("Account (cached)")} ${chalk.cyan.bold(currentAccount.accountName)}${currentAccount.accountId ? ` ${chalk.gray(`(${currentAccount.accountId})`)}` : ""}`, ); if (currentAccount.userEmail) { diff --git a/src/commands/accounts/list.ts b/src/commands/accounts/list.ts index f167e975d..4031cbf12 100644 --- a/src/commands/accounts/list.ts +++ b/src/commands/accounts/list.ts @@ -71,10 +71,16 @@ export default class AccountsList extends ControlBaseCommand { titleStyle(`Account: ${alias}`) + (isCurrent ? chalk.green(" (current)") : ""), ); - this.log( - ` ${formatLabel("Name")} ${account.accountName} (${account.accountId})`, - ); - this.log(` ${formatLabel("User")} ${account.userEmail}`); + // A local server account is named after its alias and has no + // server-assigned ID, so the Name line would just repeat the heading. + if (account.accountName !== alias || account.accountId) { + this.log( + ` ${formatLabel("Name")} ${account.accountName}${account.accountId ? ` (${account.accountId})` : ""}`, + ); + } + if (account.userEmail) { + this.log(` ${formatLabel("User")} ${account.userEmail}`); + } // Count number of apps configured for this account const appCount = account.apps ? Object.keys(account.apps).length : 0; diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index 0fedd0932..d9dce2ae7 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -4,17 +4,30 @@ import * as readline from "node:readline"; import ora from "ora"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { endpointFlag } from "../../flags.js"; +import { endpointFlag, localServerFlags } from "../../flags.js"; import { ControlApi } from "../../services/control-api.js"; import { OAuthClient, type OAuthTokens } from "../../services/oauth-client.js"; import { BaseFlags } from "../../types/cli.js"; +import { extractAppIdFromApiKey, isValidApiKey } from "../../utils/api-key.js"; +import { + formatServerUrl, + parseServerUrl, + type ServerUrl, +} from "../../utils/server-url.js"; import { errorMessage } from "../../utils/errors.js"; import { displayLogo } from "../../utils/logo.js"; import openUrl from "../../utils/open-url.js"; import { formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; +import { promptForValue } from "../../utils/prompt-value.js"; import { pickUniqueAlias, slugifyAccountName } from "../../utils/slugify.js"; +/** Offered as the default when the guided local flow prompts for a URL. */ +const DEFAULT_LOCAL_URL = "http://localhost:8081"; + +/** Alias used for a local server when --alias is not supplied. */ +const DEFAULT_LOCAL_ALIAS = "local"; + function validateAndGetAlias( input: string, logFn: (msg: string) => void, @@ -51,6 +64,9 @@ export default class AccountsLogin extends ControlBaseCommand { "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --alias mycompany", "<%= config.bin %> <%= command.id %> --no-browser", + "<%= config.bin %> <%= command.id %> --local", + "<%= config.bin %> <%= command.id %> --local --url http://localhost:8081", + "<%= config.bin %> <%= command.id %> --local --url http://localhost:8081 --control-url http://localhost:8082", "<%= config.bin %> <%= command.id %> --json", "<%= config.bin %> <%= command.id %> --pretty-json", ]; @@ -58,6 +74,7 @@ export default class AccountsLogin extends ControlBaseCommand { static override flags = { ...ControlBaseCommand.globalFlags, ...endpointFlag, + ...localServerFlags, alias: Flags.string({ char: "a", description: "Alias for this account (default account if not specified)", @@ -87,6 +104,13 @@ export default class AccountsLogin extends ControlBaseCommand { displayLogo(this.log.bind(this)); } + // A local server has no authorization server and no account directory, so + // it skips OAuth entirely and writes the profile from user-supplied values. + if (flags.local) { + await this.runLocalLogin(flags); + return; + } + let oauthTokens: OAuthTokens; try { oauthTokens = await this.oauthLogin(flags); @@ -392,6 +416,303 @@ export default class AccountsLogin extends ControlBaseCommand { } } + /** + * Log in to a locally-running server. + * + * There is no authorization server and no account directory to query, so the + * profile is assembled from the server URL and an API key the user already + * holds. The app ID is read out of the key, which makes the key the only + * credential needed for the data plane. + */ + private async runLocalLogin(flags: BaseFlags): Promise { + if (this.isWebCliMode) { + this.fail( + "The --local flag is not available in web CLI mode.", + flags, + "accountLogin", + ); + } + + const jsonMode = this.shouldOutputJson(flags); + // Passing --url signals the explicit, scriptable form; a bare + // `--local` walks through every prompt like the managed flow does. + const guided = !flags.url && !jsonMode; + + const dataPlane = await this.resolveDataPlaneUrl(flags, guided); + const apiKey = await this.resolveLocalApiKey(flags, jsonMode); + + if (!isValidApiKey(apiKey)) { + this.fail( + 'Invalid API key — expected the form ".:" so the app ID can be read from it.', + flags, + "accountLogin", + ); + } + const appId = extractAppIdFromApiKey(apiKey); + + const control = await this.resolveControlPlane(flags, guided, jsonMode); + + const alias = (flags.alias as string | undefined) ?? DEFAULT_LOCAL_ALIAS; + this.warnOnLocalAliasRebind(alias, dataPlane, flags); + + this.configManager.storeLocalAccount(alias, { + accessToken: control?.accessToken, + // A local server has no server-side identity to name it by, so the alias + // is the account name. That keeps several local profiles distinguishable + // in the "Using:" banner, exactly as managed accounts are by their name. + accountName: alias, + controlUrl: control?.url, + dataPlane: { + endpoint: dataPlane.host, + port: dataPlane.port, + tls: dataPlane.tls, + }, + }); + this.configManager.switchAccount(alias); + this.configManager.setCurrentApp(appId); + this.configManager.storeAppKey(appId, apiKey); + + // Only a local control plane can resolve the app's name; without one the + // app ID from the key is all we have to show. + const appName = control + ? await this.lookupLocalAppName(appId, control, flags) + : undefined; + if (appName) { + this.configManager.storeAppInfo(appId, { appName }); + } + + const dataPlaneUrl = formatServerUrl(dataPlane); + + if (jsonMode) { + this.logJsonResult( + { + account: { + alias, + app: { + id: appId, + ...(appName ? { name: appName } : {}), + }, + authMethod: "apiKey", + ...(control ? { controlUrl: control.url } : {}), + dataPlane: { + endpoint: dataPlane.host, + port: dataPlane.port, + tls: dataPlane.tls, + url: dataPlaneUrl, + }, + name: alias, + }, + }, + flags, + ); + } else { + this.logSuccessMessage( + `Logged in to local server at ${formatResource(dataPlaneUrl)}.`, + flags, + ); + this.log(`Account ${formatResource(alias)} is now the current account`); + this.logSuccessMessage( + `Using app ${formatResource(appName ?? appId)} (${appId}).`, + flags, + ); + + if (control) { + this.logSuccessMessage( + `Control plane configured at ${formatResource(control.url)}.`, + flags, + ); + } else { + this.logToStderr( + "No control plane configured — app and key management commands are unavailable for this account.", + ); + } + } + } + + /** Resolve and validate the data plane URL for a local login. */ + private async resolveDataPlaneUrl( + flags: BaseFlags, + guided: boolean, + ): Promise { + let raw = flags.url; + + if (!raw) { + if (!guided) { + this.fail( + "The --url flag is required when using --local with --json.", + flags, + "accountLogin", + ); + } + + raw = await promptForValue("Data plane URL:", { + defaultValue: DEFAULT_LOCAL_URL, + }); + } + + const server = this.parseUrlOrFail(raw, "data plane", flags); + + // The SDK routes by hostname, so a path would be silently discarded. + if (server.path) { + this.fail( + `The data plane URL must not include a path (got "${server.path}"). Ably routes by host, so use a value like ${DEFAULT_LOCAL_URL}.`, + flags, + "accountLogin", + ); + } + + return server; + } + + /** Resolve the API key for a local login, from the environment or a prompt. */ + private async resolveLocalApiKey( + flags: BaseFlags, + jsonMode: boolean, + ): Promise { + const fromEnv = process.env.ABLY_API_KEY?.trim(); + if (fromEnv) { + if (!jsonMode) { + this.log("Using API key from ABLY_API_KEY."); + } + return fromEnv; + } + + if (jsonMode) { + this.fail( + "No API key available. Set ABLY_API_KEY when using --local with --json.", + flags, + "accountLogin", + ); + } + + return promptForValue("API key:", { secret: true }); + } + + /** + * Resolve the optional local control plane. Returns undefined when the user + * is only running the data plane, which leaves control commands disabled for + * the account. + */ + private async resolveControlPlane( + flags: BaseFlags, + guided: boolean, + jsonMode: boolean, + ): Promise { + let raw = flags["control-url"]; + + if (!raw && guided) { + const runningLocally = await promptForConfirmation( + "Are you also running the control plane locally?", + ); + if (runningLocally) { + raw = await promptForValue("Control plane URL:"); + } + } + + if (!raw) return undefined; + + const url = formatServerUrl( + this.parseUrlOrFail(raw, "control plane", flags), + ); + + let accessToken = process.env.ABLY_ACCESS_TOKEN?.trim(); + if (accessToken) { + if (!jsonMode) { + this.log("Using Control API access token from ABLY_ACCESS_TOKEN."); + } + } else { + if (jsonMode) { + this.fail( + "No Control API access token available. Set ABLY_ACCESS_TOKEN when using --control-url with --json.", + flags, + "accountLogin", + ); + } + + accessToken = await promptForValue("Control API access token:", { + secret: true, + }); + } + + return { accessToken, url }; + } + + private parseUrlOrFail( + raw: string, + label: string, + flags: BaseFlags, + ): ServerUrl { + try { + return parseServerUrl(raw); + } catch (error) { + this.fail( + `Invalid ${label} URL: ${errorMessage(error)}`, + flags, + "accountLogin", + ); + } + } + + /** + * Warn before an alias is repointed. Local logins default to a fixed alias, + * so re-running against a different server would otherwise silently rebind + * an existing profile. + */ + private warnOnLocalAliasRebind( + alias: string, + dataPlane: ServerUrl, + flags: BaseFlags, + ): void { + const existing = this.configManager + .listAccounts() + .find((a) => a.alias === alias); + if (!existing) return; + + if (existing.account.authMethod !== "apiKey") { + this.logWarning( + `Alias "${alias}" is currently the managed account ${existing.account.accountName || existing.account.accountId} and will be replaced by this local server. Use --alias to keep both.`, + flags, + ); + return; + } + + const current = formatServerUrl({ + host: existing.account.endpoint ?? "", + path: "", + port: existing.account.port, + tls: existing.account.tls ?? true, + }); + const next = formatServerUrl(dataPlane); + if (current !== next) { + this.logWarning( + `Alias "${alias}" currently points at ${current} and will be updated to ${next}. Use --alias to keep both.`, + flags, + ); + } + } + + /** Best-effort app name lookup against a local control plane. */ + private async lookupLocalAppName( + appId: string, + control: { accessToken: string; url: string }, + flags: BaseFlags, + ): Promise { + try { + const controlApi = new ControlApi({ + accessToken: control.accessToken, + controlUrl: control.url, + }); + const app = await controlApi.getApp(appId); + return app.name; + } catch (error) { + this.logWarning( + `Could not look up app ${appId} on the local control plane: ${errorMessage(error)}`, + flags, + ); + return undefined; + } + } + private async oauthLogin(flags: BaseFlags): Promise { const oauthClient = new OAuthClient({ oauthHost: flags["oauth-host"], diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index f40dade4e..ae0dfb164 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -276,6 +276,25 @@ export default class AccountsSwitch extends ControlBaseCommand { this.configManager.storeEndpoint(flags.endpoint as string); } + // A local server account with no control plane has no /me endpoint to + // verify against, so report the switch and stop rather than emitting a + // spurious token-verification warning. + if ( + this.configManager.getAuthMethod() === "apiKey" && + !this.configManager.getControlUrl() + ) { + const endpoint = this.configManager.getEndpoint(); + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ account: { alias, authMethod: "apiKey" } }, flags); + } else { + this.logSuccessMessage( + `Switched to local server ${formatResource(alias)}${endpoint ? ` (${endpoint})` : ""}.`, + flags, + ); + } + return; + } + // Verify the account is valid by making an API call. try { const controlApi = this.createControlApi(flags); diff --git a/src/control-base-command.ts b/src/control-base-command.ts index 8b39a74b6..e096ffe04 100644 --- a/src/control-base-command.ts +++ b/src/control-base-command.ts @@ -23,6 +23,32 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { ? undefined : this.configManager.getCurrentAccount(); + // Routing is resolved from the selected account even when the token comes + // from the environment — the local control plane URL is a property of the + // profile, not of the credentials. + const currentAccount = this.configManager.getCurrentAccount(); + const hasHostOverride = Boolean( + flags["control-host"] || process.env.ABLY_CONTROL_HOST, + ); + const controlUrl = hasHostOverride + ? undefined + : this.configManager.getControlUrl(); + + // A local server account only reaches a control plane if one was given at + // login. Fail with a pointer rather than silently querying the managed + // Control API while a local profile is selected. + if ( + currentAccount?.authMethod === "apiKey" && + !controlUrl && + !hasHostOverride + ) { + this.fail( + `Control API commands aren't available for local server "${this.configManager.getCurrentAccountAlias()}" because it was configured without a control plane URL.\nRe-run "ably accounts login --local" with --control-url if you are running the control plane locally, or switch to a managed account with "ably accounts switch".`, + flags, + "auth", + ); + } + if (!accessToken) { if (!account) { this.fail( @@ -77,6 +103,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { return new ControlApi({ accessToken, controlHost, + controlUrl, tokenRefreshMiddleware, }); } diff --git a/src/data/env-vars.ts b/src/data/env-vars.ts index 48fa03633..e1ae09831 100644 --- a/src/data/env-vars.ts +++ b/src/data/env-vars.ts @@ -306,7 +306,18 @@ const ABLY_ENDPOINT = new EnvVarEntry( `export ABLY_ENDPOINT="custom-endpoint.example.com"`, `ably channels publish my-channel "Hello"`, ]), - [], + [ + new DetailSection("Local servers", [ + { + kind: "paragraph", + text: "Sets the **host only** — there is no environment variable for the port. Pair it with the hidden `--port`, `--tls-port` and `--tls` flags for one-off use.", + }, + { + kind: "important", + text: "To store host, port and TLS together so they apply to every command, run `ably accounts login --local --url http://localhost:8081` instead.", + }, + ]), + ], ); const AUTH_RESOLUTION_ORDER = new CrossCuttingSection( diff --git a/src/flags.ts b/src/flags.ts index f88a681fc..4d9f57b32 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -86,6 +86,32 @@ export const oauthHostFlag = { }), }; +/** + * Local server flags for `accounts login` only. + * + * `--url` and `--control-url` are full URLs rather than the SDK's separate + * host/port/tls options: one value is easier to supply and is decomposed by + * `parseServerUrl()` at login time. + */ +export const localServerFlags = { + // No default: oclif's dependsOn treats a defaulted flag as always present, + // which would stop it rejecting --url without --local. + local: Flags.boolean({ + description: + "Log in to a locally-running Ably server instead of the managed service", + }), + url: Flags.string({ + dependsOn: ["local"], + description: + "Data plane URL of the local server (e.g. http://localhost:8081)", + }), + "control-url": Flags.string({ + dependsOn: ["local"], + description: + "Control plane URL of the local server, if you are running it locally (e.g. http://localhost:8082)", + }), +}; + /** * endpoint flag for login / accounts switch commands only. */ diff --git a/src/services/config-manager.ts b/src/services/config-manager.ts index 2ce45c475..ec9cc14aa 100644 --- a/src/services/config-manager.ts +++ b/src/services/config-manager.ts @@ -14,9 +14,23 @@ export interface AppConfig { keyName?: string; } +/** + * Data plane routing for an account, as supplied by `--endpoint` (managed) or + * decomposed from `--url` (local server). Mirrors the SDK's client options: + * `endpoint` is a bare hostname, and the port applies to `tlsPort` or `port` + * depending on `tls`. + */ +export interface DataPlaneConfig { + endpoint?: string; + port?: number; + tls?: boolean; +} + export interface AccountConfig { // Legacy: pre-OAuth configs store the access token directly on the account. // OAuth accounts use oauthSessionKey to reference a shared OAuthSession instead. + // Local server accounts reuse this field for the Control API access token, + // which has no refresh flow. // Do not remove — needed for backward compatibility with existing configs. accessToken?: string; accessTokenExpiresAt?: number; @@ -25,10 +39,16 @@ export interface AccountConfig { apps?: { [appId: string]: AppConfig; }; - authMethod?: "oauth"; + authMethod?: "apiKey" | "oauth"; controlHost?: string; + // Full base URL (scheme, host, port, optional path prefix) for a locally-run + // control plane. Takes precedence over controlHost, which cannot express a + // port or a plaintext scheme. + controlUrl?: string; currentAppId?: string; endpoint?: string; + port?: number; + tls?: boolean; // OAuth authorization server host (ably.com or a review-app override). // Kept separate from controlHost so the session key and token-refresh // traffic always targets the host that actually minted the tokens. @@ -79,6 +99,15 @@ export interface ConfigManager { userEmail: string; }, ): void; + storeLocalAccount( + alias: string, + info: { + accessToken?: string; + accountName: string; + controlUrl?: string; + dataPlane: DataPlaneConfig; + }, + ): void; switchAccount(alias: string): boolean; removeAccount(alias: string): boolean; @@ -107,7 +136,7 @@ export interface ConfigManager { } | undefined; isAccessTokenExpired(): boolean; - getAuthMethod(alias?: string): "oauth" | undefined; + getAuthMethod(alias?: string): "apiKey" | "oauth" | undefined; getAliasesForOAuthSession(alias: string): string[]; clearOAuthSession(alias?: string): void; @@ -135,6 +164,8 @@ export interface ConfigManager { // Endpoint management getEndpoint(alias?: string): string | undefined; storeEndpoint(endpoint: string, alias?: string): void; + getDataPlane(alias?: string): DataPlaneConfig | undefined; + getControlUrl(alias?: string): string | undefined; // Help context (AI conversation) getHelpContext(): @@ -265,6 +296,36 @@ export class TomlConfigManager implements ConfigManager { return currentAccount?.endpoint; } + // Get the full data plane routing config for the current account or alias + public getDataPlane(alias?: string): DataPlaneConfig | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account) return undefined; + + if ( + account.endpoint === undefined && + account.port === undefined && + account.tls === undefined + ) { + return undefined; + } + + return { + endpoint: account.endpoint, + port: account.port, + tls: account.tls, + }; + } + + // Get the control plane base URL for the current account or alias + public getControlUrl(alias?: string): string | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + return account?.controlUrl; + } + // Get path to config file public getConfigPath(): string { return this.configPath; @@ -481,6 +542,52 @@ export class TomlConfigManager implements ConfigManager { this.saveConfig(); } + // Store a local server account. Unlike storeOAuthTokens there is no + // authorization server involved: the API key is supplied directly by the + // user, and the optional Control API token is stored on the account itself + // because a local control plane has no refresh flow. + public storeLocalAccount( + alias: string, + info: { + accessToken?: string; + accountName: string; + controlUrl?: string; + dataPlane: DataPlaneConfig; + }, + ): void { + const existing = this.config.accounts[alias]; + + this.config.accounts[alias] = { + ...existing, + accessToken: info.accessToken, + accountId: existing?.accountId ?? "", + accountName: info.accountName, + apps: existing?.apps ?? {}, + authMethod: "apiKey", + controlUrl: info.controlUrl, + currentAppId: existing?.currentAppId, + endpoint: info.dataPlane.endpoint, + port: info.dataPlane.port, + tls: info.dataPlane.tls, + userEmail: existing?.userEmail ?? "", + }; + + // A local account never references an OAuth session. Drop any fields + // carried over by the spread so re-logging in over a previous managed + // account with the same alias cannot leave stale OAuth state behind. + delete this.config.accounts[alias].accessTokenExpiresAt; + delete this.config.accounts[alias].controlHost; + delete this.config.accounts[alias].oauthHost; + delete this.config.accounts[alias].oauthSessionKey; + delete this.config.accounts[alias].tokenId; + + if (!this.config.current || !this.config.current.account) { + this.config.current = { account: alias }; + } + + this.saveConfig(); + } + // Store app information (like name) in the config public storeAppInfo( appId: string, @@ -703,7 +810,7 @@ export class TomlConfigManager implements ConfigManager { } // Get the auth method for the current account or specific alias - public getAuthMethod(alias?: string): "oauth" | undefined { + public getAuthMethod(alias?: string): "apiKey" | "oauth" | undefined { const account = alias ? this.config.accounts[alias] : this.getCurrentAccount(); diff --git a/src/services/control-api.ts b/src/services/control-api.ts index cca9438fc..bbcd761c9 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -7,6 +7,10 @@ import type { TokenRefreshMiddleware } from "./token-refresh-middleware.js"; export interface ControlApiOptions { accessToken: string; controlHost?: string; + // Full base URL for a locally-run control plane, e.g. + // "http://localhost:8082". Takes precedence over controlHost, which is only + // a hostname and so cannot express a port or a plaintext scheme. + controlUrl?: string; logErrors?: boolean; tokenRefreshMiddleware?: TokenRefreshMiddleware; } @@ -188,12 +192,14 @@ export interface AccountSummary { export class ControlApi { private accessToken: string; private controlHost: string; + private controlUrl?: string; private logErrors: boolean; private tokenRefreshMiddleware?: TokenRefreshMiddleware; constructor(options: ControlApiOptions) { this.accessToken = options.accessToken; this.controlHost = options.controlHost || "control.ably.net"; + this.controlUrl = options.controlUrl; this.tokenRefreshMiddleware = options.tokenRefreshMiddleware; // Explicit options.logErrors overrides env var; otherwise suppress in CI/test if (options.logErrors === undefined) { @@ -207,6 +213,33 @@ export class ControlApi { } } + /** + * Resolve the base URL that request paths are appended to. + * + * The dedicated Control API service (control.ably.net) serves at `/v1/`, + * while the website itself (ably.com and Heroku review apps) proxies the + * Control API at `/api/v1/`. For host-only configuration we infer which is + * which; an explicit controlUrl states it directly, and a locally-run + * control plane is the dedicated service, so it defaults to `/v1`. + */ + private baseUrl(): string { + if (this.controlUrl) { + const base = this.controlUrl.replace(/\/+$/, ""); + // A caller-supplied path is treated as the full prefix; only append the + // default when the URL is a bare origin. + const hasPath = new URL(base).pathname !== "/"; + return hasPath ? base : `${base}/v1`; + } + + // Match hosts whose first label starts with "control" so both `control.` + // and `control-*.` variants route correctly, case insensitively so + // ABLY_CONTROL_HOST values aren't locale-sensitive. + const isControlService = /^control[-.]/i.test(this.controlHost); + const scheme = this.controlHost.includes("local") ? "http" : "https"; + const prefix = isControlService ? "/v1" : "/api/v1"; + return `${scheme}://${this.controlHost}${prefix}`; + } + // Ask a question to the Ably AI agent async askHelp( question: string, @@ -566,15 +599,7 @@ export class ControlApi { await this.tokenRefreshMiddleware.getValidAccessToken(); } - // The dedicated Control API service (control.ably.net) serves at `/v1/`. - // The website itself (ably.com and Heroku review apps) proxies the - // Control API at `/api/v1/`. Match hosts whose first label starts with - // "control" so both `control.` and `control-*.` variants route correctly, - // case insensitively so ABLY_CONTROL_HOST values aren't locale-sensitive. - const isControlService = /^control[-.]/i.test(this.controlHost); - const scheme = this.controlHost.includes("local") ? "http" : "https"; - const prefix = isControlService ? "/v1" : "/api/v1"; - const url = `${scheme}://${this.controlHost}${prefix}${path}`; + const url = `${this.baseUrl()}${path}`; const isFormData = body instanceof FormData; const options: RequestInit = { diff --git a/src/types/cli.ts b/src/types/cli.ts index 03fec39a3..176bd8b7d 100644 --- a/src/types/cli.ts +++ b/src/types/cli.ts @@ -7,7 +7,10 @@ export interface BaseFlags { "api-key"?: string; // Not a CLI flag; set internally by ensureAppAndKey "client-id"?: string; "control-host"?: string; + "control-url"?: string; "dashboard-host"?: string; + local?: boolean; + url?: string; "oauth-host"?: string; endpoint?: string; port?: number; diff --git a/src/utils/api-key.ts b/src/utils/api-key.ts index 283a1fc3c..76238ff5b 100644 --- a/src/utils/api-key.ts +++ b/src/utils/api-key.ts @@ -6,6 +6,17 @@ export function extractAppIdFromApiKey(apiKey: string): string { return apiKey.split(".")[0] ?? ""; } +/** + * True when the value has the full `APP_ID.KEY_ID:KEY_SECRET` shape. + * + * `extractAppIdFromApiKey` is deliberately lenient — it returns the whole + * string for input with no separators — so callers that accept a key straight + * from the user should check the shape first. + */ +export function isValidApiKey(apiKey: string): boolean { + return /^[^\s.:]+\.[^\s.:]+:\S+$/.test(apiKey); +} + /** * Returns the "key name" portion — everything before the `:` separator * (i.e. `APP_ID.KEY_ID`). Empty string if the input is not a valid key. diff --git a/src/utils/prompt-value.ts b/src/utils/prompt-value.ts new file mode 100644 index 000000000..dd4c674a4 --- /dev/null +++ b/src/utils/prompt-value.ts @@ -0,0 +1,33 @@ +import { input, password } from "@inquirer/prompts"; + +const requireNonEmpty = (value: string) => + value.trim().length > 0 || "A value is required"; + +/** + * Prompt for a free-text value, re-asking until a non-empty answer is given. + * + * @param message - Label shown before the input + * @param options.defaultValue - Returned when the user submits an empty line + * @param options.secret - Mask the input, for credentials that should not be + * echoed into terminal scrollback + */ +export async function promptForValue( + message: string, + options: { defaultValue?: string; secret?: boolean } = {}, +): Promise { + if (options.secret) { + const answer = await password({ + mask: true, + message, + validate: requireNonEmpty, + }); + return answer.trim(); + } + + const answer = await input({ + default: options.defaultValue, + message, + validate: requireNonEmpty, + }); + return answer.trim(); +} diff --git a/src/utils/server-url.ts b/src/utils/server-url.ts new file mode 100644 index 000000000..784ba37df --- /dev/null +++ b/src/utils/server-url.ts @@ -0,0 +1,87 @@ +/** + * Parsing for local server URLs supplied to `ably accounts login --local`. + * + * The Ably SDK takes routing as three separate client options (`endpoint`, + * `port`/`tlsPort`, `tls`) rather than a URL, and the Control API takes a bare + * host. A single `--url http://localhost:8081` is far easier to type and to + * remember than three flags, so we accept a URL and decompose it here. + */ + +/** A local server URL decomposed into the parts the SDK and Control API need. */ +export interface ServerUrl { + /** Hostname with no scheme, port, or path — the SDK's `endpoint` option. */ + host: string; + /** Path component, normalised to "" when the URL has none. */ + path: string; + /** Port, or undefined when the URL relies on the scheme default. */ + port?: number; + /** True for https, false for http. */ + tls: boolean; +} + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +const SCHEME_PATTERN = /^[a-z][\d+.a-z-]*:\/\//i; + +function isLoopback(host: string): boolean { + return LOOPBACK_HOSTS.has(host.toLowerCase()); +} + +/** + * Parse a local server URL into its SDK-facing parts. + * + * Accepts a full URL ("http://localhost:8081") or a bare host and port + * ("localhost:8081"). Schemeless input defaults to http for loopback hosts and + * https for everything else, so the common local case needs no scheme while a + * remote host is never silently downgraded to plaintext. + * + * @throws Error with a user-facing message when the input cannot be parsed. + */ +export function parseServerUrl(raw: string): ServerUrl { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("URL cannot be empty."); + } + + const hasScheme = SCHEME_PATTERN.test(trimmed); + + let url: URL; + try { + url = new URL(hasScheme ? trimmed : `http://${trimmed}`); + } catch { + throw new Error( + `Invalid URL "${raw}". Expected a value like http://localhost:8081.`, + ); + } + + if (hasScheme && url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error( + `Unsupported scheme "${url.protocol.replace(":", "")}" in "${raw}". Use http:// or https://.`, + ); + } + + if (!url.hostname) { + throw new Error( + `Invalid URL "${raw}". Expected a value like http://localhost:8081.`, + ); + } + + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); + + return { + host: url.hostname, + path, + port: url.port ? Number.parseInt(url.port, 10) : undefined, + tls: hasScheme ? url.protocol === "https:" : !isLoopback(url.hostname), + }; +} + +/** + * Render a parsed server URL back to a canonical string, for display and for + * storing the control plane base URL in config. + */ +export function formatServerUrl(server: ServerUrl): string { + const scheme = server.tls ? "https" : "http"; + const port = server.port === undefined ? "" : `:${server.port}`; + return `${scheme}://${server.host}${port}${server.path}`; +} diff --git a/test/helpers/mock-config-manager.ts b/test/helpers/mock-config-manager.ts index d89129d77..e2d7565cb 100644 --- a/test/helpers/mock-config-manager.ts +++ b/test/helpers/mock-config-manager.ts @@ -24,6 +24,7 @@ import type { AccountConfig, AppConfig, ConfigManager, + DataPlaneConfig, } from "../../src/services/config-manager.js"; // Kept in sync with DEFAULT_OAUTH_HOST in src/services/oauth-client.ts. @@ -277,6 +278,34 @@ export class MockConfigManager implements ConfigManager { return currentAccount?.endpoint; } + public getDataPlane(alias?: string): DataPlaneConfig | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account) return undefined; + + if ( + account.endpoint === undefined && + account.port === undefined && + account.tls === undefined + ) { + return undefined; + } + + return { + endpoint: account.endpoint, + port: account.port, + tls: account.tls, + }; + } + + public getControlUrl(alias?: string): string | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + return account?.controlUrl; + } + public getCurrentAccount(): AccountConfig | undefined { const currentAlias = this.getCurrentAccountAlias(); if (!currentAlias) return undefined; @@ -448,6 +477,43 @@ export class MockConfigManager implements ConfigManager { this.config.accounts[targetAlias].endpoint = endpoint; } + public storeLocalAccount( + alias: string, + info: { + accessToken?: string; + accountName: string; + controlUrl?: string; + dataPlane: DataPlaneConfig; + }, + ): void { + const existing = this.config.accounts[alias]; + + this.config.accounts[alias] = { + ...existing, + accessToken: info.accessToken, + accountId: existing?.accountId ?? "", + accountName: info.accountName, + apps: existing?.apps ?? {}, + authMethod: "apiKey", + controlUrl: info.controlUrl, + currentAppId: existing?.currentAppId, + endpoint: info.dataPlane.endpoint, + port: info.dataPlane.port, + tls: info.dataPlane.tls, + userEmail: existing?.userEmail ?? "", + }; + + delete this.config.accounts[alias].accessTokenExpiresAt; + delete this.config.accounts[alias].controlHost; + delete this.config.accounts[alias].oauthHost; + delete this.config.accounts[alias].oauthSessionKey; + delete this.config.accounts[alias].tokenId; + + if (!this.config.current || !this.config.current.account) { + this.config.current = { account: alias }; + } + } + public storeAppInfo( appId: string, appInfo: { appName: string }, @@ -644,7 +710,7 @@ export class MockConfigManager implements ConfigManager { return Date.now() >= expiresAt - 60_000; } - public getAuthMethod(alias?: string): "oauth" | undefined { + public getAuthMethod(alias?: string): "apiKey" | "oauth" | undefined { const account = alias ? this.config.accounts[alias] : this.getCurrentAccount(); diff --git a/test/unit/base/auth-info-display.test.ts b/test/unit/base/auth-info-display.test.ts index 3334c9809..45b276a89 100644 --- a/test/unit/base/auth-info-display.test.ts +++ b/test/unit/base/auth-info-display.test.ts @@ -63,6 +63,8 @@ describe("Auth Info Display", function () { getAppName: ReturnType; getApiKey: ReturnType; getEndpoint: ReturnType; + getDataPlane: ReturnType; + getControlUrl: ReturnType; getKeyName: ReturnType; getAccessToken: ReturnType; getAppConfig: ReturnType; @@ -73,6 +75,16 @@ describe("Auth Info Display", function () { let logStub: ReturnType; let debugStub: ReturnType; + /** Run displayAuthInfo and return the rendered "Using:" line. */ + const usingLine = async (showAppInfo = true): Promise => { + await command.testDisplayAuthInfo({}, showAppInfo); + const outputCalls = logStub.mock.calls.map((call) => call[0]); + const line = outputCalls.find( + (output) => typeof output === "string" && output.includes("Using:"), + ); + return typeof line === "string" ? line : ""; + }; + beforeEach(function () { // Create mock config manager configManagerStub = { @@ -81,6 +93,8 @@ describe("Auth Info Display", function () { getAppName: vi.fn(), getApiKey: vi.fn(), getEndpoint: vi.fn(), + getDataPlane: vi.fn(), + getControlUrl: vi.fn(), getKeyName: vi.fn(), getAccessToken: vi.fn(), getAppConfig: vi.fn(), @@ -201,6 +215,87 @@ describe("Auth Info Display", function () { expect(outputWithUsingPrefix).toContain("Account="); }); + describe("endpoint override display", function () { + beforeEach(function () { + shouldHideAccountInfoStub.mockReturnValue(false); + delete process.env.ABLY_ENDPOINT; + delete process.env.ABLY_CONTROL_HOST; + }); + + afterEach(function () { + delete process.env.ABLY_ENDPOINT; + delete process.env.ABLY_CONTROL_HOST; + }); + + it("omits the endpoint for a profile with its own routing", async function () { + // A local server profile is a deliberate choice — not worth repeating. + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + expect(await usingLine()).not.toContain("Endpoint="); + }); + + it("omits the endpoint for a managed account with no override", async function () { + configManagerStub.getDataPlane.mockReturnValue(); + + expect(await usingLine()).not.toContain("Endpoint="); + }); + + it("shows the endpoint when ABLY_ENDPOINT redirects a managed account", async function () { + process.env.ABLY_ENDPOINT = "other.example.com"; + configManagerStub.getDataPlane.mockReturnValue(); + + expect(await usingLine()).toContain( + "Endpoint=https://other.example.com", + ); + }); + + it("shows the endpoint when ABLY_ENDPOINT redirects away from the profile", async function () { + process.env.ABLY_ENDPOINT = "other.example.com"; + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + // Port and TLS still come from the profile, so the displayed URL is + // the combination traffic will actually use. + expect(await usingLine()).toContain( + "Endpoint=http://other.example.com:8081", + ); + }); + + it("omits the endpoint when ABLY_ENDPOINT matches the profile", async function () { + process.env.ABLY_ENDPOINT = "localhost"; + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + expect(await usingLine()).not.toContain("Endpoint="); + }); + + it("shows ABLY_CONTROL_HOST on control plane commands", async function () { + process.env.ABLY_CONTROL_HOST = "control.example.com"; + + expect(await usingLine(false)).toContain( + "Endpoint=control.example.com", + ); + }); + + it("omits the endpoint on control plane commands with no override", async function () { + configManagerStub.getControlUrl.mockReturnValue( + "http://localhost:8082", + ); + + expect(await usingLine(false)).not.toContain("Endpoint="); + }); + }); + it("should not display anything when there are no parts to show", async function () { // Setup - hide account and don't show app info shouldHideAccountInfoStub.mockReturnValue(true); diff --git a/test/unit/base/base-command.test.ts b/test/unit/base/base-command.test.ts index b5ac99d5d..f6139952a 100644 --- a/test/unit/base/base-command.test.ts +++ b/test/unit/base/base-command.test.ts @@ -96,6 +96,7 @@ type MockConfigManager = ConfigManager & { getApiKey: ReturnType; getAccessToken: ReturnType; getEndpoint: ReturnType; + getDataPlane: ReturnType; selectKey: ReturnType; selectApp: ReturnType; setCurrentApp: ReturnType; @@ -135,6 +136,7 @@ describe("AblyBaseCommand", function () { getApiKey: vi.fn(), getAccessToken: vi.fn(), getEndpoint: vi.fn(), + getDataPlane: vi.fn(), selectKey: vi.fn(), selectApp: vi.fn(), setCurrentApp: vi.fn(), @@ -677,4 +679,103 @@ describe("AblyBaseCommand", function () { delete process.env.ABLY_API_KEY; }); }); + + describe("local server data plane resolution", function () { + beforeEach(function () { + delete process.env.ABLY_ENDPOINT; + process.env.ABLY_API_KEY = "test-key:secret"; + }); + + afterEach(function () { + delete process.env.ABLY_API_KEY; + }); + + it("applies a stored plaintext endpoint, port and tls setting", function () { + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.endpoint).toBe("localhost"); + expect(clientOptions.tls).toBe(false); + expect(clientOptions.port).toBe(8081); + // With TLS off the SDK reads `port`, so tlsPort must stay unset. + expect(clientOptions.tlsPort).toBeUndefined(); + }); + + it("routes a stored port to tlsPort when TLS is on", function () { + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "server.example.com", + port: 8443, + tls: true, + }); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.tls).toBe(true); + expect(clientOptions.tlsPort).toBe(8443); + expect(clientOptions.port).toBeUndefined(); + }); + + it("treats an unset tls value as TLS enabled", function () { + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "server.example.com", + port: 8443, + }); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.tlsPort).toBe(8443); + expect(clientOptions.port).toBeUndefined(); + }); + + it("lets explicit flags override stored values", function () { + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + const clientOptions = command.testGetClientOptions({ + port: 9999, + tls: "true", + "tls-port": 8443, + }); + + expect(clientOptions.tls).toBe(true); + expect(clientOptions.port).toBe(9999); + expect(clientOptions.tlsPort).toBe(8443); + }); + + it("lets ABLY_ENDPOINT override the stored endpoint but keep the stored port", function () { + process.env.ABLY_ENDPOINT = "other.example.com"; + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.endpoint).toBe("other.example.com"); + expect(clientOptions.port).toBe(8081); + + delete process.env.ABLY_ENDPOINT; + }); + + it("leaves routing options unset for a managed account", function () { + // A managed account has no routing overrides stored. + configManagerStub.getDataPlane.mockReturnValue(); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.endpoint).toBeUndefined(); + expect(clientOptions.tls).toBeUndefined(); + expect(clientOptions.port).toBeUndefined(); + expect(clientOptions.tlsPort).toBeUndefined(); + }); + }); }); diff --git a/test/unit/base/local-server-control-guard.test.ts b/test/unit/base/local-server-control-guard.test.ts new file mode 100644 index 000000000..1c91bbe7c --- /dev/null +++ b/test/unit/base/local-server-control-guard.test.ts @@ -0,0 +1,87 @@ +/** + * Control plane behaviour for local server accounts. + * + * A local server account only reaches a Control API if one was configured at + * login, so control commands must fail with an actionable message rather than + * silently querying the managed Control API. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { getMockConfigManager } from "../../helpers/mock-config-manager.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe("local server control plane guard", () => { + beforeEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + delete process.env.ABLY_CONTROL_HOST; + + const mock = getMockConfigManager(); + mock.clearAccounts(); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + delete process.env.ABLY_CONTROL_HOST; + }); + + it("fails control commands for a local account with no control plane", async () => { + const mock = getMockConfigManager(); + mock.storeLocalAccount("local", { + accountName: "local", + dataPlane: { endpoint: "localhost", port: 8081, tls: false }, + }); + mock.switchAccount("local"); + + const { stdout } = await runCommand( + ["apps:list", "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; + expect(result.error.message).toContain("aren't available for local server"); + expect(result.error.message).toContain("--control-url"); + }); + + it("routes control commands to the stored control URL when one is configured", async () => { + const mock = getMockConfigManager(); + mock.storeLocalAccount("local", { + accessToken: "local-control-token", + accountName: "local", + controlUrl: "http://localhost:8082", + dataPlane: { endpoint: "localhost", port: 8081, tls: false }, + }); + mock.switchAccount("local"); + + nock("http://localhost:8082") + .get("/v1/me") + .reply(200, { + account: { id: "local-account", name: "Local" }, + user: { email: "dev@localhost" }, + }); + nock("http://localhost:8082") + .get("/v1/accounts/local-account/apps") + .reply(200, [ + { + accountId: "local-account", + created: 1_700_000_000_000, + id: "localapp", + modified: 1_700_000_000_000, + name: "Local App", + status: "enabled", + tlsOnly: false, + }, + ]); + + const { stdout } = await runCommand( + ["apps:list", "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + expect(JSON.stringify(result)).toContain("localapp"); + }); +}); diff --git a/test/unit/commands/accounts/login.test.ts b/test/unit/commands/accounts/login.test.ts index 1c2444f38..dbd290442 100644 --- a/test/unit/commands/accounts/login.test.ts +++ b/test/unit/commands/accounts/login.test.ts @@ -522,4 +522,204 @@ describe("accounts:login command", () => { expect(authEvent).toHaveProperty("verificationUri"); }); }); + + describe("local server login", () => { + const localApiKey = "localapp.keyid:keysecret"; + + beforeEach(() => { + process.env.ABLY_API_KEY = localApiKey; + }); + + afterEach(() => { + delete process.env.ABLY_API_KEY; + delete process.env.ABLY_ACCESS_TOKEN; + }); + + it("stores a data-plane-only profile from a URL and ABLY_API_KEY", async () => { + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + const account = result.account as Record; + expect(account).toHaveProperty("alias", "local"); + expect(account).toHaveProperty("authMethod", "apiKey"); + expect(account).not.toHaveProperty("controlUrl"); + expect(account.dataPlane).toEqual({ + endpoint: "localhost", + port: 8081, + tls: false, + url: "http://localhost:8081", + }); + expect(account.app).toEqual({ id: "localapp" }); + + const config = getMockConfigManager().getConfig(); + expect(config.current?.account).toBe("local"); + const stored = config.accounts.local; + expect(stored.authMethod).toBe("apiKey"); + expect(stored.endpoint).toBe("localhost"); + expect(stored.port).toBe(8081); + expect(stored.tls).toBe(false); + expect(stored.currentAppId).toBe("localapp"); + expect(stored.apps?.localapp?.apiKey).toBe(localApiKey); + }); + + it("stores the control plane URL and token when --control-url is given", async () => { + process.env.ABLY_ACCESS_TOKEN = "local-control-token"; + + // getApp resolves the name via /me then the account's apps list. + nock("http://localhost:8082") + .get("/v1/me") + .reply(200, { + account: { id: "local-account", name: "Local" }, + user: { email: "dev@localhost" }, + }); + nock("http://localhost:8082") + .get("/v1/accounts/local-account/apps") + .reply(200, [ + { accountId: "local-account", id: "localapp", name: "Local App" }, + ]); + + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081", + "--control-url", + "http://localhost:8082", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + const account = result.account as Record; + expect(account).toHaveProperty("controlUrl", "http://localhost:8082"); + expect(account.app).toEqual({ id: "localapp", name: "Local App" }); + + const stored = getMockConfigManager().getConfig().accounts.local; + expect(stored.controlUrl).toBe("http://localhost:8082"); + expect(stored.accessToken).toBe("local-control-token"); + expect(stored.apps?.localapp?.appName).toBe("Local App"); + }); + + it("uses --alias when provided", async () => { + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081", + "--alias", + "dev-server", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result.account).toHaveProperty("alias", "dev-server"); + expect( + getMockConfigManager().getConfig().accounts["dev-server"], + ).toBeDefined(); + }); + + it("errors when --url is omitted in JSON mode", async () => { + const { stdout } = await runCommand( + ["accounts:login", "--local", "--json"], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; + expect(result.error.message).toContain("--url"); + }); + + it("errors when no API key is available in JSON mode", async () => { + delete process.env.ABLY_API_KEY; + + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; + expect(result.error.message).toContain("ABLY_API_KEY"); + }); + + it("errors when the data plane URL includes a path", async () => { + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081/realtime", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; + expect(result.error.message).toContain("must not include a path"); + }); + + it("errors when the API key has no app ID", async () => { + process.env.ABLY_API_KEY = "not-a-valid-key"; + + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; + expect(result.error.message).toContain("app ID"); + }); + + it("errors when no control API token is available in JSON mode", async () => { + const { stdout } = await runCommand( + [ + "accounts:login", + "--local", + "--url", + "http://localhost:8081", + "--control-url", + "http://localhost:8082", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; + expect(result.error.message).toContain("ABLY_ACCESS_TOKEN"); + }); + + it("rejects --url without --local", async () => { + const { error } = await runCommand( + ["accounts:login", "--url", "http://localhost:8081", "--json"], + import.meta.url, + ); + + expect(error?.message).toContain("local"); + }); + }); }); diff --git a/test/unit/data/env-vars.test.ts b/test/unit/data/env-vars.test.ts index 2c9d230b3..eaae9d55b 100644 --- a/test/unit/data/env-vars.test.ts +++ b/test/unit/data/env-vars.test.ts @@ -76,7 +76,7 @@ describe("ENV_VARS_DATA", () => { expect(byName.ABLY_HISTORY_FILE.details).toHaveLength(1); expect(byName.ABLY_CLI_DEFAULT_DURATION.details).toHaveLength(0); expect(byName.ABLY_CLI_NON_INTERACTIVE.details).toHaveLength(1); - expect(byName.ABLY_ENDPOINT.details).toHaveLength(0); + expect(byName.ABLY_ENDPOINT.details).toHaveLength(1); }); it("only the documented primary URLs appear inline in variable entries", () => { diff --git a/test/unit/services/config-manager.test.ts b/test/unit/services/config-manager.test.ts index e6d32035d..fdac1f3cd 100644 --- a/test/unit/services/config-manager.test.ts +++ b/test/unit/services/config-manager.test.ts @@ -406,4 +406,99 @@ accessToken = "testaccesstoken" expect(configManager.switchAccount("nonexistentaccount")).toBe(false); }); }); + + // Tests for local server accounts + describe("#storeLocalAccount", () => { + it("should store data plane routing and mark the account as apiKey auth", () => { + configManager.storeLocalAccount("local", { + accountName: "local", + dataPlane: { endpoint: "localhost", port: 8081, tls: false }, + }); + + expect(configManager.getAuthMethod("local")).toBe("apiKey"); + expect(configManager.getDataPlane("local")).toEqual({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + expect(configManager.getEndpoint("local")).toBe("localhost"); + expect(configManager.getControlUrl("local")).toBeUndefined(); + }); + + it("should store the control plane URL and access token when supplied", () => { + configManager.storeLocalAccount("local", { + accessToken: "local-control-token", + accountName: "local", + controlUrl: "http://localhost:8082", + dataPlane: { endpoint: "localhost", port: 8081, tls: false }, + }); + + configManager.switchAccount("local"); + + expect(configManager.getControlUrl()).toBe("http://localhost:8082"); + expect(configManager.getAccessToken()).toBe("local-control-token"); + }); + + it("should drop OAuth state when replacing a managed account", () => { + configManager.storeOAuthTokens( + "local", + { + accessToken: "oauth-token", + expiresAt: Date.now() + 3_600_000, + refreshToken: "refresh-token", + userEmail: "test@example.com", + }, + { accountId: "acc-1", accountName: "Managed" }, + ); + + configManager.storeLocalAccount("local", { + accountName: "local", + dataPlane: { endpoint: "localhost", port: 8081, tls: false }, + }); + + configManager.switchAccount("local"); + + expect(configManager.getAuthMethod("local")).toBe("apiKey"); + expect(configManager.getOAuthTokens("local")).toBeUndefined(); + expect(configManager.getAccessToken()).toBeUndefined(); + }); + + it("should preserve stored apps when repointing an existing local account", () => { + configManager.storeLocalAccount("local", { + accountName: "local", + dataPlane: { endpoint: "localhost", port: 8081, tls: false }, + }); + configManager.switchAccount("local"); + configManager.setCurrentApp("appid"); + configManager.storeAppKey("appid", "appid.keyid:secret"); + + configManager.storeLocalAccount("local", { + accountName: "local", + dataPlane: { endpoint: "localhost", port: 9091, tls: false }, + }); + + expect(configManager.getDataPlane("local")?.port).toBe(9091); + expect(configManager.getApiKey("appid")).toBe("appid.keyid:secret"); + }); + }); + + describe("#getDataPlane", () => { + it("should return undefined for an account with no routing overrides", () => { + expect(configManager.getDataPlane("default")).toBeUndefined(); + }); + + it("should return undefined for an unknown alias", () => { + expect(configManager.getDataPlane("nonexistentaccount")).toBeUndefined(); + }); + + it("should return the endpoint alone when only an endpoint is stored", () => { + configManager.storeEndpoint("custom.example.com", "default"); + + expect(configManager.getDataPlane("default")).toEqual({ + endpoint: "custom.example.com", + port: undefined, + tls: undefined, + }); + }); + }); }); diff --git a/test/unit/utils/server-url.test.ts b/test/unit/utils/server-url.test.ts new file mode 100644 index 000000000..d6d3e89b8 --- /dev/null +++ b/test/unit/utils/server-url.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { + formatServerUrl, + parseServerUrl, +} from "../../../src/utils/server-url.js"; + +describe("parseServerUrl", () => { + it("decomposes a plaintext local URL", () => { + expect(parseServerUrl("http://localhost:8081")).toEqual({ + host: "localhost", + path: "", + port: 8081, + tls: false, + }); + }); + + it("decomposes an https URL", () => { + expect(parseServerUrl("https://server.example.com:8443")).toEqual({ + host: "server.example.com", + path: "", + port: 8443, + tls: true, + }); + }); + + it("leaves port undefined when the URL relies on the scheme default", () => { + expect(parseServerUrl("https://server.example.com").port).toBeUndefined(); + }); + + it("leaves port undefined when the port matches the scheme default", () => { + // URL parsing drops a redundant default port; the SDK supplies the same + // value, so the result is identical. + expect( + parseServerUrl("https://server.example.com:443").port, + ).toBeUndefined(); + }); + + it("defaults schemeless loopback input to http", () => { + expect(parseServerUrl("localhost:8081")).toEqual({ + host: "localhost", + path: "", + port: 8081, + tls: false, + }); + }); + + it("defaults schemeless 127.0.0.1 input to http", () => { + expect(parseServerUrl("127.0.0.1:8081").tls).toBe(false); + }); + + it("defaults schemeless non-loopback input to https", () => { + expect(parseServerUrl("server.example.com:8081")).toEqual({ + host: "server.example.com", + path: "", + port: 8081, + tls: true, + }); + }); + + it("honours an explicit http scheme on a non-loopback host", () => { + expect(parseServerUrl("http://server.example.com").tls).toBe(false); + }); + + it("preserves an IPv6 host in bracketed form", () => { + // The SDK treats any endpoint containing "::" as a literal host, so the + // brackets must survive parsing. + expect(parseServerUrl("http://[::1]:8081").host).toBe("[::1]"); + }); + + it("returns a normalised path when one is present", () => { + expect(parseServerUrl("http://localhost:8082/api/v1").path).toBe("/api/v1"); + }); + + it("strips a trailing slash from the path", () => { + expect(parseServerUrl("http://localhost:8082/api/").path).toBe("/api"); + }); + + it("reports an empty path for a bare origin", () => { + expect(parseServerUrl("http://localhost:8082/").path).toBe(""); + }); + + it("throws on empty input", () => { + expect(() => parseServerUrl(" ")).toThrow("URL cannot be empty"); + }); + + it("throws on an unsupported scheme", () => { + expect(() => parseServerUrl("ws://localhost:8081")).toThrow( + /Unsupported scheme "ws"/, + ); + }); + + it("throws on unparseable input", () => { + expect(() => parseServerUrl("http://")).toThrow(/Invalid URL/); + }); +}); + +describe("formatServerUrl", () => { + it("renders a plaintext URL with a port", () => { + expect( + formatServerUrl({ host: "localhost", path: "", port: 8081, tls: false }), + ).toBe("http://localhost:8081"); + }); + + it("renders an https URL without a port", () => { + expect(formatServerUrl({ host: "example.com", path: "", tls: true })).toBe( + "https://example.com", + ); + }); + + it("includes the path when present", () => { + expect( + formatServerUrl({ + host: "localhost", + path: "/api/v1", + port: 8082, + tls: false, + }), + ).toBe("http://localhost:8082/api/v1"); + }); + + it("round-trips a parsed URL", () => { + const raw = "http://localhost:8081"; + expect(formatServerUrl(parseServerUrl(raw))).toBe(raw); + }); +}); From 9695f534d330652fa7688006ab3b7fb8034aa23a Mon Sep 17 00:00:00 2001 From: Lewis Marshall Date: Wed, 29 Jul 2026 13:11:41 +0100 Subject: [PATCH 2/4] fix(login): label delegated JSON records with the command id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ably login` constructs AccountsLogin directly instead of letting oclif dispatch it, so the instance had no id and logJsonResult fell back to `"command":"unknown"`. The completed signal, emitted by this command's own finally(), correctly read "login" — leaving a single JSON stream disagreeing with itself about which command produced it and breaking any consumer that correlates a result with its terminator. Inherit the outer command's id before running the delegate. Records now read "login" throughout, matching what the user typed rather than the delegate's own command path. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/login.ts | 5 +++++ test/unit/commands/login.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 397c72096..7cf1180c5 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -19,6 +19,11 @@ export default class Login extends ControlBaseCommand { // cached-client cleanup. AccountsLogin is run manually so its finally // doesn't emit a duplicate completed signal. const accountsLogin = new AccountsLogin(this.argv, this.config); + // oclif only assigns an id to commands it dispatches itself, so the + // manually constructed delegate would label its JSON records "unknown". + // Inherit this command's id so they read "login", matching the completed + // signal that this command's own finally() emits. + accountsLogin.id = this.id; await accountsLogin.run(); } } diff --git a/test/unit/commands/login.test.ts b/test/unit/commands/login.test.ts index 404623a43..a512199ce 100644 --- a/test/unit/commands/login.test.ts +++ b/test/unit/commands/login.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; import { standardHelpTests, standardArgValidationTests, standardFlagTests, } from "../../helpers/standard-tests.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe("login command", () => { standardHelpTests("login", import.meta.url); @@ -19,6 +20,31 @@ describe("login command", () => { expect(stdout).toBeDefined(); expect(stdout).toContain("Log in to your Ably account"); }); + + describe("JSON envelope labelling", () => { + afterEach(() => { + delete process.env.ABLY_API_KEY; + }); + + it("labels delegated records with this command's id, not 'unknown'", async () => { + // The delegate is constructed directly rather than dispatched by oclif, + // so without an inherited id its records fall back to "unknown". + // --local is used because it completes without network access. + process.env.ABLY_API_KEY = "localapp.keyid:keysecret"; + + const { stdout } = await runCommand( + ["login", "--local", "--url", "http://localhost:8081", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result")!; + const completed = records.find((r) => r.type === "status")!; + + expect(result).toHaveProperty("command", "login"); + expect(completed).toHaveProperty("command", "login"); + }); + }); }); standardFlagTests("login", import.meta.url, ["--alias"]); From b83fe27977f23cdd0a97b77cee7f55e233bd419a Mon Sep 17 00:00:00 2001 From: Lewis Marshall Date: Wed, 29 Jul 2026 13:24:21 +0100 Subject: [PATCH 3/4] fix(auth-info): show the bare app ID when the name is unresolvable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Using:" banner rendered `App=Unknown App (abc123)` whenever the app name could not be resolved, pairing a real ID with an invented name. That reads as though the app itself were unrecognised rather than merely unnamed — the ID is always known at that point, so only the name is in question. All three sites that set the placeholder now leave the name unset and fall through to showing the ID alone: a local server profile with no control plane to query, a missing access token, and a failed Control API lookup. The last two also affect managed accounts, where an expired token previously produced the same misleading label. Co-Authored-By: Claude Opus 5 (1M context) --- src/base-command.ts | 23 ++++++++--------- test/unit/base/auth-info-display.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/base-command.ts b/src/base-command.ts index ad5a16652..744cda53e 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -638,15 +638,13 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { if (appId) { let appName = this.configManager.getAppName(appId); - // If app name is missing, try to fetch it from the API and update config. - // Local server accounts with no control plane configured have nowhere - // to look it up, so skip straight to the fallback label. + // If the app name is missing, try to fetch it from the API and update + // config. Local server accounts with no control plane configured have + // nowhere to look it up, so skip the attempt entirely. const hasControlPlane = this.configManager.getAuthMethod() !== "apiKey" || Boolean(this.configManager.getControlUrl()); - if (!appName && !hasControlPlane) { - appName = "Unknown App"; - } else if (!appName) { + if (!appName && hasControlPlane) { try { // Get access token for control API const accessToken = @@ -659,7 +657,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // the user picked at login. Without the account fallback this // call silently targets control.ably.net even when the user // logged in against a review/staging deployment, the lookup - // 404s, and the banner downgrades to "Unknown App". + // 404s, and the banner degrades to showing the bare app ID. const account = this.configManager.getCurrentAccount(); const controlApi = new ControlApi({ accessToken, @@ -685,17 +683,18 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // Even without an API key, persist the app name to avoid future lookups this.configManager.storeAppInfo(appId, { appName: app.name }); } - } else { - appName = "Unknown App"; } } catch { - // If fetching fails, use fallback - appName = "Unknown App"; + // Leave the name unresolved — the ID below still identifies the app. } } + // Only the name can be unknown; the ID always is known here. Showing + // it alone beats pairing a real ID with an invented "Unknown App". displayParts.push( - `${chalk.green("App=")}${chalk.green.bold(appName)} ${chalk.gray(`(${appId})`)}`, + appName + ? `${chalk.green("App=")}${chalk.green.bold(appName)} ${chalk.gray(`(${appId})`)}` + : `${chalk.green("App=")}${chalk.green.bold(appId)}`, ); // Check auth method - token or API key diff --git a/test/unit/base/auth-info-display.test.ts b/test/unit/base/auth-info-display.test.ts index 45b276a89..317813a70 100644 --- a/test/unit/base/auth-info-display.test.ts +++ b/test/unit/base/auth-info-display.test.ts @@ -404,6 +404,38 @@ describe("Auth Info Display", function () { nock.cleanAll(); } }); + + describe("unresolved app name", function () { + beforeEach(function () { + shouldHideAccountInfoStub.mockReturnValue(false); + configManagerStub.getCurrentAppId.mockReturnValue("3p4xqQ"); + configManagerStub.getAppName.mockReturnValue(); + configManagerStub.getApiKey.mockReturnValue("3p4xqQ.keyid:secret"); + }); + + it("shows the bare app ID rather than inventing a name", async function () { + // A local server account with no control plane has nowhere to look the + // name up. The ID is known, so pairing it with a placeholder would be + // stating something false. + configManagerStub.getAuthMethod.mockReturnValue("apiKey"); + configManagerStub.getControlUrl.mockReturnValue(); + + const banner = await usingLine(); + + expect(banner).toContain("App=3p4xqQ"); + expect(banner).not.toContain("Unknown App"); + expect(banner).not.toContain("(3p4xqQ)"); + }); + + it("pairs name and ID once the name is known", async function () { + configManagerStub.getAppName.mockReturnValue("My App"); + + const banner = await usingLine(); + + expect(banner).toContain("App=My App"); + expect(banner).toContain("(3p4xqQ)"); + }); + }); }); describe("showAuthInfoIfNeeded", function () { From 1f4bfc59a245390d945e9505501f85a5e30d6b65 Mon Sep 17 00:00:00 2001 From: Lewis Marshall Date: Wed, 29 Jul 2026 13:43:05 +0100 Subject: [PATCH 4/4] feat(routing): add ABLY_URL and --url for one-off local server targeting Pointing the CLI at a local server previously meant either storing a profile or combining three hidden dev flags: ABLY_ENDPOINT sets the host alone and no environment variable existed for the port, so the shortest throwaway form was ABLY_ENDPOINT=localhost ably ... --tls=false --port 8081 ABLY_URL and --url take host, port and TLS from a single value, reusing the parser the local login flow already uses. The scheme is optional for loopback hosts, and a path is rejected rather than silently discarded, since Ably routes by host. Data plane routing now resolves --url > ABLY_URL > ABLY_ENDPOINT > profile, with --port/--tls-port/--tls applied on top so they still override an individual field. ABLY_ENDPOINT keeps its host-only semantics, retaining the profile's port and TLS, so existing usage is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Environment-Variables/General-Usage.md | 3 +- docs/Local-Server.md | 32 +++++- src/base-command.ts | 116 ++++++++++++++++---- src/data/env-vars.ts | 41 ++++++- src/flags.ts | 10 +- test/unit/base/base-command.test.ts | 90 +++++++++++++++ test/unit/commands/env.test.ts | 7 +- test/unit/data/env-vars.test.ts | 6 +- 8 files changed, 267 insertions(+), 38 deletions(-) diff --git a/docs/Environment-Variables/General-Usage.md b/docs/Environment-Variables/General-Usage.md index 4d33aefbc..1c37eff0e 100644 --- a/docs/Environment-Variables/General-Usage.md +++ b/docs/Environment-Variables/General-Usage.md @@ -18,6 +18,7 @@ These environment variables are most commonly used during development as well as | `ABLY_HISTORY_FILE` | Configuration | Custom history file location | `~/.ably/history` | | `ABLY_CLI_DEFAULT_DURATION` | Behavior | Auto-exit long-running commands (seconds) | None (forever) | | `ABLY_CLI_NON_INTERACTIVE` | Behavior | Auto-confirm "Did you mean?" prompts | Not set | -| `ABLY_ENDPOINT` | Host Override | Override Realtime/REST API endpoint | SDK default | +| `ABLY_URL` | Host Override | Route Realtime/REST API calls to a URL | SDK default | +| `ABLY_ENDPOINT` | Host Override | Override Realtime/REST API endpoint (host only) | SDK default | > For development, testing, debugging, and internal variables, see [Development Stage Usage](Development-Usage.md). diff --git a/docs/Local-Server.md b/docs/Local-Server.md index 0d945ddf8..a6272128d 100644 --- a/docs/Local-Server.md +++ b/docs/Local-Server.md @@ -125,17 +125,37 @@ ably accounts login --local --url http://localhost:8081 --json --- -## One-off overrides without a profile +## Without a profile -If you do not want to store a profile, the hidden dev flags still work and take precedence over stored values on the command they are passed to: +If you do not want to store anything, two environment variables are enough: ```bash -export ABLY_API_KEY="localapp.keyid:keysecret" -export ABLY_ENDPOINT=localhost -ably channels publish my-channel "hello" --tls=false --port 8081 +ABLY_URL=http://localhost:8081 \ +ABLY_API_KEY=appId.keyId:keySecret \ + ably channels publish my-channel "hello" ``` -Set `ABLY_SHOW_DEV_FLAGS=true` to un-hide `--port`, `--tls-port`, `--tls`, `--control-host` and `--endpoint` in `--help` output. Note that `ABLY_ENDPOINT` only sets the host — there is no environment variable for the port, which is the main reason to store a profile instead. +`ABLY_URL` sets host, port and TLS in one value. The scheme is optional for loopback hosts, so `ABLY_URL=localhost:8081` is equivalent. A path is rejected rather than silently discarded, since Ably routes by host. + +The same value is available as a flag, for a single command rather than a whole shell: + +```bash +ably channels publish my-channel "hello" --url http://localhost:8081 +``` + +### Precedence + +For the data plane, the first source that supplies routing wins: + +``` +--url > ABLY_URL > ABLY_ENDPOINT > profile > SDK default +``` + +`--port`, `--tls-port` and `--tls` are applied on top of whichever source won, so they can override a single field — `--url http://localhost:8081 --port 1234` targets `localhost:1234`. + +`ABLY_ENDPOINT` predates the URL forms and sets the **host only**, keeping the port and TLS setting from the profile. Prefer `ABLY_URL` unless you specifically want that behaviour. + +`--url`, `--port`, `--tls-port` and `--tls` are hidden from `--help` by default; set `ABLY_SHOW_DEV_FLAGS=true` to show them. --- diff --git a/src/base-command.ts b/src/base-command.ts index 744cda53e..daccd63ba 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto"; import { ConfigManager, createConfigManager, + type DataPlaneConfig, } from "./services/config-manager.js"; import { ControlApi } from "./services/control-api.js"; import { @@ -14,8 +15,12 @@ import { extractKeyNameFromApiKey, } from "./utils/api-key.js"; import { CommandError } from "./errors/command-error.js"; -import { formatServerUrl } from "./utils/server-url.js"; -import { getFriendlyAblyErrorHint } from "./utils/errors.js"; +import { + formatServerUrl, + parseServerUrl, + type ServerUrl, +} from "./utils/server-url.js"; +import { errorMessage, getFriendlyAblyErrorHint } from "./utils/errors.js"; import { coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; import { promptForConfirmation } from "./utils/prompt-confirmation.js"; @@ -738,10 +743,73 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } } + /** + * Resolve host, port and TLS for the data plane from the first source that + * supplies them: `--url`, `ABLY_URL`, `ABLY_ENDPOINT`, then the account + * profile. The individual `--port`/`--tls-port`/`--tls` flags are applied by + * the caller on top, so they can override a single field. + * + * `ABLY_ENDPOINT` names a host only and predates the URL forms, so it keeps + * the profile's port and TLS rather than resetting them. + */ + private resolveDataPlaneRouting( + flags: BaseFlags, + ): DataPlaneConfig | undefined { + if (flags.url) { + return this.routingFromUrl(flags.url, "--url", flags); + } + + if (process.env.ABLY_URL) { + return this.routingFromUrl(process.env.ABLY_URL, "ABLY_URL", flags); + } + + const profile = this.configManager.getDataPlane(); + + if (process.env.ABLY_ENDPOINT) { + return { + endpoint: process.env.ABLY_ENDPOINT, + port: profile?.port, + tls: profile?.tls, + }; + } + + return profile; + } + + /** Parse a routing URL, failing the command with the source named. */ + private routingFromUrl( + raw: string, + source: string, + flags: BaseFlags, + ): DataPlaneConfig { + let parsed: ServerUrl; + try { + parsed = parseServerUrl(raw); + } catch (error) { + this.fail( + `Invalid ${source} value: ${errorMessage(error)}`, + flags, + "routing", + ); + } + + // The SDK routes by hostname, so a path would be silently discarded. + if (parsed.path) { + this.fail( + `${source} must not include a path (got "${parsed.path}"). Ably routes by host, so use a value like http://localhost:8081.`, + flags, + "routing", + ); + } + + return { endpoint: parsed.host, port: parsed.port, tls: parsed.tls }; + } + /** * Render the effective endpoint when an environment variable redirects the * command away from what the current profile is configured with, or - * undefined when the profile's own routing is in force. + * undefined when the profile's own routing is in force. Flags are excluded + * deliberately: they are explicit on the command line and need no reminder. */ private formatRoutingOverride(showAppInfo: boolean): string | undefined { if (!showAppInfo) { @@ -749,20 +817,27 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { return process.env.ABLY_CONTROL_HOST; } - const override = process.env.ABLY_ENDPOINT; - if (!override) return undefined; + if (!process.env.ABLY_URL && !process.env.ABLY_ENDPOINT) return undefined; + + const effective = this.resolveDataPlaneRouting({}); + if (!effective?.endpoint) return undefined; - const dataPlane = this.configManager.getDataPlane(); // Setting the env var to what the profile already says is not a redirect. - if (override === dataPlane?.endpoint) return undefined; + const profile = this.configManager.getDataPlane(); + if ( + profile && + effective.endpoint === profile.endpoint && + effective.port === profile.port && + effective.tls === profile.tls + ) { + return undefined; + } - // ABLY_ENDPOINT only replaces the host; port and TLS still come from the - // profile, so show the combination that traffic will actually use. return formatServerUrl({ - host: override, + host: effective.endpoint, path: "", - port: dataPlane?.port, - tls: dataPlane?.tls ?? true, + port: effective.port, + tls: effective.tls ?? true, }); } @@ -1114,15 +1189,14 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } } - // Data plane routing: flags → env → account config. Local server accounts - // persist host, port and TLS together (see `ably accounts login --local`), - // so a stored profile targets localhost without repeating dev flags on - // every command. Explicit flags still win, for one-off overrides. - const dataPlane = this.configManager.getDataPlane(); + // Data plane routing: --url → ABLY_URL → ABLY_ENDPOINT → account config, + // with the individual --port/--tls-port/--tls flags overriding single + // fields on top. The URL forms set host, port and TLS together, which is + // what a local server needs; ABLY_ENDPOINT is host-only and predates them. + const dataPlane = this.resolveDataPlaneRouting(flags); - const endpoint = process.env.ABLY_ENDPOINT || dataPlane?.endpoint; - if (endpoint) { - options.endpoint = endpoint; + if (dataPlane?.endpoint) { + options.endpoint = dataPlane.endpoint; } if (flags.tls !== undefined) { @@ -1132,7 +1206,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } // The SDK reads `port` when TLS is off and `tlsPort` when it is on, so the - // single stored port has to be routed to whichever one is live. + // single resolved port has to be routed to whichever one is live. const tlsEnabled = options.tls !== false; if (flags.port !== undefined) { diff --git a/src/data/env-vars.ts b/src/data/env-vars.ts index e1ae09831..2a21537b6 100644 --- a/src/data/env-vars.ts +++ b/src/data/env-vars.ts @@ -299,7 +299,7 @@ const ABLY_ENDPOINT = new EnvVarEntry( "Override Realtime/REST API endpoint", "Hostname or URL (passed as-is, no normalization)", "SDK default", - "**`ABLY_ENDPOINT`** > account config endpoint > SDK default", + "`--url` > **`ABLY_URL`** > **`ABLY_ENDPOINT`** > account config endpoint > SDK default", ["channels", "rooms", "spaces", "connections", "bench", "logs"], "Override the Ably Realtime/REST API endpoint for all data plane commands.", new Example([ @@ -307,14 +307,46 @@ const ABLY_ENDPOINT = new EnvVarEntry( `ably channels publish my-channel "Hello"`, ]), [ - new DetailSection("Local servers", [ + new DetailSection("Host only", [ { kind: "paragraph", - text: "Sets the **host only** — there is no environment variable for the port. Pair it with the hidden `--port`, `--tls-port` and `--tls` flags for one-off use.", + text: "Sets the **host only**, keeping the port and TLS setting from the current profile.", }, { kind: "important", - text: "To store host, port and TLS together so they apply to every command, run `ably accounts login --local --url http://localhost:8081` instead.", + text: "To set host, port and TLS together — what pointing at a local server needs — use `ABLY_URL` instead.", + }, + ]), + ], +); + +const ABLY_URL = new EnvVarEntry( + "ABLY_URL", + "Host Override", + "Route Realtime/REST API calls to a URL", + "Full URL, e.g. `http://localhost:8081` (scheme optional for loopback hosts)", + "SDK default", + "`--url` > **`ABLY_URL`** > `ABLY_ENDPOINT` > account config endpoint > SDK default", + ["channels", "rooms", "spaces", "connections", "bench", "logs"], + "Point all data plane commands at a specific server, typically one running locally.", + new Example([ + `export ABLY_URL="http://localhost:8081"`, + `export ABLY_API_KEY="appId.keyId:keySecret"`, + `ably channels publish my-channel "Hello"`, + ]), + [ + new DetailSection("Behaviour", [ + { + kind: "paragraph", + text: "Sets host, port and TLS in one value, unlike `ABLY_ENDPOINT` which sets only the host. A missing scheme defaults to `http` for loopback hosts and `https` otherwise, so `localhost:8081` and `http://localhost:8081` are equivalent.", + }, + { + kind: "paragraph", + text: "The URL must not include a path — Ably routes by host, so a path would be silently discarded.", + }, + { + kind: "important", + text: "For repeated use, `ably accounts login --local --url http://localhost:8081` stores the same routing as a profile so no environment variable is needed.", }, ]), ], @@ -509,6 +541,7 @@ export const ENV_VARS_DATA: EnvVarsData = new EnvVarsData( ABLY_API_KEY, ABLY_TOKEN, ABLY_ACCESS_TOKEN, + ABLY_URL, ABLY_ENDPOINT, ABLY_APP_ID, ABLY_CLI_CONFIG_DIR, diff --git a/src/flags.ts b/src/flags.ts index 4d9f57b32..aec47cbb4 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -25,9 +25,17 @@ export const coreGlobalFlags = { }; /** - * Hidden flags for product API (Ably SDK) commands — port, tls, tls-port. + * Hidden flags for product API (Ably SDK) commands — url, port, tls, tls-port. + * + * `--url` sets host, port and TLS together, which is what pointing at a local + * server needs. The individual flags below override single fields on top of it. */ export const hiddenProductApiFlags = { + url: Flags.string({ + description: + "Route product API calls to this URL, e.g. http://localhost:8081", + hidden: process.env.ABLY_SHOW_DEV_FLAGS !== "true", + }), port: Flags.integer({ description: "Override the port for product API calls", hidden: process.env.ABLY_SHOW_DEV_FLAGS !== "true", diff --git a/test/unit/base/base-command.test.ts b/test/unit/base/base-command.test.ts index f6139952a..29be6d15f 100644 --- a/test/unit/base/base-command.test.ts +++ b/test/unit/base/base-command.test.ts @@ -766,6 +766,96 @@ describe("AblyBaseCommand", function () { delete process.env.ABLY_ENDPOINT; }); + it("applies ABLY_URL as host, port and TLS together", function () { + process.env.ABLY_URL = "http://localhost:8081"; + configManagerStub.getDataPlane.mockReturnValue(); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.endpoint).toBe("localhost"); + expect(clientOptions.tls).toBe(false); + expect(clientOptions.port).toBe(8081); + expect(clientOptions.tlsPort).toBeUndefined(); + + delete process.env.ABLY_URL; + }); + + it("accepts a schemeless ABLY_URL for loopback hosts", function () { + process.env.ABLY_URL = "localhost:8081"; + configManagerStub.getDataPlane.mockReturnValue(); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.endpoint).toBe("localhost"); + expect(clientOptions.tls).toBe(false); + expect(clientOptions.port).toBe(8081); + + delete process.env.ABLY_URL; + }); + + it("lets ABLY_URL win over ABLY_ENDPOINT and the profile", function () { + process.env.ABLY_URL = "http://localhost:9091"; + process.env.ABLY_ENDPOINT = "ignored.example.com"; + configManagerStub.getDataPlane.mockReturnValue({ + endpoint: "localhost", + port: 8081, + tls: false, + }); + + const clientOptions = command.testGetClientOptions({}); + + expect(clientOptions.endpoint).toBe("localhost"); + expect(clientOptions.port).toBe(9091); + + delete process.env.ABLY_URL; + delete process.env.ABLY_ENDPOINT; + }); + + it("lets --url win over ABLY_URL", function () { + process.env.ABLY_URL = "http://localhost:9091"; + configManagerStub.getDataPlane.mockReturnValue(); + + const clientOptions = command.testGetClientOptions({ + url: "http://localhost:7071", + }); + + expect(clientOptions.port).toBe(7071); + + delete process.env.ABLY_URL; + }); + + it("lets an explicit --port override a --url port", function () { + configManagerStub.getDataPlane.mockReturnValue(); + + const clientOptions = command.testGetClientOptions({ + port: 1234, + url: "http://localhost:8081", + }); + + expect(clientOptions.endpoint).toBe("localhost"); + expect(clientOptions.port).toBe(1234); + }); + + it("fails on an ABLY_URL that includes a path", function () { + process.env.ABLY_URL = "http://localhost:8081/realtime"; + configManagerStub.getDataPlane.mockReturnValue(); + + expect(() => command.testGetClientOptions({})).toThrow( + /must not include a path/, + ); + + delete process.env.ABLY_URL; + }); + + it("fails on an unparseable ABLY_URL, naming the source", function () { + process.env.ABLY_URL = "ws://localhost:8081"; + configManagerStub.getDataPlane.mockReturnValue(); + + expect(() => command.testGetClientOptions({})).toThrow(/ABLY_URL/); + + delete process.env.ABLY_URL; + }); + it("leaves routing options unset for a managed account", function () { // A managed account has no routing overrides stored. configManagerStub.getDataPlane.mockReturnValue(); diff --git a/test/unit/commands/env.test.ts b/test/unit/commands/env.test.ts index 06fe6b379..69d0e1bf3 100644 --- a/test/unit/commands/env.test.ts +++ b/test/unit/commands/env.test.ts @@ -81,14 +81,15 @@ describe("env command", () => { expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "env"); expect(result).toHaveProperty("success", true); - expect(result.envVars).toHaveLength(9); + expect(result.envVars).toHaveLength(10); expect(result.envVars[0]).toMatchObject({ name: "ABLY_API_KEY", category: "Authentication", format: "APP_ID.KEY_ID:KEY_SECRET", }); - expect(result.envVars[3].name).toBe("ABLY_ENDPOINT"); - expect(result.envVars[8].name).toBe("ABLY_CLI_NON_INTERACTIVE"); + expect(result.envVars[3].name).toBe("ABLY_URL"); + expect(result.envVars[4].name).toBe("ABLY_ENDPOINT"); + expect(result.envVars[9].name).toBe("ABLY_CLI_NON_INTERACTIVE"); expect(result.crossCutting).toBeDefined(); expect(result.crossCutting.authResolutionOrder.heading).toBe( "Authentication Resolution Order", diff --git a/test/unit/data/env-vars.test.ts b/test/unit/data/env-vars.test.ts index eaae9d55b..42548a142 100644 --- a/test/unit/data/env-vars.test.ts +++ b/test/unit/data/env-vars.test.ts @@ -14,6 +14,7 @@ const CANONICAL_NAMES = [ "ABLY_API_KEY", "ABLY_TOKEN", "ABLY_ACCESS_TOKEN", + "ABLY_URL", "ABLY_ENDPOINT", "ABLY_APP_ID", "ABLY_CLI_CONFIG_DIR", @@ -27,8 +28,8 @@ describe("ENV_VARS_DATA", () => { expect(ENV_VARS_DATA).toBeInstanceOf(EnvVarsData); }); - it("lists exactly 9 variables in canonical order", () => { - expect(ENV_VARS_DATA.variables).toHaveLength(9); + it("lists exactly 10 variables in canonical order", () => { + expect(ENV_VARS_DATA.variables).toHaveLength(10); expect(ENV_VARS_DATA.variables.map((v) => v.name)).toEqual(CANONICAL_NAMES); }); @@ -77,6 +78,7 @@ describe("ENV_VARS_DATA", () => { expect(byName.ABLY_CLI_DEFAULT_DURATION.details).toHaveLength(0); expect(byName.ABLY_CLI_NON_INTERACTIVE.details).toHaveLength(1); expect(byName.ABLY_ENDPOINT.details).toHaveLength(1); + expect(byName.ABLY_URL.details).toHaveLength(1); }); it("only the documented primary URLs appear inline in variable entries", () => {