feat: run the CLI against a local Ably server - #437
Conversation
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis PR adds first-class support for running the CLI against a locally-hosted Ably server. It introduces Changes
Review Notes
|
There was a problem hiding this comment.
Review
This is a well-designed feature with clean architecture and solid test coverage (+25 tests, comprehensive scenarios). The precedence chain (--url > ABLY_URL > ABLY_ENDPOINT > profile > SDK default) is implemented consistently, the control-plane guard is in the right place (ControlBaseCommand.createControlApi), and the drive-by fixes (auth-info display, login JSON labelling) are correct and well-tested.
Two issues worth fixing, one cosmetic note:
Bug: Duplicate app ID in accounts login --local success message
File: src/commands/accounts/login.ts, line 515
this.logSuccessMessage(
`Using app ${formatResource(appName ?? appId)} (${appId}).`,
flags,
);When no control plane is configured (appName is undefined), this produces:
✓ Using app localapp (localapp).
formatResource(appId) renders the ID in cyan, then (${appId}) repeats it in plain text. The parenthetical ID is meant to accompany a human-readable name — when only the ID is known, there's nothing to disambiguate.
Fix: Guard the parenthetical:
this.logSuccessMessage(
appName
? `Using app ${formatResource(appName)} (${appId}).`
: `Using app ${formatResource(appId)}.`,
flags,
);Defensive: baseUrl() calls new URL(base) without error handling
File: src/services/control-api.ts, line 230
const hasPath = new URL(base).pathname !== "/";controlUrl arrives from ControlApiOptions.controlUrl, which in practice is always the output of formatServerUrl(parseServerUrl(...)) and therefore valid. However, the public ControlApiOptions interface accepts any string — if a user or test passes a malformed URL directly, this throws an untyped JavaScript error from inside makeRequest, bypassing the normal error path. A try/catch here with a this.fail()-compatible throw (or at minimum a pre-construction assert) would make failures diagnosable:
let parsed: URL;
try {
parsed = new URL(base);
} catch {
throw new Error(`Invalid controlUrl "${this.controlUrl}": not a valid URL.`);
}
const hasPath = parsed.pathname !== "/";Not urgent since all current callers produce valid URLs, but it's a cheap guard given the function is called on every request.
Cosmetic: accounts switch success message shows bare hostname without port
File: src/commands/accounts/switch.ts, line 291
const endpoint = this.configManager.getEndpoint();
// → "localhost" (no port)
this.logSuccessMessage(
`Switched to local server ${formatResource(alias)}${endpoint ? ` (${endpoint})` : ""}.`,
flags,
);The message shows Switched to local server local (localhost). even when the server is on a non-default port. getDataPlane() is available and would let you render a full URL:
const dp = this.configManager.getDataPlane();
const urlSuffix = dp ? ` (${formatServerUrl({ host: dp.endpoint ?? "", path: "", port: dp.port, tls: dp.tls ?? true })})` : "";Not a correctness issue — the alias unambiguously identifies the profile — but the port is useful context.
There was a problem hiding this comment.
Review summary
| File | Status | Notes |
|---|---|---|
src/commands/accounts/login.ts |
1 bug | Duplicate app ID in success message when name unknown |
src/services/control-api.ts |
1 defensive | Unguarded new URL() in baseUrl() |
src/commands/accounts/switch.ts |
cosmetic | Success message shows bare hostname, not full URL |
src/base-command.ts |
OK | Routing resolution, precedence, and banner display are correct |
src/control-base-command.ts |
OK | Guard fires correctly for local accounts without control URL |
src/utils/server-url.ts |
OK | URL parsing is correct including IPv6, loopback defaults, path detection |
src/utils/api-key.ts |
OK | isValidApiKey regex correctly validates the 3-part shape |
src/services/config-manager.ts |
OK | storeLocalAccount correctly clears OAuth state when replacing managed account |
src/commands/login.ts |
OK | id inheritance fix is correct |
| Test files | OK | Comprehensive coverage of all new behaviour |
Overall this is clean, well-structured work. The only issue that changes observable output for users is the duplicate app ID — the rest are defensive or cosmetic. Ready to merge once the display bug is addressed.
Summary
Adds first-class support for pointing the CLI at a locally-running Ably server (e.g.
ably-server), via either a stored profile or environment variables.Previously this was possible but awkward:
ABLY_ENDPOINTsets the host only, there was no environment variable for the port, and the SDK'sendpointoption rejectslocalhost:8081as a hostname. The shortest throwaway invocation was:with the port flags needing repeating on every command.
What you can do now
Stored profile — a local server becomes an ordinary account, so
accounts list/current/switchand every data plane command work unchanged:No profile — two environment variables, or a flag:
Design notes
isValidApiKey()was added becauseextractAppIdFromApiKeyis deliberately lenient and would otherwise storenot-a-valid-keyas an app ID.--url/ABLY_URLare decomposed into the SDK'sendpoint/port/tls. The scheme is optional for loopback hosts; a path is rejected rather than silently discarded, since Ably routes by host.--url>ABLY_URL>ABLY_ENDPOINT> profile > SDK default, with--port/--tls-port/--tlsapplied on top so they still override a single field.ABLY_ENDPOINTkeeps its host-only semantics, so existing usage is unaffected.Using:banner the same way managed accounts are.--control-url, control commands fail fast with a pointer instead of silently queryingcontrol.ably.netwhile a local profile is selected.ControlApigained acontrolUrloption because it inferred scheme and path prefix from the host string, whichlocalhost:8082does not match.Drive-by fixes
Both predate this work and affect managed accounts too, so they are separate commits:
ably login --jsonlabelled its result record"command":"unknown"— the delegate is constructed directly, so oclif never assigned it an id, while the completed signal correctly readlogin. A single stream disagreed with itself.Using:banner renderedApp=Unknown App (abc123)whenever the name could not be resolved, pairing a real ID with an invented name. It now shows the bare ID.Verification
Tested end to end against a real
ably-serverinstance (memory mode, port 8099): REST publish/history, WebSocket subscribe,--rewind, presence enter/get, and realtime presencepresent/enter/leaveall work, via stored profile,ABLY_URL, and--url. Control commands correctly refuse. Unsupported surfaces (channels list,rooms) surface the server's own40400rather than masking it.pnpm prepare,pnpm generate-doc— passpnpm exec eslint src test— 0 errors (4 pre-existing deprecation warnings in untouched code)pnpm test:unit— 2594 passed, up 25pnpm test:tty— 5 passedNew docs at
docs/Local-Server.md;ABLY_URLdocumented inably env.🤖 Generated with Claude Code