334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
/**
|
|
* Shell completion script generation.
|
|
*
|
|
* Loads shell-native template files and replaces placeholders with
|
|
* dynamic values. This approach keeps the shell scripts readable
|
|
* and auditable in their native format.
|
|
*
|
|
* The generated scripts use the `xo-complete` helper binary for dynamic
|
|
* completions (invitation IDs, template names, resources, etc.).
|
|
*
|
|
* Usage:
|
|
* eval "$(xo-cli completions bash)"
|
|
* eval "$(xo-cli completions zsh)"
|
|
* xo-cli completions fish | source
|
|
*
|
|
* Install to shell config:
|
|
* xo-cli completions bash --install
|
|
* xo-cli completions zsh --install
|
|
* xo-cli completions fish --install
|
|
*/
|
|
|
|
import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { homedir } from "node:os";
|
|
|
|
/**
|
|
* Single source of truth for the CLI command tree.
|
|
* Each top-level key is a command, and its value is an array of sub-commands.
|
|
*
|
|
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
|
* - mnemonic.ts: create, import, list, expose
|
|
* - template.ts: import, list, inspect, export, set-default
|
|
* - invitation.ts: create, append, sign, broadcast, requirements, import, export, inspect, list
|
|
* - resource.ts: list, unreserve, unreserve-all
|
|
* - settings.ts: show, get, set
|
|
*/
|
|
|
|
/** Subcommands for the mnemonic command */
|
|
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
|
/** Subcommands for the template command */
|
|
const TEMPLATE_SUBS = ["import", "list", "inspect", "export", "set-default"];
|
|
/** Subcommands for the invitation command */
|
|
const INVITATION_SUBS = [
|
|
"create",
|
|
"append",
|
|
"sign",
|
|
"broadcast",
|
|
"requirements",
|
|
"import",
|
|
"export",
|
|
"inspect",
|
|
"list",
|
|
];
|
|
/** Subcommands for the resource command */
|
|
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
|
/** Subcommands for the settings command */
|
|
const SETTINGS_SUBS = ["show", "get", "set"];
|
|
/** Subcommands for the completions command */
|
|
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
|
|
|
export const COMMAND_TREE = {
|
|
mnemonic: MNEMONIC_SUBS,
|
|
template: TEMPLATE_SUBS,
|
|
invitation: INVITATION_SUBS,
|
|
receive: [],
|
|
resource: RESOURCE_SUBS,
|
|
settings: SETTINGS_SUBS,
|
|
help: [],
|
|
completions: COMPLETIONS_SUBS,
|
|
} as const;
|
|
|
|
/** Global option flags available on every command. */
|
|
const GLOBAL_OPTIONS = [
|
|
"-h",
|
|
"--help",
|
|
"-v",
|
|
"--verbose",
|
|
"-m",
|
|
"--mnemonic-file",
|
|
"--currency",
|
|
"-o",
|
|
"--output",
|
|
];
|
|
|
|
/**
|
|
* Gets the path to the scripts directory containing shell templates.
|
|
*/
|
|
function getScriptsDir(): string {
|
|
const currentFile = fileURLToPath(import.meta.url);
|
|
return join(dirname(currentFile), "scripts");
|
|
}
|
|
|
|
/**
|
|
* Loads a shell template file and replaces placeholders.
|
|
* @param templateName - The template file name (e.g., "bash.sh")
|
|
* @param binName - The CLI binary name
|
|
*/
|
|
function loadAndProcessTemplate(templateName: string, binName: string): string {
|
|
const scriptsDir = getScriptsDir();
|
|
const templatePath = join(scriptsDir, templateName);
|
|
|
|
if (!existsSync(templatePath)) {
|
|
throw new Error(`Template file not found: ${templatePath}`);
|
|
}
|
|
|
|
let content = readFileSync(templatePath, "utf8");
|
|
const funcName = binName.replace(/-/g, "_");
|
|
const commands = Object.keys(COMMAND_TREE).join(" ");
|
|
const options = GLOBAL_OPTIONS.join(" ");
|
|
|
|
// Replace all placeholders
|
|
content = content.replace(/\{\{BIN_NAME\}\}/g, binName);
|
|
content = content.replace(/\{\{FUNC_NAME\}\}/g, funcName);
|
|
content = content.replace(/\{\{COMMANDS\}\}/g, commands);
|
|
content = content.replace(/\{\{OPTIONS\}\}/g, options);
|
|
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
|
|
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
|
|
content = content.replace(
|
|
/\{\{INVITATION_SUBS\}\}/g,
|
|
INVITATION_SUBS.join(" "),
|
|
);
|
|
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
|
|
|
|
// Fish-specific placeholders
|
|
if (templateName.endsWith(".fish")) {
|
|
content = content.replace(
|
|
/\{\{TOP_LEVEL_COMMANDS\}\}/g,
|
|
generateFishTopLevelCommands(binName),
|
|
);
|
|
content = content.replace(
|
|
/\{\{STATIC_SUBCOMMANDS\}\}/g,
|
|
generateFishStaticSubcommands(binName),
|
|
);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Generates fish top-level command completions.
|
|
*/
|
|
function generateFishTopLevelCommands(binName: string): string {
|
|
const lines: string[] = [];
|
|
for (const cmd of Object.keys(COMMAND_TREE)) {
|
|
lines.push(
|
|
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Generates fish static subcommand completions.
|
|
*/
|
|
function generateFishStaticSubcommands(binName: string): string {
|
|
const lines: string[] = [];
|
|
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
|
for (const sub of subs) {
|
|
lines.push(
|
|
`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
|
|
);
|
|
}
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Generates a bash completion script.
|
|
* @param binName - The name of the CLI binary.
|
|
*/
|
|
export function generateBashCompletions(binName: string): string {
|
|
return loadAndProcessTemplate("bash.sh", binName);
|
|
}
|
|
|
|
/**
|
|
* Generates a zsh completion script.
|
|
* @param binName - The name of the CLI binary.
|
|
*/
|
|
export function generateZshCompletions(binName: string): string {
|
|
return loadAndProcessTemplate("zsh.zsh", binName);
|
|
}
|
|
|
|
/**
|
|
* Generates a fish completion script.
|
|
* @param binName - The name of the CLI binary.
|
|
*/
|
|
export function generateFishCompletions(binName: string): string {
|
|
return loadAndProcessTemplate("fish.fish", binName);
|
|
}
|
|
|
|
export type ShellType = "bash" | "zsh" | "fish";
|
|
|
|
const generators: Record<ShellType, (binName: string) => string> = {
|
|
bash: generateBashCompletions,
|
|
zsh: generateZshCompletions,
|
|
fish: generateFishCompletions,
|
|
};
|
|
|
|
/**
|
|
* Shell config file paths and startup commands for each shell type.
|
|
*/
|
|
const shellConfigs: Record<
|
|
ShellType,
|
|
{
|
|
configFile: string;
|
|
configDirCommand: string;
|
|
configDirPattern: RegExp;
|
|
evalCommand: (binName: string) => string;
|
|
}
|
|
> = {
|
|
bash: {
|
|
configFile: join(homedir(), ".bashrc"),
|
|
configDirCommand:
|
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
|
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
|
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
|
|
},
|
|
zsh: {
|
|
configFile: join(homedir(), ".zshrc"),
|
|
configDirCommand:
|
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
|
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
|
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
|
|
},
|
|
fish: {
|
|
configFile: join(homedir(), ".config", "fish", "config.fish"),
|
|
configDirCommand:
|
|
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
|
|
configDirPattern: /^\s*set\b[^\n]*\bXO_CONFIG_DIR\b/m,
|
|
evalCommand: (binName) => `${binName} completions fish | source`,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Installs completions to the user's shell config file.
|
|
* Adds a default config directory and the eval command if not already present.
|
|
* @param shell - The shell type
|
|
* @param binName - The CLI binary name
|
|
* @returns true if installed, false if already present
|
|
*/
|
|
export function installCompletions(
|
|
shell: ShellType,
|
|
binName: string,
|
|
configFile: string = shellConfigs[shell].configFile,
|
|
): boolean {
|
|
const config = { ...shellConfigs[shell], configFile };
|
|
const evalCommand = config.evalCommand(binName);
|
|
|
|
let existingContent = "";
|
|
if (existsSync(config.configFile)) {
|
|
existingContent = readFileSync(config.configFile, "utf8");
|
|
}
|
|
|
|
const commands: string[] = [];
|
|
if (!config.configDirPattern.test(existingContent)) {
|
|
commands.push(config.configDirCommand);
|
|
}
|
|
if (!existingContent.includes(evalCommand)) {
|
|
commands.push(evalCommand);
|
|
}
|
|
if (commands.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const newLine =
|
|
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
|
|
const completionBlock = `${newLine}\n# ${binName} shell completions\n${commands.join("\n")}\n`;
|
|
|
|
mkdirSync(dirname(config.configFile), { recursive: true });
|
|
appendFileSync(config.configFile, completionBlock);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handles the `completions` command.
|
|
* Prints the generated completion script for the given shell to stdout,
|
|
* or installs it to the shell config file with --install.
|
|
* @param args - Positional args after "completions", e.g. ["bash"].
|
|
* @param options - Parsed command options (may include "install").
|
|
* @param binName - The CLI binary name to use in the completion script.
|
|
*/
|
|
export function handleCompletionsCommand(
|
|
args: string[],
|
|
options: Record<string, string> = {},
|
|
binName: string = "xo-cli",
|
|
): void {
|
|
const shell = args[0] as ShellType | undefined;
|
|
const installFlag = options["install"] === "true";
|
|
|
|
if (!shell || !generators[shell]) {
|
|
const supported = Object.keys(generators).join(", ");
|
|
console.error(`Usage: ${binName} completions <${supported}> [--install]`);
|
|
console.error("");
|
|
console.error("Examples:");
|
|
console.error(
|
|
` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`,
|
|
);
|
|
console.error(
|
|
` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`,
|
|
);
|
|
console.error(
|
|
` ${binName} completions fish | source # Output to stdout (add to fish config)`,
|
|
);
|
|
console.error("");
|
|
console.error("Install directly to shell config:");
|
|
console.error(
|
|
` ${binName} completions bash --install # Appends to ~/.bashrc`,
|
|
);
|
|
console.error(
|
|
` ${binName} completions zsh --install # Appends to ~/.zshrc`,
|
|
);
|
|
console.error(
|
|
` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (installFlag) {
|
|
const config = shellConfigs[shell];
|
|
const installed = installCompletions(shell, binName);
|
|
|
|
if (installed) {
|
|
console.log(`Completions installed to ${config.configFile}`);
|
|
console.log(`Restart your shell or run: source ${config.configFile}`);
|
|
} else {
|
|
console.log(`Completions already installed in ${config.configFile}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
process.stdout.write(generators[shell](binName));
|
|
}
|