Skip to content
Open
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 @@ -103,6 +103,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`.

**When creating a new command:**
Expand Down Expand Up @@ -290,6 +291,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 @@ -13,6 +13,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 @@ -1348,7 +1349,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
7 changes: 4 additions & 3 deletions src/commands/auth/keys/revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { forceFlag } from "../../../flags.js";
import { formatCapabilities } from "../../../utils/key-display.js";
import { parseKeyIdentifier } from "../../../utils/key-parsing.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 @@ -73,8 +74,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?",
);

if (!confirmed) {
Expand Down Expand Up @@ -109,7 +110,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
144 changes: 89 additions & 55 deletions src/commands/auth/revoke-token.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,77 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";
import { Flags } from "@oclif/core";
import * as https from "node:https";

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({
char: "r",
description:
"Revoke all tokens matching this revocation key (JWT tokens only)",
exclusive: ["client-id"],
}),
"allow-reauth-margin": Flags.boolean({
default: false,
description:
"[default: false] 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 is required. See https://ably.com/docs/auth/revocation for details.",
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 +80,88 @@ 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: 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");
this.fail(
"No matching tokens found or already revoked",
flags,
"revokeToken",
);
} else {
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
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
Loading
Loading