@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
1,085 lines (1,084 loc) • 48.9 kB
JavaScript
import { Flags } from "@oclif/core";
import { InteractiveBaseCommand } from "./interactive-base-command.js";
import * as Ably from "ably";
import chalk from "chalk";
import colorJson from "color-json";
import { randomUUID } from "node:crypto";
import { ConfigManager } from "./services/config-manager.js";
import { ControlApi } from "./services/control-api.js";
import { InteractiveHelper } from "./services/interactive-helper.js";
import { getCliVersion } from "./utils/version.js";
// Export BaseFlags for potential use in other modules like MCP
// List of commands not allowed in web CLI mode - EXPORTED
export const WEB_CLI_RESTRICTED_COMMANDS = [
// All account login/management commands are not valid in a web env where auth is handled by the website
// note accounts:stats is supported
"accounts:current",
"accounts:list",
"accounts:login",
"login",
"accounts:logout",
"accounts:switch",
// You cannot switch/delete/create apps, you can only work with the current app you have selected in the web UI
"apps:create",
"apps:switch",
"apps:delete",
// The key you use for auth is controlled from the web UI
"auth:keys:switch",
"autocomplete*",
// config only applicable to local env
"config*",
// MCP functionality is not available in the web CLI
"mcp*",
];
/* Additional restricted commands when running in anonymous web CLI mode */
export const WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS = [
"accounts*", // all account commands cannot be run, don't expose account info
"apps*", // all app commands cannot be run, don't expose app info
"auth:keys*", // disallow all key commands
"auth:revoke-token", // token revocation not support when anonymous
"bench*", // all bench commands cannot be run in anonymous mode
// All enumeration and logging commands are disallowed as this could expose other anonymous user behaviour
"channels:list",
"channels:logs",
"connections:logs",
"rooms:list",
"spaces:list",
"logs*",
// Integrations and queues are not available to anonymous users
"integrations*",
"queues*",
];
/* Commands not suitable for interactive mode */
export const INTERACTIVE_UNSUITABLE_COMMANDS = [
"autocomplete", // Autocomplete setup is not needed in interactive mode
"config", // Config editing is not suitable for interactive mode
"version", // Version is shown at startup and available via --version
"mcp", // MCP server functionality is not suitable for interactive mode
];
// List of commands that should not show account/app info
const SKIP_AUTH_INFO_COMMANDS = [
"accounts:list",
"accounts:switch",
"accounts:login",
"accounts:current",
"apps:current",
"auth:keys:current",
"config",
"status",
"support:contact",
"support:info",
"support:ask",
];
export class AblyBaseCommand extends InteractiveBaseCommand {
_authInfoShown = false;
// Add static flags that will be available to all commands
static globalFlags = {
"access-token": Flags.string({
description: "Overrides any configured access token used for the Control API",
}),
"api-key": Flags.string({
description: "Overrides any configured API key used for the product APIs",
}),
"client-id": Flags.string({
description: 'Overrides any default client ID when using API authentication. Use "none" to explicitly set no client ID. Not applicable when using token authentication.',
}),
"control-host": Flags.string({
description: "Override the host endpoint for the control API, which defaults to control.ably.net",
hidden: process.env.ABLY_SHOW_DEV_FLAGS !== 'true',
}),
env: Flags.string({
description: "Override the environment for all product API calls",
}),
endpoint: Flags.string({
description: "Override the endpoint for all product API calls",
env: "ABLY_ENDPOINT",
}),
host: Flags.string({
description: "Override the host endpoint for all product API calls",
}),
port: Flags.integer({
description: "Override the port for product API calls",
hidden: process.env.ABLY_SHOW_DEV_FLAGS !== 'true',
}),
tlsPort: Flags.integer({
description: "Override the TLS port for product API calls",
hidden: process.env.ABLY_SHOW_DEV_FLAGS !== 'true',
}),
tls: Flags.string({
description: "Use TLS for product API calls (default is true)",
hidden: process.env.ABLY_SHOW_DEV_FLAGS !== 'true',
}),
json: Flags.boolean({
description: "Output in JSON format",
exclusive: ["pretty-json"], // Cannot use with pretty-json
}),
"pretty-json": Flags.boolean({
description: "Output in colorized JSON format",
exclusive: ["json"], // Cannot use with json
}),
token: Flags.string({
description: "Authenticate using an Ably Token or JWT Token instead of an API key",
}),
verbose: Flags.boolean({
char: "v",
default: false,
description: "Output verbose logs",
required: false,
}),
// Web CLI specific flag, hidden from regular help
"web-cli-help": Flags.boolean({
description: "Show help formatted for the web CLI",
hidden: true, // Hide from regular help output
}),
};
configManager;
interactiveHelper;
isWebCliMode;
constructor(argv, config) {
super(argv, config);
this.configManager = new ConfigManager();
this.interactiveHelper = new InteractiveHelper(this.configManager);
// Check if we're running in web CLI mode
this.isWebCliMode = process.env.ABLY_WEB_CLI_MODE === "true";
}
/**
* Check if we're running in test mode
* @returns true if running in test mode
*/
isTestMode() {
return process.env.ABLY_CLI_TEST_MODE === "true";
}
isAnonymousWebMode() {
// In web CLI mode, the server sets ABLY_ANONYMOUS_USER_MODE when no access token is available
return this.isWebCliMode && process.env.ABLY_ANONYMOUS_USER_MODE === 'true';
}
/**
* Check if command matches a pattern (supports wildcards)
*/
matchesCommandPattern(commandId, pattern) {
// Handle wildcard patterns
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
return commandId === prefix || commandId.startsWith(prefix);
}
// Handle exact matches
return commandId === pattern;
}
/**
* Check if command is restricted in anonymous web CLI mode
*/
isRestrictedInAnonymousMode(commandId) {
return WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS.some(pattern => this.matchesCommandPattern(commandId, pattern));
}
/**
* Check if terminal updates (like carriage returns and line clearing) should be used.
* Returns true only when:
* - Output is to a TTY (interactive terminal)
* - Not in test mode
* - Not in CI environment
*/
shouldUseTerminalUpdates() {
return process.stdout.isTTY && !this.isTestMode() && !process.env.CI;
}
/**
* Get test mocks if in test mode
* @returns Test mocks object or undefined if not in test mode
*/
getMockAblyRest() {
if (!this.isTestMode())
return undefined;
// Access global mock if running in test mode
return globalThis.__TEST_MOCKS__?.ablyRestMock;
}
/**
* Check if this is a web CLI version and return a consistent error message
* for commands that are not allowed in web CLI mode
*/
checkWebCliRestrictions() {
if (!this.isWebCliMode) {
return; // Not in web CLI mode, no restrictions
}
const commandId = this.id || "";
const commandIdForDisplay = commandId.replaceAll(":", " ");
// Check if we're in anonymous mode
if (this.isAnonymousWebMode()) {
// Anonymous web CLI mode - check both base and anonymous restrictions
let errorMessage = null;
// First check if command is in the anonymous restricted list
if (this.isRestrictedInAnonymousMode(commandId)) {
// Provide specific messages for different command types
if (commandId.startsWith("accounts")) {
errorMessage = `Account management commands are only available when logged in. Please log in at https://ably.com/login.`;
}
else if (commandId.startsWith("apps")) {
errorMessage = `App management commands are only available when logged in. Please log in at https://ably.com/login.`;
}
else if (commandId.startsWith("auth:keys")) {
errorMessage = `API key management requires you to be logged in. Please log in at https://ably.com/login.`;
}
else if (commandId === "auth:revoke-token") {
errorMessage = `Token revocation requires you to be logged in. Please log in at https://ably.com/login.`;
}
else if (commandId.startsWith("bench")) {
errorMessage = `Benchmarking commands are only available when logged in. Please log in at https://ably.com/login.`;
}
else if (commandId === "channels:list" || commandId === "rooms:list" || commandId === "spaces:list" ||
commandId.includes("logs")) {
errorMessage = `This command is not available in anonymous mode for privacy reasons. Please log in at https://ably.com/login.`;
}
else if (commandId.startsWith("integrations")) {
errorMessage = `Integration management requires you to be logged in. Please log in at https://ably.com/login.`;
}
else if (commandId.startsWith("queues")) {
errorMessage = `Queue management requires you to be logged in. Please log in at https://ably.com/login.`;
}
else {
errorMessage = `This command is not available in anonymous mode. Please log in at https://ably.com/login.`;
}
}
// Then check if command is in the base restricted list
else if (!this.isAllowedInWebCliMode()) {
// Provide specific messages for always-restricted commands
if (commandId.includes("accounts login")) {
errorMessage = `Please log in at https://ably.com/login to use authentication features.`;
}
else if (commandId.startsWith("config")) {
errorMessage = `Local configuration is not supported in the web CLI. Please install the CLI locally.`;
}
else if (commandId.startsWith("mcp")) {
errorMessage = `MCP server functionality is not available in the web CLI. Please install the CLI locally.`;
}
else {
errorMessage = `This command is not available in the web CLI. Please install the CLI locally.`;
}
}
if (errorMessage) {
this.error(chalk.red(errorMessage));
}
}
else {
// Authenticated web CLI mode - only base restrictions apply
if (!this.isAllowedInWebCliMode()) {
let errorMessage = `This command is not available in the web CLI.`;
// Provide specific messages for authenticated users
if (commandIdForDisplay.includes("accounts login")) {
errorMessage = `You are already logged in via the web CLI. This command is not available in the web CLI.`;
}
else if (commandIdForDisplay.includes("accounts list")) {
errorMessage = `This feature is not available in the web CLI. Please use the web dashboard at https://ably.com/accounts/ instead.`;
}
else if (commandIdForDisplay.includes("accounts logout")) {
errorMessage = `You cannot log out via the web CLI.`;
}
else if (commandIdForDisplay.includes("accounts switch")) {
errorMessage = `You cannot change accounts in the web CLI. Please use the dashboard at https://ably.com/accounts/ to switch accounts.`;
}
else if (commandIdForDisplay.includes("apps switch")) {
errorMessage = `You cannot switch apps from within the web CLI. Please use the web dashboard at https://ably.com/dashboard instead.`;
}
else if (commandIdForDisplay.includes("auth keys switch")) {
errorMessage = `You cannot switch API keys from within the web CLI. Please use the web interface to change keys.`;
}
else if (commandId.startsWith("config")) {
errorMessage = `Local configuration is not supported in the web CLI version.`;
}
else if (commandId.startsWith("mcp")) {
errorMessage = `MCP server functionality is not available in the web CLI. Please use the standalone CLI installation instead.`;
}
this.error(chalk.red(errorMessage));
}
}
}
/**
* Create an Ably REST client with automatic auth info display
*/
async createAblyRestClient(flags, options) {
const client = await this.createAblyClientInternal(flags, {
type: 'rest',
skipAuthInfo: options?.skipAuthInfo,
});
return client;
}
/**
* Create an Ably Realtime client with automatic auth info display
*/
async createAblyRealtimeClient(flags, options) {
const client = await this.createAblyClientInternal(flags, {
type: 'realtime',
skipAuthInfo: options?.skipAuthInfo,
});
return client;
}
/**
* @deprecated Use createAblyRestClient or createAblyRealtimeClient instead
*/
async createAblyClient(flags, options) {
return this.createAblyClientInternal(flags, options);
}
/**
* Internal method that creates either REST or Realtime client
* Shared functionality for both client types
*/
async createAblyClientInternal(flags, options) {
const clientType = options?.type || 'realtime';
// If in test mode, skip connection and use mock
if (this.isTestMode()) {
this.debug(`Running in test mode, using mock Ably ${clientType} client`);
const mockAblyRest = this.getMockAblyRest();
if (mockAblyRest) {
// Return mock as appropriate type
return mockAblyRest;
}
this.error(`No mock Ably ${clientType} client available in test mode`);
return null;
}
// If token is provided or API key is in environment, we can skip the ensureAppAndKey step
if (!flags.token && !flags["api-key"] && !process.env.ABLY_API_KEY) {
const appAndKey = await this.ensureAppAndKey(flags);
if (!appAndKey) {
this.error(`${chalk.yellow("No app or API key configured for this command")}.\nPlease log in first with "${chalk.cyan("ably accounts login")}" (recommended approach).\nAlternatively you can provide an API key with the ${chalk.cyan("--api-key")} argument or set the ${chalk.cyan("ABLY_API_KEY")} environment variable.`);
return null;
}
flags["api-key"] = appAndKey.apiKey;
}
// Show auth info at the start of the command (but not in Web CLI mode and not if skipped)
if (!this.isWebCliMode && !options?.skipAuthInfo) {
this.showAuthInfoIfNeeded(flags);
}
const clientOptions = this.getClientOptions(flags);
// isJsonMode is defined outside the try block for use in error handling
const isJsonMode = this.shouldOutputJson(flags);
// Make sure we have authentication after potentially modifying options
if (!clientOptions.key && !clientOptions.token) {
this.error("Authentication required. Please provide either an API key, a token, or log in first.");
return null;
}
try {
// Create REST client
if (clientType === 'rest') {
return new Ably.Rest(clientOptions);
}
// Create Realtime client
const client = new Ably.Realtime(clientOptions);
// Wait for the connection to be established or fail
return await new Promise((resolve, reject) => {
// Add timeout for connection attempt (especially important for E2E tests with fake credentials)
const connectionTimeout = setTimeout(() => {
client.connection.off(); // Remove event listeners
const timeoutError = new Error("Connection timeout after 3 seconds");
if (isJsonMode) {
this.outputJsonError("Connection timeout", { code: 80003 }); // Custom timeout error code
}
reject(timeoutError);
}, 3000); // 3 second timeout
client.connection.once("connected", () => {
clearTimeout(connectionTimeout);
// Use logCliEvent for connection success if verbose
this.logCliEvent(flags, "RealtimeClient", "connection", "Successfully connected to Ably Realtime.");
resolve(client);
});
client.connection.once("failed", (stateChange) => {
clearTimeout(connectionTimeout);
// Handle authentication errors specifically
if (stateChange.reason && stateChange.reason.code === 40_100) {
// Unauthorized
if (clientOptions.key) {
// Check the original options object
this.handleInvalidKey(flags);
const errorMsg = "Invalid API key. Ensure you have a valid key configured.";
if (isJsonMode) {
this.outputJsonError(errorMsg, stateChange.reason);
}
reject(new Error(errorMsg));
}
else {
const errorMsg = "Invalid token. Please provide a valid Ably Token or JWT.";
if (isJsonMode) {
this.outputJsonError(errorMsg, stateChange.reason);
}
reject(new Error(errorMsg));
}
}
else {
const errorMsg = stateChange.reason?.message || "Connection failed";
if (isJsonMode) {
this.outputJsonError(errorMsg, stateChange.reason);
}
reject(stateChange.reason || new Error(errorMsg));
}
});
});
}
catch (error) {
// Handle any synchronous errors when creating the client
const err = error; // Type assertion
if ((err.code === 40_100 || err.message?.includes("invalid key")) && // Unauthorized or invalid key format
flags["api-key"]) {
// Provided key is invalid - reset it
await this.handleInvalidKey(flags);
}
// Re-throw the error
throw error;
}
}
/**
* Display the current account, app, and authentication information
* This provides context to the user about which resources they're working with
*
* @param flags Command flags that may contain auth overrides
* @param showAppInfo Whether to show app info (for data plane commands)
*/
displayAuthInfo(flags, showAppInfo = true) {
// Get account info
const currentAccount = this.configManager.getCurrentAccount();
const accountName = currentAccount?.accountName ||
this.configManager.getCurrentAccountAlias() ||
"Unknown Account";
const accountId = currentAccount?.accountId || "";
// Start building the display string
const displayParts = [];
// Only add account info if it shouldn't be hidden
if (!this.shouldHideAccountInfo(flags)) {
displayParts.push(`${chalk.cyan("Account=")}${chalk.cyan.bold(accountName)}${accountId ? chalk.gray(` (${accountId})`) : ""}`);
}
// For data plane commands, show app and auth info
if (showAppInfo) {
// Get app info
const appId = flags.app || this.configManager.getCurrentAppId();
if (appId) {
const appName = this.configManager.getAppName(appId) || "Unknown App";
displayParts.push(`${chalk.green("App=")}${chalk.green.bold(appName)} ${chalk.gray(`(${appId})`)}`);
// Check auth method - token or API key
if (flags.token) {
// For token auth, show truncated token
const truncatedToken = flags.token.length > 20
? `${flags.token.slice(0, 17)}...`
: flags.token;
displayParts.push(`${chalk.magenta("Auth=")}${chalk.magenta.bold("Token")} ${chalk.gray(`(${truncatedToken})`)}`);
}
else {
// For API key auth
const apiKey = flags["api-key"] || this.configManager.getApiKey(appId);
if (apiKey) {
const keyId = apiKey.split(":")[0]; // Extract key ID (part before colon)
const keyName = this.configManager.getKeyName(appId) || "Default Key";
// Format the full key name (app_id.key_id)
const formattedKeyName = keyId.includes(".")
? keyId
: `${appId}.${keyId}`;
displayParts.push(`${chalk.yellow("Key=")}${chalk.yellow.bold(keyName)} ${chalk.gray(`(${formattedKeyName})`)}`);
}
}
}
}
// Only display if we have parts to show
if (displayParts.length > 0) {
// Display the info on a single line with separator bullets
this.log(`${chalk.dim("Using:")} ${displayParts.join(` ${chalk.dim("•")} `)}`);
this.log(""); // Add blank line for readability
}
}
/**
* Display information for control plane commands
* Shows only account information
*/
displayControlPlaneInfo(flags) {
if (!flags.quiet &&
!this.shouldOutputJson(flags) &&
!this.shouldSuppressOutput(flags)) {
this.displayAuthInfo(flags, false);
}
}
/**
* Display information for data plane (product API) commands
* Shows account, app, and authentication information
*/
displayDataPlaneInfo(flags) {
if (!flags.quiet &&
!this.shouldOutputJson(flags) &&
!this.shouldSuppressOutput(flags)) {
this.displayAuthInfo(flags, true);
}
}
async ensureAppAndKey(flags) {
// If in web CLI mode, use environment variables directly
if (this.isWebCliMode) {
// Extract app ID from ABLY_API_KEY environment variable
const apiKey = process.env.ABLY_API_KEY || "";
if (!apiKey) {
this.log("ABLY_API_KEY environment variable is not set");
return null;
}
// Debug log the API key format (masking the secret part)
const keyParts = apiKey.split(":");
const maskedKey = keyParts.length > 1 ? `${keyParts[0]}:***` : apiKey;
this.debug(`Using API key format: ${maskedKey}`);
// The app ID is the part before the first period in the key
const appId = apiKey.split(".")[0] || "";
if (!appId) {
this.log("Failed to extract app ID from API key");
return null;
}
this.debug(`Extracted app ID: ${appId}`);
return { apiKey, appId };
}
// If token auth is being used, we don't need an API key
if (flags.token) {
// For token auth, we still need an app ID for some operations
const appId = flags.app || this.configManager.getCurrentAppId();
if (appId) {
return { apiKey: "", appId };
}
// If no app ID is provided, we'll try to extract it from the token if it's a JWT
// But for now, just return null and let the operation proceed with token auth only
}
// Check if we have an app and key from flags or config
let appId = flags.app || this.configManager.getCurrentAppId();
let apiKey = flags["api-key"] || this.configManager.getApiKey(appId);
// If we have both, return them
if (appId && apiKey) {
return { apiKey, appId };
}
// Get access token for control API
const accessToken = process.env.ABLY_ACCESS_TOKEN ||
flags["access-token"] ||
this.configManager.getAccessToken();
if (!accessToken) {
return null;
}
const controlApi = new ControlApi({
accessToken,
controlHost: flags["control-host"],
});
// If no app is selected, prompt to select one
if (!appId) {
if (!this.shouldSuppressOutput(flags)) {
this.log("Select an app to use for this command:");
}
const selectedApp = await this.interactiveHelper.selectApp(controlApi);
if (!selectedApp)
return null;
appId = selectedApp.id;
this.configManager.setCurrentApp(appId);
// Store app name along with app ID
this.configManager.storeAppInfo(appId, { appName: selectedApp.name });
if (!this.shouldSuppressOutput(flags)) {
this.log(` Selected app: ${selectedApp.name} (${appId})\n`);
}
}
// If no key is selected, prompt to select one
if (!apiKey) {
if (!this.shouldSuppressOutput(flags)) {
this.log("Select an API key to use for this command:");
}
const selectedKey = await this.interactiveHelper.selectKey(controlApi, appId);
if (!selectedKey)
return null;
apiKey = selectedKey.key;
// Store key with metadata including key name and ID
this.configManager.storeAppKey(appId, apiKey, {
keyId: selectedKey.id,
keyName: selectedKey.name || "Unnamed key",
});
if (!this.shouldSuppressOutput(flags)) {
this.log(` Selected key: ${selectedKey.name || "Unnamed key"} (${selectedKey.id})\n`);
}
}
return { apiKey, appId };
}
/**
* This hook runs before command execution
* It's the oclif standard hook that runs before the run() method
*/
async finally(err) {
// Call super to maintain the parent class functionality
await super.finally(err);
}
formatJsonOutput(data, flags) {
if (this.isPrettyJsonOutput(flags)) {
try {
return colorJson(data);
}
catch (error) {
// Fallback to regular JSON.stringify
this.debug(`Error using color-json: ${error instanceof Error ? error.message : String(error)}. Falling back to regular JSON.`);
return JSON.stringify(data, null, 2);
}
}
// Regular JSON output
return JSON.stringify(data, null, 2);
}
getClientOptions(flags) {
const options = {};
const isJsonMode = this.shouldOutputJson(flags);
// Handle authentication - try token first, then api-key, then environment variable, then config
if (flags.token) {
options.token = flags.token;
// When using token auth, we don't set the clientId as it may conflict
// with any clientId embedded in the token
if (flags["client-id"] && !this.shouldSuppressOutput(flags)) {
this.log(chalk.yellow("Warning: clientId is ignored when using token authentication as the clientId is embedded in the token"));
}
}
else if (flags["api-key"]) {
options.key = flags["api-key"];
// In web CLI mode, validate the API key format
if (this.isWebCliMode) {
const parsedKey = this.parseApiKey(flags["api-key"]);
if (parsedKey) {
this.debug(`Using API key with appId=${parsedKey.appId}, keyId=${parsedKey.keyId}`);
// In web CLI mode, we need to explicitly configure the client for Ably.js browser library
options.key = flags["api-key"];
}
else {
this.log(chalk.yellow(`Warning: API key format appears to be invalid. Expected format: APP_ID.KEY_ID:KEY_SECRET`));
}
}
// Handle client ID for API key auth
this.setClientId(options, flags);
}
else if (process.env.ABLY_API_KEY) {
const apiKey = process.env.ABLY_API_KEY;
options.key = apiKey;
// In web CLI mode, validate the API key format
if (this.isWebCliMode) {
const parsedKey = this.parseApiKey(apiKey);
if (parsedKey) {
this.debug(`Using API key with appId=${parsedKey.appId}, keyId=${parsedKey.keyId}`);
// Ensure API key is properly formatted for Node.js SDK
options.key = apiKey;
}
else {
this.log(chalk.yellow(`Warning: API key format appears to be invalid. Expected format: APP_ID.KEY_ID:KEY_SECRET`));
}
}
// Handle client ID for API key auth
this.setClientId(options, flags);
}
else {
const apiKey = this.configManager.getApiKey();
if (apiKey) {
options.key = apiKey;
// Handle client ID for API key auth
this.setClientId(options, flags);
}
}
// Handle host and environment options
if (flags.host) {
options.realtimeHost = flags.host;
options.restHost = flags.host;
}
if (flags.env) {
options.environment = flags.env;
}
if (flags.endpoint) {
options.endpoint = flags.endpoint;
}
if (flags.port) {
options.port = flags.port;
}
if (flags.tlsPort) {
options.tlsPort = flags.tlsPort;
}
if (flags.tls) {
options.tls = flags.tls === "true";
}
// Always add a log handler to control SDK output formatting and destination
options.logHandler = (message, level) => {
if (isJsonMode) {
// JSON Mode Handling
if (flags.verbose && level <= 2) {
// Verbose JSON: Log ALL SDK messages via logCliEvent
const logData = { sdkLogLevel: level, sdkMessage: message };
this.logCliEvent(flags, "AblySDK", `LogLevel-${level}`, message, logData);
}
else if (level <= 1) {
// Standard JSON: Log only SDK ERRORS (level <= 1) to stderr as JSON
const errorData = {
level,
logType: "sdkError",
message,
timestamp: new Date().toISOString(),
};
// Log directly using console.error for SDK operational errors
console.error(this.formatJsonOutput(errorData, flags));
}
// If not verbose JSON and level > 1, suppress non-error SDK logs
}
else {
// Non-JSON Mode Handling
if (flags.verbose && level <= 2) {
// Verbose Non-JSON: Log ALL SDK messages via logCliEvent (human-readable)
const logData = { sdkLogLevel: level, sdkMessage: message };
// logCliEvent handles non-JSON formatting when verbose is true
this.logCliEvent(flags, "AblySDK", `LogLevel-${level}`, message, logData);
}
else if (level <= 1) {
// Standard Non-JSON: Log only SDK ERRORS (level <= 1) clearly
// Use a format similar to logCliEvent's non-JSON output
this.log(`${chalk.red.bold(`[AblySDK Error]`)} ${message}`);
}
// If not verbose non-JSON and level > 1, suppress non-error SDK logs
}
};
// Set logLevel to highest ONLY when using custom handler to capture everything needed by it
options.logLevel = 4;
// Add agent header to identify requests from the CLI
options.agents = { 'ably-cli': getCliVersion() };
return options;
}
// Initialize command and check restrictions
async init() {
await super.init();
// Set current command for interrupt feedback
if (this.id) {
// Convert command ID to readable format (e.g., "channels:subscribe" stays as is)
process.env.ABLY_CURRENT_COMMAND = this.id;
}
// Check if command is allowed to run in web CLI mode
this.checkWebCliRestrictions();
}
/**
* Checks if a command is allowed to run in web CLI mode
* This should be called by commands that are restricted in web CLI mode
*
* @returns True if command can run, false if it's restricted
*/
isAllowedInWebCliMode(command) {
if (!this.isWebCliMode) {
return true; // Not in web CLI mode, allow all commands
}
// Use the current command ID if none provided
const commandId = command || this.id || "";
// Check if the command matches any restricted pattern
return !WEB_CLI_RESTRICTED_COMMANDS.some(pattern => this.matchesCommandPattern(commandId, pattern));
}
isPrettyJsonOutput(flags) {
return flags["pretty-json"] === true;
}
/**
* Logs a CLI event.
* If --verbose is enabled:
* - If --json or --pretty-json is also enabled, outputs the event as structured JSON.
* - Otherwise (normal mode), outputs the human-readable message prefixed with the component.
* Does nothing if --verbose is not enabled.
*/
logCliEvent(flags, component, event, message, data = {}) {
// Only log if verbose mode is enabled
if (!flags.verbose) {
return;
}
const isJsonMode = this.shouldOutputJson(flags);
if (isJsonMode) {
// Output structured JSON log
const logEntry = {
component,
data,
event,
logType: "cliEvent",
message,
timestamp: new Date().toISOString(),
};
// Use the existing formatting method for consistency (handles pretty/plain JSON)
this.log(this.formatJsonOutput(logEntry, flags));
}
else {
// Output human-readable log in normal (verbose) mode
this.log(`${chalk.dim(`[${component}]`)} ${message}`);
}
}
/** Helper to output errors in JSON format */
outputJsonError(message, errorDetails = {}) {
const errorOutput = {
details: errorDetails,
error: true,
message,
};
// Use console.error to send JSON errors to stderr
console.error(JSON.stringify(errorOutput));
}
/**
* Helper method to parse and validate an API key
* Returns null if invalid, or the parsed components if valid
*/
parseApiKey(apiKey) {
if (!apiKey)
return null;
// API key format should be APP_ID.KEY_ID:KEY_SECRET
const parts = apiKey.split(":");
if (parts.length !== 2) {
this.debug(`Invalid API key format: missing colon separator`);
return null;
}
const keyParts = parts[0].split(".");
if (keyParts.length !== 2) {
this.debug(`Invalid API key format: missing period separator in key`);
return null;
}
const appId = keyParts[0];
const keyId = keyParts[1];
const keySecret = parts[1];
if (!appId || !keyId || !keySecret) {
this.debug(`Invalid API key format: missing required parts`);
return null;
}
return { appId, keyId, keySecret };
}
shouldOutputJson(flags) {
return (flags.json === true ||
flags["pretty-json"] === true ||
flags.format === "json");
}
/**
* Determine if this command should show account/app info
* Based on a centralized list of exceptions
*/
shouldShowAuthInfo() {
// Convert command ID to normalized format for comparison
const commandId = (this.id || "").replaceAll(" ", ":").toLowerCase();
// Check if command is in the exceptions list
for (const skipCmd of SKIP_AUTH_INFO_COMMANDS) {
// Check exact match
if (commandId === skipCmd) {
return false;
}
// Check if this is a subcommand of a skip command
if (commandId.startsWith(skipCmd + ":")) {
return false;
}
// Check if command ID path includes the skip command
// This covers case when command ID is space-separated
const spacedCommandId = this.id?.toLowerCase() || "";
const spacedSkipCmd = skipCmd.replaceAll(":", " ").toLowerCase();
if (spacedCommandId === spacedSkipCmd ||
spacedCommandId.startsWith(spacedSkipCmd + " ")) {
return false;
}
}
return true;
}
// Add this method to check if we should suppress output
shouldSuppressOutput(flags) {
return flags["token-only"] === true;
}
/**
* Display auth info at the beginning of command execution
* This should be called at the start of run() in command implementations
*/
showAuthInfoIfNeeded(flags = {}) {
// Skip if already shown
if (this._authInfoShown) {
this.debug(`Auth info already shown for command: ${this.id}`);
return;
}
// Skip auth info if specified in the exceptions list
if (!this.shouldShowAuthInfo()) {
this.debug(`Skipping auth info display for command: ${this.id}`);
return;
}
// Skip auth info if output is suppressed
const shouldSuppress = flags.quiet ||
this.shouldOutputJson(flags) ||
flags["token-only"] ||
this.shouldSuppressOutput(flags);
if (shouldSuppress) {
return;
}
// Skip auth info display in Web CLI mode
if (this.isWebCliMode) {
this.debug(`Skipping auth info display in Web CLI mode: ${this.id}`);
return;
}
// Determine command type and show appropriate info
if (this.id?.startsWith("apps") ||
this.id?.startsWith("channels") ||
this.id?.startsWith("auth") ||
this.id?.startsWith("rooms") ||
this.id?.startsWith("spaces") ||
this.id?.startsWith("logs") ||
this.id?.startsWith("connections") ||
this.id?.startsWith("queues") ||
this.id?.startsWith("bench")) {
// Data plane commands (product API)
this.displayDataPlaneInfo(flags);
this._authInfoShown = true;
}
else if (this.id?.startsWith("accounts") ||
this.id?.startsWith("integrations")) {
// Control plane commands
this.displayControlPlaneInfo(flags);
this._authInfoShown = true;
}
}
async handleInvalidKey(flags) {
const appId = flags.app || this.configManager.getCurrentAppId();
if (appId) {
this.log("The configured API key appears to be invalid or revoked.");
const shouldRemove = await this.interactiveHelper.confirm("Would you like to remove this invalid key from your configuration?");
if (shouldRemove) {
this.configManager.removeApiKey(appId);
this.log("Invalid key removed from configuration.");
}
}
}
setClientId(options, flags) {
if (flags["client-id"]) {
// Special case: "none" means explicitly no client ID
if (flags["client-id"].toLowerCase() === "none") {
// Don't set clientId at all
}
else {
options.clientId = flags["client-id"];
}
}
else {
// Generate a default client ID for the CLI
options.clientId = `ably-cli-${randomUUID().slice(0, 8)}`;
}
}
/**
* Centralized handler for cleaning up resources like Ably connections
* Includes a timeout to prevent hanging if cleanup takes too long
* @param cleanupFunction The async function to perform cleanup
* @param timeoutMs Timeout duration in milliseconds (default 5000)
*/
setupCleanupHandler(cleanupFunction, timeoutMs = 5_000) {
// In interactive mode, respect the 5-second SIGINT timeout
// Leave 500ms buffer for the process to exit cleanly
const isInteractive = process.env.ABLY_INTERACTIVE_MODE === 'true';
const effectiveTimeout = isInteractive ? Math.min(timeoutMs, 4500) : timeoutMs;
return new Promise((resolve, reject) => {
let cleanupTimedOut = false;
const timeout = setTimeout(() => {
cleanupTimedOut = true;
// Log timeout only if not in JSON mode
if (!this.shouldOutputJson({})) {
// TODO: Pass actual flags here
this.log(chalk.yellow("Cleanup operation timed out."));
}
reject(new Error("Cleanup timed out")); // Reject promise on timeout
}, effectiveTimeout);
// Execute the cleanup function
(async () => {
try {
await cleanupFunction();
}
catch (error) {
// Log cleanup error only if not in JSON mode
if (!this.shouldOutputJson({})) {
// TODO: Pass actual flags here
this.log(chalk.red(`Error during cleanup: ${error.message}`));
}
// Don't necessarily reject the main promise here, depends on desired behavior
// For now, we just log it
}
finally {
clearTimeout(timeout);
// Only resolve if the timeout didn't already reject
if (!cleanupTimedOut) {
resolve();
}
}
})();
});
}
/**
* Check if account information should be hidden for this command execution
* This is the case when:
* 1. No account is configured
* 2. Explicit API key or token is provided
* 3. Explicit access token is provided
* 4. Environment variables are used for auth
*/
shouldHideAccountInfo(flags) {
// Check if there's no account configured
const currentAccount = this.configManager.getCurrentAccount();
if (!currentAccount) {
return true;
}
// Hide account info if explicit auth credentials are provided
return (Boolean(flags["api-key"]) ||
Boolean(flags.token) ||
Boolean(flags["access-token"]) ||
Boolean(process.env.ABLY_API_KEY) ||
Boolean(process.env.ABLY_ACCESS_TOKEN));
}
/**
* Set up connection state logging for a Realtime client
* This should be called after creating a Realtime client for long-running commands
*/
setupConnectionStateLogging(client, flags, options) {
const component = options?.component || "connection";
const showUserMessages = options?.includeUserFriendlyMessages || false;
const connectionStateHandler = (stateChange) => {
this.logCliEvent(flags, component, stateChange.current, `Connection state changed to ${stateChange.current}`, { reason: stateChange.reason });
// Optional user-friendly messages for non-JSON output
if (showUserMessages && !this.shouldOutputJson(flags)) {
switch (stateChange.current) {
case "connected": {
// Don't show connected message - it's implied by successful channel/space operations
break;
}
case "disconnected": {
this.log(chalk.yellow("! Disconnected from Ably"));
break;
}
case "failed": {
this.log(chalk.red(`✗ Connection failed: ${stateChange.reason?.message || "Unknown error"}`));
break;
}
case "suspended": {
this.log(chalk.yellow("! Connection suspended"));
break;
}
case "connecting": {
// Don't show connecting message - it's too transient
break;
}
}
}
};
client.connection.on(connectionStateHandler);
// Return cleanup function
return () => {
client.connection.off(connectionStateHandler);
};
}
/**
* Set up channel state logging for a channel
* This should be called after creating/getting a channel for long-running commands
*/
setupChannelStateLogging(channel, flags, options) {
const component = options?.component || "channel";
const showUserMessages = options?.includeUserFriendlyMessages || false;
const stateChangeHandler = (stateChange) => {
this.logCliEvent(flags, component, stateChange.current, `Channel '${channel.name}' state changed to ${stateChange.current}`, { channel: channel.name, reason: stateChange.reason });
if (showUserMessages && !this.shouldOutputJson(flags)) {
switch (stateChange.current) {
case "attached": {
// Success will be shown by the command itself in context
break;
}
case "failed": {
this.log(chalk.red(`✗ Failed to attach to channel ${chalk.cyan(channel.name)}: ${stateChange.reason?.message || "Unknown error"}`));
break;
}
case "detached": {
this.log(chalk.yellow(`! Detached from channel: ${chalk.cyan(channel.name)} ${stateChange.reason ? `(Reason: ${stateChange.reason.message})` : ""}`));
break;
}
case "attaching": {
// Don't show attaching message - only show when attached or failed
break;
}
}
}
};
channel.on(stateChangeHandler);
// Return cleanup function
return () => {
channel.off(stateChangeHandler);
};
}
}