391 lines
9.8 KiB
JavaScript
391 lines
9.8 KiB
JavaScript
#!/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
|
|
* fields <category> <template> - List fields for a template category
|
|
* 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 } 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 { SettingsService } from "../../services/settings.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 settings = new SettingsService(getWalletConfigPath());
|
|
const mnemonicFile = settings.getDefaultMnemonic();
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves a template by name or ID.
|
|
*/
|
|
async function resolveTemplate(
|
|
engine: Awaited<
|
|
ReturnType<
|
|
Awaited<
|
|
ReturnType<typeof getOfflineEngineModule>
|
|
>["tryCreateOfflineEngine"]
|
|
>
|
|
>,
|
|
templateQuery: string,
|
|
) {
|
|
if (!engine) return null;
|
|
|
|
const { generateTemplateIdentifier } = await getEngineModule();
|
|
const templates = await engine.listImportedTemplates();
|
|
|
|
// Try exact match on name or ID
|
|
let template = templates.find(
|
|
(t) =>
|
|
t.name === templateQuery ||
|
|
generateTemplateIdentifier(t) === templateQuery,
|
|
);
|
|
|
|
// Try partial match on name
|
|
if (!template) {
|
|
template = templates.find((t) =>
|
|
t.name?.toLowerCase().includes(templateQuery.toLowerCase()),
|
|
);
|
|
}
|
|
|
|
return template ?? null;
|
|
}
|
|
|
|
/**
|
|
* 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 engine = await tryCreateOfflineEngine(mnemonic, {
|
|
databasePath: getDataDir(),
|
|
databaseFilename: "xo-wallet.db",
|
|
});
|
|
|
|
if (!engine) return;
|
|
|
|
try {
|
|
const template = await resolveTemplate(engine, templateQuery);
|
|
|
|
if (template && template.actions) {
|
|
const actions = Object.keys(template.actions);
|
|
outputCompletions(actions, prefix);
|
|
}
|
|
} finally {
|
|
await engine.stop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
|
|
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
|
|
*/
|
|
async function listFields(
|
|
category: string,
|
|
templateQuery: string,
|
|
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 template = await resolveTemplate(engine, templateQuery);
|
|
if (!template) return;
|
|
|
|
let fields: string[] = [];
|
|
|
|
switch (category) {
|
|
case "action":
|
|
fields = Object.keys(template.actions || {});
|
|
break;
|
|
case "transaction":
|
|
fields = Object.keys(template.transactions || {});
|
|
break;
|
|
case "output":
|
|
fields = Object.keys(template.outputs || {});
|
|
break;
|
|
case "lockingscript":
|
|
fields = Object.keys(template.lockingScripts || {});
|
|
break;
|
|
case "variable":
|
|
fields = Object.keys(template.variables || {});
|
|
break;
|
|
}
|
|
|
|
outputCompletions(fields, 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 "fields":
|
|
// fields <category> <template> [prefix]
|
|
if (arg1 && arg2) {
|
|
await listFields(arg1, arg2, process.argv[5]);
|
|
}
|
|
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);
|
|
});
|