UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

976 lines (975 loc) 43.6 kB
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 { 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'; 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); // Only show version for alpha/beta releases const version = this.config.version; if (version.includes('alpha') || version.includes('beta')) { console.log(` Version: ${version}\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: '$ ', 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 process.env.ABLY_WEB_CLI_MODE === 'true'; } /** * 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); } }