Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI.

This commit is contained in:
2026-04-20 10:30:38 +00:00
parent df4f438f6d
commit ff2fe126c6
44 changed files with 8220 additions and 1503 deletions

View File

@@ -0,0 +1,568 @@
/**
* Shell completion script generation.
*
* Defines the CLI command tree in one place and generates
* bash/zsh/fish completion scripts from it. Users source the output
* in their shell profile for tab-completion support.
*
* 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
*/
/**
* 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, set-default
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
* - resource.ts: list, unreserve, unreserve-all
*/
/** Subcommands for the mnemonic command */
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
/** Subcommands for the template command */
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
/** Subcommands for the invitation command */
const INVITATION_SUBS = ["create", "append", "sign", "broadcast", "requirements", "import", "inspect", "list"];
/** Subcommands for the resource command */
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
/** 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,
help: [],
completions: COMPLETIONS_SUBS,
} as const;
/** Global option flags available on every command. */
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"];
/**
* Generates a bash completion script with dynamic completion support.
* @param binName - The name of the CLI binary (used in the `complete` registration).
*/
export function generateBashCompletions(binName: string): string {
const commands = Object.keys(COMMAND_TREE).join(" ");
const options = GLOBAL_OPTIONS.join(" ");
const funcName = binName.replace(/-/g, "_");
return `# bash completion for ${binName}
# Add to ~/.bashrc: eval "$(${binName} completions bash)"
# Find xo-complete in the same directory as xo-cli
__xo_complete_bin=""
if command -v xo-complete &>/dev/null; then
__xo_complete_bin="xo-complete"
elif command -v ${binName} &>/dev/null; then
__xo_complete_bin="$(dirname "$(command -v ${binName})")/xo-complete"
fi
# Wrapper to call xo-complete helper
__xo_complete() {
[[ -n "\${__xo_complete_bin}" ]] && "\${__xo_complete_bin}" "$@" 2>/dev/null
}
_${funcName}_completions() {
local cur prev words cword
_init_completion || return
# Handle -m/--mnemonic-file argument (previous word was -m)
if [[ "\${prev}" == "-m" || "\${prev}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=$(__xo_complete mnemonics "\${cur}")
if [[ -n "\${mnemonics}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${mnemonics}"
return 0
fi
fi
# If the current word starts with "-", offer option flags
if [[ "\${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "${options}" -- "\${cur}"))
return 0
fi
# Find the command and subcommand positions
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=1; i < cword; i++)); do
if [[ "\${words[i]}" != -* ]]; then
if [[ -z "\${cmd}" ]]; then
cmd="\${words[i]}"
cmd_idx=\$i
else
subcmd="\${words[i]}"
subcmd_idx=\$i
break
fi
fi
done
# No command yet — offer the top-level commands
if [[ -z "\${cmd}" ]]; then
COMPREPLY=($(compgen -W "${commands}" -- "\${cur}"))
return 0
fi
# Handle each command's completion
case "\${cmd}" in
mnemonic)
if [[ -z "\${subcmd}" ]]; then
COMPREPLY=($(compgen -W "${MNEMONIC_SUBS.join(" ")}" -- "\${cur}"))
fi
;;
template)
if [[ -z "\${subcmd}" ]]; then
COMPREPLY=($(compgen -W "${TEMPLATE_SUBS.join(" ")}" -- "\${cur}"))
elif [[ "\${subcmd}" == "list" || "\${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template
local pos=$((cword - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "\${cur}"))
elif [[ \$pos -eq 2 ]]; then
local templates
templates=$(__xo_complete templates "\${cur}")
if [[ -n "\${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${templates}"
fi
fi
elif [[ "\${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
local pos=$((cword - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "\${cur}")
if [[ -n "\${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${templates}"
fi
fi
fi
;;
invitation)
if [[ -z "\${subcmd}" ]]; then
COMPREPLY=($(compgen -W "${INVITATION_SUBS.join(" ")}" -- "\${cur}"))
else
case "\${subcmd}" in
create)
# invitation create <template> <action> - offer templates then actions
local pos=$((cword - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "\${cur}")
if [[ -n "\${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${templates}"
fi
elif [[ \$pos -eq 2 ]]; then
local template_arg="\${words[subcmd_idx + 1]}"
local actions
actions=$(__xo_complete actions "\${template_arg}" "\${cur}")
if [[ -n "\${actions}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${actions}"
fi
fi
;;
append|sign|broadcast|requirements|inspect)
# These take an invitation ID
local pos=$((cword - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local invitations
invitations=$(__xo_complete invitations "\${cur}")
if [[ -n "\${invitations}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${invitations}"
fi
fi
;;
import)
# import takes a file path - use default file completion
COMPREPLY=($(compgen -f -- "\${cur}"))
;;
esac
fi
;;
resource)
if [[ -z "\${subcmd}" ]]; then
COMPREPLY=($(compgen -W "${RESOURCE_SUBS.join(" ")}" -- "\${cur}"))
elif [[ "\${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout> - offer resources
local pos=$((cword - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local resources
resources=$(__xo_complete resources "\${cur}")
if [[ -n "\${resources}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${resources}"
fi
fi
fi
;;
receive)
# receive <template> [output] - offer templates
local pos=$((cword - cmd_idx))
if [[ \$pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "\${cur}")
if [[ -n "\${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("\$line")
done <<< "\${templates}"
fi
fi
;;
completions)
if [[ -z "\${subcmd}" ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "\${cur}"))
fi
;;
esac
}
complete -F _${funcName}_completions ${binName}
`;
}
/**
* Generates a zsh completion script with dynamic completion support.
* @param binName - The name of the CLI binary.
*/
export function generateZshCompletions(binName: string): string {
const commands = Object.keys(COMMAND_TREE).join(" ");
const options = GLOBAL_OPTIONS.join(" ");
const funcName = binName.replace(/-/g, "_");
return `# zsh completion for ${binName}
# Add to ~/.zshrc: eval "$(${binName} completions zsh)"
# Find xo-complete in the same directory as xo-cli
__xo_complete_bin=""
if (( \$+commands[xo-complete] )); then
__xo_complete_bin="xo-complete"
elif (( \$+commands[${binName}] )); then
__xo_complete_bin="\${commands[${binName}]:h}/xo-complete"
fi
# Wrapper to call xo-complete helper
__xo_complete() {
[[ -n "\${__xo_complete_bin}" ]] && "\${__xo_complete_bin}" "$@" 2>/dev/null
}
_${funcName}_completions() {
local -a commands
commands=(${commands})
# Handle -m/--mnemonic-file argument (previous word was -m)
if [[ "\${words[CURRENT-1]}" == "-m" || "\${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=("\${(@f)$(__xo_complete mnemonics "\${words[CURRENT]}")}")
if [[ \${#mnemonics[@]} -gt 0 ]]; then
compadd -- "\${mnemonics[@]}"
return
fi
fi
# If typing an option flag, complete options
if [[ "\${words[\${CURRENT}]}" == -* ]]; then
compadd -- ${options}
return
fi
# Find the command and subcommand
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=2; i < CURRENT; i++)); do
if [[ "\${words[i]}" != -* ]]; then
if [[ -z "\${cmd}" ]]; then
cmd="\${words[i]}"
cmd_idx=\$i
else
subcmd="\${words[i]}"
subcmd_idx=\$i
break
fi
fi
done
# No command yet — offer top-level commands
if [[ -z "\${cmd}" ]]; then
compadd -- \${commands[@]}
return
fi
# Handle each command's completion
case "\${cmd}" in
mnemonic)
if [[ -z "\${subcmd}" ]]; then
compadd -- ${MNEMONIC_SUBS.join(" ")}
fi
;;
template)
if [[ -z "\${subcmd}" ]]; then
compadd -- ${TEMPLATE_SUBS.join(" ")}
elif [[ "\${subcmd}" == "list" || "\${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> - category first
local pos=$((CURRENT - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
compadd -- action transaction output lockingscript variable
elif [[ \$pos -eq 2 ]]; then
local templates
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
if [[ \${#templates[@]} -gt 0 ]]; then
compadd -- "\${templates[@]}"
fi
fi
elif [[ "\${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
local pos=$((CURRENT - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local templates
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
if [[ \${#templates[@]} -gt 0 ]]; then
compadd -- "\${templates[@]}"
fi
fi
fi
;;
invitation)
if [[ -z "\${subcmd}" ]]; then
compadd -- ${INVITATION_SUBS.join(" ")}
else
case "\${subcmd}" in
create)
local pos=$((CURRENT - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local templates
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
if [[ \${#templates[@]} -gt 0 ]]; then
compadd -- "\${templates[@]}"
fi
elif [[ \$pos -eq 2 ]]; then
local template_arg="\${words[subcmd_idx + 1]}"
local actions
actions=("\${(@f)$(__xo_complete actions "\${template_arg}" "\${words[CURRENT]}")}")
if [[ \${#actions[@]} -gt 0 ]]; then
compadd -- "\${actions[@]}"
fi
fi
;;
append|sign|broadcast|requirements|inspect)
local pos=$((CURRENT - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local invitations
invitations=("\${(@f)$(__xo_complete invitations "\${words[CURRENT]}")}")
if [[ \${#invitations[@]} -gt 0 ]]; then
compadd -- "\${invitations[@]}"
fi
fi
;;
import)
_files
;;
esac
fi
;;
resource)
if [[ -z "\${subcmd}" ]]; then
compadd -- ${RESOURCE_SUBS.join(" ")}
elif [[ "\${subcmd}" == "unreserve" ]]; then
local pos=$((CURRENT - subcmd_idx))
if [[ \$pos -eq 1 ]]; then
local resources
resources=("\${(@f)$(__xo_complete resources "\${words[CURRENT]}")}")
if [[ \${#resources[@]} -gt 0 ]]; then
compadd -- "\${resources[@]}"
fi
fi
fi
;;
receive)
local pos=$((CURRENT - cmd_idx))
if [[ \$pos -eq 1 ]]; then
local templates
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
if [[ \${#templates[@]} -gt 0 ]]; then
compadd -- "\${templates[@]}"
fi
fi
;;
completions)
if [[ -z "\${subcmd}" ]]; then
compadd -- bash zsh fish
fi
;;
esac
}
compdef _${funcName}_completions ${binName}
`;
}
/**
* Generates a fish completion script with dynamic completion support.
* @param binName - The name of the CLI binary.
*/
export function generateFishCompletions(binName: string): string {
const lines: string[] = [
`# fish completion for ${binName}`,
`# Add to fish config: ${binName} completions fish | source`,
"",
`# Disable file completions by default`,
`complete -c ${binName} -f`,
"",
`# Helper function to get dynamic completions`,
`# Finds xo-complete in the same directory as ${binName}`,
`function __${binName.replace(/-/g, "_")}_complete_dynamic`,
` set -l xo_complete_bin ""`,
` if command -q xo-complete`,
` set xo_complete_bin xo-complete`,
` else if command -q ${binName}`,
` set xo_complete_bin (dirname (command -s ${binName}))/xo-complete`,
` end`,
` if test -n "$xo_complete_bin"`,
` $xo_complete_bin $argv 2>/dev/null`,
` end`,
`end`,
"",
];
// Global options
for (const opt of GLOBAL_OPTIONS) {
const isShort = !opt.startsWith("--");
const flag = opt.replace(/^-+/, "");
if (isShort) {
lines.push(`complete -c ${binName} -s ${flag} -d "Option flag"`);
} else {
lines.push(`complete -c ${binName} -l ${flag} -d "Option flag"`);
}
}
// Mnemonic file completion for -m
lines.push("");
lines.push(`# Dynamic mnemonic file completion for -m`);
lines.push(`complete -c ${binName} -s m -l mnemonic-file -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic mnemonics)'`);
lines.push("");
// Top-level commands (only when no sub-command is given yet)
lines.push(`# Top-level commands`);
const commandNames = Object.keys(COMMAND_TREE);
for (const cmd of commandNames) {
lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`);
}
lines.push("");
// Static sub-commands for each command
lines.push(`# Static sub-commands`);
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}"`);
}
}
lines.push("");
// Dynamic completions
lines.push(`# Dynamic completions`);
lines.push("");
// invitation create <template> <action>
lines.push(`# invitation create: template names`);
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
lines.push("");
// invitation append/sign/broadcast/requirements/inspect: invitation IDs
lines.push(`# invitation append/sign/broadcast/requirements/inspect: invitation IDs`);
for (const sub of ["append", "sign", "broadcast", "requirements", "inspect"]) {
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from ${sub}; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic invitations)'`);
}
lines.push("");
// invitation import: file completion
lines.push(`# invitation import: file completion`);
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F`);
lines.push("");
// template list/inspect: category first, then template
lines.push(`# template list/inspect: category first (pos 3), then template (pos 4)`);
for (const sub of ["list", "inspect"]) {
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from ${sub}; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'`);
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from ${sub}; and test (count (commandline -opc)) -eq 4" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
}
lines.push("");
// template set-default: template first
lines.push(`# template set-default: template first`);
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
lines.push("");
// resource unreserve: outpoints
lines.push(`# resource unreserve: UTXO outpoints`);
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic resources)'`);
lines.push("");
// receive: template names
lines.push(`# receive: template names`);
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
return lines.join("\n") + "\n";
}
type ShellType = "bash" | "zsh" | "fish";
const generators: Record<ShellType, (binName: string) => string> = {
bash: generateBashCompletions,
zsh: generateZshCompletions,
fish: generateFishCompletions,
};
/**
* Handles the `completions` command.
* Prints the generated completion script for the given shell to stdout.
* @param args - Positional args after "completions", e.g. ["bash"].
* @param binName - The CLI binary name to use in the completion script.
*/
export function handleCompletionsCommand(args: string[], binName: string = "xo-cli"): void {
const shell = args[0] as ShellType | undefined;
if (!shell || !generators[shell]) {
const supported = Object.keys(generators).join(", ");
console.error(`Usage: ${binName} completions <${supported}>`);
console.error("");
console.error("Examples:");
console.error(` eval "$(${binName} completions bash)" # Add to ~/.bashrc`);
console.error(` eval "$(${binName} completions zsh)" # Add to ~/.zshrc`);
console.error(` ${binName} completions fish | source # Add to fish config`);
process.exit(1);
}
process.stdout.write(generators[shell](binName));
}