@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
317 lines (316 loc) • 16.5 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;