@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
1,024 lines (1,023 loc) • 44.6 kB
JavaScript
import { Command } from "@oclif/core";
import * as readline from "node:readline";
import * as path from "node:path";
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
import { HistoryManager } from "../services/history-manager.js";
import { displayLogo } from "../utils/logo.js";
import { formatReleaseStatus } from "../utils/version.js";
import { WEB_CLI_RESTRICTED_COMMANDS, WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS, INTERACTIVE_UNSUITABLE_COMMANDS, } from "../base-command.js";
import { TerminalDiagnostics } from "../utils/terminal-diagnostics.js";
import "../utils/sigint-exit.js";
import isWebCliMode from "../utils/web-mode.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default class Interactive extends Command {
static description = "Launch interactive Ably shell (ALPHA - experimental feature)";
static hidden = true; // Hide from help until stable
static EXIT_CODE_USER_EXIT = 42; // Special code for 'exit' command
rl;
historyManager;
isWrapperMode = process.env.ABLY_WRAPPER_MODE === "1";
_flagsCache;
_manifestCache;
runningCommand = false;
cleanupDone = false;
historySearch = {
active: false,
searchTerm: "",
matches: [],
currentIndex: 0,
originalLine: "",
originalCursorPos: 0,
};
constructor(argv, config) {
super(argv, config);
}
async run() {
TerminalDiagnostics.log("Interactive.run() started");
// In non-TTY mode, readline doesn't convert \x03 to SIGINT
// We need to handle this at the process level for wrapper compatibility
if (!process.stdin.isTTY) {
// Install a data handler on stdin to detect Ctrl+C
const handleStdinData = (data) => {
if (data.includes(0x03)) {
// Ctrl+C byte
if (this.runningCommand) {
// Exit immediately with 130 during command execution
process.exit(130);
}
else {
// Emit SIGINT event to readline
this.rl?.emit("SIGINT");
}
}
};
// We'll set this up after readline is created
this._handleStdinData = handleStdinData;
}
try {
// Don't automatically use wrapper - let users choose
// Don't install any signal handlers at the process level
// When SIGINT is received:
// - If at prompt: readline handles it (shows ^C and new prompt)
// - If running command: process exits with 130, wrapper restarts
// Set environment variable to indicate we're in interactive mode
process.env.ABLY_INTERACTIVE_MODE = "true";
// SIGINT handling will be set up after readline is created
// Disable stack traces in interactive mode unless explicitly debugging
if (!process.env.DEBUG) {
process.env.NODE_ENV = "production";
}
// Silence oclif's error output
const originalConsoleError = console.error;
let suppressNextError = false;
console.error = ((...args) => {
// Skip oclif error stack traces in interactive mode
if (suppressNextError ||
(args[0] &&
typeof args[0] === "string" &&
(args[0].includes("at async Config.runCommand") ||
args[0].includes("at Object.hook")))) {
suppressNextError = false;
return;
}
originalConsoleError.apply(console, args);
});
// Store readline instance globally for hooks to access
globalThis.__ablyInteractiveReadline = null;
// Show welcome message only on first run
if (!process.env.ABLY_SUPPRESS_WELCOME) {
// Display logo
displayLogo(console.log);
// Show release status
console.log(` ${formatReleaseStatus(this.config.version, true)}\n`);
// Show appropriate tagline based on mode
let tagline = "ably.com ";
if (this.isWebCliMode()) {
tagline += "browser-based ";
}
tagline += "interactive CLI for Pub/Sub, Chat and Spaces";
console.log(chalk.bold(tagline));
console.log();
// Warn if running without wrapper
if (!this.isWrapperMode && !this.isWebCliMode()) {
console.log(chalk.yellow("⚠️ Running without the wrapper script. Ctrl+C will exit the shell."));
console.log(chalk.yellow(" For better experience with automatic restart after Ctrl+C, use: ably-interactive\n"));
}
// Show formatted common commands
console.log(chalk.bold("COMMON COMMANDS"));
const isAnonymousMode = this.isAnonymousWebMode();
const commands = [];
// Basic commands always available
commands.push(["help", "Show help for any command"], [
"channels publish [channel] [message]",
"Publish a message to a channel",
], ["channels subscribe [channel]", "Subscribe to a channel"]);
// Commands available only for authenticated users
if (!isAnonymousMode) {
commands.push(["channels logs", "View live channel events"], ["channels list", "List active channels"]);
}
commands.push(["spaces enter [space]", "Enter a collaborative space"], [
"rooms messages send [room] [message]",
"Send a message to a chat room",
], ["exit", "Exit the interactive shell"]);
// Calculate padding for alignment
const maxCmdLength = Math.max(...commands.map(([cmd]) => cmd.length));
// Display commands with proper alignment
commands.forEach(([cmd, desc]) => {
const paddedCmd = cmd.padEnd(maxCmdLength + 2);
console.log(` ${chalk.cyan(paddedCmd)}${desc}`);
});
console.log();
console.log("Type " +
chalk.cyan("help") +
" to see the complete list of commands.");
console.log();
}
this.historyManager = new HistoryManager();
await this.setupReadline();
await this.historyManager.loadHistory(this.rl);
// Don't install SIGINT handler - sigint-exit.ts handles this with proper feedback
// It will show "↓ Stopping command..." and give 5 seconds for cleanup
// Also handle SIGTERM to ensure cleanup
process.once("SIGTERM", () => {
if (!this.cleanupDone) {
this.cleanupAndExit(143); // Standard SIGTERM exit code
}
});
// Handle unexpected exits to ensure terminal is restored
process.once("exit", () => {
if (!this.cleanupDone && // Emergency cleanup - just restore terminal
process.stdin.isTTY &&
typeof process.stdin.setRawMode === "function") {
try {
process.stdin.setRawMode(false);
}
catch {
// Ignore errors
}
}
});
this.rl.prompt();
}
catch (error) {
// If there's an error starting up, exit gracefully
console.error("Failed to start interactive mode:", error);
process.exit(1);
}
}
async setupReadline() {
// Debug terminal capabilities
if (process.env.ABLY_DEBUG_KEYS === "true") {
console.error("[DEBUG] Terminal capabilities:");
console.error(` - process.stdin.isTTY: ${process.stdin.isTTY}`);
console.error(` - process.stdout.isTTY: ${process.stdout.isTTY}`);
console.error(` - TERM env: ${process.env.TERM}`);
console.error(` - COLORTERM env: ${process.env.COLORTERM}`);
console.error(` - terminal mode: ${process.stdin.isTTY ? "TTY" : "pipe"}`);
console.error(` - setRawMode available: ${typeof process.stdin.setRawMode === "function"}`);
}
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "ably> ",
terminal: true,
completer: this.completer.bind(this),
});
// Install stdin data handler for non-TTY mode
if (this
._handleStdinData) {
process.stdin.on("data", this
._handleStdinData);
}
// Store readline instance globally for hooks to access
globalThis.__ablyInteractiveReadline = this.rl;
// Setup keypress handler for Ctrl+R and other special keys
this.setupKeypressHandler();
// Don't install any SIGINT handler initially
this.rl.on("line", async (input) => {
// Exit history search mode when a command is executed
if (this.historySearch.active) {
this.exitHistorySearch();
}
await this.handleCommand(input.trim());
});
// SIGINT handling is done through readline's built-in mechanism
// Handle SIGINT events on readline
this.rl.on("SIGINT", () => {
if (this.runningCommand) {
// Don't handle SIGINT here - sigint-exit.ts will handle it
// with proper feedback and 5-second timeout
return;
}
// If in history search mode, exit it
if (this.historySearch.active) {
this.exitHistorySearch();
return;
}
// Clear the current line similar to how zsh behaves
const currentLine = this.rl.line || "";
if (currentLine.length > 0) {
// Clear the entire line content
this.rl._deleteLineLeft();
this.rl._deleteLineRight();
// Show ^C and new prompt
process.stdout.write("^C\n");
}
else {
// At empty prompt - show message about how to exit
process.stdout.write("^C\n");
console.log(chalk.yellow("Signal received. To exit this shell, type 'exit' and press Enter."));
}
this.rl.prompt();
});
// For non-TTY environments, we need special SIGINT handling
// But we should NOT interfere with sigint-exit.ts handler
if (!process.stdin.isTTY) {
// Don't install any handler here - sigint-exit.ts handles everything
// It will show feedback and manage the 5-second timeout
}
this.rl.on("close", () => {
TerminalDiagnostics.log("readline close event triggered");
if (!this.runningCommand) {
this.cleanup();
// Use special exit code when in wrapper mode
const exitCode = this.isWrapperMode
? Interactive.EXIT_CODE_USER_EXIT
: 0;
process.exit(exitCode);
}
});
}
async handleCommand(input) {
if (input === "exit" || input === ".exit") {
this.rl.close();
return;
}
if (input === "") {
this.rl.prompt();
return;
}
// Save to history (before handling any commands)
await this.historyManager.saveCommand(input);
// Handle version command (hidden command)
if (input === "version") {
const { getVersionInfo } = await import("../utils/version.js");
const versionInfo = getVersionInfo(this.config);
this.log(`Version: ${versionInfo.version}`);
this.rl.prompt();
return;
}
// Handle "ably" command - inform user they're already in interactive mode
if (input === "ably") {
console.log(chalk.yellow("You're already in interactive mode. Type 'help' or press TAB to see available commands."));
this.rl.prompt();
return;
}
// Set command running state
this.runningCommand = true;
globalThis.__ablyInteractiveRunningCommand =
true;
// Pause readline
TerminalDiagnostics.log("Pausing readline for command execution");
this.rl.pause();
// CRITICAL FIX: Set stdin to cooked mode to allow Ctrl+C to generate SIGINT
// Readline keeps stdin in raw mode even when paused, which prevents signal generation
if (process.stdin.isTTY &&
typeof process.stdin.setRawMode === "function") {
TerminalDiagnostics.log("Setting terminal to cooked mode for command execution");
process.stdin.setRawMode(false);
}
// SIGINT handling is done at module level in sigint-handler.ts
try {
const args = this.parseCommand(input);
// Separate command parts from args (everything before first flag)
const commandParts = [];
let firstFlagIndex = args.findIndex((arg) => arg.startsWith("-"));
if (firstFlagIndex === -1) {
// No flags, all args are command parts
commandParts.push(...args);
}
else {
// Everything before first flag is command parts
commandParts.push(...args.slice(0, firstFlagIndex));
}
// Everything from first flag onwards stays together for oclif to parse
const remainingArgs = firstFlagIndex === -1 ? [] : args.slice(firstFlagIndex);
// Handle special case of only flags (like --version)
if (commandParts.length === 0 && remainingArgs.length > 0) {
// Check for version flag
if (remainingArgs.includes("--version") ||
remainingArgs.includes("-v")) {
const { getVersionInfo } = await import("../utils/version.js");
const versionInfo = getVersionInfo(this.config);
this.log(`Version: ${versionInfo.version}`);
return;
}
// For other global flags, show help
await this.config.runCommand("help", []);
return;
}
// Find the command by trying different combinations
// Commands in oclif use colons, e.g., "help:ask" for "help ask"
let commandId;
let commandArgs = [];
// Try to find a matching command
for (let i = commandParts.length; i > 0; i--) {
const possibleId = commandParts.slice(0, i).join(":");
const cmd = this.config.findCommand(possibleId);
if (cmd) {
commandId = possibleId;
// Include remaining command parts and all remaining args
commandArgs = [...commandParts.slice(i), ...remainingArgs];
break;
}
}
if (!commandId) {
// No command found - this will trigger command_not_found hook
commandId = commandParts.join(":");
commandArgs = remainingArgs;
}
// Check if the command is restricted
if (this.isCommandRestricted(commandId)) {
const displayCommand = commandId.replaceAll(":", " ");
let errorMessage;
if (this.isAnonymousWebMode()) {
errorMessage = `The '${displayCommand}' command is not available in anonymous mode.\nPlease provide an access token to use this command.`;
}
else if (this.isWebCliMode()) {
errorMessage = `The '${displayCommand}' command is not available in the web CLI.`;
}
else {
errorMessage = `The '${displayCommand}' command is not available in interactive mode.`;
}
console.error(chalk.red("Error:"), errorMessage);
return;
}
// Special handling for help flags
if (commandArgs.includes("--help") || commandArgs.includes("-h")) {
// If the command has help flags, we need to handle it specially
// because oclif's runCommand doesn't properly handle help for subcommands
const { default: CustomHelp } = await import("../help.js");
const help = new CustomHelp(this.config);
// Find the actual command
const cmd = this.config.findCommand(commandId);
if (cmd) {
await help.showCommandHelp(cmd);
return;
}
}
// Run command without any timeout
await this.config.runCommand(commandId, commandArgs);
}
catch (error) {
const err = error;
// Special handling for intentional exits
if (err.code === "EEXIT" && err.exitCode === 0) {
// Normal exit (like from help command) - don't display anything
return;
}
// Always show errors in red
let errorMessage = err.message || "Unknown error";
// Clean up the error message if it has ANSI codes or extra formatting
// eslint-disable-next-line no-control-regex
errorMessage = errorMessage.replaceAll(/\u001B\[[0-9;]*m/g, ""); // Remove ANSI codes
// Check for specific error types
if (err.isCommandNotFound) {
// Command not found - already has appropriate message
console.error(chalk.red(errorMessage));
}
else if (err.oclif?.exit !== undefined ||
err.exitCode !== undefined ||
err.code === "EEXIT") {
// This is an oclif error or exit that would normally exit the process
// Show in red without the "Error:" prefix as it's already formatted
console.error(chalk.red(errorMessage));
}
else if (err.stack && process.env.DEBUG) {
// Show stack trace in debug mode
console.error(chalk.red("Error:"), errorMessage);
console.error(err.stack);
}
else {
// All other errors - show with Error prefix
console.error(chalk.red("Error:"), errorMessage);
}
}
finally {
// SIGINT handling is done at module level
// Reset command running state
this.runningCommand = false;
globalThis.__ablyInteractiveRunningCommand =
false;
// Restore raw mode for readline with error handling
if (process.stdin.isTTY &&
typeof process.stdin.setRawMode === "function") {
try {
TerminalDiagnostics.log("Restoring terminal to raw mode for readline");
process.stdin.setRawMode(true);
TerminalDiagnostics.log("Terminal restored to raw mode successfully");
}
catch (error) {
TerminalDiagnostics.log("Error restoring terminal to raw mode", error);
// Terminal might be in a bad state after SIGINT
// Try to recover by recreating the readline interface
if (error.code === "EIO") {
console.error(chalk.yellow("\nTerminal state corrupted. Please restart the interactive shell."));
this.cleanup(false);
process.exit(1);
}
}
}
// Resume readline
this.rl.resume();
// Small delay to ensure error messages are visible
setTimeout(() => {
if (this.rl) {
this.rl.prompt();
}
}, 50);
}
}
parseCommand(input) {
const args = [];
let current = "";
let inDoubleQuote = false;
let inSingleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const char = input[i];
const nextChar = input[i + 1];
if (escaped) {
// Add the escaped character literally
current += char;
escaped = false;
continue;
}
if (char === "\\" && (inDoubleQuote || inSingleQuote)) {
// Check if this is an escape sequence
if (inDoubleQuote &&
(nextChar === '"' ||
nextChar === "\\" ||
nextChar === "$" ||
nextChar === "`")) {
escaped = true;
continue;
}
else if (inSingleQuote && nextChar === "'") {
escaped = true;
continue;
}
// Otherwise, backslash is literal
current += char;
continue;
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
// If we're closing a quote and have content, that's an argument
if (!inDoubleQuote && current === "") {
// Empty string argument
args.push("");
current = "";
}
continue;
}
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
// If we're closing a quote and have content, that's an argument
if (!inSingleQuote && current === "") {
// Empty string argument
args.push("");
current = "";
}
continue;
}
if (char === " " && !inDoubleQuote && !inSingleQuote) {
// Space outside quotes - end current argument
if (current.length > 0) {
args.push(current);
current = "";
}
continue;
}
// Regular character - add to current argument
current += char;
}
// Handle any remaining content
if (current.length > 0 || inDoubleQuote || inSingleQuote) {
args.push(current);
}
// Warn about unclosed quotes
if (inDoubleQuote || inSingleQuote) {
const quoteType = inDoubleQuote ? "double" : "single";
console.error(chalk.yellow(`Warning: Unclosed ${quoteType} quote in command`));
}
return args;
}
cleanup(showGoodbye = true) {
TerminalDiagnostics.log("cleanup() called");
// Close the readline interface first
if (this.rl) {
try {
TerminalDiagnostics.log("Closing readline interface");
this.rl.close();
TerminalDiagnostics.log("Readline interface closed");
}
catch (error) {
TerminalDiagnostics.log("Error closing readline", error);
}
}
// Ensure terminal is restored to normal mode
if (process.stdin.isTTY &&
typeof process.stdin.setRawMode === "function") {
try {
TerminalDiagnostics.log("Restoring terminal to cooked mode");
process.stdin.setRawMode(false);
TerminalDiagnostics.log("Terminal restored successfully");
}
catch (error) {
TerminalDiagnostics.log("Error restoring terminal", error);
}
}
// Ensure stdin is unrefed so it doesn't keep the process alive
if (process.stdin && typeof process.stdin.unref === "function") {
process.stdin.unref();
}
if (showGoodbye) {
console.log("\nGoodbye!");
}
}
cleanupAndExit(code) {
TerminalDiagnostics.log(`cleanupAndExit(${code}) called`);
// Mark cleanup as done to prevent double cleanup
this.cleanupDone = true;
// Perform cleanup without goodbye message
this.cleanup(false);
TerminalDiagnostics.log(`Exiting with code ${code}`);
// Exit with the specified code
process.exit(code);
}
completer(line, callback) {
// Debug logging
if (process.env.ABLY_DEBUG_KEYS === "true") {
console.error(`[DEBUG] Completer called with line: "${line}"`);
}
// Don't provide completions during history search
if (this.historySearch.active) {
const emptyResult = [[], line];
if (callback) {
callback(null, emptyResult);
}
else {
return emptyResult;
}
return;
}
// Support both sync and async patterns
const result = this.getCompletions(line);
// Debug logging
if (process.env.ABLY_DEBUG_KEYS === "true") {
console.error(`[DEBUG] Completer returning:`, result);
}
if (callback) {
// Async mode - used by readline for custom display
callback(null, result);
}
else {
// Sync mode - fallback
return result;
}
}
getCompletions(line) {
const words = line.trim().split(/\s+/);
const lastWord = words.at(-1) || "";
// If line ends with a space, we're starting a new word
const isNewWord = line.endsWith(" ");
const currentWord = isNewWord ? "" : lastWord;
// Get the command path (excluding the last word if not new)
const commandPath = isNewWord ? words : words.slice(0, -1);
if (commandPath.length === 0 || (!isNewWord && words.length === 1)) {
// Complete top-level commands
const commands = this.getTopLevelCommands();
const matches = commands.filter((cmd) => cmd.startsWith(currentWord));
// Custom display for multiple matches
if (matches.length > 1) {
this.displayCompletions(matches, "command");
return [[], line]; // Don't auto-complete, just show options
}
return [matches, currentWord];
}
// Check if we're completing flags
if (currentWord.startsWith("-")) {
const flags = this.getFlagsForCommandSync(commandPath);
const matches = flags.filter((flag) => flag.startsWith(currentWord));
if (matches.length > 1) {
this.displayCompletions(matches, "flag");
return [[], line];
}
return [matches, currentWord];
}
// Try to find subcommands
const subcommands = this.getSubcommandsForPath(commandPath);
const matches = subcommands.filter((cmd) => cmd.startsWith(currentWord));
if (matches.length > 1) {
this.displayCompletions(matches, "subcommand", commandPath);
return [[], line];
}
return [matches.length > 0 ? matches : [], currentWord];
}
getTopLevelCommands() {
// Cache this on first use
if (!this._commandCache) {
this._commandCache = [];
for (const command of this.config.commands) {
if (!command.hidden &&
!command.id.includes(":") && // Filter out restricted commands
!this.isCommandRestricted(command.id)) {
this._commandCache.push(command.id);
}
}
// Add special commands that aren't filtered
// Only add 'exit' since help, version, config, and autocomplete are filtered out
this._commandCache.push("exit");
this._commandCache.sort();
}
return this._commandCache;
}
getSubcommandsForPath(commandPath) {
// Convert space-separated path to colon-separated for oclif
const parentCommand = commandPath.filter(Boolean).join(":");
const subcommands = [];
for (const command of this.config.commands) {
if (!command.hidden &&
command.id.startsWith(parentCommand + ":") && // Filter out restricted commands
!this.isCommandRestricted(command.id)) {
// Get the next part of the command
const remaining = command.id.slice(parentCommand.length + 1);
const parts = remaining.split(":");
const nextPart = parts[0];
// Only add direct children (one level deep)
if (nextPart && parts.length === 1) {
subcommands.push(nextPart);
}
}
}
return [...new Set(subcommands)].sort();
}
getFlagsForCommandSync(commandPath) {
// Get cached flags if available
const commandId = commandPath.filter(Boolean).join(":");
if (this._flagsCache && this._flagsCache[commandId]) {
return this._flagsCache[commandId];
}
// Basic flags available for all commands
const flags = ["--help", "-h"];
// Try to get flags from manifest first
try {
// Load manifest if not already loaded
if (!this._manifestCache) {
const manifestPath = path.join(this.config.root, "oclif.manifest.json");
if (fs.existsSync(manifestPath)) {
this._manifestCache = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
}
}
// Get flags from manifest
if (this._manifestCache && this._manifestCache.commands) {
const manifestCommand = this._manifestCache.commands[commandId];
if (manifestCommand && manifestCommand.flags) {
for (const [name, flag] of Object.entries(manifestCommand.flags)) {
const flagDef = flag;
// Skip hidden flags unless in dev mode
if (flagDef.hidden && process.env.ABLY_SHOW_DEV_FLAGS !== "true") {
continue;
}
flags.push(`--${name}`);
if (flagDef.char) {
flags.push(`-${flagDef.char}`);
}
}
}
}
}
catch {
// Fall back to trying to get from loaded command
try {
const command = this.config.findCommand(commandId);
if (command && command.flags) {
// Add flags from command definition (these are already loaded)
for (const [name, flag] of Object.entries(command.flags)) {
flags.push(`--${name}`);
if (flag.char) {
flags.push(`-${flag.char}`);
}
}
}
}
catch {
// Ignore errors
}
}
// Add global flags for top-level
if (commandPath.length === 0 || commandPath[0] === "") {
flags.push("--version", "-v");
}
const uniqueFlags = [...new Set(flags)].sort();
// Cache for next time
if (!this._flagsCache) {
this._flagsCache = {};
}
this._flagsCache[commandId] = uniqueFlags;
return uniqueFlags;
}
displayCompletions(matches, type, commandPath) {
console.log(); // New line for better display
// Get descriptions for each match
const items = [];
for (const match of matches) {
let description = "";
if (type === "command" || type === "subcommand") {
const fullId = commandPath ? [...commandPath, match].join(":") : match;
const cmd = this.config.findCommand(fullId);
if (cmd && cmd.description) {
description = cmd.description;
}
}
else if (type === "flag" && // Extract flag description from manifest first, then fall back to command
commandPath) {
const commandId = commandPath.filter(Boolean).join(":");
const flagName = match.replace(/^--?/, "");
// Try manifest first
if (this._manifestCache && this._manifestCache.commands) {
const manifestCommand = this._manifestCache.commands[commandId];
if (manifestCommand && manifestCommand.flags) {
// Find flag by name or char
for (const [name, flag] of Object.entries(manifestCommand.flags)) {
const flagDef = flag;
if (name === flagName ||
(flagDef.char && flagDef.char === flagName)) {
description = flagDef.description || "";
break;
}
}
}
}
// Fall back to loaded command if no description found
if (!description) {
try {
const command = this.config.findCommand(commandId);
if (command && command.flags) {
const flag = Object.entries(command.flags).find(([name, f]) => name === flagName || (f.char && f.char === flagName));
if (flag && flag[1].description) {
description = flag[1].description;
}
}
}
catch {
// Ignore errors
}
}
}
items.push({ name: match, description });
}
// Calculate max width for alignment
const maxNameWidth = Math.max(...items.map((item) => item.name.length));
// Display in zsh-like format
for (const item of items) {
const paddedName = item.name.padEnd(maxNameWidth + 2);
if (item.description) {
console.log(` ${chalk.cyan(paddedName)} -- ${chalk.gray(item.description)}`);
}
else {
console.log(` ${chalk.cyan(paddedName)}`);
}
}
// Redraw the prompt with current input
if (this.rl) {
this.rl.prompt(true);
}
}
_commandCache;
/**
* Check if we're running in web CLI mode
*/
isWebCliMode() {
return isWebCliMode();
}
/**
* Check if we're running in anonymous web CLI mode
*/
isAnonymousWebMode() {
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 a command should be filtered out based on restrictions
*/
isCommandRestricted(commandId) {
// Commands not suitable for interactive mode (exit is handled separately)
if (INTERACTIVE_UNSUITABLE_COMMANDS.includes(commandId)) {
return true;
}
// Check web CLI restrictions
if (this.isWebCliMode()) {
// Check base web CLI restrictions
if (WEB_CLI_RESTRICTED_COMMANDS.some((pattern) => this.matchesCommandPattern(commandId, pattern))) {
return true;
}
// Check anonymous mode restrictions
if (this.isAnonymousWebMode() &&
WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS.some((pattern) => this.matchesCommandPattern(commandId, pattern))) {
return true;
}
}
return false;
}
setupKeypressHandler() {
// Enable keypress events on stdin
readline.emitKeypressEvents(process.stdin);
// Enable raw mode for keypress handling
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
// Note: We don't call setRawMode(true) here because readline manages it
// The keypress event handler will still work
process.stdin.on("keypress", (str, key) => {
// Debug logging for all keypresses
if (process.env.ABLY_DEBUG_KEYS === "true") {
const keyInfo = key
? {
name: key.name,
ctrl: key.ctrl,
meta: key.meta,
shift: key.shift,
sequence: key.sequence
? [...key.sequence]
.map((c) => `\\x${c.codePointAt(0)?.toString(16).padStart(2, "0") ?? "00"}`)
.join("")
: undefined,
}
: null;
console.error(`[DEBUG] Keypress event - str: "${str}", key:`, JSON.stringify(keyInfo));
}
if (!key)
return;
// Ctrl+R: Start or cycle through history search
if (key.ctrl && key.name === "r") {
if (this.historySearch.active) {
this.cycleHistorySearch();
}
else {
this.startHistorySearch();
}
return;
}
// Handle keys during history search
if (this.historySearch.active) {
// Escape: Exit history search
if (key.name === "escape") {
this.exitHistorySearch();
return;
}
// Enter: Accept current match
if (key.name === "return") {
this.acceptHistoryMatch();
return;
}
// Backspace: Remove character from search
if (key.name === "backspace") {
if (this.historySearch.searchTerm.length > 0) {
this.historySearch.searchTerm =
this.historySearch.searchTerm.slice(0, -1);
this.updateHistorySearch();
}
else {
// Exit search if no search term
this.exitHistorySearch();
}
return;
}
// Regular character: Add to search term
if (str && str.length === 1 && !key.ctrl && !key.meta) {
this.historySearch.searchTerm += str;
this.updateHistorySearch();
return;
}
}
});
}
}
startHistorySearch() {
// Save current line state
this.historySearch.originalLine =
this.rl.line || "";
this.historySearch.originalCursorPos =
this.rl.cursor || 0;
// Initialize search state
this.historySearch.active = true;
this.historySearch.searchTerm = "";
this.historySearch.matches = [];
this.historySearch.currentIndex = 0;
// Update display
this.updateHistorySearchDisplay();
}
updateHistorySearch() {
// Get history from readline
const history = this.rl.history || [];
// Find matches (search from most recent to oldest)
// Note: readline stores history in reverse order (most recent first)
this.historySearch.matches = [];
for (let i = 0; i < history.length; i++) {
const command = history[i];
if (command
.toLowerCase()
.includes(this.historySearch.searchTerm.toLowerCase())) {
this.historySearch.matches.push(command);
}
}
// Reset index to show most recent match
this.historySearch.currentIndex = 0;
// Update display
this.updateHistorySearchDisplay();
}
cycleHistorySearch() {
if (this.historySearch.matches.length === 0)
return;
// Cycle to next match
this.historySearch.currentIndex =
(this.historySearch.currentIndex + 1) % this.historySearch.matches.length;
// Update display
this.updateHistorySearchDisplay();
}
updateHistorySearchDisplay() {
// Clear current line
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
if (this.historySearch.matches.length > 0) {
// Show current match
const currentMatch = this.historySearch.matches[this.historySearch.currentIndex];
const searchPrompt = `(reverse-i-search\`${this.historySearch.searchTerm}'): `;
// Write the search prompt and matched command
process.stdout.write(chalk.dim(searchPrompt) + currentMatch);
// Update readline's internal state
this.rl.line = currentMatch;
this.rl.cursor =
currentMatch.length;
}
else {
// No matches found
const searchPrompt = `(failed reverse-i-search\`${this.historySearch.searchTerm}'): `;
process.stdout.write(chalk.dim(searchPrompt));
// Clear readline's line
this.rl.line = "";
this.rl.cursor = 0;
}
}
acceptHistoryMatch() {
if (this.historySearch.matches.length === 0) {
this.exitHistorySearch();
return;
}
// Get current match
const currentMatch = this.historySearch.matches[this.historySearch.currentIndex];
// Exit search mode
this.historySearch.active = false;
// Clear and redraw with normal prompt
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
// Set the line and display it
this.rl.line = currentMatch;
this.rl.cursor =
currentMatch.length;
this.rl.prompt(true);
// Write the command after the prompt
process.stdout.write(currentMatch);
}
exitHistorySearch() {
// Exit search mode
this.historySearch.active = false;
// Clear current line
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
// Restore original line
this.rl.line =
this.historySearch.originalLine;
this.rl.cursor =
this.historySearch.originalCursorPos;
// Redraw prompt with original content
this.rl.prompt(true);
process.stdout.write(this.historySearch.originalLine);
readline.cursorTo(process.stdout, this.rl._prompt
.length + this.historySearch.originalCursorPos);
}
}