Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI.
This commit is contained in:
298
src/cli/autocomplete/complete.ts
Normal file
298
src/cli/autocomplete/complete.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lightweight shell completion helper for xo-cli.
|
||||
*
|
||||
* This script reads from local SQLite only - no network connections.
|
||||
* It's designed to be fast enough for interactive tab completion.
|
||||
*
|
||||
* Usage: xo-complete <context> [args...]
|
||||
*
|
||||
* Contexts:
|
||||
* mnemonics - List mnemonic file names
|
||||
* templates - List template names/IDs
|
||||
* actions <template> - List actions for a template
|
||||
* invitations - List invitation IDs
|
||||
* resources - List UTXO outpoints (txhash:vout)
|
||||
* subcommands <command> - List subcommands for a top-level command
|
||||
*
|
||||
* Output: One completion suggestion per line, suitable for shell completion.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Success (may output zero or more completions)
|
||||
* 1 - Error (no output, fails silently for shell integration)
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { getDataDir, getMnemonicsDir, getWalletConfigPath } from "../../utils/paths.js";
|
||||
import { loadMnemonic } from "../mnemonic.js";
|
||||
import { Storage } from "../../services/storage.js";
|
||||
import { COMMAND_TREE } from "./completions.js";
|
||||
|
||||
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
||||
let _offlineEngineModule: typeof import("./offline-engine.js") | null = null;
|
||||
let _engineModule: typeof import("@xo-cash/engine") | null = null;
|
||||
|
||||
async function getOfflineEngineModule() {
|
||||
if (!_offlineEngineModule) {
|
||||
_offlineEngineModule = await import("./offline-engine.js");
|
||||
}
|
||||
return _offlineEngineModule;
|
||||
}
|
||||
|
||||
async function getEngineModule() {
|
||||
if (!_engineModule) {
|
||||
_engineModule = await import("@xo-cash/engine");
|
||||
}
|
||||
return _engineModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs completions to stdout, one per line.
|
||||
* Optionally filters by a prefix (for partial word completion).
|
||||
*/
|
||||
function outputCompletions(items: readonly string[], prefix?: string): void {
|
||||
const filtered = prefix
|
||||
? items.filter((item) => item.toLowerCase().startsWith(prefix.toLowerCase()))
|
||||
: items;
|
||||
|
||||
for (const item of filtered) {
|
||||
console.log(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists mnemonic file names from the mnemonics directory.
|
||||
* Fast path: no engine needed, just filesystem.
|
||||
*/
|
||||
function listMnemonics(prefix?: string): void {
|
||||
try {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
const files = readdirSync(mnemonicsDir).filter((f) => f.startsWith("mnemonic-"));
|
||||
outputCompletions(files, prefix);
|
||||
} catch {
|
||||
// Silently fail - no completions available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists subcommands for a given top-level command.
|
||||
* Uses the static COMMAND_TREE.
|
||||
*/
|
||||
function listSubcommands(command: string, prefix?: string): void {
|
||||
if (command in COMMAND_TREE) {
|
||||
const subcommands = COMMAND_TREE[command as keyof typeof COMMAND_TREE];
|
||||
outputCompletions(subcommands, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current wallet's mnemonic seed from the saved config.
|
||||
* Returns null if no wallet is configured.
|
||||
*/
|
||||
function getCurrentMnemonic(): string | null {
|
||||
try {
|
||||
const walletConfigPath = getWalletConfigPath();
|
||||
if (!existsSync(walletConfigPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
|
||||
if (!mnemonicFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
return loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists templates from the engine.
|
||||
*/
|
||||
async function listTemplates(prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
const { generateTemplateIdentifier } = await getEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
const templates = await engine.listImportedTemplates();
|
||||
const completions: string[] = [];
|
||||
|
||||
for (const template of templates) {
|
||||
// Add template name (for user-friendly completion)
|
||||
if (template.name) {
|
||||
completions.push(template.name);
|
||||
}
|
||||
// Also add template identifier (for precise matching)
|
||||
const id = generateTemplateIdentifier(template);
|
||||
if (id && !completions.includes(id)) {
|
||||
completions.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
outputCompletions(completions, prefix);
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists actions for a specific template.
|
||||
*/
|
||||
async function listActions(templateQuery: string, prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
const { generateTemplateIdentifier } = await getEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
// Try to resolve the template by name or ID
|
||||
const templates = await engine.listImportedTemplates();
|
||||
let template = templates.find(
|
||||
(t) => t.name === templateQuery || generateTemplateIdentifier(t) === templateQuery,
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
// Try partial match on name
|
||||
template = templates.find((t) =>
|
||||
t.name?.toLowerCase().includes(templateQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
if (template && template.actions) {
|
||||
const actions = Object.keys(template.actions);
|
||||
outputCompletions(actions, prefix);
|
||||
}
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists invitation IDs from the invitation storage.
|
||||
*/
|
||||
async function listInvitations(prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
try {
|
||||
// Compute seed hash to find the right storage namespace
|
||||
const seedHash = createHash("sha256").update(mnemonic).digest("hex");
|
||||
const invitationsDbPath = join(getDataDir(), "xo-invitations.db");
|
||||
|
||||
if (!existsSync(invitationsDbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = await Storage.create(invitationsDbPath);
|
||||
const walletStorage = storage.child(seedHash.slice(0, 8));
|
||||
const invitationsStorage = walletStorage.child("invitations");
|
||||
|
||||
const invitations = await invitationsStorage.all();
|
||||
const ids = invitations.map((inv) => inv.key);
|
||||
|
||||
outputCompletions(ids, prefix);
|
||||
} catch {
|
||||
// Silently fail - no completions available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists UTXO outpoints (resources) from the engine.
|
||||
*/
|
||||
async function listResources(prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
const utxos = await engine.listUnspentOutputsData();
|
||||
const outpoints = utxos.map((u) => `${u.outpointTransactionHash}:${u.outpointIndex}`);
|
||||
outputCompletions(outpoints, prefix);
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const context = process.argv[2];
|
||||
const arg1 = process.argv[3];
|
||||
const arg2 = process.argv[4];
|
||||
|
||||
if (!context) {
|
||||
// No context provided - output nothing
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
switch (context) {
|
||||
case "mnemonics":
|
||||
listMnemonics(arg1);
|
||||
break;
|
||||
|
||||
case "subcommands":
|
||||
if (arg1) {
|
||||
listSubcommands(arg1, arg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case "templates":
|
||||
await listTemplates(arg1);
|
||||
break;
|
||||
|
||||
case "actions":
|
||||
if (arg1) {
|
||||
await listActions(arg1, arg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case "invitations":
|
||||
await listInvitations(arg1);
|
||||
break;
|
||||
|
||||
case "resources":
|
||||
await listResources(arg1);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown context - output nothing
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
// Silently fail for shell integration
|
||||
process.exit(1);
|
||||
});
|
||||
568
src/cli/autocomplete/completions.ts
Normal file
568
src/cli/autocomplete/completions.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Shell completion script generation.
|
||||
*
|
||||
* Defines the CLI command tree in one place and generates
|
||||
* bash/zsh/fish completion scripts from it. Users source the output
|
||||
* in their shell profile for tab-completion support.
|
||||
*
|
||||
* The generated scripts use the `xo-complete` helper binary for dynamic
|
||||
* completions (invitation IDs, template names, resources, etc.).
|
||||
*
|
||||
* Usage:
|
||||
* eval "$(xo-cli completions bash)"
|
||||
* eval "$(xo-cli completions zsh)"
|
||||
* xo-cli completions fish | source
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single source of truth for the CLI command tree.
|
||||
* Each top-level key is a command, and its value is an array of sub-commands.
|
||||
*
|
||||
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
||||
* - mnemonic.ts: create, import, list, expose
|
||||
* - template.ts: import, list, inspect, set-default
|
||||
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
||||
* - resource.ts: list, unreserve, unreserve-all
|
||||
*/
|
||||
|
||||
/** Subcommands for the mnemonic command */
|
||||
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
||||
/** Subcommands for the template command */
|
||||
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
|
||||
/** Subcommands for the invitation command */
|
||||
const INVITATION_SUBS = ["create", "append", "sign", "broadcast", "requirements", "import", "inspect", "list"];
|
||||
/** Subcommands for the resource command */
|
||||
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
||||
/** Subcommands for the completions command */
|
||||
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
||||
|
||||
export const COMMAND_TREE = {
|
||||
mnemonic: MNEMONIC_SUBS,
|
||||
template: TEMPLATE_SUBS,
|
||||
invitation: INVITATION_SUBS,
|
||||
receive: [],
|
||||
resource: RESOURCE_SUBS,
|
||||
help: [],
|
||||
completions: COMPLETIONS_SUBS,
|
||||
} as const;
|
||||
|
||||
/** Global option flags available on every command. */
|
||||
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"];
|
||||
|
||||
/**
|
||||
* Generates a bash completion script with dynamic completion support.
|
||||
* @param binName - The name of the CLI binary (used in the `complete` registration).
|
||||
*/
|
||||
export function generateBashCompletions(binName: string): string {
|
||||
const commands = Object.keys(COMMAND_TREE).join(" ");
|
||||
const options = GLOBAL_OPTIONS.join(" ");
|
||||
const funcName = binName.replace(/-/g, "_");
|
||||
|
||||
return `# bash completion for ${binName}
|
||||
# Add to ~/.bashrc: eval "$(${binName} completions bash)"
|
||||
|
||||
# Find xo-complete in the same directory as xo-cli
|
||||
__xo_complete_bin=""
|
||||
if command -v xo-complete &>/dev/null; then
|
||||
__xo_complete_bin="xo-complete"
|
||||
elif command -v ${binName} &>/dev/null; then
|
||||
__xo_complete_bin="$(dirname "$(command -v ${binName})")/xo-complete"
|
||||
fi
|
||||
|
||||
# Wrapper to call xo-complete helper
|
||||
__xo_complete() {
|
||||
[[ -n "\${__xo_complete_bin}" ]] && "\${__xo_complete_bin}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_${funcName}_completions() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# Handle -m/--mnemonic-file argument (previous word was -m)
|
||||
if [[ "\${prev}" == "-m" || "\${prev}" == "--mnemonic-file" ]]; then
|
||||
local mnemonics
|
||||
mnemonics=$(__xo_complete mnemonics "\${cur}")
|
||||
if [[ -n "\${mnemonics}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${mnemonics}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# If the current word starts with "-", offer option flags
|
||||
if [[ "\${cur}" == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "${options}" -- "\${cur}"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the command and subcommand positions
|
||||
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||
for ((i=1; i < cword; i++)); do
|
||||
if [[ "\${words[i]}" != -* ]]; then
|
||||
if [[ -z "\${cmd}" ]]; then
|
||||
cmd="\${words[i]}"
|
||||
cmd_idx=\$i
|
||||
else
|
||||
subcmd="\${words[i]}"
|
||||
subcmd_idx=\$i
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# No command yet — offer the top-level commands
|
||||
if [[ -z "\${cmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "${commands}" -- "\${cur}"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Handle each command's completion
|
||||
case "\${cmd}" in
|
||||
mnemonic)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "${MNEMONIC_SUBS.join(" ")}" -- "\${cur}"))
|
||||
fi
|
||||
;;
|
||||
|
||||
template)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "${TEMPLATE_SUBS.join(" ")}" -- "\${cur}"))
|
||||
elif [[ "\${subcmd}" == "list" || "\${subcmd}" == "inspect" ]]; then
|
||||
# template list/inspect <category> <template> [field] - category first, then template
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "\${cur}"))
|
||||
elif [[ \$pos -eq 2 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "\${cur}")
|
||||
if [[ -n "\${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${templates}"
|
||||
fi
|
||||
fi
|
||||
elif [[ "\${subcmd}" == "set-default" ]]; then
|
||||
# template set-default <template> <output> <role> - template first
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "\${cur}")
|
||||
if [[ -n "\${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${templates}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
invitation)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "${INVITATION_SUBS.join(" ")}" -- "\${cur}"))
|
||||
else
|
||||
case "\${subcmd}" in
|
||||
create)
|
||||
# invitation create <template> <action> - offer templates then actions
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "\${cur}")
|
||||
if [[ -n "\${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${templates}"
|
||||
fi
|
||||
elif [[ \$pos -eq 2 ]]; then
|
||||
local template_arg="\${words[subcmd_idx + 1]}"
|
||||
local actions
|
||||
actions=$(__xo_complete actions "\${template_arg}" "\${cur}")
|
||||
if [[ -n "\${actions}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${actions}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
append|sign|broadcast|requirements|inspect)
|
||||
# These take an invitation ID
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local invitations
|
||||
invitations=$(__xo_complete invitations "\${cur}")
|
||||
if [[ -n "\${invitations}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${invitations}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
import)
|
||||
# import takes a file path - use default file completion
|
||||
COMPREPLY=($(compgen -f -- "\${cur}"))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
|
||||
resource)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "${RESOURCE_SUBS.join(" ")}" -- "\${cur}"))
|
||||
elif [[ "\${subcmd}" == "unreserve" ]]; then
|
||||
# resource unreserve <txhash:vout> - offer resources
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local resources
|
||||
resources=$(__xo_complete resources "\${cur}")
|
||||
if [[ -n "\${resources}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${resources}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
receive)
|
||||
# receive <template> [output] - offer templates
|
||||
local pos=$((cword - cmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "\${cur}")
|
||||
if [[ -n "\${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("\$line")
|
||||
done <<< "\${templates}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
completions)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "bash zsh fish" -- "\${cur}"))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _${funcName}_completions ${binName}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a zsh completion script with dynamic completion support.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateZshCompletions(binName: string): string {
|
||||
const commands = Object.keys(COMMAND_TREE).join(" ");
|
||||
const options = GLOBAL_OPTIONS.join(" ");
|
||||
const funcName = binName.replace(/-/g, "_");
|
||||
|
||||
return `# zsh completion for ${binName}
|
||||
# Add to ~/.zshrc: eval "$(${binName} completions zsh)"
|
||||
|
||||
# Find xo-complete in the same directory as xo-cli
|
||||
__xo_complete_bin=""
|
||||
if (( \$+commands[xo-complete] )); then
|
||||
__xo_complete_bin="xo-complete"
|
||||
elif (( \$+commands[${binName}] )); then
|
||||
__xo_complete_bin="\${commands[${binName}]:h}/xo-complete"
|
||||
fi
|
||||
|
||||
# Wrapper to call xo-complete helper
|
||||
__xo_complete() {
|
||||
[[ -n "\${__xo_complete_bin}" ]] && "\${__xo_complete_bin}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_${funcName}_completions() {
|
||||
local -a commands
|
||||
commands=(${commands})
|
||||
|
||||
# Handle -m/--mnemonic-file argument (previous word was -m)
|
||||
if [[ "\${words[CURRENT-1]}" == "-m" || "\${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
|
||||
local mnemonics
|
||||
mnemonics=("\${(@f)$(__xo_complete mnemonics "\${words[CURRENT]}")}")
|
||||
if [[ \${#mnemonics[@]} -gt 0 ]]; then
|
||||
compadd -- "\${mnemonics[@]}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# If typing an option flag, complete options
|
||||
if [[ "\${words[\${CURRENT}]}" == -* ]]; then
|
||||
compadd -- ${options}
|
||||
return
|
||||
fi
|
||||
|
||||
# Find the command and subcommand
|
||||
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||
for ((i=2; i < CURRENT; i++)); do
|
||||
if [[ "\${words[i]}" != -* ]]; then
|
||||
if [[ -z "\${cmd}" ]]; then
|
||||
cmd="\${words[i]}"
|
||||
cmd_idx=\$i
|
||||
else
|
||||
subcmd="\${words[i]}"
|
||||
subcmd_idx=\$i
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# No command yet — offer top-level commands
|
||||
if [[ -z "\${cmd}" ]]; then
|
||||
compadd -- \${commands[@]}
|
||||
return
|
||||
fi
|
||||
|
||||
# Handle each command's completion
|
||||
case "\${cmd}" in
|
||||
mnemonic)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
compadd -- ${MNEMONIC_SUBS.join(" ")}
|
||||
fi
|
||||
;;
|
||||
|
||||
template)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
compadd -- ${TEMPLATE_SUBS.join(" ")}
|
||||
elif [[ "\${subcmd}" == "list" || "\${subcmd}" == "inspect" ]]; then
|
||||
# template list/inspect <category> <template> - category first
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
compadd -- action transaction output lockingscript variable
|
||||
elif [[ \$pos -eq 2 ]]; then
|
||||
local templates
|
||||
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
|
||||
if [[ \${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "\${templates[@]}"
|
||||
fi
|
||||
fi
|
||||
elif [[ "\${subcmd}" == "set-default" ]]; then
|
||||
# template set-default <template> <output> <role> - template first
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
|
||||
if [[ \${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "\${templates[@]}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
invitation)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
compadd -- ${INVITATION_SUBS.join(" ")}
|
||||
else
|
||||
case "\${subcmd}" in
|
||||
create)
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
|
||||
if [[ \${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "\${templates[@]}"
|
||||
fi
|
||||
elif [[ \$pos -eq 2 ]]; then
|
||||
local template_arg="\${words[subcmd_idx + 1]}"
|
||||
local actions
|
||||
actions=("\${(@f)$(__xo_complete actions "\${template_arg}" "\${words[CURRENT]}")}")
|
||||
if [[ \${#actions[@]} -gt 0 ]]; then
|
||||
compadd -- "\${actions[@]}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
append|sign|broadcast|requirements|inspect)
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local invitations
|
||||
invitations=("\${(@f)$(__xo_complete invitations "\${words[CURRENT]}")}")
|
||||
if [[ \${#invitations[@]} -gt 0 ]]; then
|
||||
compadd -- "\${invitations[@]}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
import)
|
||||
_files
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
|
||||
resource)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
compadd -- ${RESOURCE_SUBS.join(" ")}
|
||||
elif [[ "\${subcmd}" == "unreserve" ]]; then
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local resources
|
||||
resources=("\${(@f)$(__xo_complete resources "\${words[CURRENT]}")}")
|
||||
if [[ \${#resources[@]} -gt 0 ]]; then
|
||||
compadd -- "\${resources[@]}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
receive)
|
||||
local pos=$((CURRENT - cmd_idx))
|
||||
if [[ \$pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=("\${(@f)$(__xo_complete templates "\${words[CURRENT]}")}")
|
||||
if [[ \${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "\${templates[@]}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
completions)
|
||||
if [[ -z "\${subcmd}" ]]; then
|
||||
compadd -- bash zsh fish
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
compdef _${funcName}_completions ${binName}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a fish completion script with dynamic completion support.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateFishCompletions(binName: string): string {
|
||||
const lines: string[] = [
|
||||
`# fish completion for ${binName}`,
|
||||
`# Add to fish config: ${binName} completions fish | source`,
|
||||
"",
|
||||
`# Disable file completions by default`,
|
||||
`complete -c ${binName} -f`,
|
||||
"",
|
||||
`# Helper function to get dynamic completions`,
|
||||
`# Finds xo-complete in the same directory as ${binName}`,
|
||||
`function __${binName.replace(/-/g, "_")}_complete_dynamic`,
|
||||
` set -l xo_complete_bin ""`,
|
||||
` if command -q xo-complete`,
|
||||
` set xo_complete_bin xo-complete`,
|
||||
` else if command -q ${binName}`,
|
||||
` set xo_complete_bin (dirname (command -s ${binName}))/xo-complete`,
|
||||
` end`,
|
||||
` if test -n "$xo_complete_bin"`,
|
||||
` $xo_complete_bin $argv 2>/dev/null`,
|
||||
` end`,
|
||||
`end`,
|
||||
"",
|
||||
];
|
||||
|
||||
// Global options
|
||||
for (const opt of GLOBAL_OPTIONS) {
|
||||
const isShort = !opt.startsWith("--");
|
||||
const flag = opt.replace(/^-+/, "");
|
||||
if (isShort) {
|
||||
lines.push(`complete -c ${binName} -s ${flag} -d "Option flag"`);
|
||||
} else {
|
||||
lines.push(`complete -c ${binName} -l ${flag} -d "Option flag"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mnemonic file completion for -m
|
||||
lines.push("");
|
||||
lines.push(`# Dynamic mnemonic file completion for -m`);
|
||||
lines.push(`complete -c ${binName} -s m -l mnemonic-file -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic mnemonics)'`);
|
||||
lines.push("");
|
||||
|
||||
// Top-level commands (only when no sub-command is given yet)
|
||||
lines.push(`# Top-level commands`);
|
||||
const commandNames = Object.keys(COMMAND_TREE);
|
||||
for (const cmd of commandNames) {
|
||||
lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Static sub-commands for each command
|
||||
lines.push(`# Static sub-commands`);
|
||||
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
||||
for (const sub of subs) {
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Dynamic completions
|
||||
lines.push(`# Dynamic completions`);
|
||||
lines.push("");
|
||||
|
||||
// invitation create <template> <action>
|
||||
lines.push(`# invitation create: template names`);
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
|
||||
lines.push("");
|
||||
|
||||
// invitation append/sign/broadcast/requirements/inspect: invitation IDs
|
||||
lines.push(`# invitation append/sign/broadcast/requirements/inspect: invitation IDs`);
|
||||
for (const sub of ["append", "sign", "broadcast", "requirements", "inspect"]) {
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from ${sub}; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic invitations)'`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// invitation import: file completion
|
||||
lines.push(`# invitation import: file completion`);
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F`);
|
||||
lines.push("");
|
||||
|
||||
// template list/inspect: category first, then template
|
||||
lines.push(`# template list/inspect: category first (pos 3), then template (pos 4)`);
|
||||
for (const sub of ["list", "inspect"]) {
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from ${sub}; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'`);
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from ${sub}; and test (count (commandline -opc)) -eq 4" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// template set-default: template first
|
||||
lines.push(`# template set-default: template first`);
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
|
||||
lines.push("");
|
||||
|
||||
// resource unreserve: outpoints
|
||||
lines.push(`# resource unreserve: UTXO outpoints`);
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic resources)'`);
|
||||
lines.push("");
|
||||
|
||||
// receive: template names
|
||||
lines.push(`# receive: template names`);
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__${binName.replace(/-/g, "_")}_complete_dynamic templates)'`);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
const generators: Record<ShellType, (binName: string) => string> = {
|
||||
bash: generateBashCompletions,
|
||||
zsh: generateZshCompletions,
|
||||
fish: generateFishCompletions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the `completions` command.
|
||||
* Prints the generated completion script for the given shell to stdout.
|
||||
* @param args - Positional args after "completions", e.g. ["bash"].
|
||||
* @param binName - The CLI binary name to use in the completion script.
|
||||
*/
|
||||
export function handleCompletionsCommand(args: string[], binName: string = "xo-cli"): void {
|
||||
const shell = args[0] as ShellType | undefined;
|
||||
|
||||
if (!shell || !generators[shell]) {
|
||||
const supported = Object.keys(generators).join(", ");
|
||||
console.error(`Usage: ${binName} completions <${supported}>`);
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(` eval "$(${binName} completions bash)" # Add to ~/.bashrc`);
|
||||
console.error(` eval "$(${binName} completions zsh)" # Add to ~/.zshrc`);
|
||||
console.error(` ${binName} completions fish | source # Add to fish config`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(generators[shell](binName));
|
||||
}
|
||||
99
src/cli/autocomplete/offline-engine.ts
Normal file
99
src/cli/autocomplete/offline-engine.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Offline Engine Factory
|
||||
*
|
||||
* Creates a lightweight engine instance that reads from SQLite without any
|
||||
* network connections. Used for fast shell completion queries.
|
||||
*
|
||||
* This bypasses the normal Engine.create() which initializes electrum connections,
|
||||
* and instead constructs the engine directly with an in-memory blockchain provider.
|
||||
*/
|
||||
|
||||
import { BlockchainMonitor, Engine, InMemoryBlockchainProvider } from "@xo-cash/engine";
|
||||
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
|
||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||
import { binToHex, hash256 } from "@bitauth/libauth";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
/**
|
||||
* Options for creating an offline engine.
|
||||
*/
|
||||
export interface OfflineEngineOptions {
|
||||
/** Path to the directory containing SQLite database files. */
|
||||
databasePath: string;
|
||||
/** Filename of the SQLite database (will be prefixed with seed hash). */
|
||||
databaseFilename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an engine instance in offline mode - SQLite only, no network.
|
||||
* Used for fast completion queries where we only need to read local data.
|
||||
*
|
||||
* This is significantly faster than Engine.create() because it:
|
||||
* - Skips electrum application initialization
|
||||
* - Uses an in-memory blockchain provider (no network)
|
||||
* - Skips state sync initialization
|
||||
*
|
||||
* @param seed - The wallet seed phrase
|
||||
* @param options - Database configuration options
|
||||
* @returns An engine instance configured for offline/read-only use
|
||||
*/
|
||||
export async function createOfflineEngine(
|
||||
seed: string,
|
||||
options: OfflineEngineOptions,
|
||||
): Promise<Engine> {
|
||||
// Compute the seed hash (same logic as AppService.create)
|
||||
const seedHash = createHash("sha256").update(seed).digest("hex");
|
||||
const prefixedDatabaseFilename = `${seedHash.slice(0, 8)}-${options.databaseFilename}`;
|
||||
|
||||
// Generate account hash for storage namespace (must match Engine.create which uses hash256)
|
||||
const seedAccountHash = hash256(convertMnemonicToSeedBytes(seed));
|
||||
|
||||
// Create the IndexedDB storage adapter (matches Engine.create default)
|
||||
// Note: IndexedDB in Node uses a shim that stores data in SQLite files with .sqlite extension
|
||||
const storageAdapter = await createStorageAdapter({
|
||||
storageType: StorageType.INDEXEDDB,
|
||||
databasePath: options.databasePath,
|
||||
databaseFilename: prefixedDatabaseFilename,
|
||||
accountHash: binToHex(seedAccountHash),
|
||||
});
|
||||
|
||||
// Initialize the storage adapter
|
||||
await storageAdapter.initialize();
|
||||
|
||||
// Create the state instance
|
||||
const state = new State(storageAdapter);
|
||||
|
||||
// Use in-memory blockchain provider (no network connections)
|
||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||
await blockchainProvider.initialize({
|
||||
applicationIdentifier: "xo-cli-completions",
|
||||
electrumOptions: {},
|
||||
});
|
||||
|
||||
// Create a minimal blockchain monitor
|
||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||
|
||||
// Construct engine directly without state sync
|
||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to create an offline engine, returning null on failure.
|
||||
* Useful for completion scripts where we don't want to crash on errors.
|
||||
*
|
||||
* @param seed - The wallet seed phrase
|
||||
* @param options - Database configuration options
|
||||
* @returns An engine instance or null if creation failed
|
||||
*/
|
||||
export async function tryCreateOfflineEngine(
|
||||
seed: string,
|
||||
options: OfflineEngineOptions,
|
||||
): Promise<Engine | null> {
|
||||
try {
|
||||
return await createOfflineEngine(seed, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user