Compare commits
6 Commits
2f2e515d72
...
c2334b2cdd
| Author | SHA1 | Date | |
|---|---|---|---|
| c2334b2cdd | |||
| dec228063b | |||
| 3c47ee8a4c | |||
| 8d7856f32e | |||
| b8b0a4a1ba | |||
| dedfb69dff |
@@ -16,8 +16,6 @@
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"nuke": "tsx scripts/rm-dbs.ts",
|
||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||
"format:check": "prettier --check .",
|
||||
"autocomplete:install": "node dist/cli/index.js completions bash --install",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
|
||||
/**
|
||||
* Remove all the databases without the use of external tools
|
||||
* TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json
|
||||
*/
|
||||
const rmDbs = async (dry = false) => {
|
||||
// First, we need to find all the database base files
|
||||
// These end in either .db.sqlite, .sqlite, .db
|
||||
// Get all the files in the current directory
|
||||
const files = await fs.readdir("./");
|
||||
|
||||
// Filter out the files that end in .db.sqlite, .sqlite, .db
|
||||
const dbFiles = files.filter(
|
||||
(file) =>
|
||||
file.endsWith(".db.sqlite") ||
|
||||
file.endsWith(".sqlite") ||
|
||||
file.endsWith(".db"),
|
||||
);
|
||||
|
||||
// We need to remove all the files
|
||||
await deleteFiles(dbFiles, dry);
|
||||
};
|
||||
|
||||
const deleteFiles = async (files: string[], dry = false) => {
|
||||
if (dry) {
|
||||
console.log("Dry run, would delete:", files);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(files.map((file) => fs.rm(file)));
|
||||
console.log("All databases removed");
|
||||
};
|
||||
|
||||
// Read args
|
||||
const args = process.argv.slice(2);
|
||||
const dry = args.includes("--dry");
|
||||
|
||||
// Delete the files
|
||||
await rmDbs(dry);
|
||||
@@ -1,104 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* This just convers the <template>.ts file to a <template>.json file.
|
||||
* Im fairly sure there is a util in the engine or engine-packages for this, but I decided to just keep it as simple as possible because I didn't feel like digging around for it.
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prints usage to stderr and exits with a non-zero code.
|
||||
*/
|
||||
function printUsageAndExit(): never {
|
||||
console.error(
|
||||
[
|
||||
"Usage: tsx scripts/template-to-json.ts <input.ts> <output.json> [exportName]",
|
||||
"",
|
||||
"Loads a TypeScript module, picks one exported value, and writes JSON.stringify to the output path.",
|
||||
"If exportName is omitted: uses default export, or the only non-function export if there is exactly one.",
|
||||
"",
|
||||
"Example:",
|
||||
" tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects runtime export keys whose values are not functions (typical for data/template objects).
|
||||
*/
|
||||
function listDataExportKeys(mod: Record<string, unknown>): string[] {
|
||||
return Object.keys(mod).filter((key) => {
|
||||
if (key === "__esModule") {
|
||||
return false;
|
||||
}
|
||||
const value = mod[key];
|
||||
return typeof value !== "function";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which export to serialize: explicit name, default, or a single unambiguous data export.
|
||||
*/
|
||||
function resolveExportedValue(
|
||||
mod: Record<string, unknown>,
|
||||
exportName: string | undefined,
|
||||
): unknown {
|
||||
if (exportName !== undefined) {
|
||||
if (!(exportName in mod)) {
|
||||
const keys = listDataExportKeys(mod);
|
||||
console.error(
|
||||
`Export "${exportName}" not found. Available data exports: ${keys.length ? keys.join(", ") : "(none)"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return mod[exportName];
|
||||
}
|
||||
|
||||
if ("default" in mod && mod.default !== undefined) {
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
const keys = listDataExportKeys(mod);
|
||||
if (keys.length === 1) {
|
||||
return mod[keys[0]!];
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.error(
|
||||
"No suitable exports found (need default or a non-function export).",
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
printUsageAndExit();
|
||||
}
|
||||
|
||||
const [inputRel, outputRel, exportName] = args;
|
||||
const inputPath = path.resolve(process.cwd(), inputRel!);
|
||||
const outputPath = path.resolve(process.cwd(), outputRel!);
|
||||
|
||||
/** Dynamic import needs a file URL so Windows paths and ESM resolution behave. */
|
||||
const fileUrl = pathToFileURL(inputPath).href;
|
||||
const mod = (await import(fileUrl)) as Record<string, unknown>;
|
||||
const value = resolveExportedValue(mod, exportName);
|
||||
|
||||
const json = `${JSON.stringify(value, null, 2)}\n`;
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, json, "utf8");
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
appendFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||
|
||||
/**
|
||||
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
|
||||
* At the time of writing the storage adapter, the engine provided no way to read data about your currenty invitations, so that is where this is coming in.
|
||||
* Its providing a Developer facing way to store/read the invitation data and then we can just import them into the engine whenever we want to interact with an invitation.
|
||||
*/
|
||||
export abstract class BaseStorage {
|
||||
abstract all(): Promise<{ key: string; value: any }[]>;
|
||||
abstract set(key: string, value: any): Promise<void>;
|
||||
@@ -10,6 +15,9 @@ export abstract class BaseStorage {
|
||||
abstract child(key: string): BaseStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite Database Storage Adapter.
|
||||
*/
|
||||
export class Storage extends BaseStorage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
// Create the database
|
||||
@@ -134,6 +142,9 @@ export class Storage extends BaseStorage {
|
||||
*
|
||||
* This adapter is useful for tests and short-lived sessions where persisted
|
||||
* SQLite state is not needed.
|
||||
*
|
||||
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
|
||||
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
|
||||
*/
|
||||
export class InMemoryStorage extends BaseStorage {
|
||||
static async create(): Promise<InMemoryStorage> {
|
||||
|
||||
@@ -350,29 +350,32 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
const indicator = isFocused ? '▸ ' : ' ';
|
||||
const groupingPrefix = row.isNested ? ' -> ' : '';
|
||||
|
||||
if (row.type === 'invitation') {
|
||||
if (row.type === 'history_item') {
|
||||
const sats = row.valueSatoshis ?? 0n;
|
||||
const fiatSuffix = getFiatSuffix(sats);
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>
|
||||
{indicator}[Invitation] {row.label}
|
||||
{indicator}{formatSatoshis(sats)}{fiatSuffix}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {row.label}</Text>
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'invitation_input') {
|
||||
const inputSatoshis = row.utxo?.valueSatoshis;
|
||||
const inputFiatSuffix = inputSatoshis !== undefined
|
||||
? getFiatSuffix(inputSatoshis)
|
||||
: '';
|
||||
if (row.type === 'history_input') {
|
||||
const sats = row.valueSatoshis ?? 0n;
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={itemColor}>
|
||||
{indicator}{groupingPrefix}[Input] {row.label}
|
||||
{inputFiatSuffix}
|
||||
{indicator}{groupingPrefix}[Input] {formatSatoshis(sats)}
|
||||
{getFiatSuffix(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {row.label}</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
@@ -380,8 +383,9 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'invitation_output') {
|
||||
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||
if (row.type === 'history_output') {
|
||||
const sats = row.valueSatoshis ?? 0n;
|
||||
const reservedTag = row.reserved ? ' [Reserved]' : '';
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
@@ -389,6 +393,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||
{getFiatSuffix(sats)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> {row.label}{reservedTag}</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
@@ -396,23 +401,6 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'utxo') {
|
||||
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>
|
||||
{indicator}{formatSatoshis(sats)}
|
||||
{getFiatSuffix(sats)}
|
||||
</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for other types
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
@@ -515,7 +503,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
height={14}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
||||
<Text color={colors.primary} bold> Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''}</Text>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { Invitation } from '../../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
|
||||
import {
|
||||
getInvitationState,
|
||||
@@ -29,12 +29,12 @@ import {
|
||||
getInvitationInputs,
|
||||
getInvitationOutputs,
|
||||
getInvitationVariables,
|
||||
getUserRole,
|
||||
formatInvitationListItem,
|
||||
formatInvitationId,
|
||||
} from '../../../utils/invitation-utils.js';
|
||||
|
||||
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||
|
||||
/**
|
||||
* Map state color name to theme color.
|
||||
@@ -80,6 +80,29 @@ const invitationListGroups: ListGroup[] = [
|
||||
{ id: 'invitations', separator: true },
|
||||
];
|
||||
|
||||
type OwnInvitationContext = {
|
||||
entityIdentifier: string | null;
|
||||
roleIdentifier: string | null;
|
||||
};
|
||||
|
||||
function getRoleIdentifierFromCommits(commits: XOInvitationCommit[]): string | null {
|
||||
for (const commit of commits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
if (input.roleIdentifier) return input.roleIdentifier;
|
||||
}
|
||||
|
||||
for (const output of commit.data.outputs ?? []) {
|
||||
if (output.roleIdentifier) return output.roleIdentifier;
|
||||
}
|
||||
|
||||
for (const variable of commit.data.variables ?? []) {
|
||||
if (variable.roleIdentifier) return variable.roleIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation Screen Component.
|
||||
*/
|
||||
@@ -107,6 +130,10 @@ export function InvitationScreen(): React.ReactElement {
|
||||
// ── Template cache ───────────────────────────────────────────────────────
|
||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||
const [ownInvitationContext, setOwnInvitationContext] = useState<OwnInvitationContext>({
|
||||
entityIdentifier: null,
|
||||
roleIdentifier: null,
|
||||
});
|
||||
|
||||
// Check if we should open import dialog on mount
|
||||
const initialMode = navData.mode as string | undefined;
|
||||
@@ -180,6 +207,43 @@ export function InvitationScreen(): React.ReactElement {
|
||||
.then(template => setSelectedTemplate(template ?? null));
|
||||
}, [selectedInvitation, appService]);
|
||||
|
||||
/**
|
||||
* Load the current engine entity's commits for the selected invitation.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!selectedInvitation || !appService) {
|
||||
setOwnInvitationContext({
|
||||
entityIdentifier: null,
|
||||
roleIdentifier: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
|
||||
appService.engine.getOwnCommits(selectedInvitation.data)
|
||||
.then((ownCommits) => {
|
||||
if (!isCurrent) return;
|
||||
|
||||
setOwnInvitationContext({
|
||||
entityIdentifier: ownCommits[0]?.entityIdentifier ?? null,
|
||||
roleIdentifier: getRoleIdentifierFromCommits(ownCommits),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent) return;
|
||||
|
||||
setOwnInvitationContext({
|
||||
entityIdentifier: null,
|
||||
roleIdentifier: null,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [selectedInvitation, appService]);
|
||||
|
||||
// ── Import flow callbacks ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -512,9 +576,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const inputs = getInvitationInputs(selectedInvitation);
|
||||
const outputs = getInvitationOutputs(selectedInvitation);
|
||||
const variables = getInvitationVariables(selectedInvitation);
|
||||
|
||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||
const userEntityId = ownInvitationContext.entityIdentifier;
|
||||
const userRole = ownInvitationContext.roleIdentifier;
|
||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
|
||||
@@ -619,9 +682,17 @@ export function InvitationScreen(): React.ReactElement {
|
||||
key={`input-${idx}`}
|
||||
color={isUserInput ? colors.success : colors.text}
|
||||
>
|
||||
{/* Indicator for whether this is the user's input */}
|
||||
{' '}{isUserInput ? '• ' : '○ '}
|
||||
|
||||
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
|
||||
{/* Input name */}
|
||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
|
||||
{/* Input role */}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
|
||||
{/* Input value */}
|
||||
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
|
||||
</Text>
|
||||
);
|
||||
@@ -645,8 +716,19 @@ export function InvitationScreen(): React.ReactElement {
|
||||
key={`output-${idx}`}
|
||||
color={isUserOutput ? colors.success : colors.text}
|
||||
>
|
||||
{/* Indicator for whether this is the user's output */}
|
||||
{' '}{isUserOutput ? '• ' : '○ '}
|
||||
|
||||
{/* Output name */}
|
||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
|
||||
{/* Output description */}
|
||||
{outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => {
|
||||
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>))}
|
||||
|
||||
{/* Output value */}
|
||||
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryInvitationItem,
|
||||
HistoryUtxoItem,
|
||||
WalletHistoryInput,
|
||||
WalletHistoryItem,
|
||||
WalletHistoryOutput,
|
||||
} from "../services/history.js";
|
||||
|
||||
export type HistoryColorName =
|
||||
@@ -13,10 +14,9 @@ export type HistoryColorName =
|
||||
| "text";
|
||||
|
||||
export type HistoryRowType =
|
||||
| "invitation"
|
||||
| "invitation_input"
|
||||
| "invitation_output"
|
||||
| "utxo";
|
||||
| "history_item"
|
||||
| "history_input"
|
||||
| "history_output";
|
||||
|
||||
export interface HistoryDisplayRow {
|
||||
id: string;
|
||||
@@ -25,8 +25,11 @@ export interface HistoryDisplayRow {
|
||||
description?: string;
|
||||
timestamp?: number;
|
||||
isNested: boolean;
|
||||
utxo?: HistoryUtxoItem;
|
||||
invitation?: HistoryInvitationItem;
|
||||
valueSatoshis?: bigint;
|
||||
reserved?: boolean;
|
||||
input?: WalletHistoryInput;
|
||||
output?: WalletHistoryOutput;
|
||||
item?: WalletHistoryItem;
|
||||
}
|
||||
|
||||
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||
@@ -40,61 +43,68 @@ export function buildHistoryDisplayRows(
|
||||
const rows: HistoryDisplayRow[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === "invitation") {
|
||||
const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown";
|
||||
if (item.source === "utxo") {
|
||||
for (const output of item.outputs) {
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: "invitation",
|
||||
label: item.description,
|
||||
id: `${item.id}-output-${output.id}`,
|
||||
type: "history_output",
|
||||
label: output.outpoint
|
||||
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||
: output.outputIdentifier ?? "Output",
|
||||
description: `${item.template} | ${roles} | ${output.description}`,
|
||||
timestamp: item.createdAtTimestamp,
|
||||
isNested: false,
|
||||
invitation: item,
|
||||
valueSatoshis: output.valueSatoshis,
|
||||
reserved: output.reserved,
|
||||
output,
|
||||
item,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: "history_item",
|
||||
label: `${item.template} | ${roles} | ${item.description}`,
|
||||
description: item.action,
|
||||
timestamp: item.createdAtTimestamp,
|
||||
isNested: false,
|
||||
valueSatoshis: item.valueSatoshis,
|
||||
item,
|
||||
});
|
||||
|
||||
if (item.source !== "invitation") continue;
|
||||
|
||||
for (const input of item.inputs) {
|
||||
const satsPrefix =
|
||||
input.valueSatoshis !== undefined
|
||||
? `${input.valueSatoshis.toLocaleString()} sats `
|
||||
: "";
|
||||
rows.push({
|
||||
id: `${item.id}-input-${input.id}`,
|
||||
type: "invitation_input",
|
||||
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
|
||||
type: "history_input",
|
||||
label: `${input.outpoint.txid}:${input.outpoint.index}`,
|
||||
description: input.description,
|
||||
isNested: true,
|
||||
utxo: input,
|
||||
invitation: item,
|
||||
valueSatoshis: input.valueSatoshis,
|
||||
input,
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
for (const output of item.outputs) {
|
||||
rows.push({
|
||||
id: `${item.id}-output-${output.id}`,
|
||||
type: "invitation_output",
|
||||
label:
|
||||
output.valueSatoshis !== undefined
|
||||
? `${output.valueSatoshis.toLocaleString()} sats`
|
||||
: "Output",
|
||||
type: "history_output",
|
||||
label: output.outpoint
|
||||
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||
: output.outputIdentifier ?? "Output",
|
||||
description: output.description,
|
||||
isNested: true,
|
||||
utxo: output,
|
||||
invitation: item,
|
||||
valueSatoshis: output.valueSatoshis,
|
||||
reserved: output.reserved,
|
||||
output,
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: "utxo",
|
||||
label:
|
||||
item.valueSatoshis !== undefined
|
||||
? `${item.valueSatoshis.toLocaleString()} sats`
|
||||
: "UTXO",
|
||||
description: item.description,
|
||||
isNested: false,
|
||||
utxo: item,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
@@ -106,14 +116,14 @@ export function getHistoryItemColorName(
|
||||
): HistoryColorName {
|
||||
if (isSelected) return "info";
|
||||
switch (row.type) {
|
||||
case "invitation":
|
||||
return "text";
|
||||
case "invitation_input":
|
||||
case "history_input":
|
||||
return "error";
|
||||
case "invitation_output":
|
||||
return "success";
|
||||
case "utxo":
|
||||
return row.utxo?.reserved ? "warning" : "success";
|
||||
case "history_output":
|
||||
return row.reserved ? "warning" : "success";
|
||||
case "history_item":
|
||||
if ((row.valueSatoshis ?? 0n) < 0n) return "error";
|
||||
if ((row.valueSatoshis ?? 0n) > 0n) return "success";
|
||||
return "text";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export class Logger {
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
private readonly token: string,
|
||||
private readonly path: string,
|
||||
) {}
|
||||
|
||||
send(
|
||||
level: "log" | "error" | "warn" | "info",
|
||||
message: string,
|
||||
...metadata: unknown[]
|
||||
) {
|
||||
const data = {
|
||||
level,
|
||||
message: `${this.path}: ${message}`,
|
||||
metadata,
|
||||
};
|
||||
|
||||
fetch(`${this.endpoint}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.token,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send log to logger:", error);
|
||||
});
|
||||
}
|
||||
|
||||
log(message: string, ...metadata: unknown[]) {
|
||||
this.send("log", message, ...metadata);
|
||||
}
|
||||
|
||||
error(message: string, ...metadata: unknown[]) {
|
||||
this.send("error", message, ...metadata);
|
||||
}
|
||||
|
||||
warn(message: string, ...metadata: unknown[]) {
|
||||
this.send("warn", message, ...metadata);
|
||||
}
|
||||
|
||||
info(message: string, ...metadata: unknown[]) {
|
||||
this.send("info", message, ...metadata);
|
||||
}
|
||||
|
||||
child(path: string): Logger {
|
||||
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user