Add documentation to the commands for the CLI

This commit is contained in:
2026-05-04 12:14:40 +00:00
parent 8d7856f32e
commit 3c47ee8a4c
11 changed files with 480 additions and 55 deletions

View File

@@ -23,7 +23,6 @@ import {
existsSync,
readFileSync,
appendFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

View File

@@ -1,7 +1,16 @@
# bash completion for {{BIN_NAME}}
# Add to ~/.bashrc: eval "$({{BIN_NAME}} completions bash)"
# ------------------------------------------------------------------------------
# Bash completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# eval "$({{BIN_NAME}} completions bash)"
#
# This file is generated from a template. Placeholders (for example `{{OPTIONS}}`)
# are replaced at build/runtime with concrete command data from the CLI.
# ------------------------------------------------------------------------------
# Find xo-complete in the same directory as xo-cli
# Prefer a globally-installed helper, but fall back to a helper co-located with
# the CLI binary. This lets completions work in both "installed via PATH" and
# "single extracted directory" workflows.
__xo_complete_bin=""
if command -v xo-complete &>/dev/null; then
__xo_complete_bin="xo-complete"
@@ -9,16 +18,28 @@ elif command -v {{BIN_NAME}} &>/dev/null; then
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
fi
# Wrapper to call xo-complete helper
# @description
# Calls the dynamic completion helper and suppresses helper stderr so the shell
# completion menu stays clean even when the helper is unavailable or errors.
# @param "$@" Arguments forwarded to xo-complete.
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
# @description
# Main completion dispatcher invoked by bash's `complete -F`.
# It determines context (command/subcommand/argument position) and then mixes:
# - static completions (known command words)
# - dynamic completions (resolved by xo-complete)
# - filesystem completions (when a subcommand expects file paths)
_{{FUNC_NAME}}_completions() {
local cur prev words cword
# Populates `cur`, `prev`, `words`, and `cword`.
# `_init_completion` is provided by bash-completion.
_init_completion || return
# Handle -m/--mnemonic-file argument (previous word was -m)
# If the previous token is `-m/--mnemonic-file`, this argument expects a
# mnemonic file alias/path. Ask the helper for mnemonic suggestions.
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=$(__xo_complete mnemonics "${cur}")
@@ -30,13 +51,14 @@ _{{FUNC_NAME}}_completions() {
fi
fi
# If the current word starts with "-", offer option flags
# Option context: show global options when the current token starts with `-`.
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
return 0
fi
# Find the command and subcommand positions
# Parse command/subcommand from non-option tokens before the current cursor.
# We track indices so argument-position logic can be computed later.
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" != -* ]]; then
@@ -51,13 +73,13 @@ _{{FUNC_NAME}}_completions() {
fi
done
# No command yet — offer the top-level commands
# No command selected yet: complete top-level commands.
if [[ -z "${cmd}" ]]; then
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
return 0
fi
# Handle each command's completion
# Command-specific completion rules.
case "${cmd}" in
mnemonic)
if [[ -z "${subcmd}" ]]; then
@@ -69,7 +91,9 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field
# template list/inspect <category> <template> [field]
# Position is computed relative to the subcommand token:
# 1 => category, 2 => template, 3 => field (inspect only)
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
@@ -82,7 +106,7 @@ _{{FUNC_NAME}}_completions() {
done <<< "${templates}"
fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args
# Field names depend on both selected category and template.
local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}"
local fields
@@ -94,7 +118,8 @@ _{{FUNC_NAME}}_completions() {
fi
fi
elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
# template set-default <template> <output> <role>
# We only complete the first positional argument (template) here.
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
@@ -114,7 +139,8 @@ _{{FUNC_NAME}}_completions() {
else
case "${subcmd}" in
create)
# invitation create <template> <action> - offer templates then actions
# invitation create <template> <action>
# The available actions depend on the selected template.
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
@@ -136,7 +162,7 @@ _{{FUNC_NAME}}_completions() {
fi
;;
append|sign|broadcast|requirements|inspect)
# These take an invitation ID
# These subcommands expect an invitation identifier as first arg.
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local invitations
@@ -149,7 +175,7 @@ _{{FUNC_NAME}}_completions() {
fi
;;
import)
# import takes a file path - use default file completion
# File import path: delegate to bash's built-in file completion.
COMPREPLY=($(compgen -f -- "${cur}"))
;;
esac
@@ -160,7 +186,8 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout> - offer resources
# resource unreserve <txhash:vout>
# Suggest known reserved outpoints from the helper.
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local resources
@@ -175,7 +202,8 @@ _{{FUNC_NAME}}_completions() {
;;
receive)
# receive <template> [output] - offer templates
# receive <template> [output]
# Template is the first positional argument after `receive`.
local pos=$((cword - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
@@ -189,6 +217,7 @@ _{{FUNC_NAME}}_completions() {
;;
completions)
# Shell target for generating completion scripts.
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
fi
@@ -196,4 +225,5 @@ _{{FUNC_NAME}}_completions() {
esac
}
# Register the completion function for the CLI binary.
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -1,11 +1,21 @@
# fish completion for {{BIN_NAME}}
# Add to fish config: {{BIN_NAME}} completions fish | source
# ------------------------------------------------------------------------------
# Fish completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# {{BIN_NAME}} completions fish | source
#
# This file is generated from a template. Placeholders (for example
# `{{TOP_LEVEL_COMMANDS}}`) are replaced with concrete completion definitions.
# ------------------------------------------------------------------------------
# Disable file completions by default
# Fish offers file completion by default. Disable that globally first so command
# words are preferred, then selectively re-enable `-F` where paths are expected.
complete -c {{BIN_NAME}} -f
# Helper function to get dynamic completions
# Finds xo-complete in the same directory as {{BIN_NAME}}
# @description
# Resolves and calls `xo-complete` for dynamic values (templates, invitations,
# fields, etc.). We first try PATH, then a helper next to `{{BIN_NAME}}`.
# @param $argv Arguments forwarded directly to xo-complete.
function __{{FUNC_NAME}}_complete_dynamic
set -l xo_complete_bin ""
if command -q xo-complete
@@ -18,7 +28,7 @@ function __{{FUNC_NAME}}_complete_dynamic
end
end
# Global options
# Global option flags available across top-level command contexts.
complete -c {{BIN_NAME}} -s h -d "Show help"
complete -c {{BIN_NAME}} -l help -d "Show help"
complete -c {{BIN_NAME}} -s v -d "Verbose output"
@@ -26,45 +36,61 @@ complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
complete -c {{BIN_NAME}} -s o -d "Output file"
complete -c {{BIN_NAME}} -l output -d "Output file"
# Dynamic mnemonic file completion for -m
# Dynamic completion for `-m/--mnemonic-file`.
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
# Top-level commands
# Top-level command registrations inserted by template expansion.
{{TOP_LEVEL_COMMANDS}}
# Static sub-commands
# Static subcommand registrations inserted by template expansion.
{{STATIC_SUBCOMMANDS}}
# Dynamic completions
# ---------------------------------------------------------------------------
# Dynamic completions by command/subcommand.
#
# Fish condition notes:
# - `__fish_seen_subcommand_from <name>` checks whether `<name>` exists in the
# current tokenized command line.
# - `count (commandline -opc)` returns how many tokens were entered.
# We use this to infer positional argument index.
# ---------------------------------------------------------------------------
# invitation create: template names
# invitation create <template> <action>
# Position 3 => template argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# invitation create: action names (2nd arg)
# invitation create <template> <action>
# Position 4 => action argument, filtered by selected template token.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
# invitation append/sign/broadcast/requirements/inspect: invitation IDs
# invitation append/sign/broadcast/requirements/inspect <invitation-id>
# Position 3 => invitation identifier.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
# invitation import: file completion
# invitation import <path>
# Re-enable default filesystem completion for path argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
# template list/inspect: category first (pos 3), then template (pos 4), then field (pos 5 for inspect)
# template list/inspect <category> <template> [field]
# Position 3 => category, 4 => template, 5 => field (inspect only).
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
# template set-default: template first
# template set-default <template> <output> <role>
# Position 3 => template argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# resource unreserve: UTXO outpoints
# resource unreserve <txhash:vout>
# Position 3 => outpoint to unreserve.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
# receive: template names
# receive <template> [output]
# Position 2 => template argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'

View File

@@ -1,7 +1,15 @@
# zsh completion for {{BIN_NAME}}
# Add to ~/.zshrc: eval "$({{BIN_NAME}} completions zsh)"
# ------------------------------------------------------------------------------
# Zsh completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# eval "$({{BIN_NAME}} completions zsh)"
#
# This file is generated from a template. Placeholders (for example
# `{{MNEMONIC_SUBS}}`) are replaced with concrete command values.
# ------------------------------------------------------------------------------
# Find xo-complete in the same directory as xo-cli
# Prefer a helper on PATH; otherwise fall back to helper next to the CLI binary.
# This keeps dynamic completion functional in both installed and portable layouts.
__xo_complete_bin=""
if (( $+commands[xo-complete] )); then
__xo_complete_bin="xo-complete"
@@ -9,16 +17,25 @@ elif (( $+commands[{{BIN_NAME}}] )); then
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
fi
# Wrapper to call xo-complete helper
# @description
# Calls the dynamic helper while silencing helper stderr to avoid noisy
# completion menus if helper lookup fails.
# @param "$@" Arguments forwarded to xo-complete.
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
# @description
# Main zsh completion dispatcher registered via `compdef`.
# It resolves command context from `$words`/`$CURRENT` and serves:
# - static command words via `compadd`
# - dynamic values from `xo-complete`
# - filesystem completions where file paths are expected
_{{FUNC_NAME}}_completions() {
local -a commands
commands=({{COMMANDS}})
# Handle -m/--mnemonic-file argument (previous word was -m)
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
@@ -28,13 +45,14 @@ _{{FUNC_NAME}}_completions() {
fi
fi
# If typing an option flag, complete options
# Option context: if current token starts with `-`, complete known options.
if [[ "${words[${CURRENT}]}" == -* ]]; then
compadd -- {{OPTIONS}}
return
fi
# Find the command and subcommand
# Find first and second non-option tokens before the cursor.
# `cmd_idx` and `subcmd_idx` are used for positional argument calculations.
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=2; i < CURRENT; i++)); do
if [[ "${words[i]}" != -* ]]; then
@@ -49,13 +67,13 @@ _{{FUNC_NAME}}_completions() {
fi
done
# No command yet offer top-level commands
# No command token yet: offer top-level commands.
if [[ -z "${cmd}" ]]; then
compadd -- ${commands[@]}
return
fi
# Handle each command's completion
# Command-specific completion behavior.
case "${cmd}" in
mnemonic)
if [[ -z "${subcmd}" ]]; then
@@ -67,7 +85,9 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then
compadd -- {{TEMPLATE_SUBS}}
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field
# template list/inspect <category> <template> [field]
# Relative positions from subcommand:
# 1 => category, 2 => template, 3 => field (inspect only)
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
compadd -- action transaction output lockingscript variable
@@ -78,7 +98,7 @@ _{{FUNC_NAME}}_completions() {
compadd -- "${templates[@]}"
fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args
# Field suggestions depend on selected category and template.
local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}"
local fields
@@ -88,7 +108,8 @@ _{{FUNC_NAME}}_completions() {
fi
fi
elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
# template set-default <template> <output> <role>
# First positional argument is template name.
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
@@ -106,6 +127,8 @@ _{{FUNC_NAME}}_completions() {
else
case "${subcmd}" in
create)
# invitation create <template> <action>
# Action list is template-specific.
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
@@ -123,6 +146,7 @@ _{{FUNC_NAME}}_completions() {
fi
;;
append|sign|broadcast|requirements|inspect)
# These subcommands take invitation ID as first argument.
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local invitations
@@ -133,6 +157,7 @@ _{{FUNC_NAME}}_completions() {
fi
;;
import)
# invitation import <path>: delegate to zsh file completion.
_files
;;
esac
@@ -143,6 +168,7 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then
compadd -- {{RESOURCE_SUBS}}
elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout>
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local resources
@@ -155,6 +181,7 @@ _{{FUNC_NAME}}_completions() {
;;
receive)
# receive <template> [output]
local pos=$((CURRENT - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
@@ -166,6 +193,7 @@ _{{FUNC_NAME}}_completions() {
;;
completions)
# Shell target for completion generation.
if [[ -z "${subcmd}" ]]; then
compadd -- bash zsh fish
fi
@@ -173,4 +201,5 @@ _{{FUNC_NAME}}_completions() {
esac
}
# Register completion function for the executable name.
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -42,6 +42,7 @@ function parseVariablesFromOptions(
): { variableIdentifier: string; value: string; roleIdentifier?: string }[] {
const roleIdentifier = options["role"];
// Parse the variables from the options by checking if its starts with "var"
return Object.entries(options)
.filter(([key]) => key.startsWith("var"))
.map(([key, value]) => ({
@@ -81,10 +82,12 @@ async function buildAppendParams(
const suitableResources = await invitation.findSuitableResources();
const selectable = mapUnspentOutputsToSelectable(suitableResources);
// Get the required sats out with the default fee
const requiredWithFee =
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
autoSelectGreedyUtxos(selectable, requiredWithFee);
// Get the inputs from the selectable UTXOs
inputs = selectable
.filter((u) => u.selected)
.map((u) => ({
@@ -92,12 +95,15 @@ async function buildAppendParams(
outpointIndex: u.outpointIndex,
}));
// If no inputs are found, print a message and return null
if (inputs.length === 0) {
deps.io.err("No suitable UTXOs found for auto-input selection.");
return null;
}
deps.io.verbose(`Auto-selected ${inputs.length} input(s)`);
} else if (options["addInput"]) {
}
// If the add input option is provided, parse the inputs from the options
else if (options["addInput"]) {
inputs = options["addInput"].split(",").map((entry) => {
const separatorIndex = entry.lastIndexOf(":");
if (separatorIndex === -1) {
@@ -105,8 +111,12 @@ async function buildAppendParams(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
);
}
// Get the tx hash and vout from the entry
const txHash = entry.substring(0, separatorIndex);
const vout = parseInt(entry.substring(separatorIndex + 1), 10);
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
if (!txHash || isNaN(vout)) {
throw new Error(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
@@ -161,10 +171,12 @@ async function buildAppendParams(
}
}
// Get the template from the engine
const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
// Get the outputs from the template
const outputs: any[] = await Promise.all(
outputIdentifiers.map(async (outputId) => {
// Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript)
@@ -205,28 +217,37 @@ async function buildAppendParams(
]),
);
// Sum the total input sats
let totalInputSats = 0n;
// Iterate through the inputs and sum the valueSatoshis
for (const input of inputs) {
// Get the tx hash hex
const txHashHex = binToHex(input.outpointTransactionHash);
// Get the utxo from the utxo map
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
if (!utxo) {
// If the utxo is not found, print a message and return null
deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
);
return null;
}
// Sum the valueSatoshis
totalInputSats += BigInt(utxo.valueSatoshis);
}
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
// Get the required sats out
const requiredSats = await invitation.getSatsOut();
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
// Get the change amount by subtracting the required sats out from the total input sats and the default fee
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
);
// If the change amount is less than 0, print a message and return null
if (changeAmount < 0n) {
deps.io.err(
`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`,
@@ -234,10 +255,13 @@ async function buildAppendParams(
return null;
}
// If the change amount is greater than or equal to the dust threshold, add the change output
if (changeAmount >= DUST_THRESHOLD) {
outputs.push({ valueSatoshis: changeAmount });
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
} else if (changeAmount > 0n) {
}
// If the change amount is greater than 0, print a message
else if (changeAmount > 0n) {
deps.io.out(
`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`,
);
@@ -311,6 +335,7 @@ export const handleInvitationCommand = async (
const subCommand = args[0];
deps.io.verbose(`Invitation sub-command: ${subCommand}`);
// If there was no subcommand provided, print the help message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printInvitationHelp(deps.io);
@@ -320,14 +345,18 @@ export const handleInvitationCommand = async (
);
}
// Switch statement to handle the different subcommands
switch (subCommand) {
case "create": {
// Get the template query and action identifier from the arguments
const templateQuery = args[1];
const actionIdentifier = args[2];
deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
);
// If they didnt provide us with a template query or action identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!templateQuery || !actionIdentifier) {
deps.io.verbose("No template file or action identifier provided");
printInvitationHelp(deps.io);
@@ -337,25 +366,31 @@ export const handleInvitationCommand = async (
);
}
// Resolve the template, this will check both filepath and identifier. Because we are flexible here, we will need to generate the identifier again after
const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template);
// Create an XOInvitation. We will convert this into our own invitation instance afterwards
const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
// Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session
const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Read the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitationInstance.addVariables(variables);
}
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) {
throw new CommandError(
@@ -364,11 +399,14 @@ export const handleInvitationCommand = async (
);
}
// Append the inputs and outputs to the invitation
const { inputs, outputs } = params;
if (inputs.length > 0 || outputs.length > 0) {
await invitationInstance.append({ inputs, outputs });
}
// Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync(
@@ -379,6 +417,7 @@ export const handleInvitationCommand = async (
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
);
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
const missingRequirements =
await invitationInstance.getMissingRequirements();
const hasMissing =
@@ -388,14 +427,17 @@ export const handleInvitationCommand = async (
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out
if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
// If there are no missing requirements, sign the invitation if the user has requested it
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
// Sign the invitation if the user has requested it
if (shouldSign) {
await invitationInstance.sign();
deps.io.out(
@@ -403,6 +445,7 @@ export const handleInvitationCommand = async (
);
}
// Broadcast the transaction if the user has requested it
if (shouldBroadcast) {
const txHash = await invitationInstance.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
@@ -412,15 +455,20 @@ export const handleInvitationCommand = async (
);
}
}
// Return the invitation identifier
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "append": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
@@ -430,9 +478,12 @@ export const handleInvitationCommand = async (
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
@@ -442,12 +493,14 @@ export const handleInvitationCommand = async (
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Parse the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitation.addVariables(variables);
}
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitation, options);
if (!params) {
throw new CommandError(
@@ -456,6 +509,7 @@ export const handleInvitationCommand = async (
);
}
// If there are no variables, inputs, or outputs, print an error and throw an error
const { inputs, outputs } = params;
if (
variables.length === 0 &&
@@ -468,18 +522,22 @@ export const handleInvitationCommand = async (
throw new CommandError("invitation.append.empty", error);
}
// Append the inputs and outputs to the invitation
if (inputs.length > 0 || outputs.length > 0) {
await invitation.append({ inputs, outputs });
}
deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation appended: ${invitationIdentifier}`);
// Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
);
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
const missingRequirements = await invitation.getMissingRequirements();
const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 ||
@@ -488,19 +546,23 @@ export const handleInvitationCommand = async (
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out
if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
// If there are no missing requirements, sign the invitation if the user has requested it
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
// Sign the invitation if the user has requested it
if (shouldSign) {
await invitation.sign();
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
}
// Broadcast the transaction if the user has requested it
if (shouldBroadcast) {
const txHash = await invitation.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
@@ -514,8 +576,12 @@ export const handleInvitationCommand = async (
}
case "sign": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
@@ -525,10 +591,13 @@ export const handleInvitationCommand = async (
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
@@ -538,15 +607,22 @@ export const handleInvitationCommand = async (
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Sign the invitation
await invitation.sign();
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
// Return the invitation identifier
return { invitationIdentifier };
}
case "broadcast": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
@@ -556,10 +632,13 @@ export const handleInvitationCommand = async (
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
@@ -569,17 +648,24 @@ export const handleInvitationCommand = async (
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Broadcast the transaction
const txHash = await invitation.broadcast();
deps.io.verbose(
`Invitation broadcasted: ${formatObject(invitation.data)}`,
);
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
// Return the invitation identifier and transaction hash
return { invitationIdentifier, txHash };
}
case "requirements": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
@@ -589,10 +675,13 @@ export const handleInvitationCommand = async (
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
@@ -602,18 +691,26 @@ export const handleInvitationCommand = async (
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// List the requirements for the invitation
const requirements = await deps.app.engine.listRequirements(
invitation.data,
);
deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
deps.io.out(formatObject(requirements));
// Return the invitation identifier
return { invitationIdentifier };
}
case "inspect": {
// Get the invitation file path from the arguments
const invitationFilePath = args[1];
// If they didnt provide us with an invitation file path, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// Read the invitation file
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
@@ -623,24 +720,31 @@ export const handleInvitationCommand = async (
);
}
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Get the template for the invitation
const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier,
);
// Get the action for the invitation
const action =
template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`);
// If the action is not found, print an error and throw an error
if (!action) {
deps.io.err(
`Action not found: ${invitationInstance.data.actionIdentifier}`,
@@ -651,9 +755,11 @@ export const handleInvitationCommand = async (
);
}
// Get the status for the invitation
const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`);
// Get the entities for the invitation
const entities = Array.from(
new Set(
invitationInstance.data.commits.map(
@@ -663,6 +769,7 @@ export const handleInvitationCommand = async (
);
deps.io.verbose(`Entities: ${formatObject(entities)}`);
// Get the entities with roles for the invitation
const entitiesWithRoles = entities.map((entity) => {
return {
entityIdentifier: entity,
@@ -685,21 +792,25 @@ export const handleInvitationCommand = async (
};
});
// Get the inputs for the invitation
const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [],
);
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
// Get the outputs for the invitation
const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [],
);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
// Get the variables for the invitation
const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [],
);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
// Return the invitation details
return {
templateName: template?.name ?? "Unknown",
actionIdentifier: invitationInstance.data.actionIdentifier,
@@ -712,9 +823,12 @@ export const handleInvitationCommand = async (
}
case "import": {
// Get the invitation file path from the arguments
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// If they didnt provide us with an invitation file path, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
@@ -724,26 +838,41 @@ export const handleInvitationCommand = async (
);
}
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in
const xoInvitation = await deps.app.engine.createInvitation(invitation);
deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Return the invitation identifier
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "list": {
// List all the invitations
const invitations = await Promise.all(
// Iterate over the invitations and compile them into a list of data that we can use to display them with another loop later.
deps.app.invitations.map(async (invitation) => {
// Get the template for the invitation
const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
// Get the role identifier for the invitation
return {
invitationIdentifier: invitation.data.invitationIdentifier,
templateIdentifier: invitation.data.templateIdentifier,
@@ -755,15 +884,22 @@ export const handleInvitationCommand = async (
}),
);
deps.io.verbose(`Invitations: ${formatObject(invitations)}`);
// Format the invitations into a list of strings that we can display to the user
const formattedInvitations = invitations.map(
(invitation) =>
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
);
// Display the invitations to the user
deps.io.out(formattedInvitations.join("\n"));
// Return the number of invitations
return { count: invitations.length };
}
default:
// If the sub-command is not found, print an error and throw an error
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
printInvitationHelp(deps.io);
throw new CommandError(

View File

@@ -41,9 +41,11 @@ export const handleMnemonicCommand = async (
args: string[],
options: Record<string, string>,
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
// Get the sub-command from the arguments
const subCommand = args[0];
const { mnemonicsDir } = deps.paths;
// If no sub-command is provided, print the help message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printMnemonicHelp(deps.io);
@@ -53,22 +55,29 @@ export const handleMnemonicCommand = async (
);
}
// Handle the sub-command
switch (subCommand) {
case "create": {
// Create a new mnemonic seed
const mnemonicSeed = createMnemonicSeed();
// Create a new mnemonic file
const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
// Display the mnemonic file to the user
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
return { savedAs };
}
case "import": {
// Get the mnemonic seed from the arguments
const mnemonicSeed = args.slice(1).join(" ");
// If no mnemonic seed is provided, print the help message and throw an error
if (!mnemonicSeed) {
deps.io.verbose("No mnemonic seed provided");
printMnemonicHelp(deps.io);
@@ -78,25 +87,33 @@ export const handleMnemonicCommand = async (
);
}
// Create a new mnemonic file
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
// Display the mnemonic file to the user
deps.io.out(`Mnemonic file created: ${savedAs}`);
return { savedAs };
}
case "list": {
// List all the mnemonic files
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
deps.io.out(mnemonicFiles.join("\n"));
// Return the number of mnemonic files
return { count: mnemonicFiles.length };
}
case "expose": {
// Get the mnemonic file from the arguments
const mnemonicFile = args[1];
// If no mnemonic file is provided, print the help message and throw an error
if (!mnemonicFile) {
deps.io.verbose("No mnemonic file provided");
printMnemonicHelp(deps.io);
@@ -106,11 +123,15 @@ export const handleMnemonicCommand = async (
);
}
// Try to load the mnemonic file
try {
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
deps.io.out(mnemonic);
// Return the mnemonic
return { mnemonic };
} catch (error) {
// If the mnemonic file is not found, print an error and throw an error
throw new CommandError(
"mnemonic.expose.file_not_found",
`Mnemonic file not found: ${mnemonicFile}`,
@@ -119,6 +140,7 @@ export const handleMnemonicCommand = async (
}
default:
// If the sub-command is not found, print an error and throw an error
deps.io.err(`Unknown sub-command: ${subCommand}`);
printMnemonicHelp(deps.io);
throw new CommandError(

View File

@@ -44,14 +44,17 @@ export const handleReceiveCommand = async (
args: string[],
_options: Record<string, string>,
): Promise<{ address: string }> => {
// Get the template query, output identifier, and role identifier from the arguments
const templateQuery = args[0];
const outputIdentifier = args[1];
const roleIdentifier = args[2];
// Log the receive args
deps.io.verbose(
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
);
// If no template query or output identifier is provided, print the help message and throw an error
if (!templateQuery || !outputIdentifier) {
deps.io.verbose("Missing required arguments");
printReceiveHelp(deps.io);

View File

@@ -30,18 +30,27 @@ function formatResource(
resource: UnspentOutputData,
showReserved = false,
): string {
// Format the outpoint
const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
);
// Format the value
const value = dim(`${resource.valueSatoshis} sats`);
// Format the output
const output = dim(resource.outputIdentifier);
// Format the height
const height = dim(`(height ${resource.minedAtHeight})`);
// If the resource is reserved, format the reservation info
if (showReserved && resource.reservedBy) {
const inv = dim(`reserved for ${resource.reservedBy}`);
return `${outpoint} ${value} ${output} ${height} ${inv}`;
}
// Otherwise, format the resource without reservation info
return `${outpoint} ${value} ${output} ${height}`;
}
@@ -60,6 +69,7 @@ export const handleResourceCommand = async (
const subCommand = args[0];
deps.io.verbose(`Resource sub-command: ${subCommand}`);
// If no sub-command is provided, print the help message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printResourceHelp(deps.io);
@@ -69,39 +79,59 @@ export const handleResourceCommand = async (
);
}
// Handle the sub-command
switch (subCommand) {
case "list": {
// Get the qualifier from the arguments - This could be "reserved", "all", or omitted (which defaults to "unreserved")
const qualifier = args[1];
// List all the unspent outputs data
const allResources = await deps.app.engine.listUnspentOutputsData();
let filtered;
// If the qualifier is "reserved", return only the reserved resources
if (qualifier === "reserved") {
filtered = allResources.filter((r) => r.reservedBy);
} else if (qualifier === "all") {
}
// If the qualifier is "all", return all the resources
else if (qualifier === "all") {
filtered = allResources;
} else {
}
// If the qualifier is not "reserved" or "all", return only the unreserved resources
else {
filtered = allResources.filter((r) => !r.reservedBy);
}
// If no resources are found, print a message and return 0
if (filtered.length === 0) {
deps.io.out(dim("No resources found."));
return { count: 0 };
}
// Format the resources into a list of strings that we can display to the user
const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = filtered.map((r) =>
formatResource(r, showReserved),
);
// Display the resources to the user
deps.io.out(formattedResources.join("\n"));
// Display the total satoshis
deps.io.out(
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
);
// Display the total resources
deps.io.out(`Total resources: ${filtered.length}`);
return { count: filtered.length };
}
case "unreserve": {
// Get the outpoint from the arguments
const outpointArg = args[1];
// If no outpoint is provided, print a message and throw an error
if (!outpointArg) {
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
printResourceHelp(deps.io);
@@ -111,8 +141,10 @@ export const handleResourceCommand = async (
);
}
// Get the separator index
const separatorIndex = outpointArg.lastIndexOf(":");
if (separatorIndex === -1) {
// If the separator index is -1 (not found), print a message and throw an error
deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
@@ -122,8 +154,11 @@ export const handleResourceCommand = async (
);
}
// Get the tx hash and vout
const txHash = outpointArg.substring(0, separatorIndex);
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
if (!txHash || isNaN(vout)) {
deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
@@ -134,11 +169,15 @@ export const handleResourceCommand = async (
);
}
// Gather all of our resources
const allResources = await deps.app.engine.listUnspentOutputsData();
// Find the target resource
const target = allResources.find(
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
);
// If the target resource is not found, print a message and throw an error
if (!target) {
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
throw new CommandError(
@@ -147,28 +186,39 @@ export const handleResourceCommand = async (
);
}
// If the target resource is not reserved, print a message and return
if (!target.reservedBy) {
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
return {};
}
// Unreserve the resources
await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.reservedBy,
);
deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
);
// TODO: What do I want to return here?
return {};
}
case "unreserve-all": {
// Unreserve all the resources
const count = await deps.app.unreserveAllResources();
// If no resources are reserved, print a message and return
if (count === 0) {
deps.io.out(dim("No reserved resources to unreserve."));
} else {
}
// If some resources were unreserved, print a message and return the count
else {
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
}
return { count };
}

View File

@@ -37,22 +37,33 @@ export const handleTemplateListCommand = async (
deps: CommandDependencies,
args: string[],
): Promise<{ count?: number }> => {
// Get the template category from the arguments - This could be "action", "transaction", "output", "lockingscript", or "variable"
const templateCategory = args[0];
deps.io.verbose(`Template list category: ${templateCategory}`);
// If no template category is provided, list all the imported templates
if (!templateCategory) {
// List all the imported templates
const templates = await deps.app.engine.listImportedTemplates();
// Format the templates into a list of strings that we can display to the user
const formattedTemplates = templates.map(
(template: XOTemplate) =>
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
);
// Display the templates to the user
deps.io.out(formattedTemplates.join("\n"));
// Return the number of templates
return { count: templates.length };
}
// Get the template identifier from the arguments
const templateIdentifier = args[1];
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
// If no template identifier is provided, print a message and throw an error
if (!templateIdentifier) {
deps.io.err("No template identifier provided");
throw new CommandError(
@@ -61,7 +72,10 @@ export const handleTemplateListCommand = async (
);
}
// Get the raw template from the engine
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
// If the raw template is not found, print a message and throw an error
if (!rawTemplate) {
deps.io.err(`No template found: ${templateIdentifier}`);
throw new CommandError(
@@ -70,53 +84,91 @@ export const handleTemplateListCommand = async (
);
}
// Resolve the template deeply - Deeply nested objects instead of shallow objects referencing keys at the top level.
// Reduces the load of having to call multiple lookups just to get some resolved value like the outputIdentifer that comes from calling an action.
const template = await resolveTemplateReferences(rawTemplate);
deps.io.verbose(`Template: ${formatObject(template)}`);
// Handle the template category
switch (templateCategory) {
case "action": {
// Get the actions from the template
const actions = template.actions;
// Format the actions into a list of strings that we can display to the user
const formattedActions = Object.entries(actions).map(
([actionIdentifier, action]) =>
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
);
// Display the actions to the user
deps.io.out(formattedActions.join("\n"));
// Return the number of actions
return {};
}
case "transaction": {
// Get the transactions from the template
const transactions = template.transactions;
// Format the transactions into a list of strings that we can display to the user
const formattedTransactions = Object.entries(transactions).map(
([transactionIdentifier, transaction]) =>
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
);
// Display the transactions to the user
deps.io.out(formattedTransactions.join("\n"));
// Return the number of transactions
return {};
}
case "output": {
// Get the outputs from the template
const outputs = template.outputs;
// Format the outputs into a list of strings that we can display to the user
const formattedOutputs = Object.entries(outputs).map(
([outputIdentifier, output]) =>
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
);
// Display the outputs to the user
deps.io.out(formattedOutputs.join("\n"));
// Return the number of outputs
return {};
}
case "lockingscript": {
// Get the lockingscripts from the template
const lockingscripts = template.lockingScripts;
// Format the lockingscripts into a list of strings that we can display to the user
const formattedLockingscripts = Object.entries(lockingscripts).map(
([lockingScriptIdentifier, lockingScript]) =>
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
);
// Display the lockingscripts to the user
deps.io.out(formattedLockingscripts.join("\n"));
// Return the number of lockingscripts
return {};
}
case "variable": {
// Get the variables from the template
const variables = template.variables || {};
// Format the variables into a list of strings that we can display to the user
const formattedVariables = Object.entries(variables).map(
([variableIdentifier, variable]) =>
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
);
// Display the variables to the user
deps.io.out(formattedVariables.join("\n"));
// Return the number of variables
return {};
}
default: {
@@ -162,6 +214,7 @@ export const handleTemplateInspectCommand = async (
deps: CommandDependencies,
args: string[],
): Promise<Record<string, never>> => {
// Get the template category, identifier, and field from the arguments
const templateCategory = args[0];
const templateQuery = args[1];
const templateField = args[2];
@@ -170,6 +223,7 @@ export const handleTemplateInspectCommand = async (
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
);
// If no template category, identifier, or field is provided, print a message and throw an error
if (!templateCategory || !templateQuery || !templateField) {
deps.io.err("No template category, identifier, or field provided");
printTemplateInspectHelp(deps.io);
@@ -179,15 +233,21 @@ export const handleTemplateInspectCommand = async (
);
}
// Resolve the template
const originalTemplate = await resolveTemplate(deps, templateQuery);
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
// Resolve the template references
const template = await resolveTemplateReferences(originalTemplate);
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
// Handle the template category
switch (templateCategory) {
case "action": {
// Get the action from the template
const action = template.actions[templateField];
// If the action is not found, print a message and throw an error
if (!action) {
deps.io.err(`No action found: ${templateField}`);
throw new CommandError(
@@ -195,11 +255,16 @@ export const handleTemplateInspectCommand = async (
`No action found: ${templateField}`,
);
}
// Display the action to the user
deps.io.out(formatObject(action));
return {};
}
case "transaction": {
// Get the transaction from the template
const transaction = template.transactions?.[templateField];
// If the transaction is not found, print a message and throw an error
if (!transaction) {
deps.io.err(`No transaction found: ${templateField}`);
throw new CommandError(
@@ -207,11 +272,16 @@ export const handleTemplateInspectCommand = async (
`No transaction found: ${templateField}`,
);
}
// Display the transaction to the user
deps.io.out(formatObject(transaction));
return {};
}
case "output": {
// Get the output from the template
const output = template.outputs[templateField];
// If the output is not found, print a message and throw an error
if (!output) {
deps.io.err(`No output found: ${templateField}`);
throw new CommandError(
@@ -219,11 +289,16 @@ export const handleTemplateInspectCommand = async (
`No output found: ${templateField}`,
);
}
// Display the output to the user
deps.io.out(formatObject(output));
return {};
}
case "lockingscript": {
// Get the lockingscript from the template
const lockingscript = template.lockingScripts[templateField];
// If the lockingscript is not found, print a message and throw an error
if (!lockingscript) {
deps.io.err(`No lockingscript found: ${templateField}`);
throw new CommandError(
@@ -231,11 +306,16 @@ export const handleTemplateInspectCommand = async (
`No lockingscript found: ${templateField}`,
);
}
// Display the lockingscript to the user
deps.io.out(formatObject(lockingscript));
return {};
}
case "variable": {
// Get the variable from the template
const variable = template.variables?.[templateField];
// If the variable is not found, print a message and throw an error
if (!variable) {
deps.io.err(`No variable found: ${templateField}`);
throw new CommandError(
@@ -243,6 +323,8 @@ export const handleTemplateInspectCommand = async (
`No variable found: ${templateField}`,
);
}
// Display the variable to the user
deps.io.out(formatObject(variable));
return {};
}
@@ -268,8 +350,10 @@ export const handleTemplateCommand = async (
args: string[],
_options: Record<string, string>,
): Promise<{ templateFile?: string; count?: number }> => {
// Get the sub-command from the arguments
const subCommand = args[0];
// If no sub-command is provided, print a message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printTemplateHelp(deps.io);
@@ -279,9 +363,13 @@ export const handleTemplateCommand = async (
);
}
// Handle the sub-command
switch (subCommand) {
case "import": {
// Get the template file from the arguments
const templateFile = args[1];
// If no template file is provided, print a message and throw an error
deps.io.verbose(`Template file: ${templateFile}`);
if (!templateFile) {
@@ -293,9 +381,11 @@ export const handleTemplateCommand = async (
);
}
// Resolve the template path
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
deps.io.verbose(`Template path: ${templatePath}`);
// If the template file does not exist, print a message and throw an error
if (!existsSync(templatePath)) {
deps.io.err(`Template file does not exist: ${templatePath}`);
printTemplateHelp(deps.io);
@@ -305,23 +395,32 @@ export const handleTemplateCommand = async (
);
}
// Read the template file
const template = await readFileSync(templatePath, "utf8");
deps.io.verbose(`Importing template: ${templateFile}`);
// Import the template
await deps.app.engine.importTemplate(template);
deps.io.verbose(`Template imported: ${templateFile}`);
// Return the template file
return { templateFile };
}
case "list": {
// Handle the template list command, We offload here as it has lots of arguments and is quite long
return handleTemplateListCommand(deps, args.slice(1));
}
case "inspect": {
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
return handleTemplateInspectCommand(deps, args.slice(1));
}
case "set-default": {
// Get the template file, output identifier, and role identifier from the arguments
const templateFile = args[1];
const outputIdentifier = args[2];
const roleIdentifier = args[3];
// If no template file, output identifier, or role identifier is provided, print a message and throw an error
if (!templateFile || !outputIdentifier || !roleIdentifier) {
deps.io.verbose(
"No template file, output identifier, or role identifier provided",
@@ -332,17 +431,24 @@ export const handleTemplateCommand = async (
"No template file, output identifier, or role identifier provided",
);
}
// Set the default locking parameters
deps.io.verbose(
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
);
// Set the default locking parameters
await deps.app.engine.setDefaultLockingParameters(
templateFile,
outputIdentifier,
roleIdentifier,
);
// Return an empty object
return {};
}
default:
// If the sub-command is not found, print a message and throw an error
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
printTemplateHelp(deps.io);
throw new CommandError(

View File

@@ -57,20 +57,24 @@ export const resolveMnemonicFilePath = (
mnemonicsDir: string,
mnemonicRef: string,
): string => {
// Try to resolve the mnemonic file as an absolute path
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef;
}
// Try to resolve the mnemonic file relative to the current working directory
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) {
return relativeToCwd;
}
// Try to resolve the mnemonic file in the mnemonics directory
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
if (existsSync(inMnemonics)) {
return inMnemonics;
}
// If the mnemonic file is not found, throw an error
throw new Error(
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
);
@@ -86,18 +90,26 @@ export const loadMnemonic = (
mnemonicsDir: string,
mnemonicFile: string,
): string => {
// Resolve the mnemonic file path
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
// Read the mnemonic file
const mnemonicUrl = BCHMnemonicURL.fromURL(
readFileSync(resolvedPath, "utf8"),
);
// Get the entropy from the mnemonic url
const { entropy } = mnemonicUrl.toObject();
// Encode the entropy to a mnemonic
const mnemonic = encodeBip39Mnemonic(entropy);
// If the mnemonic is not a string, throw an error
if (typeof mnemonic === "string") {
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
}
// Return the mnemonic phrase
return mnemonic.phrase;
};
@@ -107,9 +119,12 @@ export const loadMnemonic = (
* @returns Basenames suitable for `-m <name>`
*/
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
// List the mnemonic files in the given directory
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
f.startsWith("mnemonic-"),
);
// Return the mnemonic files
return filenames;
};
@@ -119,5 +134,6 @@ export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
* @returns Basenames suitable for `-m <name>`
*/
export const listGlobalMnemonicFiles = (): string[] => {
// List the mnemonic files in the global mnemonics directory
return listMnemonicFiles(getGlobalMnemonicsDir());
};

View File

@@ -23,22 +23,28 @@ export const resolveTemplate = async (
deps: CommandDependencies,
query: string,
): Promise<XOTemplate> => {
// Gather all of our imported templates
const templates = await deps.app.engine.listImportedTemplates();
// Create a set to store the matches
const matches = new Set<XOTemplate>();
// Iterate through the templates and check if the identifier matches the query
for (const template of templates) {
if (generateTemplateIdentifier(template) === query) {
// Return early if we got a match since identifiers are always unique by content
return template;
}
}
// Iterate through the templates and check if the name matches the query
for (const template of templates) {
if (template.name === query) {
matches.add(template);
}
}
// If there are multiple matches, throw an error
if (matches.size > 1) {
throw new CommandError(
"template.resolve.multiple_matches",
@@ -51,10 +57,12 @@ export const resolveTemplate = async (
);
}
// If there is one match, return the match
if (matches.size === 1) {
return matches.values().next().value!;
}
// If there are no matches, throw an error
throw new CommandError(
"template.resolve.not_found",
`Template not found: ${query}`,