@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
298 lines (297 loc) • 16.1 kB
JavaScript
import chalk from 'chalk';
import inquirer from 'inquirer';
import pkg from "fast-levenshtein";
import { runInquirerWithReadlineRestore } from '../../utils/readline-helper.js';
const { get: levenshteinDistance } = pkg;
/**
* Internal implementation of closest command matching
* to avoid import issues between compiled and test code
*/
const findClosestCommand = (target, possibilities) => {
if (possibilities.length === 0)
return "";
// Normalize the target input to use colons for consistent comparison
const normalizedTarget = target.replaceAll(' ', ':');
const distances = possibilities.map((id) => ({
distance: levenshteinDistance(normalizedTarget, id, { useCollator: true }),
id,
}));
distances.sort((a, b) => a.distance - b.distance);
const closestMatch = distances[0];
if (!closestMatch)
return "";
// Use threshold based on word length
const threshold = Math.max(1, Math.floor(normalizedTarget.length / 2));
const maxDistance = 3; // Maximum acceptable distance
if (closestMatch.distance <= Math.min(threshold, maxDistance)) {
return closestMatch.id;
}
return ""; // No suggestion found within threshold
};
/**
* Hook that runs when a command is not found. Suggests similar commands
* and runs them if confirmed, in a similar style to the official oclif plugin.
*/
const hook = async function (opts) {
const { id, argv, config } = opts;
const isInteractiveMode = process.env.ABLY_INTERACTIVE_MODE === 'true';
// Get all command IDs to compare against
const commandIDs = config.commandIDs;
// In actual CLI usage, the id comes with colons as separators
// For example "channels:publis:foo:bar" for "ably channels publis foo bar"
// We need to split the command and try different combinations to find the closest match
const commandParts = id.split(':');
// Try to find a command match by considering progressively shorter prefixes
let suggestion = '';
let commandPartCount = 0;
let argumentsFromId = [];
// Try different command parts
for (let i = commandParts.length; i > 0; i--) {
const possibleCommandParts = commandParts.slice(0, i);
const possibleCommand = possibleCommandParts.join(':');
suggestion = findClosestCommand(possibleCommand, commandIDs);
if (suggestion) {
commandPartCount = i;
// Extract potential arguments from the ID (for CLI execution)
// These would be parts after the matched command parts
argumentsFromId = commandParts.slice(i);
break;
}
}
// Format the input command for display (replace colons with spaces)
const displayOriginal = commandPartCount > 0
? commandParts.slice(0, commandPartCount).join(' ')
: id.replaceAll(':', ' ');
if (suggestion) {
// Format the suggestion for display (replace colons with spaces)
const displaySuggestion = suggestion.replaceAll(':', ' ');
// Get all arguments - either from id split or from argv
// In tests, argv contains the arguments, but in CLI execution, we extract them from id
const allArgs = (argv || []).length > 0 ? (argv || []) : argumentsFromId;
// Warn about command not found and suggest alternative with colored command names
const warningMessage = `${chalk.cyan(displayOriginal.replaceAll(':', ' '))} is not an ably command.`;
if (isInteractiveMode) {
console.log(chalk.yellow(`Warning: ${warningMessage}`));
}
else {
this.warn(warningMessage);
}
// Skip confirmation in tests or non-interactive mode
const skipConfirmation = process.env.SKIP_CONFIRMATION === 'true' || process.env.ABLY_CLI_NON_INTERACTIVE === 'true';
// Variable to hold confirmation state
let confirmed = false;
if (skipConfirmation) {
// Auto-confirm in test/non-interactive environment
// Important: We still proceed to *try* running the command, but tests assert it *fails* correctly
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) {
try {
// Run the suggested command with all arguments
return await config.runCommand(suggestion, allArgs);
}
catch (error) {
// Handle the error in the same way as direct command execution
const err = error;
const exitCode = typeof err.oclif?.exit === 'number' ? err.oclif.exit : 1;
// Check if it's a missing arguments error
const isMissingArgsError = err.message?.includes('Missing') &&
(err.message?.includes('required arg') ||
err.message?.includes('required flag'));
// Get command details to show help if it's a missing args error
if (isMissingArgsError) {
try {
// Find the command and load it
const cmd = config.findCommand(suggestion);
if (cmd) {
// Get command help
const commandHelp = cmd.load ? await cmd.load() : null;
if (commandHelp && commandHelp.id) {
// Format usage to use spaces instead of colons
const usage = commandHelp.usage || commandHelp.id;
const formattedUsage = typeof usage === 'string' ? usage.replaceAll(':', ' ') : usage;
// Extract error details for later display
const errorMsg = err.message || '';
// Show command help/usage info without duplicating error
const logFn = isInteractiveMode ? console.log : this.log.bind(this);
const binPrefix = isInteractiveMode ? '' : `${config.bin} `;
logFn('\nUSAGE');
logFn(` $ ${binPrefix}${formattedUsage}`);
if (commandHelp.args && Object.keys(commandHelp.args).length > 0) {
logFn('\nARGUMENTS');
for (const [name, arg] of Object.entries(commandHelp.args)) {
logFn(` ${name} ${arg.description || ''}`);
}
}
// Add a line of vertical space
logFn('');
// Show the full help command with color
const fullHelpCommand = isInteractiveMode
? `${displaySuggestion} --help`
: `${config.bin} ${displaySuggestion} --help`;
logFn(`${chalk.dim('See more help with:')} ${chalk.cyan(fullHelpCommand)}`);
// Add a line of vertical space
logFn('');
// Show the error message at the end, without the "See more help" line
const errorLines = errorMsg.split('\n');
// Filter out the "See more help with --help" line if present
const filteredErrorLines = errorLines.filter((line) => !line.includes('See more help with --help'));
// If we filtered out a help line, add our custom one
const customError = filteredErrorLines.join('\n');
// Show the styled error message
if (isInteractiveMode) {
// In interactive mode, don't exit - just throw the error
// The interactive command will display it
const error = new Error(customError);
error.oclif = { exit: exitCode };
throw error;
}
else {
this.error(customError, { exit: exitCode });
}
}
}
}
catch {
// If something goes wrong showing help, just show the original error
}
}
// Default error handling if not a missing args error or if showing help failed
if (err.message && err.message.includes('See more help with --help') && suggestion) {
// Format the error message to use the full command for help
const displaySuggestion = suggestion.replaceAll(':', ' ');
const lines = err.message.split('\n');
const filteredLines = lines.map((line) => {
if (line.includes('See more help with --help')) {
return isInteractiveMode
? `See more help with: ${displaySuggestion} --help`
: `See more help with: ${config.bin} ${displaySuggestion} --help`;
}
return line;
});
if (isInteractiveMode) {
const error = new Error(filteredLines.join('\n'));
error.oclif = { exit: exitCode };
throw error;
}
else {
this.error(filteredLines.join('\n'), { exit: exitCode });
}
}
else {
// Original error message
if (isInteractiveMode) {
const error = new Error(err.message || 'Unknown error');
error.oclif = { exit: exitCode };
throw error;
}
else {
this.error(err.message || 'Unknown error', { exit: exitCode });
}
}
// This won't be reached due to this.error/this.exit, but TypeScript needs it
return;
}
}
// User declined the suggestion - check if we should show topic commands
if (suggestion) {
// Extract the topic from the suggestion (e.g., "accounts:current" -> "accounts")
const topicParts = suggestion.split(':');
if (topicParts.length > 1) {
const topicCommand = topicParts[0];
const topicCmd = config.findCommand(topicCommand);
if (topicCmd) {
// In interactive mode, directly output the topic commands
if (isInteractiveMode) {
try {
// Get all commands for this topic
const topicPrefix = `${topicCommand}:`;
const subcommands = [];
for (const cmd of 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) {
try {
const loadedCmd = await cmd.load();
if (!loadedCmd.hidden) {
subcommands.push({
id: cmd.id.replaceAll(':', ' '),
description: loadedCmd.description || ''
});
}
}
catch {
// Skip commands that can't be loaded
}
}
}
}
if (subcommands.length > 0) {
// Sort commands alphabetically
subcommands.sort((a, b) => a.id.localeCompare(b.id));
// Display the topic commands
const logFn = console.log;
logFn(`Ably ${topicCommand} management commands:`);
logFn('');
const maxLength = Math.max(...subcommands.map(cmd => cmd.id.length));
const prefix = '';
const prefixLength = prefix.length;
for (const cmd of subcommands) {
const paddedId = `${prefix}${cmd.id}`.padEnd(maxLength + prefixLength + 2);
const description = cmd.description || '';
logFn(` ${chalk.cyan(paddedId)} - ${description}`);
}
logFn('');
const helpCommand = `${topicCommand.replaceAll(':', ' ')} COMMAND --help`;
logFn(`Run \`${chalk.cyan(helpCommand)}\` for more information on a command.`);
return;
}
}
catch {
// Fall through to running the command if direct output fails
}
}
// For non-interactive mode, run the topic command
try {
await config.runCommand(topicCommand, []);
return;
}
catch {
// If running the topic command fails, fall through to show generic error
}
}
}
}
}
else {
// No suggestion found
const displayCommand = id.replaceAll(':', ' ');
const errorMessage = isInteractiveMode
? `Command ${displayCommand} not found. Run 'help' for a list of available commands.`
: `Command ${displayCommand} not found.\nRun ${config.bin} --help for a list of available commands.`;
if (isInteractiveMode) {
// In interactive mode, just throw the error without logging
// The interactive command will handle displaying it
const error = new Error(errorMessage);
error.isCommandNotFound = true;
throw error;
}
else {
this.error(errorMessage, { exit: 127 });
}
}
};
export default hook;