@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
210 lines (209 loc) • 10.8 kB
JavaScript
import chalk from 'chalk';
import inquirer from 'inquirer';
import pkg from "fast-levenshtein";
import { InteractiveBaseCommand } from './interactive-base-command.js';
import { runInquirerWithReadlineRestore } from './utils/readline-helper.js';
import { WEB_CLI_RESTRICTED_COMMANDS, WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS } from './base-command.js';
const { get: levenshteinDistance } = pkg;
export class BaseTopicCommand extends InteractiveBaseCommand {
// Allow any arguments to enable did-you-mean functionality
static strict = false;
async run() {
// Check for --help flag first
if (this.argv.includes('--help') || this.argv.includes('-h')) {
// Show help for this topic command using CustomHelp
const { default: CustomHelp } = await import('./help.js');
const help = new CustomHelp(this.config);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await help.showCommandHelp(this.constructor);
return;
}
// Check for potential subcommands in raw argv
const rawArgv = this.argv.filter(arg => !arg.startsWith('-')); // Filter out flags
if (rawArgv.length > 0) {
// User provided what might be a subcommand
const possibleSubcommand = rawArgv[0];
const fullCommandId = `${this.topicName}:${possibleSubcommand}`;
// Check if this exact command exists
const exactCommand = this.config.findCommand(fullCommandId);
if (exactCommand) {
// Exact command found - run it with remaining args and flags
const remainingArgs = [...rawArgv.slice(1), ...this.argv.filter(arg => arg.startsWith('-'))];
// Special handling for help flags in interactive mode
if (process.env.ABLY_INTERACTIVE_MODE === 'true' &&
(remainingArgs.includes('--help') || remainingArgs.includes('-h'))) {
const { default: CustomHelp } = await import('./help.js');
const help = new CustomHelp(this.config);
const cmd = this.config.findCommand(fullCommandId);
if (cmd) {
await help.showCommandHelp(cmd);
return;
}
}
return await this.config.runCommand(fullCommandId, remainingArgs);
}
else {
// Try to find the closest subcommand
const subcommands = await this.getTopicCommands();
const subcommandIds = subcommands.map(cmd => `${this.topicName}:${cmd.id.split(' ').at(-1)}`);
let closestCommand = '';
let closestDistance = Infinity;
for (const cmdId of subcommandIds) {
const distance = levenshteinDistance(fullCommandId, cmdId, { useCollator: true });
if (distance < closestDistance) {
closestDistance = distance;
closestCommand = cmdId;
}
}
// Check if we found a close match
const threshold = Math.max(1, Math.floor(possibleSubcommand.length / 2));
const maxDistance = 3;
if (closestCommand && closestDistance <= Math.min(threshold, maxDistance)) {
const isInteractiveMode = process.env.ABLY_INTERACTIVE_MODE === 'true';
const displayOriginal = `${this.topicName} ${possibleSubcommand}`;
const displaySuggestion = closestCommand.replaceAll(':', ' ');
// Warn about command not found
const warningMessage = `${chalk.cyan(displayOriginal)} is not an ably command.`;
// In interactive mode, we need to ensure the message is visible
// Write directly to stderr to avoid readline interference
if (isInteractiveMode) {
process.stderr.write(chalk.yellow(`Warning: ${warningMessage}\n`));
}
else {
this.warn(warningMessage);
}
// Handle confirmation
let confirmed = false;
const skipConfirmation = process.env.SKIP_CONFIRMATION === 'true' || process.env.ABLY_CLI_NON_INTERACTIVE === 'true';
if (skipConfirmation) {
confirmed = true;
}
else {
// In interactive mode, we need to handle readline carefully
const interactiveReadline = isInteractiveMode ? globalThis.__ablyInteractiveReadline : null;
const result = await runInquirerWithReadlineRestore(async () => inquirer.prompt([{
name: 'confirmed',
type: 'confirm',
message: `Did you mean ${chalk.green(displaySuggestion)}?`,
default: true
}]), interactiveReadline);
confirmed = result.confirmed;
}
if (confirmed) {
// Run the suggested command with remaining args and original flags
const remainingArgs = [...rawArgv.slice(1), ...this.argv.filter(arg => arg.startsWith('-'))];
try {
return await this.config.runCommand(closestCommand, remainingArgs);
}
catch (error) {
// Handle errors in interactive mode
if (isInteractiveMode) {
throw error;
}
else {
const err = error;
this.error(err.message || 'Unknown error', { exit: err.oclif?.exit || 1 });
}
}
}
// If not confirmed, show available commands
// Fall through to the help display below
}
else {
// No close match found - show error first, then commands
const isInteractiveMode = process.env.ABLY_INTERACTIVE_MODE === 'true';
const errorMessage = `Command ${this.topicName} ${possibleSubcommand} not found.`;
// Show the error
if (isInteractiveMode) {
console.error(chalk.red(errorMessage));
}
else {
this.warn(errorMessage);
}
// Fall through to show available commands
}
}
}
// No arguments provided or only unknown flags - show help for this topic
const commands = await this.getTopicCommands();
const isInteractiveMode = process.env.ABLY_INTERACTIVE_MODE === 'true';
this.log(`Ably ${this.commandGroup} commands:`);
this.log('');
// If no commands found, show message and return
if (commands.length === 0) {
this.log(' No commands found.');
return;
}
const maxLength = Math.max(...commands.map(cmd => cmd.id.length));
const prefix = isInteractiveMode ? '' : 'ably ';
const prefixLength = prefix.length;
for (const cmd of commands) {
const paddedId = `${prefix}${cmd.id}`.padEnd(maxLength + prefixLength + 2); // +2 for spacing
const description = cmd.description || '';
this.log(` ${chalk.cyan(paddedId)} - ${description}`);
}
this.log('');
const helpCommand = isInteractiveMode
? `${this.topicName.replaceAll(':', ' ')} COMMAND --help`
: `ably ${this.topicName.replaceAll(':', ' ')} COMMAND --help`;
this.log(`Run \`${chalk.cyan(helpCommand)}\` for more information on a command.`);
}
/**
* Check if a command should be displayed based on web CLI and anonymous mode restrictions
*/
shouldDisplayCommand(commandId) {
// Not in web CLI mode - show all commands
if (process.env.ABLY_WEB_CLI_MODE !== 'true') {
return true;
}
// Check web CLI restrictions
const isWebRestricted = WEB_CLI_RESTRICTED_COMMANDS.some((restricted) => {
if (restricted.endsWith("*")) {
const prefix = restricted.slice(0, -1);
return commandId === prefix || commandId.startsWith(prefix + ":") || commandId.startsWith(prefix);
}
return commandId === restricted || commandId.startsWith(restricted + ":");
});
if (isWebRestricted) {
return false;
}
// Check anonymous mode restrictions
if (process.env.ABLY_ANONYMOUS_USER_MODE === 'true') {
const isAnonymousRestricted = WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS.some((restricted) => {
if (restricted.endsWith("*")) {
const prefix = restricted.slice(0, -1);
return commandId === prefix || commandId.startsWith(prefix + ":") || commandId.startsWith(prefix);
}
return commandId === restricted || commandId.startsWith(restricted + ":");
});
return !isAnonymousRestricted;
}
return true;
}
async getTopicCommands() {
const commands = [];
const topicPrefix = `${this.topicName}:`;
for (const cmd of this.config.commands) {
if (cmd.id.startsWith(topicPrefix) && !cmd.hidden) {
// Check if this is a direct child (no additional colons after the topic prefix)
const remainingId = cmd.id.slice(topicPrefix.length);
const isDirectChild = !remainingId.includes(':');
if (isDirectChild && this.shouldDisplayCommand(cmd.id)) {
try {
const loadedCmd = await cmd.load();
if (!loadedCmd.hidden) {
commands.push({
id: cmd.id.replaceAll(':', ' '),
description: loadedCmd.description || ''
});
}
}
catch {
// Skip commands that can't be loaded
}
}
}
}
return commands.sort((a, b) => a.id.localeCompare(b.id));
}
}