Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v
- **`durationFlag`** — `--duration` / `-D`. Use for long-running subscribe/stream commands that auto-exit after N seconds.
- **`rewindFlag`** — `--rewind`. Use for subscribe commands that support message replay (default: 0).
- **`timeRangeFlags`** — `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`).
- **`forceFlag`** — `--force` / `-f`. Use for destructive commands (delete, revoke) that require user confirmation. When `--force` is provided, skip the interactive prompt. When `--json` is used without `--force`, fail with an error requiring `--force`. Use `promptForConfirmation()` from `src/utils/prompt-confirmation.js` for the interactive prompt — do NOT use `interactiveHelper.confirm()` (inquirer-based, inconsistent UX).
- **`endpointFlag`** — `--endpoint`. Hidden, only on `accounts login` and `accounts switch`.

**Flags vs positional arguments (POSIX / docopt convention):**
Expand Down Expand Up @@ -311,6 +312,10 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers,
- `--direction`: `"Direction of message retrieval"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`.
- Channels use "publish", Rooms use "send" (matches SDK terminology)
- Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`)
- **Destructive command confirmation pattern**: Commands that perform irreversible actions (delete, revoke) must use `...forceFlag` and `promptForConfirmation()`. The pattern:
1. If `--json` without `--force`: `this.fail("The --force flag is required when using --json to confirm <action>", flags, component)`
2. If no `--force` and not JSON: show what will be affected, then call `promptForConfirmation()` for yes/no
3. If `--force`: skip prompt, proceed directly

## Ably Knowledge

Expand Down
3 changes: 2 additions & 1 deletion src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CommandError } from "./errors/command-error.js";
import { getFriendlyAblyErrorHint } from "./utils/errors.js";
import { coreGlobalFlags } from "./flags.js";
import { InteractiveHelper } from "./services/interactive-helper.js";
import { promptForConfirmation } from "./utils/prompt-confirmation.js";
import { BaseFlags, CommandConfig } from "./types/cli.js";
import {
JsonRecordType,
Expand Down Expand Up @@ -1368,7 +1369,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
"The configured API key appears to be invalid or revoked.",
);

const shouldRemove = await this.interactiveHelper.confirm(
const shouldRemove = await promptForConfirmation(
"Would you like to remove this invalid key from your configuration?",
);

Expand Down
35 changes: 21 additions & 14 deletions src/commands/accounts/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,23 @@ export default class AccountsSwitch extends ControlBaseCommand {
this.configManager.storeEndpoint(flags.endpoint as string);
}

this.log(
`Switched to account: ${formatResource(remoteAccount.name)} (${remoteAccount.id})`,
);
this.log(`Saved as alias: ${formatResource(newAlias)}`);
if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
account: {
alias: newAlias,
id: remoteAccount.id,
name: remoteAccount.name,
},
},
flags,
);
} else {
this.logSuccessMessage(
`Switched to account ${formatResource(remoteAccount.name)} (${remoteAccount.id}). Saved as alias ${formatResource(newAlias)}.`,
flags,
);
}
}

private async switchToLocalAccount(
Expand Down Expand Up @@ -292,20 +305,14 @@ export default class AccountsSwitch extends ControlBaseCommand {
}
} catch {
// The account switch already happened above, so this is non-fatal.
// Warn the user but still report success with a warning field.
// Report the switch success, then surface the verification failure as
// a separate warning record (consistent with other commands' JSON shape).
const warningMessage =
"Access token may have expired or is invalid. The account was switched, but token verification failed.";
if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
account: { alias },
warning: warningMessage,
},
flags,
);
} else {
this.logWarning(warningMessage, flags);
this.logJsonResult({ account: { alias } }, flags);
}
this.logWarning(warningMessage, flags);
}
}
}
7 changes: 4 additions & 3 deletions src/commands/auth/keys/revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ControlBaseCommand } from "../../../control-base-command.js";
import { forceFlag } from "../../../flags.js";
import { formatCapabilities } from "../../../utils/key-display.js";
import { formatLabel, formatResource } from "../../../utils/output.js";
import { promptForConfirmation } from "../../../utils/prompt-confirmation.js";

export default class KeysRevokeCommand extends ControlBaseCommand {
static args = {
Expand Down Expand Up @@ -70,8 +71,8 @@ export default class KeysRevokeCommand extends ControlBaseCommand {
}

if (!flags.force && !this.shouldOutputJson(flags)) {
const confirmed = await this.interactiveHelper.confirm(
"This will permanently revoke this key and any applications using it will stop working. Continue?",
const confirmed = await promptForConfirmation(
"\nThis will permanently revoke this key and any applications using it will stop working. Are you sure you want to continue?",
);

if (!confirmed) {
Expand Down Expand Up @@ -106,7 +107,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand {
// Auto-remove in JSON mode — key is already revoked, can't be used
this.configManager.removeApiKey(appId);
} else {
const shouldRemove = await this.interactiveHelper.confirm(
const shouldRemove = await promptForConfirmation(
"The revoked key was your current key for this app. Remove it from configuration?",
);

Expand Down
8 changes: 2 additions & 6 deletions src/commands/auth/keys/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,8 @@ export default class KeysSwitchCommand extends ControlBaseCommand {
`Switched to key ${formatResource(keyName)}.`,
flags,
);
} catch {
this.fail(
`Key "${keyIdentifier}" not found or access denied. Run "ably auth keys list" to see available keys.`,
flags,
"keySwitch",
);
} catch (error) {
this.fail(error, flags, "keySwitch", { keyIdentifier });
}
}
}
162 changes: 97 additions & 65 deletions src/commands/auth/revoke-token.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,76 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";
import { Flags } from "@oclif/core";
import * as https from "node:https";
import stripAnsi from "strip-ansi";

import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags } from "../../flags.js";
import { forceFlag, productApiFlags } from "../../flags.js";
import { formatLabel, formatResource } from "../../utils/output.js";
import { promptForConfirmation } from "../../utils/prompt-confirmation.js";

export default class RevokeTokenCommand extends AblyBaseCommand {
static args = {
token: Args.string({
description: "Token to revoke",
name: "token",
required: true,
}),
};

static description = "Revoke a token";
static description = "Revoke tokens by client ID or revocation key";

static examples = [
"$ ably auth revoke-token TOKEN",
"$ ably auth revoke-token TOKEN --client-id clientid",
"$ ably auth revoke-token TOKEN --json",
"$ ably auth revoke-token TOKEN --pretty-json",
`$ ably auth revoke-token --client-id "userClientId"`,
`$ ably auth revoke-token --client-id "userClientId" --force`,
`$ ably auth revoke-token --revocation-key group1`,
`$ ably auth revoke-token --client-id "userClientId" --allow-reauth-margin`,
`$ ably auth revoke-token --client-id "userClientId" --json --force`,
];

static flags = {
...productApiFlags,
...forceFlag,
app: Flags.string({
description: "The app ID or name (defaults to current app)",
env: "ABLY_APP_ID",
}),

"client-id": Flags.string({
char: "c",
description: "Client ID to revoke tokens for",
description: "Revoke all tokens issued to this client ID",
exclusive: ["revocation-key"],
}),
"revocation-key": Flags.string({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question, should these be mandatory mutally exclusive flags, or would it even be better to have separate commands, e.g. revoke-token by-clientid, revoke-token by-key ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I looked at OCLIF_AUTOGENERATED_DOC.md for all commands, we have no by-* subcommand pattern anywhere. All commands follow the pattern, and targeting/filtering is always done via flags (--client-id, --device-id, --type, --prefix, etc.). Introducing by-* subcommands here would break that consistency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

revocation-key is only valid for JWT tokens with following criteria

To designate a revocation key, include the following additional claim in the JWT:

- `x-ably-revocation-key`: a string used to identify which token(s) to revoke in the revocation request.

This target specifier will match tokens that have the specified `revocationKey`.

Each flag is designed for a specific use case, so they are kept mutually exclusive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm fine with how this is done currently e.g.

  • Manual mandatory check (if (!clientId && !revocationKey)) instead of exactlyOne - we get slightly better help formatting using our manual check instead of the oclif property... also this is a codebase wide standard we can revisit later if we want (not important for GA)
  • Correct use of the exclusive property

I also agree using the flags is better than having separate commands. It just makes more sense in the CLI.

One thing I would change... the fail message needs updating to match what we do elsewhere...

it is currently ""Either --client-id or --revocation-key is required. See https://ably.com/docs/auth/revocation for details."

However:

  1. We don't add doc urls in any fail messages - this should be removed.
  2. "Either --client-id or --revocation-key must be provided" follows the language of other similar fail messages

Copy link
Copy Markdown
Contributor Author

@sacOO7 sacOO7 Apr 30, 2026

Choose a reason for hiding this comment

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

Fixed a3e41c2

description:
"Revoke all tokens matching this revocation key (JWT tokens only)",
exclusive: ["client-id"],
}),
"allow-reauth-margin": Flags.boolean({
default: false,
description:
"Delay enforcement by 30s so connected clients can obtain a new token before disconnection.",
}),
};

// Property to store the Ably client
private ablyClient?: Ably.Realtime;

async run(): Promise<void> {
const { args, flags } = await this.parse(RevokeTokenCommand);
const { flags } = await this.parse(RevokeTokenCommand);

const clientId = flags["client-id"];
const revocationKey = flags["revocation-key"];

// Require at least one target specifier
if (!clientId && !revocationKey) {
this.fail(
"Either --client-id or --revocation-key must be provided",
flags,
"revokeToken",
);
}

// Build target specifier
const targetSpecifier = clientId
? `clientId:${clientId}`
: `revocationKey:${revocationKey}`;
const targetLabel = clientId ? "Client ID" : "Revocation Key";
const targetValue = (clientId ?? revocationKey)!;

// JSON mode guard — fail fast before config lookup
if (!flags.force && this.shouldOutputJson(flags)) {
this.fail(
"The --force flag is required when using --json to confirm revocation",
flags,
"revokeToken",
Comment thread
sacOO7 marked this conversation as resolved.
);
}

// Get app and key
const appAndKey = await this.ensureAppAndKey(flags);
Expand All @@ -49,85 +79,87 @@ export default class RevokeTokenCommand extends AblyBaseCommand {
}

const { apiKey } = appAndKey;
const { token } = args;

try {
// Create Ably Realtime client
const client = await this.createAblyRealtimeClient(flags);
if (!client) return;

this.ablyClient = client;
// Interactive confirmation
if (!flags.force && !this.shouldOutputJson(flags)) {
this.logToStderr(`\nYou are about to revoke all tokens matching:`);
this.logToStderr(
`${formatLabel(targetLabel)} ${formatResource(targetValue)}`,
);

const clientId = flags["client-id"] || token;
const confirmed = await promptForConfirmation(
"\nThis will permanently revoke all matching tokens, and any applications using those tokens will need to be issued new tokens. Are you sure?",
);

if (!flags["client-id"]) {
// We need to warn the user that we're using the token as a client ID
this.logWarning(
"Revoking a specific token is only possible if it has a client ID or revocation key.",
flags,
);
this.logWarning(
"For advanced token revocation options, see: https://ably.com/docs/auth/revocation.",
flags,
);
this.logWarning(
"Using the token argument as a client ID for this operation.",
flags,
);
if (!confirmed) {
this.logWarning("Revocation cancelled.", flags);
return;
}
}

try {
// Extract the keyName (appId.keyId) from the API key
const keyParts = apiKey.split(":");
if (keyParts.length !== 2) {
this.fail(
"Invalid API key format. Expected format: appId.keyId:secret",
flags,
"tokenRevoke",
"revokeToken",
);
}

const keyName = keyParts[0]!; // This gets the appId.keyId portion
const keyName = keyParts[0]!;
const secret = keyParts[1]!;

// Create the properly formatted body for token revocation
const requestBody = {
targets: [`clientId:${clientId}`],
const requestBody: Record<string, unknown> = {
targets: [targetSpecifier],
};

let reauthNote = "";
if (flags["allow-reauth-margin"]) {
requestBody.allowReauthMargin = true;
reauthNote =
" Connected clients have a 30s grace period to obtain new tokens before disconnection.";
}

try {
// Make direct HTTPS request to Ably REST API
const response = await this.makeHttpRequest(
keyName,
secret,
requestBody,
);
const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`;

if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
revocation: {
message: "Token revocation processed successfully",
allowReauthMargin: flags["allow-reauth-margin"],
message: stripAnsi(successMessage),
target: targetSpecifier,
response,
},
},
flags,
);
} else {
this.logSuccessMessage("Token successfully revoked.", flags);
this.logSuccessMessage(successMessage, flags);
}
} catch (requestError: unknown) {
// Handle specific API errors
const error = requestError as Error;
if (error.message && error.message.includes("token_not_found")) {
this.fail("Token not found or already revoked", flags, "tokenRevoke");
} else {
throw requestError;
const error = requestError as Error & { statusCode?: number };
if (error.statusCode === 404) {
this.fail(
"No matching tokens found or already revoked",
flags,
"revokeToken",
);
}
throw requestError;
}
} catch (error) {
this.fail(error, flags, "tokenRevoke");
this.fail(error, flags, "revokeToken");
}
// Client cleanup is handled by base class finally() method
}

// Helper method to make a direct HTTP request to the Ably REST API
Expand Down Expand Up @@ -172,11 +204,11 @@ export default class RevokeTokenCommand extends AblyBaseCommand {
resolve(data);
}
} else {
reject(
new Error(
`Request failed with status code ${res.statusCode}: ${data}`,
),
);
const err = new Error(
`Request failed with status code ${res.statusCode}: ${data}`,
) as Error & { statusCode?: number };
err.statusCode = res.statusCode;
reject(err);
}
});
});
Expand Down
3 changes: 2 additions & 1 deletion src/commands/bench/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Table from "cli-table3";
import { AblyBaseCommand } from "../../base-command.js";
import { clientIdFlag, productApiFlags } from "../../flags.js";
import { errorMessage } from "../../utils/errors.js";
import { promptForConfirmation } from "../../utils/prompt-confirmation.js";
import type { BenchPresenceData } from "../../types/bench.js";

interface TestMetrics {
Expand Down Expand Up @@ -388,7 +389,7 @@ export default class BenchPublisher extends AblyBaseCommand {
`Found ${subscribers.length} subscribers present`,
);
if (subscribers.length === 0 && !this.shouldOutputJson(flags)) {
const shouldContinue = await this.interactiveHelper.confirm(
const shouldContinue = await promptForConfirmation(
"No subscribers found. Continue anyway?",
);
if (!shouldContinue) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/queues/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default class QueuesDeleteCommand extends ControlBaseCommand {
async run(): Promise<void> {
const { args, flags } = await this.parse(QueuesDeleteCommand);
if (!args.queueNameOrId.trim()) {
this.fail("Queue name or ID cannot be empty", flags, "parse");
this.fail("Queue name or ID cannot be empty", flags, "queueDelete");
}

const appId = await this.requireAppId(flags);
Expand Down
Loading
Loading