Skip to content

feat: run the CLI against a local Ably server - #437

Open
lmars wants to merge 4 commits into
mainfrom
local-login
Open

feat: run the CLI against a local Ably server#437
lmars wants to merge 4 commits into
mainfrom
local-login

Conversation

@lmars

@lmars lmars commented Jul 29, 2026

Copy link
Copy Markdown
Member

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_ENDPOINT sets the host only, there was no environment variable for the port, and the SDK's endpoint option rejects localhost:8081 as a hostname. The shortest throwaway invocation was:

ABLY_ENDPOINT=localhost ably channels publish ch msg --tls=false --port 8081

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/switch and every data plane command work unchanged:

ably accounts login --local                                      # guided prompts
ably accounts login --local --url http://localhost:8081          # data plane only
ably accounts login --local --url http://localhost:8081 \
                            --control-url http://localhost:8082  # both planes

No profile — two environment variables, or a flag:

ABLY_URL=http://localhost:8081 ABLY_API_KEY=appId.keyId:secret ably channels publish ch msg
ably channels publish ch msg --url http://localhost:8081

Design notes

  • The API key is the only data plane credential. There is no OAuth flow and no account directory on a local server, so the app ID is read out of the key. isValidApiKey() was added because extractAppIdFromApiKey is deliberately lenient and would otherwise store not-a-valid-key as an app ID.
  • URLs, not three flags. --url / ABLY_URL are decomposed into the SDK's endpoint/port/tls. The scheme is optional for loopback hosts; a path is rejected rather than silently discarded, since Ably routes by host.
  • Precedence: --url > ABLY_URL > ABLY_ENDPOINT > profile > SDK default, with --port/--tls-port/--tls applied on top so they still override a single field. ABLY_ENDPOINT keeps its host-only semantics, so existing usage is unaffected.
  • The alias is the account name. A local server has no server-side identity, so several local profiles stay distinguishable in the Using: banner the same way managed accounts are.
  • Control plane is optional. Without --control-url, control commands fail fast with a pointer instead of silently querying control.ably.net while a local profile is selected. ControlApi gained a controlUrl option because it inferred scheme and path prefix from the host string, which localhost:8082 does not match.

Drive-by fixes

Both predate this work and affect managed accounts too, so they are separate commits:

  • ably login --json labelled its result record "command":"unknown" — the delegate is constructed directly, so oclif never assigned it an id, while the completed signal correctly read login. A single stream disagreed with itself.
  • The Using: banner rendered App=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-server instance (memory mode, port 8099): REST publish/history, WebSocket subscribe, --rewind, presence enter/get, and realtime presence present/enter/leave all work, via stored profile, ABLY_URL, and --url. Control commands correctly refuse. Unsupported surfaces (channels list, rooms) surface the server's own 40400 rather than masking it.

  • pnpm prepare, pnpm generate-doc — pass
  • pnpm exec eslint src test — 0 errors (4 pre-existing deprecation warnings in untouched code)
  • pnpm test:unit — 2594 passed, up 25
  • pnpm test:tty — 5 passed

New docs at docs/Local-Server.md; ABLY_URL documented in ably env.

🤖 Generated with Claude Code

lmars and others added 4 commits July 29, 2026 13:01
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>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cli-web-cli Ready Ready Preview, Comment Jul 29, 2026 1:08pm

Request Review

@claude-code-ably-assistant

Copy link
Copy Markdown

Walkthrough

This PR adds first-class support for running the CLI against a locally-hosted Ably server. It introduces --url / ABLY_URL for one-off targeting and a --local login flow that stores a named local-server profile (host, port, API key) without needing the Control API. Two drive-by fixes are bundled: the ably login --json envelope was labelled "command":"unknown" (now fixed), and the Using: banner showed App=Unknown App (abc123) for unresolvable names (now shows the bare app ID).

Changes

Area Files Summary
Commands src/commands/accounts/login.ts, src/commands/accounts/current.ts, src/commands/accounts/list.ts, src/commands/accounts/switch.ts, src/commands/login.ts Adds --local login flow (~300 lines); fast-paths switch for local accounts; hides empty accountId; fixes delegate command-id labelling
Base / Core src/base-command.ts, src/control-base-command.ts Adds resolveDataPlaneRouting() with precedence chain (--urlABLY_URLABLY_ENDPOINT › profile); guards control commands for local-only accounts
Services src/services/config-manager.ts, src/services/control-api.ts New DataPlaneConfig interface; storeLocalAccount() / getDataPlane() / getControlUrl(); ControlApi gains controlUrl option and extracted baseUrl()
Utils src/utils/server-url.ts (new), src/utils/prompt-value.ts (new), src/utils/api-key.ts parseServerUrl() / formatServerUrl(); generic masked/free-text prompt helper; isValidApiKey()
Flags / Types src/flags.ts, src/types/cli.ts Adds localServerFlags (--local, --url, --control-url) and hidden --url on product API; extends BaseFlags
Data / Env vars src/data/env-vars.ts Registers ABLY_URL; expands ABLY_ENDPOINT details
Tests test/unit/base/, test/unit/commands/accounts/login.test.ts, test/unit/utils/server-url.test.ts (new), test/unit/base/local-server-control-guard.test.ts (new), others 50+ new unit tests covering routing precedence, local login, URL parsing, control guard, and banner fixes
Docs docs/Local-Server.md (new), docs/Environment-Variables/General-Usage.md, docs/Project-Structure.md New 165-line local-server guide; env-var table updated; project structure updated
Test helpers test/helpers/mock-config-manager.ts Implements getDataPlane(), getControlUrl(), storeLocalAccount(); narrows getAuthMethod() return type

Review Notes

  • Routing precedence is critical: --url > ABLY_URL > ABLY_ENDPOINT > stored profile > SDK default. The ABLY_ENDPOINT (host-only) and ABLY_URL (host+port+TLS) env vars coexist — reviewers should confirm the interaction when both are set is intentional and documented.
  • authMethod type narrowed: config-manager.ts changes authMethod from string to "apiKey" | "oauth" | undefined. Any caller that previously set or compared authMethod with other string values will silently break at runtime (TypeScript will catch compile-time uses, but check for dynamic/JSON-deserialized paths).
  • Control API guard: ControlBaseCommand now fails fast for local accounts without a controlUrl. Reviewers should verify the error message is actionable and that existing integrations that mix control and data-plane commands won't hit unexpected failures.
  • ControlApi.baseUrl() inference: The extracted method infers /api/v1 vs /v1 path suffix based on the host. This heuristic should be reviewed for edge cases with custom control-plane hostnames.
  • No new npm dependencies added.
  • E2E / integration tests against a real local server are not included — the new flows are covered by unit tests only. Consider whether a smoke test against an actual local server instance is needed before merging.

@lmars
lmars requested a review from umair-ably July 29, 2026 13:10

@claude-code-ably-assistant claude-code-ably-assistant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude-code-ably-assistant claude-code-ably-assistant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant