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

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

View File

@@ -0,0 +1,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);
});