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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@ Electrum.sqlite
|
|||||||
XO.sqlite
|
XO.sqlite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
@@ -10,5 +11,5 @@ dist/
|
|||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
resolvedTemplate.json
|
resolvedTemplate.json
|
||||||
mnemonic-*
|
mnemonic-*
|
||||||
.xo-cli-wallet
|
|
||||||
inv-*.json
|
inv-*.json
|
||||||
|
.xo-cli-wallet
|
||||||
2237
package-lock.json
generated
2237
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -3,13 +3,19 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"xo-cli": "./dist/cli/index.js",
|
||||||
|
"xo-tui": "./dist/index.js",
|
||||||
|
"xo-complete": "./dist/cli/autocomplete/complete.bundle.js"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc && npm run build:autocomplete",
|
||||||
|
"build:autocomplete": "node scripts/build-autocomplete.mjs",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run --passWithNoTests",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
"nuke": "tsx scripts/rm-dbs.ts",
|
"nuke": "tsx scripts/rm-dbs.ts",
|
||||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
||||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
|
"@xo-cash/crypto": "file:../crypto",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
@@ -44,6 +51,8 @@
|
|||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
|||||||
62
scripts/build-autocomplete.mjs
Normal file
62
scripts/build-autocomplete.mjs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build script for xo-complete autocomplete helper.
|
||||||
|
*
|
||||||
|
* Bundles the autocomplete script with all dependencies into a single file
|
||||||
|
* for faster startup time. This avoids the ~600ms module import overhead
|
||||||
|
* that occurs with dynamic ESM imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = join(__dirname, "..");
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
try {
|
||||||
|
const result = await esbuild.build({
|
||||||
|
entryPoints: [join(projectRoot, "src/cli/autocomplete/complete.ts")],
|
||||||
|
bundle: true,
|
||||||
|
platform: "node",
|
||||||
|
target: "node20",
|
||||||
|
format: "esm",
|
||||||
|
outfile: join(projectRoot, "dist/cli/autocomplete/complete.bundle.js"),
|
||||||
|
|
||||||
|
// Mark modules as external that either have native components or bundling issues
|
||||||
|
// We keep heavy deps external, and also keep offline-engine external so it stays
|
||||||
|
// as a dynamic import (it pulls in the heavy engine dependencies)
|
||||||
|
external: [
|
||||||
|
"better-sqlite3",
|
||||||
|
"fsevents",
|
||||||
|
"@bitauth/libauth",
|
||||||
|
"@xo-cash/*",
|
||||||
|
"@electrum-cash/*",
|
||||||
|
"./offline-engine.js",
|
||||||
|
],
|
||||||
|
|
||||||
|
// Generate source maps for debugging
|
||||||
|
sourcemap: true,
|
||||||
|
|
||||||
|
// Minify for slightly smaller bundle
|
||||||
|
minify: false,
|
||||||
|
|
||||||
|
// Keep names for better error messages
|
||||||
|
keepNames: true,
|
||||||
|
|
||||||
|
// Log build info
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Autocomplete bundle built successfully!");
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
console.warn("Warnings:", result.warnings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Build failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
102
scripts/template-to-json.ts
Normal file
102
scripts/template-to-json.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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();
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
* Simplified to render TUI immediately and let it handle AppService creation.
|
* Simplified to render TUI immediately and let it handle AppService creation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { join } from "node:path";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, type Instance } from "ink";
|
import { render, type Instance } from "ink";
|
||||||
import { App as AppComponent } from "./tui/App.js";
|
import { App as AppComponent } from "./tui/App.js";
|
||||||
|
import { getDataDir } from "./utils/paths.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for the CLI application.
|
* Configuration options for the CLI application.
|
||||||
@@ -46,13 +48,14 @@ export class App {
|
|||||||
* @returns Running App instance
|
* @returns Running App instance
|
||||||
*/
|
*/
|
||||||
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
||||||
|
const dataDir = getDataDir();
|
||||||
// Set default configuration
|
// Set default configuration
|
||||||
const fullConfig: AppConfig = {
|
const fullConfig: AppConfig = {
|
||||||
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
|
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
|
||||||
databasePath: config.databasePath ?? "./",
|
databasePath: config.databasePath ?? dataDir,
|
||||||
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
||||||
invitationStoragePath:
|
invitationStoragePath:
|
||||||
config.invitationStoragePath ?? "./xo-invitations.db",
|
config.invitationStoragePath ?? join(dataDir, "xo-invitations.db"),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Full config:", fullConfig);
|
console.log("Full config:", fullConfig);
|
||||||
|
|||||||
@@ -2,112 +2,135 @@
|
|||||||
|
|
||||||
Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network.
|
Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network.
|
||||||
|
|
||||||
## Getting Started
|
There are two global commands after install:
|
||||||
|
|
||||||
|
- **`xo-cli`** — non-interactive commands (this document).
|
||||||
|
- **`xo-tui`** — interactive terminal wallet UI (Ink/React).
|
||||||
|
|
||||||
|
## Global config directory
|
||||||
|
|
||||||
|
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||||
|
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||||
|
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
|
||||||
|
|
||||||
|
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||||
|
|
||||||
|
## Install (global, from this repo)
|
||||||
|
|
||||||
|
`@xo-cash/*` dependencies use `file:` paths, so publish to npm is a separate step. For local development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run any command via tsx from the cli/ directory
|
cd engine/cli
|
||||||
npx tsx src/cli/index.ts <command> [options]
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm link
|
||||||
|
# xo-cli and xo-tui are now on your PATH
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Development without linking:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd engine/cli
|
||||||
|
npx tsx src/cli/index.ts <command> [options]
|
||||||
|
npx tsx src/index.ts # TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables (TUI / `xo-tui`)
|
||||||
|
|
||||||
|
| Variable | Default |
|
||||||
|
|----------|---------|
|
||||||
|
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||||
|
| `DB_PATH` | `~/.config/xo-cli/data` |
|
||||||
|
| `DB_FILENAME` | `xo-wallet.db` |
|
||||||
|
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
### Wallet Setup
|
### Wallet Setup
|
||||||
|
|
||||||
Before using most commands you need a mnemonic wallet file.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate a new mnemonic and save it to a file
|
# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/)
|
||||||
xo-cli mnemonic create
|
xo-cli mnemonic create
|
||||||
|
|
||||||
# Import an existing mnemonic seed phrase
|
# Import an existing mnemonic seed phrase
|
||||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
||||||
|
|
||||||
# List available mnemonic files in the current directory
|
# List mnemonic basenames (use with -m)
|
||||||
xo-cli mnemonic list
|
xo-cli mnemonic list
|
||||||
```
|
```
|
||||||
|
|
||||||
Mnemonic files are stored in the working directory with the prefix `mnemonic-`.
|
**Options:** `-o <filename>` — basename only; file is written under the global mnemonics directory.
|
||||||
|
|
||||||
### Wallet Persistence
|
### Wallet Persistence
|
||||||
|
|
||||||
The first time you pass `-m <file>`, the choice is saved to `.xo-cli-wallet`. Subsequent commands will use that wallet automatically so you can omit `-m`.
|
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
||||||
|
|
||||||
|
Mnemonic resolution order:
|
||||||
|
|
||||||
|
1. Absolute path, if the file exists
|
||||||
|
2. Path relative to the current working directory
|
||||||
|
3. `~/.config/xo-cli/mnemonics/<basename>`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First run — pass the wallet explicitly
|
|
||||||
xo-cli resource list -m mnemonic-nuclear
|
xo-cli resource list -m mnemonic-nuclear
|
||||||
|
|
||||||
# All future runs remember the wallet
|
|
||||||
xo-cli resource list
|
xo-cli resource list
|
||||||
```
|
```
|
||||||
|
|
||||||
To switch wallets, pass `-m` again with a different file.
|
## Global Options (`xo-cli`)
|
||||||
|
|
||||||
## Global Options
|
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|---|---|
|
|------|-------------|
|
||||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file to use (persisted after first use) |
|
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||||
| `-v`, `--verbose` | Show detailed debug output |
|
| `-v`, `--verbose` | Verbose output |
|
||||||
| `-h`, `--help` | Show help message |
|
| `-h`, `--help` | Help |
|
||||||
|
|
||||||
|
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### `mnemonic` — Manage Wallet Files
|
### `mnemonic` — Manage Wallet Files
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xo-cli mnemonic create # Generate a new mnemonic
|
xo-cli mnemonic create
|
||||||
xo-cli mnemonic import <seed words...> # Import a mnemonic from seed words
|
xo-cli mnemonic import <seed words...>
|
||||||
xo-cli mnemonic list # List mnemonic files in cwd
|
xo-cli mnemonic list
|
||||||
```
|
```
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `-o <filename>` — Custom output filename for the mnemonic file.
|
|
||||||
|
|
||||||
### `template` — Manage Templates
|
### `template` — Manage Templates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xo-cli template import <template-file> # Import a template
|
xo-cli template import <template-file>
|
||||||
xo-cli template list # List imported templates
|
xo-cli template list
|
||||||
xo-cli template list <category> <template-id> # List items in a category
|
xo-cli template list <category> <template-id>
|
||||||
xo-cli template inspect <category> <template-id> <field> # Inspect a specific field
|
xo-cli template inspect <category> <template-id> <field>
|
||||||
xo-cli template set-default <template-file> <output-id> <role> # Set default locking params
|
xo-cli template set-default <template-file> <output-id> <role>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
|
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
|
||||||
|
|
||||||
**Example — discover what a template offers:**
|
Template paths are resolved relative to the **current working directory**.
|
||||||
|
|
||||||
```bash
|
|
||||||
xo-cli template import p2pkh-template.json
|
|
||||||
xo-cli template list
|
|
||||||
xo-cli template list action <template-identifier>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `resource` — Manage UTXOs
|
### `resource` — Manage UTXOs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xo-cli resource list # List unreserved (spendable) UTXOs
|
xo-cli resource list
|
||||||
xo-cli resource list reserved # List UTXOs reserved by invitations
|
xo-cli resource list reserved
|
||||||
xo-cli resource list all # List all UTXOs (reserved + unreserved)
|
xo-cli resource list all
|
||||||
xo-cli resource unreserve <txhash:vout> # Unreserve a specific UTXO
|
xo-cli resource unreserve <txhash:vout>
|
||||||
xo-cli resource unreserve-all # Unreserve all reserved UTXOs
|
xo-cli resource unreserve-all
|
||||||
```
|
```
|
||||||
|
|
||||||
Each UTXO is displayed as `<txhash>:<vout> <sats> <outputId> (height <n>)`.
|
|
||||||
|
|
||||||
### `receive` — Generate a Receiving Address
|
### `receive` — Generate a Receiving Address
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xo-cli receive <template-file> <output-identifier> [role-identifier]
|
xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates a single-use BCH cash address from a template. If the role is omitted, the first available role is used.
|
### `invitation` — Build, Sign & Broadcast
|
||||||
|
|
||||||
```bash
|
|
||||||
xo-cli receive p2pkh-template.json receiveOutput receiver
|
|
||||||
```
|
|
||||||
|
|
||||||
### `invitation` — Build, Sign & Broadcast Transactions
|
|
||||||
|
|
||||||
This is the core command for sending funds. An invitation goes through these stages: **create** → **sign** → **broadcast**.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xo-cli invitation create <template-file> <action-id> [options]
|
xo-cli invitation create <template-file> <action-id> [options]
|
||||||
@@ -119,42 +142,24 @@ xo-cli invitation import <invitation-file>
|
|||||||
xo-cli invitation list
|
xo-cli invitation list
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Create / Append Options
|
**Create / append options:**
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|---|---|
|
|------|-------------|
|
||||||
| `-var-<name> <value>` | Set a template variable (kebab-case → camelCase) |
|
| `-var-<name> <value>` | Template variable |
|
||||||
| `--add-input <txhash:vout>` | Add UTXO input(s), comma-separated |
|
| `--add-input <txhash:vout>` | Inputs (comma-separated) |
|
||||||
| `--add-output <id>` | Override output(s) — **omit to auto-discover from template** |
|
| `--add-output <id>` | Override outputs (omit to auto-discover) |
|
||||||
| `--auto-inputs` | Automatically select UTXOs to cover the required amount |
|
| `--auto-inputs` | Auto-select UTXOs |
|
||||||
| `-role <role>` | Role identifier for variable scoping and bytecode generation |
|
| `-role <role>` | Role for variables / bytecode |
|
||||||
| `--sign` | Auto-sign after all requirements are satisfied |
|
| `--sign` | Auto-sign when complete |
|
||||||
| `--broadcast` | Auto-broadcast after signing (implies `--sign`) |
|
| `--broadcast` | Auto-broadcast (implies `--sign`) |
|
||||||
|
|
||||||
When inputs are provided, a **change output is automatically added** if the input total exceeds the required amount + fee (500 sats). Change below the dust threshold (546 sats) is donated as fee.
|
Invitation JSON files from `create` / `append` are written to the **current working directory**.
|
||||||
|
|
||||||
Outputs are **auto-discovered from the template** when `--add-output` is omitted, so you typically don't need to specify them.
|
### One-command send
|
||||||
|
|
||||||
#### Variable Naming
|
|
||||||
|
|
||||||
Variable flags use kebab-case which maps to the template's camelCase identifiers:
|
|
||||||
|
|
||||||
```
|
|
||||||
-var-transferred-satoshis 4678 → transferredSatoshis = "4678"
|
|
||||||
-var-recipient-lockingscript <addr> → recipientLockingscript = "<addr>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Full Send Flow
|
|
||||||
|
|
||||||
### One-Command Send (Recommended)
|
|
||||||
|
|
||||||
With `--broadcast`, the entire create → sign → broadcast flow happens in a single invocation:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. List UTXOs to pick an input
|
|
||||||
xo-cli resource list
|
xo-cli resource list
|
||||||
|
|
||||||
# 2. Send in one shot
|
|
||||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
||||||
-var-transferred-satoshis 4678 \
|
-var-transferred-satoshis 4678 \
|
||||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
-var-recipient-lockingscript "bitcoincash:qz..." \
|
||||||
@@ -163,72 +168,25 @@ xo-cli invitation create p2pkh-template.json sendSatoshis \
|
|||||||
--broadcast
|
--broadcast
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
### `xo-tui`
|
||||||
|
|
||||||
```
|
|
||||||
Invitation created: abc123.json (abc123)
|
|
||||||
Invitation signed: abc123
|
|
||||||
Transaction broadcast: <txid>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step-by-Step Send
|
|
||||||
|
|
||||||
For multi-party transactions or debugging, you can run each step separately:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Import template (only needed once)
|
xo-tui
|
||||||
xo-cli template import p2pkh-template.json
|
|
||||||
|
|
||||||
# 2. Check available UTXOs
|
|
||||||
xo-cli resource list
|
|
||||||
|
|
||||||
# 3. Create the invitation with variables and inputs
|
|
||||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
|
||||||
-var-transferred-satoshis 4678 \
|
|
||||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
|
||||||
--add-input <txhash>:<vout> \
|
|
||||||
-role sender
|
|
||||||
|
|
||||||
# 4. Sign
|
|
||||||
xo-cli invitation sign <invitation-id>
|
|
||||||
|
|
||||||
# 5. Broadcast
|
|
||||||
xo-cli invitation broadcast <invitation-id>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Auto-Inputs
|
Launches the full-screen wallet UI; uses the same global data directory unless overridden by env vars.
|
||||||
|
|
||||||
Instead of manually selecting UTXOs, let the CLI pick them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
|
||||||
-var-transferred-satoshis 4678 \
|
|
||||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
|
||||||
--auto-inputs \
|
|
||||||
-role sender \
|
|
||||||
--broadcast
|
|
||||||
```
|
|
||||||
|
|
||||||
## Shell Completions
|
## Shell Completions
|
||||||
|
|
||||||
Tab-completion is available for bash, zsh, and fish:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Bash
|
|
||||||
eval "$(xo-cli completions bash)"
|
eval "$(xo-cli completions bash)"
|
||||||
|
|
||||||
# Zsh
|
|
||||||
eval "$(xo-cli completions zsh)"
|
eval "$(xo-cli completions zsh)"
|
||||||
|
|
||||||
# Fish
|
|
||||||
xo-cli completions fish | source
|
xo-cli completions fish | source
|
||||||
```
|
```
|
||||||
|
|
||||||
## File Conventions
|
## File Conventions
|
||||||
|
|
||||||
| File/Pattern | Purpose |
|
| Location | Purpose |
|
||||||
|---|---|
|
|----------|---------|
|
||||||
| `mnemonic-*` | Wallet mnemonic files |
|
| `~/.config/xo-cli/` | Global wallet state |
|
||||||
| `.xo-cli-wallet` | Persisted wallet selection |
|
| `./` (cwd) | Templates, invitation JSON, explicit paths |
|
||||||
| `*.json` | Invitation files (saved by create/append) |
|
|
||||||
| `*.db` | Engine database files |
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,3 @@ export const formatObject = (obj: unknown) => {
|
|||||||
compact: false
|
compact: false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const objectPrint = (obj: unknown) => {
|
|
||||||
console.log(formatObject(obj));
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export type { CommandDependencies } from "./types.js";
|
|
||||||
|
|
||||||
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||||
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||||
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||||
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||||
export { handleResourceCommand, printResourceHelp } from "./resource.js";
|
export { handleResourceCommand, printResourceHelp } from "./resource.js";
|
||||||
|
|
||||||
|
export * from "./types.js";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
import { binToHex, hexToBin } from "@bitauth/libauth";
|
import { binToHex, hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
import { bold, dim, formatObject } from "../cli-utils.js";
|
import { bold, dim, formatObject } from "../cli-utils.js";
|
||||||
import type { CommandDependencies } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
import type { Invitation } from "../../services/invitation.js";
|
import type { Invitation } from "../../services/invitation.js";
|
||||||
import {
|
import {
|
||||||
resolveProvidedLockingBytecodeHex,
|
resolveProvidedLockingBytecodeHex,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
autoSelectGreedyUtxos,
|
autoSelectGreedyUtxos,
|
||||||
} from "../../utils/invitation-flow.js";
|
} from "../../utils/invitation-flow.js";
|
||||||
import { encodeExtendedJson } from "../../utils/ext-json.js";
|
import { encodeExtendedJson } from "../../utils/ext-json.js";
|
||||||
|
import { resolveTemplate } from "../utils.js";
|
||||||
|
|
||||||
const DEFAULT_FEE = 500n;
|
const DEFAULT_FEE = 500n;
|
||||||
const DUST_THRESHOLD = 546n;
|
const DUST_THRESHOLD = 546n;
|
||||||
@@ -89,10 +91,10 @@ async function buildAppendParams(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (inputs.length === 0) {
|
if (inputs.length === 0) {
|
||||||
console.error("No suitable UTXOs found for auto-input selection.");
|
deps.io.err("No suitable UTXOs found for auto-input selection.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Auto-selected ${inputs.length} input(s)`);
|
deps.io.verbose(`Auto-selected ${inputs.length} input(s)`);
|
||||||
} else if (options["addInput"]) {
|
} else if (options["addInput"]) {
|
||||||
inputs = options["addInput"].split(",").map((entry) => {
|
inputs = options["addInput"].split(",").map((entry) => {
|
||||||
const separatorIndex = entry.lastIndexOf(":");
|
const separatorIndex = entry.lastIndexOf(":");
|
||||||
@@ -110,7 +112,7 @@ async function buildAppendParams(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Inputs: ${formatObject(inputs.map(i => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`);
|
deps.io.verbose(`Inputs: ${formatObject(inputs.map(i => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`);
|
||||||
|
|
||||||
// --- Outputs ---
|
// --- Outputs ---
|
||||||
// When --add-output is provided, use those identifiers explicitly.
|
// When --add-output is provided, use those identifiers explicitly.
|
||||||
@@ -133,7 +135,7 @@ async function buildAppendParams(
|
|||||||
}
|
}
|
||||||
outputIdentifiers = [...discovered];
|
outputIdentifiers = [...discovered];
|
||||||
if (outputIdentifiers.length > 0) {
|
if (outputIdentifiers.length > 0) {
|
||||||
deps.verboseLogger(`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`);
|
deps.io.verbose(`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,14 +163,14 @@ async function buildAppendParams(
|
|||||||
const lockingBytecodeHex = providedHex
|
const lockingBytecodeHex = providedHex
|
||||||
?? await invitation.generateLockingBytecode(outputId, roleIdentifier);
|
?? await invitation.generateLockingBytecode(outputId, roleIdentifier);
|
||||||
|
|
||||||
deps.verboseLogger(`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`);
|
deps.io.verbose(`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`);
|
||||||
return {
|
return {
|
||||||
outputIdentifier: outputId,
|
outputIdentifier: outputId,
|
||||||
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
|
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
deps.verboseLogger(`Outputs: ${formatObject(outputs.map(o => o.outputIdentifier))}`);
|
deps.io.verbose(`Outputs: ${formatObject(outputs.map(o => o.outputIdentifier))}`);
|
||||||
|
|
||||||
// --- Auto change output ---
|
// --- Auto change output ---
|
||||||
// When inputs are provided, look up each UTXO's value, compute the
|
// When inputs are provided, look up each UTXO's value, compute the
|
||||||
@@ -182,29 +184,29 @@ async function buildAppendParams(
|
|||||||
const txHashHex = binToHex(input.outpointTransactionHash);
|
const txHashHex = binToHex(input.outpointTransactionHash);
|
||||||
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
|
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
|
||||||
if (!utxo) {
|
if (!utxo) {
|
||||||
console.error(`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`);
|
deps.io.err(`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
totalInputSats += BigInt(utxo.valueSatoshis);
|
totalInputSats += BigInt(utxo.valueSatoshis);
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Total input value: ${totalInputSats} satoshis`);
|
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
|
||||||
|
|
||||||
const requiredSats = await invitation.getSatsOut();
|
const requiredSats = await invitation.getSatsOut();
|
||||||
deps.verboseLogger(`Required output value: ${requiredSats} satoshis`);
|
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
|
||||||
|
|
||||||
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
|
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
|
||||||
deps.verboseLogger(`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`);
|
deps.io.verbose(`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`);
|
||||||
|
|
||||||
if (changeAmount < 0n) {
|
if (changeAmount < 0n) {
|
||||||
console.error(`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`);
|
deps.io.err(`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changeAmount >= DUST_THRESHOLD) {
|
if (changeAmount >= DUST_THRESHOLD) {
|
||||||
outputs.push({ valueSatoshis: changeAmount });
|
outputs.push({ valueSatoshis: changeAmount });
|
||||||
console.log(`Auto-adding change output: ${changeAmount} satoshis`);
|
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
|
||||||
} else if (changeAmount > 0n) {
|
} else if (changeAmount > 0n) {
|
||||||
console.log(`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`);
|
deps.io.out(`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +216,8 @@ async function buildAppendParams(
|
|||||||
/**
|
/**
|
||||||
* Prints the help message for the invitation command
|
* Prints the help message for the invitation command
|
||||||
*/
|
*/
|
||||||
export const printInvitationHelp = () => {
|
export const printInvitationHelp = (io: CommandIO): void => {
|
||||||
console.log(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli invitation <sub-command>
|
${bold("Usage:")} xo-cli invitation <sub-command>
|
||||||
|
|
||||||
@@ -242,82 +244,87 @@ ${bold("Create / Append options:")}
|
|||||||
`);
|
`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result data returned by invitation commands on success.
|
||||||
|
*/
|
||||||
|
export type InvitationCommandResult = {
|
||||||
|
invitationIdentifier?: string;
|
||||||
|
txHash?: string;
|
||||||
|
count?: number;
|
||||||
|
templateName?: string;
|
||||||
|
actionIdentifier?: string;
|
||||||
|
status?: string;
|
||||||
|
entities?: { entityIdentifier: string; roles: (string | undefined)[] }[];
|
||||||
|
inputs?: unknown[];
|
||||||
|
outputs?: unknown[];
|
||||||
|
variables?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the invitation command.
|
* Handles the invitation command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"].
|
* @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"].
|
||||||
* @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }.
|
* @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }.
|
||||||
*/
|
*/
|
||||||
export const handleInvitationCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
export const handleInvitationCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<InvitationCommandResult> => {
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
deps.verboseLogger(`Invitation sub-command: ${subCommand}`);
|
deps.io.verbose(`Invitation sub-command: ${subCommand}`);
|
||||||
|
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.verboseLogger("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.subcommand.missing", "No sub-command provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "create": {
|
case "create": {
|
||||||
const templateFile = args[1];
|
const templateQuery = args[1];
|
||||||
const actionIdentifier = args[2];
|
const actionIdentifier = args[2];
|
||||||
deps.verboseLogger(`Template file: ${templateFile}, action identifier: ${actionIdentifier}`);
|
deps.io.verbose(`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`);
|
||||||
|
|
||||||
if (!templateFile || !actionIdentifier) {
|
if (!templateQuery || !actionIdentifier) {
|
||||||
deps.verboseLogger("No template file or action identifier provided");
|
deps.io.verbose("No template file or action identifier provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.create.arguments_missing", "No template file or action identifier provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and validate the template file path
|
const template = await resolveTemplate(deps, templateQuery);
|
||||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
deps.verboseLogger(`Template path: ${templatePath}`);
|
|
||||||
|
|
||||||
if (!existsSync(templatePath)) {
|
|
||||||
console.error(`Template file does not exist: ${templatePath}`);
|
|
||||||
printInvitationHelp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await readFileSync(templatePath, "utf8");
|
|
||||||
const templateIdentifier = generateTemplateIdentifier(JSON.parse(template));
|
|
||||||
|
|
||||||
// Create the base invitation via the engine
|
|
||||||
const rawInvitation = await deps.app.engine.createInvitation({
|
const rawInvitation = await deps.app.engine.createInvitation({
|
||||||
templateIdentifier: templateIdentifier,
|
templateIdentifier,
|
||||||
actionIdentifier: actionIdentifier,
|
actionIdentifier,
|
||||||
});
|
});
|
||||||
deps.verboseLogger(`XOInvitation created: ${formatObject(rawInvitation)}`);
|
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
|
||||||
|
|
||||||
const invitationInstance = await deps.app.createInvitation(rawInvitation);
|
const invitationInstance = await deps.app.createInvitation(rawInvitation);
|
||||||
deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
deps.io.verbose(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
||||||
|
|
||||||
// Commit variables first so getSatsOut can resolve them for change calc
|
|
||||||
// and resolveProvidedLockingBytecodeHex can read them from commits.
|
|
||||||
const variables = parseVariablesFromOptions(options);
|
const variables = parseVariablesFromOptions(options);
|
||||||
deps.verboseLogger(`Variables: ${formatObject(variables)}`);
|
deps.io.verbose(`Variables: ${formatObject(variables)}`);
|
||||||
if (variables.length > 0) {
|
if (variables.length > 0) {
|
||||||
await invitationInstance.addVariables(variables);
|
await invitationInstance.addVariables(variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse inputs/outputs and calculate change (variables are now committed)
|
|
||||||
const params = await buildAppendParams(deps, invitationInstance, options);
|
const params = await buildAppendParams(deps, invitationInstance, options);
|
||||||
if (!params) return;
|
if (!params) {
|
||||||
|
throw new CommandError("invitation.create.append_params_failed", "Failed to build append parameters");
|
||||||
|
}
|
||||||
|
|
||||||
const { inputs, outputs } = params;
|
const { inputs, outputs } = params;
|
||||||
|
|
||||||
if (inputs.length > 0 || outputs.length > 0) {
|
if (inputs.length > 0 || outputs.length > 0) {
|
||||||
await invitationInstance.append({ inputs, outputs });
|
await invitationInstance.append({ inputs, outputs });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the invitation to a file
|
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
|
||||||
const invitationFilePath = `${process.cwd()}/inv-${invitationInstance.data.invitationIdentifier}.json`;
|
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||||
deps.verboseLogger(`Invitation file path: ${invitationFilePath}`);
|
|
||||||
writeFileSync(invitationFilePath, encodeExtendedJson(invitationInstance.data, 2));
|
writeFileSync(invitationFilePath, encodeExtendedJson(invitationInstance.data, 2));
|
||||||
console.log(`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`);
|
deps.io.out(`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`);
|
||||||
|
|
||||||
// Check remaining requirements
|
|
||||||
const missingRequirements = await invitationInstance.getMissingRequirements();
|
const missingRequirements = await invitationInstance.getMissingRequirements();
|
||||||
const hasMissing =
|
const hasMissing =
|
||||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||||
@@ -326,76 +333,74 @@ export const handleInvitationCommand = async (deps: CommandDependencies, args: s
|
|||||||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0);
|
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0);
|
||||||
|
|
||||||
if (hasMissing) {
|
if (hasMissing) {
|
||||||
console.log(`\n${bold("Remaining requirements:")}`);
|
deps.io.out(`\n${bold("Remaining requirements:")}`);
|
||||||
console.log(formatObject(missingRequirements));
|
deps.io.out(formatObject(missingRequirements));
|
||||||
} else {
|
} else {
|
||||||
// --broadcast implies --sign
|
|
||||||
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true";
|
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true";
|
||||||
const shouldBroadcast = options["broadcast"] === "true";
|
const shouldBroadcast = options["broadcast"] === "true";
|
||||||
|
|
||||||
if (shouldSign) {
|
if (shouldSign) {
|
||||||
await invitationInstance.sign();
|
await invitationInstance.sign();
|
||||||
console.log(`Invitation signed: ${invitationInstance.data.invitationIdentifier}`);
|
deps.io.out(`Invitation signed: ${invitationInstance.data.invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldBroadcast) {
|
if (shouldBroadcast) {
|
||||||
const txHash = await invitationInstance.broadcast();
|
const txHash = await invitationInstance.broadcast();
|
||||||
console.log(`Transaction broadcast: ${bold(txHash)}`);
|
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
|
||||||
} else if (!shouldSign) {
|
} else if (!shouldSign) {
|
||||||
console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`);
|
deps.io.out(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
return { invitationIdentifier: invitationInstance.data.invitationIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "append": {
|
case "append": {
|
||||||
const invitationIdentifier = args[1];
|
const invitationIdentifier = args[1];
|
||||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||||
|
|
||||||
if (!invitationIdentifier) {
|
if (!invitationIdentifier) {
|
||||||
deps.verboseLogger("No invitation identifier provided");
|
deps.io.verbose("No invitation identifier provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.append.identifier_missing", "No invitation identifier provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the invitation by identifier
|
const invitation = deps.app.invitations.find(
|
||||||
const invitation = deps.app.invitations.find(inv => inv.data.invitationIdentifier === invitationIdentifier);
|
(inv) => inv.data.invitationIdentifier === invitationIdentifier,
|
||||||
|
);
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||||
return;
|
throw new CommandError("invitation.append.not_found", `Invitation not found: ${invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||||
|
|
||||||
// Commit variables first so getSatsOut can resolve them for change calc
|
|
||||||
const variables = parseVariablesFromOptions(options);
|
const variables = parseVariablesFromOptions(options);
|
||||||
deps.verboseLogger(`Variables to append: ${formatObject(variables)}`);
|
deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
|
||||||
if (variables.length > 0) {
|
if (variables.length > 0) {
|
||||||
await invitation.addVariables(variables);
|
await invitation.addVariables(variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse inputs/outputs and calculate change (variables are now committed)
|
|
||||||
const params = await buildAppendParams(deps, invitation, options);
|
const params = await buildAppendParams(deps, invitation, options);
|
||||||
if (!params) return;
|
if (!params) {
|
||||||
|
throw new CommandError("invitation.append.params_failed", "Failed to build append parameters");
|
||||||
|
}
|
||||||
|
|
||||||
const { inputs, outputs } = params;
|
const { inputs, outputs } = params;
|
||||||
|
|
||||||
if (variables.length === 0 && inputs.length === 0 && outputs.length === 0) {
|
if (variables.length === 0 && inputs.length === 0 && outputs.length === 0) {
|
||||||
console.error("Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).");
|
const error = "Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).";
|
||||||
return;
|
deps.io.err(error);
|
||||||
|
throw new CommandError("invitation.append.empty", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputs.length > 0 || outputs.length > 0) {
|
if (inputs.length > 0 || outputs.length > 0) {
|
||||||
await invitation.append({ inputs, outputs });
|
await invitation.append({ inputs, outputs });
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Invitation appended: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
|
||||||
console.log(`Invitation appended: ${invitationIdentifier}`);
|
deps.io.out(`Invitation appended: ${invitationIdentifier}`);
|
||||||
|
|
||||||
// Save the updated invitation to a file
|
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
|
||||||
const invitationFilePath = `${process.cwd()}/inv-${invitation.data.invitationIdentifier}.json`;
|
|
||||||
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
|
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
|
||||||
console.log(`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`);
|
deps.io.out(`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`);
|
||||||
|
|
||||||
// Check remaining requirements
|
|
||||||
const missingRequirements = await invitation.getMissingRequirements();
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
const hasMissing =
|
const hasMissing =
|
||||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||||
@@ -404,138 +409,189 @@ export const handleInvitationCommand = async (deps: CommandDependencies, args: s
|
|||||||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0);
|
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0);
|
||||||
|
|
||||||
if (hasMissing) {
|
if (hasMissing) {
|
||||||
console.log(`\n${bold("Remaining requirements:")}`);
|
deps.io.out(`\n${bold("Remaining requirements:")}`);
|
||||||
console.log(formatObject(missingRequirements));
|
deps.io.out(formatObject(missingRequirements));
|
||||||
} else {
|
} else {
|
||||||
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true";
|
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true";
|
||||||
const shouldBroadcast = options["broadcast"] === "true";
|
const shouldBroadcast = options["broadcast"] === "true";
|
||||||
|
|
||||||
if (shouldSign) {
|
if (shouldSign) {
|
||||||
await invitation.sign();
|
await invitation.sign();
|
||||||
console.log(`Invitation signed: ${invitationIdentifier}`);
|
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldBroadcast) {
|
if (shouldBroadcast) {
|
||||||
const txHash = await invitation.broadcast();
|
const txHash = await invitation.broadcast();
|
||||||
console.log(`Transaction broadcast: ${bold(txHash)}`);
|
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
|
||||||
} else if (!shouldSign) {
|
} else if (!shouldSign) {
|
||||||
console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`);
|
deps.io.out(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
return { invitationIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "sign": {
|
case "sign": {
|
||||||
const invitationIdentifier = args[1];
|
const invitationIdentifier = args[1];
|
||||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||||
|
|
||||||
// Check if the invitation identifier is provided
|
|
||||||
if (!invitationIdentifier) {
|
if (!invitationIdentifier) {
|
||||||
deps.verboseLogger("No invitation identifier provided");
|
deps.io.verbose("No invitation identifier provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.sign.identifier_missing", "No invitation identifier provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the invitation by identifier
|
const invitation = deps.app.invitations.find(
|
||||||
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier);
|
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
|
||||||
|
);
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||||
return;
|
throw new CommandError("invitation.sign.not_found", `Invitation not found: ${invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||||
|
|
||||||
// Sign the invitation
|
|
||||||
await invitation.sign();
|
await invitation.sign();
|
||||||
deps.verboseLogger(`Invitation signed: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
|
||||||
console.log(`Invitation signed: ${invitationIdentifier}`);
|
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
|
||||||
break;
|
return { invitationIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "broadcast": {
|
case "broadcast": {
|
||||||
const invitationIdentifier = args[1];
|
const invitationIdentifier = args[1];
|
||||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||||
|
|
||||||
// Check if the invitation identifier is provided
|
|
||||||
if (!invitationIdentifier) {
|
if (!invitationIdentifier) {
|
||||||
deps.verboseLogger("No invitation identifier provided");
|
deps.io.verbose("No invitation identifier provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.broadcast.identifier_missing", "No invitation identifier provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the invitation by identifier
|
const invitation = deps.app.invitations.find(
|
||||||
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier);
|
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
|
||||||
|
);
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||||
return;
|
throw new CommandError("invitation.broadcast.not_found", `Invitation not found: ${invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||||
|
|
||||||
// Broadcast the invitation
|
|
||||||
const txHash = await invitation.broadcast();
|
const txHash = await invitation.broadcast();
|
||||||
deps.verboseLogger(`Invitation broadcasted: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation broadcasted: ${formatObject(invitation.data)}`);
|
||||||
console.log(`Transaction broadcast: ${bold(txHash)}`);
|
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
|
||||||
break;
|
return { invitationIdentifier, txHash };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "requirements": {
|
case "requirements": {
|
||||||
const invitationIdentifier = args[1];
|
const invitationIdentifier = args[1];
|
||||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||||
|
|
||||||
// Check if the invitation identifier is provided
|
|
||||||
if (!invitationIdentifier) {
|
if (!invitationIdentifier) {
|
||||||
deps.verboseLogger("No invitation identifier provided");
|
deps.io.verbose("No invitation identifier provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.requirements.identifier_missing", "No invitation identifier provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the invitation by identifier
|
const invitation = deps.app.invitations.find(
|
||||||
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier);
|
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
|
||||||
|
);
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||||
return;
|
throw new CommandError("invitation.requirements.not_found", `Invitation not found: ${invitationIdentifier}`);
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||||
|
|
||||||
// List the requirements for the invitation
|
|
||||||
const requirements = await deps.app.engine.listRequirements(invitation.data);
|
const requirements = await deps.app.engine.listRequirements(invitation.data);
|
||||||
deps.verboseLogger(`Requirements: ${formatObject(requirements)}`);
|
deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
|
||||||
console.log(formatObject(requirements));
|
deps.io.out(formatObject(requirements));
|
||||||
break;
|
return { invitationIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "inspect": {
|
||||||
|
const invitationFilePath = args[1];
|
||||||
|
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||||
|
|
||||||
|
if (!invitationFilePath) {
|
||||||
|
deps.io.verbose("No invitation file provided");
|
||||||
|
printInvitationHelp(deps.io);
|
||||||
|
throw new CommandError("invitation.inspect.file_missing", "No invitation file provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
||||||
|
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
||||||
|
|
||||||
|
const invitation = JSON.parse(invitationFile);
|
||||||
|
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
||||||
|
|
||||||
|
const invitationInstance = await deps.app.createInvitation(invitation);
|
||||||
|
deps.io.verbose(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
||||||
|
|
||||||
|
const template = await deps.app.engine.getTemplate(invitationInstance.data.templateIdentifier);
|
||||||
|
|
||||||
|
const action = template?.actions[invitationInstance.data.actionIdentifier];
|
||||||
|
deps.io.verbose(`Action: ${formatObject(action)}`);
|
||||||
|
if (!action) {
|
||||||
|
deps.io.err(`Action not found: ${invitationInstance.data.actionIdentifier}`);
|
||||||
|
throw new CommandError("invitation.inspect.action_not_found", `Action not found: ${invitationInstance.data.actionIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = invitationInstance.status;
|
||||||
|
deps.io.verbose(`Status: ${status}`);
|
||||||
|
|
||||||
|
const entities = Array.from(new Set(invitationInstance.data.commits.map((commit) => commit.entityIdentifier)));
|
||||||
|
deps.io.verbose(`Entities: ${formatObject(entities)}`);
|
||||||
|
|
||||||
|
const entitiesWithRoles = entities.map((entity) => {
|
||||||
|
return {
|
||||||
|
entityIdentifier: entity,
|
||||||
|
roles: invitationInstance.data.commits.filter((commit) => commit.entityIdentifier === entity).map((commit) => {
|
||||||
|
return [
|
||||||
|
...(commit.data.inputs?.map((input) => input.roleIdentifier) ?? []),
|
||||||
|
...(commit.data.outputs?.map((output) => output.roleIdentifier) ?? []),
|
||||||
|
...(commit.data.variables?.map((variable) => variable.roleIdentifier) ?? []),
|
||||||
|
];
|
||||||
|
}).flat().filter((role) => role !== undefined)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputs = invitationInstance.data.commits.flatMap((commit) => commit.data.inputs ?? []);
|
||||||
|
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
|
||||||
|
|
||||||
|
const outputs = invitationInstance.data.commits.flatMap((commit) => commit.data.outputs ?? []);
|
||||||
|
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
|
||||||
|
|
||||||
|
const variables = invitationInstance.data.commits.flatMap((commit) => commit.data.variables ?? []);
|
||||||
|
deps.io.verbose(`Variables: ${formatObject(variables)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateName: template?.name ?? "Unknown",
|
||||||
|
actionIdentifier: invitationInstance.data.actionIdentifier,
|
||||||
|
status: status,
|
||||||
|
entities: entitiesWithRoles,
|
||||||
|
inputs: inputs,
|
||||||
|
outputs: outputs,
|
||||||
|
variables: variables,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "import": {
|
case "import": {
|
||||||
// Check if the invitation file path is provided
|
|
||||||
const invitationFilePath = args[1];
|
const invitationFilePath = args[1];
|
||||||
deps.verboseLogger(`Invitation file path: ${invitationFilePath}`);
|
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||||
|
|
||||||
// Check if the invitation file path is provided
|
|
||||||
if (!invitationFilePath) {
|
if (!invitationFilePath) {
|
||||||
deps.verboseLogger("No invitation file provided");
|
deps.io.verbose("No invitation file provided");
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.import.file_missing", "No invitation file provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the invitation file
|
|
||||||
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
||||||
deps.verboseLogger(`Invitation file: ${invitationFile}`);
|
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
||||||
|
|
||||||
// Parse the invitation file
|
|
||||||
const invitation = JSON.parse(invitationFile);
|
const invitation = JSON.parse(invitationFile);
|
||||||
deps.verboseLogger(`Invitation: ${formatObject(invitation)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
||||||
|
|
||||||
// Create the invitation (internal to XO Engine)
|
|
||||||
const xoInvitation = await deps.app.engine.createInvitation(invitation);
|
const xoInvitation = await deps.app.engine.createInvitation(invitation);
|
||||||
|
|
||||||
// Create the invitation instance
|
|
||||||
const invitationInstance = await deps.app.createInvitation(xoInvitation);
|
const invitationInstance = await deps.app.createInvitation(xoInvitation);
|
||||||
deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
deps.io.verbose(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
||||||
break;
|
return { invitationIdentifier: invitationInstance.data.invitationIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
// List all invitations
|
const invitations = await Promise.all(
|
||||||
const invitations = await Promise.all(deps.app.invitations.map(async invitation => {
|
deps.app.invitations.map(async (invitation) => {
|
||||||
// Get the template for the invitation so we can display the name of it
|
|
||||||
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier);
|
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
return {
|
return {
|
||||||
invitationIdentifier: invitation.data.invitationIdentifier,
|
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||||
@@ -543,20 +599,22 @@ export const handleInvitationCommand = async (deps: CommandDependencies, args: s
|
|||||||
actionIdentifier: invitation.data.actionIdentifier,
|
actionIdentifier: invitation.data.actionIdentifier,
|
||||||
templateName: template?.name ?? "Unknown",
|
templateName: template?.name ?? "Unknown",
|
||||||
status: invitation.status,
|
status: invitation.status,
|
||||||
roleIdentifier: 'TODO: Get role identifier',
|
roleIdentifier: "TODO: Get role identifier",
|
||||||
};
|
};
|
||||||
}));
|
}),
|
||||||
deps.verboseLogger(`Invitations: ${formatObject(invitations)}`);
|
);
|
||||||
|
deps.io.verbose(`Invitations: ${formatObject(invitations)}`);
|
||||||
// Format the invitations for display and print it
|
const formattedInvitations = invitations.map(
|
||||||
const formattedInvitations = invitations.map(invitation => `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`);
|
(invitation) =>
|
||||||
console.log(formattedInvitations.join('\n'));
|
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
|
||||||
break;
|
);
|
||||||
|
deps.io.out(formattedInvitations.join("\n"));
|
||||||
|
return { count: invitations.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
deps.verboseLogger(`Unknown invitation sub-command: ${subCommand}`);
|
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
|
||||||
printInvitationHelp();
|
printInvitationHelp(deps.io);
|
||||||
return;
|
throw new CommandError("invitation.subcommand.unknown", `Unknown invitation sub-command: ${subCommand}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { bold, dim } from "../cli-utils.js";
|
import { bold, dim } from "../cli-utils.js";
|
||||||
import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed } from "../mnemonic.js";
|
import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed, loadMnemonic } from "../mnemonic.js";
|
||||||
import type { CommandDependencies } from "./types.js";
|
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the mnemonic command
|
* Prints the help message for the mnemonic command
|
||||||
*/
|
*/
|
||||||
export const printMnemonicHelp = () => {
|
export const printMnemonicHelp = (io: CommandIO): void => {
|
||||||
console.log(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
||||||
|
|
||||||
@@ -22,52 +23,79 @@ ${bold("Options:")}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the mnemonic command.
|
* Handles the mnemonic command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...].
|
* @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...].
|
||||||
* @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }.
|
* @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }.
|
||||||
*/
|
*/
|
||||||
export const handleMnemonicCommand = async (deps: Omit<CommandDependencies, "app">, args: string[], options: Record<string, string>): Promise<void> => {
|
export const handleMnemonicCommand = async (
|
||||||
|
deps: BaseCommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
|
const { mnemonicsDir } = deps.paths;
|
||||||
|
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.verboseLogger("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printMnemonicHelp();
|
printMnemonicHelp(deps.io);
|
||||||
return;
|
throw new CommandError("mnemonic.subcommand.missing", "No sub-command provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "create": {
|
case "create": {
|
||||||
const mnemonicSeed = createMnemonicSeed();
|
const mnemonicSeed = createMnemonicSeed();
|
||||||
await createMnemonicFile(mnemonicSeed, options["output"]);
|
const savedAs = createMnemonicFile(mnemonicsDir, mnemonicSeed, options["output"]);
|
||||||
|
|
||||||
console.log(`Mnemonic file created: ${options["output"]} (${mnemonicSeed})`);
|
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
|
||||||
break;
|
return { savedAs };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "import": {
|
case "import": {
|
||||||
// The mnemonic seed words are all the positional args after the sub-command
|
|
||||||
const mnemonicSeed = args.slice(1).join(" ");
|
const mnemonicSeed = args.slice(1).join(" ");
|
||||||
|
|
||||||
if (!mnemonicSeed) {
|
if (!mnemonicSeed) {
|
||||||
deps.verboseLogger("No mnemonic seed provided");
|
deps.io.verbose("No mnemonic seed provided");
|
||||||
printMnemonicHelp();
|
printMnemonicHelp(deps.io);
|
||||||
return;
|
throw new CommandError("mnemonic.import.seed_missing", "No mnemonic seed provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.verboseLogger(`Mnemonic seed: ${mnemonicSeed}`);
|
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
|
||||||
await createMnemonicFile(mnemonicSeed, options["output"]);
|
const savedAs = createMnemonicFile(mnemonicsDir, mnemonicSeed, options["output"]);
|
||||||
break;
|
deps.io.out(`Mnemonic file created: ${savedAs}`);
|
||||||
|
return { savedAs };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
const mnemonicFiles = listMnemonicFiles();
|
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
|
||||||
console.log(mnemonicFiles.join('\n'));
|
deps.io.out(mnemonicFiles.join('\n'));
|
||||||
break;
|
return { count: mnemonicFiles.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "expose": {
|
||||||
|
const mnemonicFile = args[1];
|
||||||
|
|
||||||
|
if (!mnemonicFile) {
|
||||||
|
deps.io.verbose("No mnemonic file provided");
|
||||||
|
printMnemonicHelp(deps.io);
|
||||||
|
throw new CommandError("mnemonic.expose.file_missing", "No mnemonic file provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||||
|
deps.io.out(mnemonic);
|
||||||
|
return { mnemonic };
|
||||||
|
} catch (error) {
|
||||||
|
throw new CommandError(
|
||||||
|
"mnemonic.expose.file_not_found",
|
||||||
|
`Mnemonic file not found: ${mnemonicFile}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.error(`Unknown sub-command: ${subCommand}`);
|
deps.io.err(`Unknown sub-command: ${subCommand}`);
|
||||||
printMnemonicHelp();
|
printMnemonicHelp(deps.io);
|
||||||
return;
|
throw new CommandError("mnemonic.subcommand.unknown", `Unknown sub-command: ${subCommand}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { existsSync, readFileSync } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
||||||
|
|
||||||
import { bold, dim } from "../cli-utils.js";
|
import { bold, dim } from "../cli-utils.js";
|
||||||
import type { CommandDependencies } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
|
import { resolveTemplate } from "../utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the receive command
|
* Prints the help message for the receive command
|
||||||
*/
|
*/
|
||||||
export const printReceiveHelp = () => {
|
export const printReceiveHelp = (io: CommandIO): void => {
|
||||||
console.log(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||||
|
|
||||||
@@ -29,36 +30,35 @@ ${bold("Options:")}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Command which creates a single-use address/lockingScript for a given template and role.
|
* Command which creates a single-use address/lockingScript for a given template and role.
|
||||||
|
* Throws CommandError on failure, returns address data on success.
|
||||||
|
*
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
|
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
|
||||||
* @param options - Parsed option flags.
|
* @param options - Parsed option flags.
|
||||||
|
* @returns The address data.
|
||||||
|
* @throws CommandError if the command fails.
|
||||||
*/
|
*/
|
||||||
export const handleReceiveCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
export const handleReceiveCommand = async (
|
||||||
const templateFile = args[0];
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<{ address: string }> => {
|
||||||
|
const templateQuery = args[0];
|
||||||
const outputIdentifier = args[1];
|
const outputIdentifier = args[1];
|
||||||
const roleIdentifier = args[2];
|
const roleIdentifier = args[2];
|
||||||
|
|
||||||
deps.verboseLogger(`Receive args - template: ${templateFile}, output: ${outputIdentifier}, role: ${roleIdentifier}`);
|
deps.io.verbose(`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`);
|
||||||
|
|
||||||
if (!templateFile || !outputIdentifier) {
|
if (!templateQuery || !outputIdentifier) {
|
||||||
deps.verboseLogger("Missing required arguments");
|
deps.io.verbose("Missing required arguments");
|
||||||
printReceiveHelp();
|
printReceiveHelp(deps.io);
|
||||||
return;
|
throw new CommandError("receive.arguments.missing", "Missing required arguments");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and read the template file
|
// Resolve and read the template file
|
||||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
const template = await resolveTemplate(deps, templateQuery);
|
||||||
deps.verboseLogger(`Template path: ${templatePath}`);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
|
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||||
if (!existsSync(templatePath)) {
|
|
||||||
console.error(`Template file does not exist: ${templatePath}`);
|
|
||||||
printReceiveHelp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = readFileSync(templatePath, "utf8");
|
|
||||||
const templateIdentifier = generateTemplateIdentifier(JSON.parse(template));
|
|
||||||
deps.verboseLogger(`Template identifier: ${templateIdentifier}`);
|
|
||||||
|
|
||||||
// Generate the locking bytecode (returned as a hex string)
|
// Generate the locking bytecode (returned as a hex string)
|
||||||
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
|
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
|
||||||
@@ -66,15 +66,16 @@ export const handleReceiveCommand = async (deps: CommandDependencies, args: stri
|
|||||||
outputIdentifier,
|
outputIdentifier,
|
||||||
roleIdentifier,
|
roleIdentifier,
|
||||||
);
|
);
|
||||||
deps.verboseLogger(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
||||||
|
|
||||||
// Convert the locking bytecode to a BCH cash address
|
// Convert the locking bytecode to a BCH cash address
|
||||||
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
|
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
|
||||||
|
|
||||||
if (typeof result === 'string') {
|
if (typeof result === 'string') {
|
||||||
console.error(`Failed to encode address: ${result}`);
|
deps.io.err(`Failed to encode address: ${result}`);
|
||||||
return;
|
throw new CommandError("receive.address.encode_failed", `Failed to encode address: ${result}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(result.address);
|
deps.io.out(result.address);
|
||||||
|
return { address: result.address };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { hexToBin } from "@bitauth/libauth";
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
import { bold, dim } from "../cli-utils.js";
|
import { bold, dim } from "../cli-utils.js";
|
||||||
import type { CommandDependencies } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import type { UnspentOutputData } from "@xo-cash/state";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the resource command.
|
* Prints the help message for the resource command.
|
||||||
*/
|
*/
|
||||||
export const printResourceHelp = () => {
|
export const printResourceHelp = (io: CommandIO): void => {
|
||||||
console.log(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli resource <sub-command>
|
${bold("Usage:")} xo-cli resource <sub-command>
|
||||||
|
|
||||||
@@ -23,14 +25,14 @@ ${bold("Sub-commands:")}
|
|||||||
/**
|
/**
|
||||||
* Formats a single UTXO for display, optionally including reservation info.
|
* Formats a single UTXO for display, optionally including reservation info.
|
||||||
*/
|
*/
|
||||||
function formatResource(resource: { outpointTransactionHash: string; outpointIndex: number; valueSatoshis: number; outputIdentifier: string; minedAtHeight: number; reserved?: boolean; invitationIdentifier?: string }, showReserved = false): string {
|
function formatResource(resource: UnspentOutputData, showReserved = false): string {
|
||||||
const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`);
|
const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`);
|
||||||
const value = dim(`${resource.valueSatoshis} sats`);
|
const value = dim(`${resource.valueSatoshis} sats`);
|
||||||
const output = dim(resource.outputIdentifier);
|
const output = dim(resource.outputIdentifier);
|
||||||
const height = dim(`(height ${resource.minedAtHeight})`);
|
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||||
|
|
||||||
if (showReserved && resource.reserved) {
|
if (showReserved && resource.reservedBy) {
|
||||||
const inv = dim(`reserved for ${resource.invitationIdentifier}`);
|
const inv = dim(`reserved for ${resource.reservedBy}`);
|
||||||
return `${outpoint} ${value} ${output} ${height} ${inv}`;
|
return `${outpoint} ${value} ${output} ${height} ${inv}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,107 +41,110 @@ function formatResource(resource: { outpointTransactionHash: string; outpointInd
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the resource command.
|
* Handles the resource command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["list"].
|
* @param args - Positional args after the command name, e.g. ["list"].
|
||||||
* @param options - Parsed option flags.
|
* @param options - Parsed option flags.
|
||||||
*/
|
*/
|
||||||
export const handleResourceCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
export const handleResourceCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<{ count?: number }> => {
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
deps.verboseLogger(`Resource sub-command: ${subCommand}`);
|
deps.io.verbose(`Resource sub-command: ${subCommand}`);
|
||||||
|
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.verboseLogger("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printResourceHelp();
|
printResourceHelp(deps.io);
|
||||||
return;
|
throw new CommandError("resource.subcommand.missing", "No sub-command provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "list": {
|
case "list": {
|
||||||
const qualifier = args[1]; // "reserved", "all", or undefined (defaults to unreserved)
|
const qualifier = args[1];
|
||||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
let filtered;
|
let filtered;
|
||||||
if (qualifier === "reserved") {
|
if (qualifier === "reserved") {
|
||||||
filtered = allResources.filter((r) => r.reserved);
|
filtered = allResources.filter((r) => r.reservedBy);
|
||||||
} else if (qualifier === "all") {
|
} else if (qualifier === "all") {
|
||||||
filtered = allResources;
|
filtered = allResources;
|
||||||
} else {
|
} else {
|
||||||
// Default: show only unreserved (selectable) resources
|
filtered = allResources.filter((r) => !r.reservedBy);
|
||||||
filtered = allResources.filter((r) => !r.reserved);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
console.log(dim("No resources found."));
|
deps.io.out(dim("No resources found."));
|
||||||
return;
|
return { count: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const showReserved = qualifier === "all" || qualifier === "reserved";
|
const showReserved = qualifier === "all" || qualifier === "reserved";
|
||||||
const formattedResources = filtered.map((r) => formatResource(r, showReserved));
|
const formattedResources = filtered.map((r) => formatResource(r, showReserved));
|
||||||
console.log(formattedResources.join("\n"));
|
deps.io.out(formattedResources.join("\n"));
|
||||||
console.log(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`);
|
deps.io.out(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`);
|
||||||
console.log(`Total resources: ${filtered.length}`);
|
deps.io.out(`Total resources: ${filtered.length}`);
|
||||||
break;
|
return { count: filtered.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "unreserve": {
|
case "unreserve": {
|
||||||
const outpointArg = args[1];
|
const outpointArg = args[1];
|
||||||
if (!outpointArg) {
|
if (!outpointArg) {
|
||||||
console.error("Please provide a UTXO in <txhash>:<vout> format.");
|
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
|
||||||
printResourceHelp();
|
printResourceHelp(deps.io);
|
||||||
return;
|
throw new CommandError("resource.unreserve.outpoint_missing", "Please provide a UTXO in <txhash>:<vout> format.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const separatorIndex = outpointArg.lastIndexOf(":");
|
const separatorIndex = outpointArg.lastIndexOf(":");
|
||||||
if (separatorIndex === -1) {
|
if (separatorIndex === -1) {
|
||||||
console.error(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
deps.io.err(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
||||||
return;
|
throw new CommandError("resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const txHash = outpointArg.substring(0, separatorIndex);
|
const txHash = outpointArg.substring(0, separatorIndex);
|
||||||
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
||||||
if (!txHash || isNaN(vout)) {
|
if (!txHash || isNaN(vout)) {
|
||||||
console.error(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
deps.io.err(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
||||||
return;
|
throw new CommandError("resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the UTXO to get its invitation identifier (required by the engine).
|
|
||||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||||
const target = allResources.find(
|
const target = allResources.find(
|
||||||
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
console.error(`UTXO not found: ${txHash}:${vout}`);
|
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
|
||||||
return;
|
throw new CommandError("resource.unreserve.utxo_missing", `UTXO not found: ${txHash}:${vout}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target.reserved) {
|
if (!target.reservedBy) {
|
||||||
console.log(dim("UTXO is not reserved. Nothing to do."));
|
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
|
||||||
return;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.app.engine.unreserveResources(
|
await deps.app.engine.unreserveResources(
|
||||||
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
||||||
target.invitationIdentifier,
|
target.reservedBy ,
|
||||||
);
|
);
|
||||||
console.log(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.invitationIdentifier})`);
|
deps.io.out(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`);
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "unreserve-all": {
|
case "unreserve-all": {
|
||||||
const count = await deps.app.unreserveAllResources();
|
const count = await deps.app.unreserveAllResources();
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
console.log(dim("No reserved resources to unreserve."));
|
deps.io.out(dim("No reserved resources to unreserve."));
|
||||||
} else {
|
} else {
|
||||||
console.log(`Unreserved ${bold(String(count))} resource(s).`);
|
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
|
||||||
}
|
}
|
||||||
break;
|
return { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
deps.verboseLogger(`Unknown resource sub-command: ${subCommand}`);
|
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
|
||||||
printResourceHelp();
|
printResourceHelp(deps.io);
|
||||||
return;
|
throw new CommandError("resource.subcommand.unknown", `Unknown resource sub-command: ${subCommand}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import path from "path";
|
|||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
import type { XOTemplate } from "@xo-cash/types";
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
import { bold, dim, formatObject, objectPrint } from "../cli-utils.js";
|
import { bold, dim, formatObject } from "../cli-utils.js";
|
||||||
import { resolveTemplateReferences } from "../../utils/templates.js";
|
import { resolveTemplateReferences } from "../../utils/templates.js";
|
||||||
import type { CommandDependencies } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
import { resolveTemplate } from "../utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the template command
|
* Prints the help message for the template command
|
||||||
*/
|
*/
|
||||||
export const printTemplateHelp = () => {
|
export const printTemplateHelp = (io: CommandIO): void => {
|
||||||
console.log(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli template <sub-command>
|
${bold("Usage:")} xo-cli template <sub-command>
|
||||||
|
|
||||||
@@ -26,76 +28,75 @@ ${bold("Sub-commands:")}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the template list command.
|
* Handles the template list command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"].
|
* @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"].
|
||||||
*/
|
*/
|
||||||
export const handleTemplateListCommand = async (deps: CommandDependencies, args: string[]): Promise<void> => {
|
export const handleTemplateListCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ count?: number }> => {
|
||||||
const templateCategory = args[0];
|
const templateCategory = args[0];
|
||||||
deps.verboseLogger(`Template list category: ${templateCategory}`);
|
deps.io.verbose(`Template list category: ${templateCategory}`);
|
||||||
|
|
||||||
// If no category was provided to list, we assume its listing out the templates
|
|
||||||
if (!templateCategory) {
|
if (!templateCategory) {
|
||||||
const templates = await deps.app.engine.listImportedTemplates();
|
const templates = await deps.app.engine.listImportedTemplates();
|
||||||
const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`);
|
const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`);
|
||||||
console.log(formattedTemplates.join('\n'));
|
deps.io.out(formattedTemplates.join('\n'));
|
||||||
return;
|
return { count: templates.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the template identifier from the positional args
|
|
||||||
const templateIdentifier = args[1];
|
const templateIdentifier = args[1];
|
||||||
deps.verboseLogger(`Template identifier: ${templateIdentifier}`);
|
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||||
|
|
||||||
if (!templateIdentifier) {
|
if (!templateIdentifier) {
|
||||||
console.error("No template identifier provided");
|
deps.io.err("No template identifier provided");
|
||||||
return;
|
throw new CommandError("template.list.identifier_missing", "No template identifier provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the template from the engine
|
|
||||||
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||||
if (!rawTemplate) {
|
if (!rawTemplate) {
|
||||||
console.error(`No template found: ${templateIdentifier}`);
|
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||||
return;
|
throw new CommandError("template.list.not_found", `No template found: ${templateIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the template references
|
|
||||||
const template = await resolveTemplateReferences(rawTemplate);
|
const template = await resolveTemplateReferences(rawTemplate);
|
||||||
deps.verboseLogger(`Template: ${formatObject(template)}`);
|
deps.io.verbose(`Template: ${formatObject(template)}`);
|
||||||
|
|
||||||
// List the templates in the category
|
|
||||||
switch (templateCategory) {
|
switch (templateCategory) {
|
||||||
case "action": {
|
case "action": {
|
||||||
const actions = template.actions;
|
const actions = template.actions;
|
||||||
const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`);
|
const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`);
|
||||||
console.log(formattedActions.join('\n'));
|
deps.io.out(formattedActions.join('\n'));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "transaction": {
|
case "transaction": {
|
||||||
const transactions = template.transactions;
|
const transactions = template.transactions;
|
||||||
const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`);
|
const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`);
|
||||||
console.log(formattedTransactions.join('\n'));
|
deps.io.out(formattedTransactions.join('\n'));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "output": {
|
case "output": {
|
||||||
const outputs = template.outputs;
|
const outputs = template.outputs;
|
||||||
const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`);
|
const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`);
|
||||||
console.log(formattedOutputs.join('\n'));
|
deps.io.out(formattedOutputs.join('\n'));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "lockingscript": {
|
case "lockingscript": {
|
||||||
const lockingscripts = template.lockingScripts;
|
const lockingscripts = template.lockingScripts;
|
||||||
const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`);
|
const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`);
|
||||||
console.log(formattedLockingscripts.join('\n'));
|
deps.io.out(formattedLockingscripts.join('\n'));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "variable": {
|
case "variable": {
|
||||||
const variables = template.variables || {};
|
const variables = template.variables || {};
|
||||||
const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`);
|
const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`);
|
||||||
console.log(formattedVariables.join('\n'));
|
deps.io.out(formattedVariables.join('\n'));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
deps.verboseLogger(`Unknown template category: ${templateCategory}`);
|
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||||
return;
|
throw new CommandError("template.list.category_unknown", `Unknown template category: ${templateCategory}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,8 +104,8 @@ export const handleTemplateListCommand = async (deps: CommandDependencies, args:
|
|||||||
/**
|
/**
|
||||||
* Prints the help message for the template inspect command
|
* Prints the help message for the template inspect command
|
||||||
*/
|
*/
|
||||||
export const printTemplateInspectHelp = () => {
|
export const printTemplateInspectHelp = (io: CommandIO): void => {
|
||||||
console.log(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
||||||
|
|
||||||
@@ -124,153 +125,151 @@ ${bold("Categories:")}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the template inspect command.
|
* Handles the template inspect command.
|
||||||
|
* Throws CommandError on failure, returns empty object on success.
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"].
|
* @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"].
|
||||||
*/
|
*/
|
||||||
export const handleTemplateInspectCommand = async (deps: CommandDependencies, args: string[]): Promise<void> => {
|
export const handleTemplateInspectCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
): Promise<Record<string, never>> => {
|
||||||
const templateCategory = args[0];
|
const templateCategory = args[0];
|
||||||
const templateIdentifier = args[1];
|
const templateQuery = args[1];
|
||||||
const templateField = args[2];
|
const templateField = args[2];
|
||||||
|
|
||||||
deps.verboseLogger(`Template inspect args - category: ${templateCategory}, identifier: ${templateIdentifier}, field: ${templateField}`);
|
deps.io.verbose(`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`);
|
||||||
|
|
||||||
if (!templateCategory || !templateIdentifier || !templateField) {
|
if (!templateCategory || !templateQuery || !templateField) {
|
||||||
console.log("No template category, identifier, or field provided");
|
deps.io.err("No template category, identifier, or field provided");
|
||||||
printTemplateInspectHelp();
|
printTemplateInspectHelp(deps.io);
|
||||||
return;
|
throw new CommandError("template.inspect.arguments_missing", "No template category, identifier, or field provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the template from the engine
|
const template = await resolveTemplate(deps, templateQuery);
|
||||||
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
deps.io.verbose(`Template: ${formatObject(template)}`);
|
||||||
if (!rawTemplate) {
|
|
||||||
console.error(`No template found: ${templateIdentifier}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the template references
|
|
||||||
const template = await resolveTemplateReferences(rawTemplate);
|
|
||||||
deps.verboseLogger(`Template: ${formatObject(template)}`);
|
|
||||||
|
|
||||||
// Inspect the template in the category
|
|
||||||
switch (templateCategory) {
|
switch (templateCategory) {
|
||||||
case "action": {
|
case "action": {
|
||||||
const action = template.actions[templateField];
|
const action = template.actions[templateField];
|
||||||
if (!action) {
|
if (!action) {
|
||||||
console.error(`No action found: ${templateField}`);
|
deps.io.err(`No action found: ${templateField}`);
|
||||||
return;
|
throw new CommandError("template.inspect.action_missing", `No action found: ${templateField}`);
|
||||||
}
|
}
|
||||||
objectPrint(action);
|
deps.io.out(formatObject(action));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "transaction": {
|
case "transaction": {
|
||||||
const transaction = template.transactions[templateField];
|
const transaction = template.transactions?.[templateField];
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
console.error(`No transaction found: ${templateField}`);
|
deps.io.err(`No transaction found: ${templateField}`);
|
||||||
return;
|
throw new CommandError("template.inspect.transaction_missing", `No transaction found: ${templateField}`);
|
||||||
}
|
}
|
||||||
objectPrint(transaction);
|
deps.io.out(formatObject(transaction));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "output": {
|
case "output": {
|
||||||
const output = template.outputs[templateField];
|
const output = template.outputs[templateField];
|
||||||
if (!output) {
|
if (!output) {
|
||||||
console.error(`No output found: ${templateField}`);
|
deps.io.err(`No output found: ${templateField}`);
|
||||||
return;
|
throw new CommandError("template.inspect.output_missing", `No output found: ${templateField}`);
|
||||||
}
|
}
|
||||||
objectPrint(output);
|
deps.io.out(formatObject(output));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "lockingscript": {
|
case "lockingscript": {
|
||||||
const lockingscript = template.lockingScripts[templateField];
|
const lockingscript = template.lockingScripts[templateField];
|
||||||
if (!lockingscript) {
|
if (!lockingscript) {
|
||||||
console.error(`No lockingscript found: ${templateField}`);
|
deps.io.err(`No lockingscript found: ${templateField}`);
|
||||||
return;
|
throw new CommandError("template.inspect.lockingscript_missing", `No lockingscript found: ${templateField}`);
|
||||||
}
|
}
|
||||||
objectPrint(lockingscript);
|
deps.io.out(formatObject(lockingscript));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
case "variable": {
|
case "variable": {
|
||||||
const variable = template.variables?.[templateField];
|
const variable = template.variables?.[templateField];
|
||||||
if (!variable) {
|
if (!variable) {
|
||||||
console.error(`No variable found: ${templateField}`);
|
deps.io.err(`No variable found: ${templateField}`);
|
||||||
return;
|
throw new CommandError("template.inspect.variable_missing", `No variable found: ${templateField}`);
|
||||||
}
|
}
|
||||||
objectPrint(variable);
|
deps.io.out(formatObject(variable));
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
deps.verboseLogger(`Unknown template category: ${templateCategory}`);
|
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||||
return;
|
throw new CommandError("template.inspect.category_unknown", `Unknown template category: ${templateCategory}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the template command.
|
* Handles the template command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
* @param deps - The command dependencies.
|
* @param deps - The command dependencies.
|
||||||
* @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"].
|
* @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"].
|
||||||
* @param options - Parsed option flags.
|
* @param options - Parsed option flags.
|
||||||
*/
|
*/
|
||||||
export const handleTemplateCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
export const handleTemplateCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<{ templateFile?: string; count?: number }> => {
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
|
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.verboseLogger("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printTemplateHelp();
|
printTemplateHelp(deps.io);
|
||||||
return;
|
throw new CommandError("template.subcommand.missing", "No sub-command provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "import": {
|
case "import": {
|
||||||
const templateFile = args[1];
|
const templateFile = args[1];
|
||||||
deps.verboseLogger(`Template file: ${templateFile}`);
|
deps.io.verbose(`Template file: ${templateFile}`);
|
||||||
|
|
||||||
if (!templateFile) {
|
if (!templateFile) {
|
||||||
deps.verboseLogger("No template file provided");
|
deps.io.verbose("No template file provided");
|
||||||
printTemplateHelp();
|
printTemplateHelp(deps.io);
|
||||||
return;
|
throw new CommandError("template.import.file_missing", "No template file provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||||
deps.verboseLogger(`Template path: ${templatePath}`);
|
deps.io.verbose(`Template path: ${templatePath}`);
|
||||||
|
|
||||||
if (!existsSync(templatePath)) {
|
if (!existsSync(templatePath)) {
|
||||||
console.error(`Template file does not exist: ${templatePath}`);
|
deps.io.err(`Template file does not exist: ${templatePath}`);
|
||||||
printTemplateHelp();
|
printTemplateHelp(deps.io);
|
||||||
return;
|
throw new CommandError("template.import.file_not_found", `Template file does not exist: ${templatePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await readFileSync(templatePath, "utf8");
|
const template = await readFileSync(templatePath, "utf8");
|
||||||
|
|
||||||
deps.verboseLogger(`Importing template: ${templateFile}`);
|
deps.io.verbose(`Importing template: ${templateFile}`);
|
||||||
await deps.app.engine.importTemplate(template);
|
await deps.app.engine.importTemplate(template);
|
||||||
deps.verboseLogger(`Template imported: ${templateFile}`);
|
deps.io.verbose(`Template imported: ${templateFile}`);
|
||||||
break;
|
return { templateFile };
|
||||||
}
|
}
|
||||||
case "list": {
|
case "list": {
|
||||||
await handleTemplateListCommand(deps, args.slice(1));
|
return handleTemplateListCommand(deps, args.slice(1));
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case "inspect": {
|
case "inspect": {
|
||||||
await handleTemplateInspectCommand(deps, args.slice(1));
|
return handleTemplateInspectCommand(deps, args.slice(1));
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case "set-default": {
|
case "set-default": {
|
||||||
const templateFile = args[1];
|
const templateFile = args[1];
|
||||||
const outputIdentifier = args[2];
|
const outputIdentifier = args[2];
|
||||||
const roleIdentifier = args[3];
|
const roleIdentifier = args[3];
|
||||||
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
||||||
deps.verboseLogger("No template file, output identifier, or role identifier provided");
|
deps.io.verbose("No template file, output identifier, or role identifier provided");
|
||||||
printTemplateHelp();
|
printTemplateHelp(deps.io);
|
||||||
return;
|
throw new CommandError("template.default.arguments_missing", "No template file, output identifier, or role identifier provided");
|
||||||
}
|
}
|
||||||
deps.verboseLogger(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`);
|
deps.io.verbose(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`);
|
||||||
await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier);
|
await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier);
|
||||||
break;
|
return {};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
deps.verboseLogger(`Unknown template sub-command: ${subCommand}`);
|
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
|
||||||
printTemplateHelp();
|
printTemplateHelp(deps.io);
|
||||||
return;
|
throw new CommandError("template.subcommand.unknown", `Unknown template sub-command: ${subCommand}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,59 @@
|
|||||||
import type { AppService } from "../../services/app.js";
|
import type { AppService } from "../../services/app.js";
|
||||||
|
|
||||||
export type CommandDependencies = {
|
/**
|
||||||
verboseLogger: (message: string) => void;
|
* IO contract for CLI command handlers.
|
||||||
|
* Handlers write user-visible output through this abstraction so unit tests can
|
||||||
|
* assert behavior without spying on global console methods.
|
||||||
|
*/
|
||||||
|
export type CommandIO = {
|
||||||
|
out: (message: string) => void;
|
||||||
|
err: (message: string) => void;
|
||||||
|
verbose: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths configuration for CLI commands.
|
||||||
|
* Allows injection of custom paths for testing.
|
||||||
|
*/
|
||||||
|
export type CommandPaths = {
|
||||||
|
/** Directory for mnemonic wallet files */
|
||||||
|
mnemonicsDir: string;
|
||||||
|
/** Directory for engine DB and invitation storage files */
|
||||||
|
dataDir: string;
|
||||||
|
/** File storing the last-used mnemonic reference */
|
||||||
|
walletConfigPath: string;
|
||||||
|
/** Working directory for file output (invitation files, etc.) */
|
||||||
|
workingDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base dependencies available to every command handler.
|
||||||
|
*/
|
||||||
|
export type BaseCommandDependencies = {
|
||||||
|
io: CommandIO;
|
||||||
|
paths: CommandPaths;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies for app-backed commands.
|
||||||
|
*/
|
||||||
|
export type CommandDependencies = BaseCommandDependencies & {
|
||||||
app: AppService;
|
app: AppService;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for command failures.
|
||||||
|
* Thrown by command handlers when an operation fails.
|
||||||
|
* The `event` property can be used for telemetry/testing to identify failure types.
|
||||||
|
*/
|
||||||
|
export class CommandError extends Error {
|
||||||
|
public readonly event: string;
|
||||||
|
public readonly code: number;
|
||||||
|
|
||||||
|
constructor(event: string, message: string, code = 1) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CommandError";
|
||||||
|
this.event = event;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export const COMMAND_TREE: Record<string, string[]> = {
|
|
||||||
mnemonic: ["create", "import", "list"],
|
|
||||||
template: ["import", "list", "set-default"],
|
|
||||||
invitation: ["create", "import", "list"],
|
|
||||||
receive: [],
|
|
||||||
resource: ["list"],
|
|
||||||
help: [],
|
|
||||||
completions: ["bash", "zsh", "fish"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Global option flags available on every command. */
|
|
||||||
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a bash completion script.
|
|
||||||
* @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(" ");
|
|
||||||
|
|
||||||
// Build the case arms for each command's sub-commands
|
|
||||||
const caseArms = Object.entries(COMMAND_TREE)
|
|
||||||
.filter(([, subs]) => subs.length > 0)
|
|
||||||
.map(([cmd, subs]) => ` ${cmd})\n COMPREPLY=($(compgen -W "${subs.join(" ")}" -- "\${cur}"))\n return 0\n ;;`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return `# bash completion for ${binName}
|
|
||||||
# Add to ~/.bashrc: eval "$(${binName} completions bash)"
|
|
||||||
_${binName.replace(/-/g, "_")}_completions() {
|
|
||||||
local cur prev words cword
|
|
||||||
_init_completion || return
|
|
||||||
|
|
||||||
# If the current word starts with "-", offer option flags
|
|
||||||
if [[ "\${cur}" == -* ]]; then
|
|
||||||
COMPREPLY=($(compgen -W "${options}" -- "\${cur}"))
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find the command (first non-option positional arg after the binary)
|
|
||||||
local cmd=""
|
|
||||||
for ((i=1; i < cword; i++)); do
|
|
||||||
if [[ "\${words[i]}" != -* ]]; then
|
|
||||||
cmd="\${words[i]}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# No command yet — offer the top-level commands
|
|
||||||
if [[ -z "\${cmd}" ]]; then
|
|
||||||
COMPREPLY=($(compgen -W "${commands}" -- "\${cur}"))
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Offer sub-commands for the matched command
|
|
||||||
case "\${cmd}" in
|
|
||||||
${caseArms}
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
complete -F _${binName.replace(/-/g, "_")}_completions ${binName}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a zsh completion script.
|
|
||||||
* @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 caseArms = Object.entries(COMMAND_TREE)
|
|
||||||
.filter(([, subs]) => subs.length > 0)
|
|
||||||
.map(([cmd, subs]) => ` ${cmd})\n compadd -- ${subs.join(" ")}\n ;;`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return `# zsh completion for ${binName}
|
|
||||||
# Add to ~/.zshrc: eval "$(${binName} completions zsh)"
|
|
||||||
_${binName.replace(/-/g, "_")}_completions() {
|
|
||||||
local -a commands
|
|
||||||
commands=(${commands})
|
|
||||||
|
|
||||||
# If typing an option flag, complete options
|
|
||||||
if [[ "\${words[\${CURRENT}]}" == -* ]]; then
|
|
||||||
compadd -- ${options}
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find the command (first non-option positional arg)
|
|
||||||
local cmd=""
|
|
||||||
for ((i=2; i < CURRENT; i++)); do
|
|
||||||
if [[ "\${words[i]}" != -* ]]; then
|
|
||||||
cmd="\${words[i]}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# No command yet — offer top-level commands
|
|
||||||
if [[ -z "\${cmd}" ]]; then
|
|
||||||
compadd -- \${commands[@]}
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Offer sub-commands
|
|
||||||
case "\${cmd}" in
|
|
||||||
${caseArms}
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
compdef _${binName.replace(/-/g, "_")}_completions ${binName}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a fish completion script.
|
|
||||||
* @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`,
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
// Top-level commands (only when no sub-command is given yet)
|
|
||||||
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("");
|
|
||||||
|
|
||||||
// Sub-commands for each command
|
|
||||||
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}" -a "${sub}" -d "${cmd} ${sub}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
134
src/cli/index.ts
134
src/cli/index.ts
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* CLI entry point.
|
* CLI entry point.
|
||||||
*
|
*
|
||||||
@@ -35,17 +36,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
import { AppService } from "../services/app.js";
|
import { AppService } from "../services/app.js";
|
||||||
import { convertArgsToObject } from "./arguments.js";
|
import { convertArgsToObject } from "./arguments.js";
|
||||||
import { bold, dim, formatObject } from "./cli-utils.js";
|
import { bold, dim, formatObject } from "./cli-utils.js";
|
||||||
import { listMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||||
|
import { getDataDir, getMnemonicsDir, getWalletConfigPath } from "../utils/paths.js";
|
||||||
/** File that remembers the last-used mnemonic so `-m` can be omitted. */
|
|
||||||
const WALLET_CONFIG_FILE = ".xo-cli-wallet";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type CommandDependencies,
|
type CommandDependencies,
|
||||||
|
type CommandIO,
|
||||||
|
type CommandPaths,
|
||||||
|
CommandError,
|
||||||
handleMnemonicCommand,
|
handleMnemonicCommand,
|
||||||
handleTemplateCommand,
|
handleTemplateCommand,
|
||||||
handleInvitationCommand,
|
handleInvitationCommand,
|
||||||
@@ -53,15 +56,19 @@ import {
|
|||||||
handleResourceCommand,
|
handleResourceCommand,
|
||||||
} from "./commands/index.js";
|
} from "./commands/index.js";
|
||||||
|
|
||||||
import { handleCompletionsCommand } from "./completions.js";
|
import { handleCompletionsCommand } from "./autocomplete/completions.js";
|
||||||
|
|
||||||
const createConditionalLogger = (verbose: boolean) => {
|
const createCommandIO = (verbose: boolean): CommandIO => ({
|
||||||
return (message: string) => {
|
out: (message: string) => {
|
||||||
if (verbose) {
|
|
||||||
console.log(message);
|
console.log(message);
|
||||||
}
|
},
|
||||||
};
|
err: (message: string) => {
|
||||||
};
|
console.error(message);
|
||||||
|
},
|
||||||
|
verbose: (message: string) => {
|
||||||
|
if (verbose) console.log(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main entry point.
|
* Main entry point.
|
||||||
@@ -79,106 +86,138 @@ async function main(): Promise<void> {
|
|||||||
const { args, options } = convertArgsToObject(process.argv.slice(2));
|
const { args, options } = convertArgsToObject(process.argv.slice(2));
|
||||||
|
|
||||||
// Create a verbose logger if the user set the verbose flag
|
// Create a verbose logger if the user set the verbose flag
|
||||||
const verboseLogger = createConditionalLogger(options["verbose"] === "true");
|
const io = createCommandIO(options["verbose"] === "true");
|
||||||
|
|
||||||
// Log the parsed app args
|
// Log the parsed app args
|
||||||
verboseLogger(`Parsed args: ${formatObject(args)}`);
|
io.verbose(`Parsed args: ${formatObject(args)}`);
|
||||||
verboseLogger(`Parsed options: ${formatObject(options)}`);
|
io.verbose(`Parsed options: ${formatObject(options)}`);
|
||||||
|
|
||||||
// Handle the command
|
// Handle the command
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
verboseLogger(`Command: ${command}`);
|
io.verbose(`Command: ${command}`);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
// TODO: Print help, probably...
|
// TODO: Print help, probably...
|
||||||
console.error("No command provided");
|
io.err("No command provided");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Positional args after the command name (sub-command, files, etc.)
|
// Positional args after the command name (sub-command, files, etc.)
|
||||||
const subArgs = args.slice(1);
|
const subArgs = args.slice(1);
|
||||||
|
|
||||||
|
// Build paths object from global path functions
|
||||||
|
const paths: CommandPaths = {
|
||||||
|
mnemonicsDir: getMnemonicsDir(),
|
||||||
|
dataDir: getDataDir(),
|
||||||
|
walletConfigPath: getWalletConfigPath(),
|
||||||
|
workingDir: process.cwd(),
|
||||||
|
};
|
||||||
|
|
||||||
// Early handling if we are calling the mnemonic command
|
// Early handling if we are calling the mnemonic command
|
||||||
// TODO: This is ugly. I would like to find a nicer way of doing this.
|
// TODO: This is ugly. I would like to find a nicer way of doing this.
|
||||||
if (command === "completions") {
|
if (command === "completions") {
|
||||||
handleCompletionsCommand(subArgs);
|
handleCompletionsCommand(subArgs);
|
||||||
return;
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === "mnemonic") {
|
if (command === "mnemonic") {
|
||||||
await handleMnemonicCommand({ verboseLogger }, subArgs, options);
|
try {
|
||||||
return;
|
await handleMnemonicCommand({ io, paths }, subArgs, options);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
process.exit(error.code);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve mnemonic file: explicit flag > persisted config > error.
|
// Resolve mnemonic file: explicit flag > persisted config > error.
|
||||||
let mnemonicFile = options["mnemonicFile"];
|
let mnemonicFile = options["mnemonicFile"];
|
||||||
if (!mnemonicFile && existsSync(WALLET_CONFIG_FILE)) {
|
if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
|
||||||
mnemonicFile = readFileSync(WALLET_CONFIG_FILE, "utf8").trim();
|
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
|
||||||
verboseLogger(`Using persisted wallet: ${mnemonicFile}`);
|
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
||||||
}
|
}
|
||||||
if (!mnemonicFile) {
|
if (!mnemonicFile) {
|
||||||
console.error("No mnemonic file provided");
|
io.err("No mnemonic file provided");
|
||||||
console.log(`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listMnemonicFiles().join("\n")}`);
|
io.out(`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`);
|
||||||
console.log(`\nTip: pass -m <file> once and it will be remembered in ${WALLET_CONFIG_FILE}`);
|
io.out(`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the choice so subsequent commands can omit -m.
|
// Persist the choice so subsequent commands can omit -m.
|
||||||
writeFileSync(WALLET_CONFIG_FILE, mnemonicFile);
|
writeFileSync(paths.walletConfigPath, mnemonicFile);
|
||||||
|
|
||||||
const mnemonic = await loadMnemonic(mnemonicFile);
|
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
||||||
verboseLogger(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||||
|
|
||||||
// Create an App instance
|
// Create an App instance
|
||||||
verboseLogger("Creating app instance...");
|
io.verbose("Creating app instance...");
|
||||||
const app = await AppService.create(mnemonic, {
|
const app = await AppService.create(mnemonic, {
|
||||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||||
engineConfig: {
|
engineConfig: {
|
||||||
databasePath: options["databasePath"] ?? "./",
|
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||||
databaseFilename: options["databaseFilename"] ?? 'xo-wallet.db',
|
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||||
},
|
},
|
||||||
invitationStoragePath: options["invitationStoragePath"] ?? "./xo-invitations.db",
|
invitationStoragePath:
|
||||||
|
options["invitationStoragePath"] ?? join(paths.dataDir, "xo-invitations.db"),
|
||||||
});
|
});
|
||||||
verboseLogger("App instance created");
|
io.verbose("App instance created");
|
||||||
|
|
||||||
// Start the app
|
// Start the app
|
||||||
verboseLogger("Starting app...");
|
// TODO: Rethink this. Do we really want to start the app here? It just slows it down if we dont actually have to have it started for the command
|
||||||
|
io.verbose("Starting app...");
|
||||||
await app.start();
|
await app.start();
|
||||||
verboseLogger("App started");
|
io.verbose("App started");
|
||||||
|
|
||||||
const commandDependencies: CommandDependencies = {
|
const commandDependencies: CommandDependencies = {
|
||||||
verboseLogger,
|
io,
|
||||||
|
paths,
|
||||||
app,
|
app,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle the command
|
// Handle the command
|
||||||
|
try {
|
||||||
|
let result: unknown;
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "template":
|
case "template":
|
||||||
await handleTemplateCommand(commandDependencies, subArgs, options);
|
result = await handleTemplateCommand(commandDependencies, subArgs, options);
|
||||||
break;
|
break;
|
||||||
case "invitation":
|
case "invitation":
|
||||||
await handleInvitationCommand(commandDependencies, subArgs, options);
|
result = await handleInvitationCommand(commandDependencies, subArgs, options);
|
||||||
break;
|
break;
|
||||||
case "receive":
|
case "receive":
|
||||||
await handleReceiveCommand(commandDependencies, subArgs, options);
|
result = await handleReceiveCommand(commandDependencies, subArgs, options);
|
||||||
break;
|
break;
|
||||||
case "resource":
|
case "resource":
|
||||||
await handleResourceCommand(commandDependencies, subArgs, options);
|
result = await handleResourceCommand(commandDependencies, subArgs, options);
|
||||||
break;
|
break;
|
||||||
case "help":
|
case "help":
|
||||||
await handleHelpCommand(commandDependencies, subArgs, options);
|
result = await handleHelpCommand(commandDependencies, subArgs, options);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.error(`Unknown command: ${command}`);
|
io.err(`Unknown command: ${command}`);
|
||||||
process.exit(1);
|
throw new CommandError("cli.command.unknown", `Unknown command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit the process
|
// console.log(result);
|
||||||
|
|
||||||
|
// objectPrint(result);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
io.err(error.message);
|
||||||
|
process.exit(error.code);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHelpCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
const handleHelpCommand = async (
|
||||||
// Im sorry about the formatting here. I'm not sure how to handle this better.
|
deps: CommandDependencies,
|
||||||
console.log(
|
_args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<Record<string, never>> => {
|
||||||
|
deps.io.out(
|
||||||
`${bold("XO-CLI Help:")}
|
`${bold("XO-CLI Help:")}
|
||||||
|
|
||||||
${bold("Usage:")} xo-cli <command> [options]
|
${bold("Usage:")} xo-cli <command> [options]
|
||||||
@@ -196,6 +235,7 @@ Options:
|
|||||||
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
||||||
-v, --verbose ${dim("Show verbose output")}`
|
-v, --verbose ${dim("Show verbose output")}`
|
||||||
);
|
);
|
||||||
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -1,72 +1,116 @@
|
|||||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { BCHMnemonicURL, } from "../utils/bch-mnemonic-url.js";
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||||
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
|
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
import { BCHMnemonicURL } from "../utils/bch-mnemonic-url.js";
|
||||||
|
import { getMnemonicsDir as getGlobalMnemonicsDir } from "../utils/paths.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new mnemonic seed phrase
|
* Create a new mnemonic seed phrase
|
||||||
*/
|
*/
|
||||||
export const createMnemonicSeed = (): string => {
|
export const createMnemonicSeed = (): string => {
|
||||||
// Generate a new mnemonic seed
|
return generateBip39Mnemonic();
|
||||||
const mnemonic = generateBip39Mnemonic();
|
|
||||||
|
|
||||||
// Return the mnemonic phrase
|
|
||||||
return mnemonic;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mnemonic file from a mnemonic seed
|
* Creates a mnemonic file from a mnemonic seed
|
||||||
|
* @param mnemonicsDir - Directory to store mnemonic files
|
||||||
* @param mnemonic - The mnemonic seed
|
* @param mnemonic - The mnemonic seed
|
||||||
* @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename
|
* @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename
|
||||||
* @returns The filename of the created mnemonic file
|
* @returns The filename of the created mnemonic file
|
||||||
*/
|
*/
|
||||||
export const createMnemonicFile = (mnemonic: string, outputFilename?: string): string => {
|
export const createMnemonicFile = (
|
||||||
// Convert the mnemonic seed to a BCH Mnemonic URL
|
mnemonicsDir: string,
|
||||||
|
mnemonic: string,
|
||||||
|
outputFilename?: string,
|
||||||
|
): string => {
|
||||||
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
|
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
|
||||||
|
|
||||||
// If no output filename is provided, use the first word from the mnemonic as the filename
|
|
||||||
let fileName = outputFilename;
|
let fileName = outputFilename;
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
const firstWord = mnemonic.at(0)?.toLowerCase();
|
const firstWord = mnemonic.split(' ')[0]?.toLowerCase();
|
||||||
if (!firstWord) {
|
if (!firstWord) {
|
||||||
throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic");
|
throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic");
|
||||||
}
|
}
|
||||||
fileName = `mnemonic-${firstWord}`;
|
fileName = `mnemonic-${firstWord}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the mnemonic URL to a file
|
const safeName = basename(fileName);
|
||||||
// TODO: May need PWD or something to ensure we are writing to the correct directory
|
const outPath = join(mnemonicsDir, safeName);
|
||||||
writeFileSync(fileName, mnemonicUrl.toURL());
|
writeFileSync(outPath, mnemonicUrl.toURL());
|
||||||
|
|
||||||
return fileName;
|
return safeName;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a mnemonic reference to an absolute path.
|
||||||
|
* Order: absolute path if it exists → path relative to cwd → mnemonicsDir/<basename>.
|
||||||
|
*
|
||||||
|
* @param mnemonicsDir - Directory containing mnemonic files
|
||||||
|
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||||
|
* @returns Absolute path to the mnemonic file
|
||||||
|
* @throws If no matching file exists
|
||||||
|
*/
|
||||||
|
export const resolveMnemonicFilePath = (
|
||||||
|
mnemonicsDir: string,
|
||||||
|
mnemonicRef: string,
|
||||||
|
): string => {
|
||||||
|
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
||||||
|
return mnemonicRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
||||||
|
if (existsSync(relativeToCwd)) {
|
||||||
|
return relativeToCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
|
||||||
|
if (existsSync(inMnemonics)) {
|
||||||
|
return inMnemonics;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a mnemonic from a mnemonic file
|
* Loads a mnemonic from a mnemonic file
|
||||||
|
* @param mnemonicsDir - Directory containing mnemonic files
|
||||||
* @param mnemonicFile - The filename of the mnemonic file
|
* @param mnemonicFile - The filename of the mnemonic file
|
||||||
* @returns The mnemonic seed
|
* @returns The mnemonic seed
|
||||||
*/
|
*/
|
||||||
export const loadMnemonic = (mnemonicFile: string): string => {
|
export const loadMnemonic = (mnemonicsDir: string, mnemonicFile: string): string => {
|
||||||
const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(mnemonicFile, "utf8"));
|
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
|
||||||
|
const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(resolvedPath, "utf8"));
|
||||||
const { entropy } = mnemonicUrl.toObject();
|
const { entropy } = mnemonicUrl.toObject();
|
||||||
|
|
||||||
// Convert the entropy to a mnemonic seed
|
|
||||||
const mnemonic = encodeBip39Mnemonic(entropy);
|
const mnemonic = encodeBip39Mnemonic(entropy);
|
||||||
|
|
||||||
// If the conversion failed, throw an error
|
|
||||||
if (typeof mnemonic === "string") {
|
if (typeof mnemonic === "string") {
|
||||||
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the mnemonic phrase
|
|
||||||
return mnemonic.phrase;
|
return mnemonic.phrase;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all mnemonic files in the current directory
|
* Lists mnemonic files in the given directory.
|
||||||
* @returns An array of mnemonic file names
|
* @param mnemonicsDir - Directory containing mnemonic files
|
||||||
|
* @returns Basenames suitable for `-m <name>`
|
||||||
*/
|
*/
|
||||||
export const listMnemonicFiles = (): string[] => {
|
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
|
||||||
const cwd = process.cwd();
|
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
|
||||||
const filenames = readdirSync(cwd).filter((f: string) => f.startsWith('mnemonic-'));
|
f.startsWith("mnemonic-"),
|
||||||
|
);
|
||||||
return filenames;
|
return filenames;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists mnemonic files using the global mnemonics directory.
|
||||||
|
* Convenience function for use in the CLI entry point before paths are resolved.
|
||||||
|
* @returns Basenames suitable for `-m <name>`
|
||||||
|
*/
|
||||||
|
export const listGlobalMnemonicFiles = (): string[] => {
|
||||||
|
return listMnemonicFiles(getGlobalMnemonicsDir());
|
||||||
|
};
|
||||||
|
|||||||
48
src/cli/utils.ts
Normal file
48
src/cli/utils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import type { CommandDependencies } from "./commands/types.js";
|
||||||
|
import { CommandError } from "./commands/types.js";
|
||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate through the templates, trying to match the id or the name with the given input.
|
||||||
|
* Use multiple for-loops.
|
||||||
|
* First, check the id of every template
|
||||||
|
* Then, check the name of every template. If multiple names match, throw an error.
|
||||||
|
* If no match is found, throw an error.
|
||||||
|
*
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param query - The id or name of the template to resolve.
|
||||||
|
* @returns The template object.
|
||||||
|
* @throws CommandError if no template is found.
|
||||||
|
* @throws CommandError if multiple templates are found.
|
||||||
|
*/
|
||||||
|
export const resolveTemplate = async (deps: CommandDependencies, query: string): Promise<XOTemplate> => {
|
||||||
|
const templates = await deps.app.engine.listImportedTemplates();
|
||||||
|
|
||||||
|
const matches = new Set<XOTemplate>();
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
if (generateTemplateIdentifier(template) === query) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
if (template.name === query) {
|
||||||
|
matches.add(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.size > 1) {
|
||||||
|
throw new CommandError(
|
||||||
|
"template.resolve.multiple_matches",
|
||||||
|
`Multiple templates found for "${query}": ${Array.from(matches).map(template => `${template.name} (${generateTemplateIdentifier(template)})`).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.size === 1) {
|
||||||
|
return matches.values().next().value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CommandError("template.resolve.not_found", `Template not found: ${query}`);
|
||||||
|
}
|
||||||
10
src/index.ts
10
src/index.ts
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
||||||
*
|
*
|
||||||
@@ -9,18 +10,25 @@
|
|||||||
* 5. Real-time updates via SSE
|
* 5. Real-time updates via SSE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { App } from "./app.js";
|
import { App } from "./app.js";
|
||||||
|
import { getDataDir } from "./utils/paths.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main entry point.
|
* Main entry point.
|
||||||
*/
|
*/
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const dataDir = getDataDir();
|
||||||
// Create and start the application
|
// Create and start the application
|
||||||
await App.create({
|
await App.create({
|
||||||
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
|
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
|
||||||
databasePath: process.env["DB_PATH"] ?? "./",
|
databasePath: process.env["DB_PATH"] ?? dataDir,
|
||||||
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
|
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
|
||||||
|
invitationStoragePath:
|
||||||
|
process.env["INVITATION_STORAGE_PATH"] ??
|
||||||
|
join(dataDir, "xo-invitations.db"),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start XO Wallet CLI:", error);
|
console.error("Failed to start XO Wallet CLI:", error);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
import type { XOInvitation } from "@xo-cash/types";
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
import { Invitation } from "./invitation.js";
|
import { Invitation } from "./invitation.js";
|
||||||
import { Storage } from "./storage.js";
|
import { BaseStorage, Storage } from "./storage.js";
|
||||||
import { SyncServer } from "../utils/sync-server.js";
|
import { SyncServer } from "../utils/sync-server.js";
|
||||||
import { HistoryService } from "./history.js";
|
import { HistoryService } from "./history.js";
|
||||||
import { ElectrumService } from "./electrum.js";
|
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||||
|
|
||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
|
||||||
@@ -42,10 +42,10 @@ export interface AppConfig {
|
|||||||
|
|
||||||
export class AppService extends EventEmitter<AppEventMap> {
|
export class AppService extends EventEmitter<AppEventMap> {
|
||||||
public engine: Engine;
|
public engine: Engine;
|
||||||
public storage: Storage;
|
public storage: BaseStorage;
|
||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
public electrum: ElectrumService;
|
public electrum: BlockchainService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
private invitationEventCleanup = new Map<
|
private invitationEventCleanup = new Map<
|
||||||
@@ -101,9 +101,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
storage: Storage,
|
storage: BaseStorage,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
electrum: ElectrumService,
|
electrum: BlockchainService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -224,14 +224,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
*/
|
*/
|
||||||
async unreserveAllResources(): Promise<number> {
|
async unreserveAllResources(): Promise<number> {
|
||||||
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
|
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
|
||||||
const reserved = allUnspentOutputs.filter((o) => o.reserved);
|
const reserved = allUnspentOutputs.filter((o) => o.reservedBy);
|
||||||
|
|
||||||
// Group by invitation identifier so the engine can clear them properly.
|
// Group by invitation identifier so the engine can clear them properly.
|
||||||
const byInvitation = new Map<string, typeof reserved>();
|
const byInvitation = new Map<string, typeof reserved>();
|
||||||
for (const output of reserved) {
|
for (const output of reserved) {
|
||||||
const existing = byInvitation.get(output.invitationIdentifier) ?? [];
|
const existing = byInvitation.get(output.reservedBy!) ?? [];
|
||||||
existing.push(output);
|
existing.push(output);
|
||||||
byInvitation.set(output.invitationIdentifier, existing);
|
byInvitation.set(output.reservedBy!, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [invitationIdentifier, outputs] of byInvitation) {
|
for (const [invitationIdentifier, outputs] of byInvitation) {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface ElectrumServiceConfig {
|
|||||||
applicationIdentifier?: string;
|
applicationIdentifier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export abstract class BlockchainService {
|
||||||
|
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small Electrum adapter used by CLI services.
|
* Small Electrum adapter used by CLI services.
|
||||||
* Keeps connection logic in one place and exposes a tiny API.
|
* Keeps connection logic in one place and exposes a tiny API.
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ export class HistoryService {
|
|||||||
return {
|
return {
|
||||||
kind: "utxo",
|
kind: "utxo",
|
||||||
id: this.getUtxoId(utxo),
|
id: this.getUtxoId(utxo),
|
||||||
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
invitationIdentifier: utxo.reservedBy || undefined,
|
||||||
templateIdentifier: utxo.templateIdentifier,
|
templateIdentifier: utxo.templateIdentifier,
|
||||||
outputIdentifier: utxo.outputIdentifier,
|
outputIdentifier: utxo.outputIdentifier,
|
||||||
outpoint: {
|
outpoint: {
|
||||||
@@ -409,7 +409,7 @@ export class HistoryService {
|
|||||||
index: utxo.outpointIndex,
|
index: utxo.outpointIndex,
|
||||||
},
|
},
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
reserved: utxo.reserved,
|
reserved: utxo.reservedBy ? true : false,
|
||||||
direction,
|
direction,
|
||||||
description,
|
description,
|
||||||
descriptionParts: {
|
descriptionParts: {
|
||||||
@@ -517,7 +517,7 @@ export class HistoryService {
|
|||||||
utxo: UnspentOutputData,
|
utxo: UnspentOutputData,
|
||||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
|
if (utxo.reservedBy) return utxo.reservedBy;
|
||||||
const originKey = this.getUtxoOriginKey(
|
const originKey = this.getUtxoOriginKey(
|
||||||
utxo.templateIdentifier,
|
utxo.templateIdentifier,
|
||||||
utxo.outputIdentifier,
|
utxo.outputIdentifier,
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
|
|
||||||
import type { SSEvent } from "../utils/sse-client.js";
|
import type { SSEvent } from "../utils/sse-client.js";
|
||||||
import type { SyncServer } from "../utils/sync-server.js";
|
import type { SyncServer } from "../utils/sync-server.js";
|
||||||
import type { Storage } from "./storage.js";
|
import type { BaseStorage } from "./storage.js";
|
||||||
import type { ElectrumService } from "./electrum.js";
|
import type { BlockchainService } from "./electrum.js";
|
||||||
|
|
||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
||||||
@@ -39,9 +39,9 @@ export type InvitationEventMap = {
|
|||||||
|
|
||||||
export type InvitationDependencies = {
|
export type InvitationDependencies = {
|
||||||
syncServer: SyncServer;
|
syncServer: SyncServer;
|
||||||
storage: Storage;
|
storage: BaseStorage;
|
||||||
engine: Engine;
|
engine: Engine;
|
||||||
electrum: ElectrumService;
|
electrum: BlockchainService;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||||
@@ -119,8 +119,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* The storage instance.
|
* The storage instance.
|
||||||
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||||
*/
|
*/
|
||||||
private storage: Storage;
|
private storage: BaseStorage;
|
||||||
private electrum: ElectrumService;
|
private electrum: BlockchainService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||||
|
|
||||||
export class Storage {
|
export abstract class BaseStorage {
|
||||||
|
abstract all(): Promise<{ key: string; value: any }[]>;
|
||||||
|
abstract set(key: string, value: any): Promise<void>;
|
||||||
|
abstract get(key: string): Promise<any>;
|
||||||
|
abstract remove(key: string): Promise<void>;
|
||||||
|
abstract clear(): Promise<void>;
|
||||||
|
abstract child(key: string): BaseStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Storage extends BaseStorage {
|
||||||
static async create(dbPath: string): Promise<Storage> {
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
// Create the database
|
// Create the database
|
||||||
const database = new Database(dbPath);
|
const database = new Database(dbPath);
|
||||||
@@ -19,7 +28,9 @@ export class Storage {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly database: Database.Database,
|
private readonly database: Database.Database,
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
) {}
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full key with basePath prefix
|
* Get the full key with basePath prefix
|
||||||
@@ -117,3 +128,104 @@ export class Storage {
|
|||||||
return new Storage(this.database, this.getFullKey(key));
|
return new Storage(this.database, this.getFullKey(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory storage adapter with the same namespaced API as {@link Storage}.
|
||||||
|
*
|
||||||
|
* This adapter is useful for tests and short-lived sessions where persisted
|
||||||
|
* SQLite state is not needed.
|
||||||
|
*/
|
||||||
|
export class InMemoryStorage extends BaseStorage {
|
||||||
|
static async create(): Promise<InMemoryStorage> {
|
||||||
|
return new InMemoryStorage(new Map<string, string>(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly store: Map<string, string>,
|
||||||
|
private readonly basePath: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full key with basePath prefix.
|
||||||
|
*/
|
||||||
|
private getFullKey(key: string): string {
|
||||||
|
return this.basePath ? `${this.basePath}.${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the basePath prefix from a key.
|
||||||
|
*/
|
||||||
|
private stripBasePath(fullKey: string): string {
|
||||||
|
if (!this.basePath) return fullKey;
|
||||||
|
const prefix = `${this.basePath}.`;
|
||||||
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any): Promise<void> {
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
const encodedValue = encodeExtendedJson(value);
|
||||||
|
this.store.set(fullKey, encodedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all key-value pairs from this storage namespace (shallow only).
|
||||||
|
*/
|
||||||
|
async all(): Promise<{ key: string; value: any }[]> {
|
||||||
|
const rows: Array<{ key: string; value: string }> = [];
|
||||||
|
const prefix = this.basePath ? `${this.basePath}.` : "";
|
||||||
|
|
||||||
|
for (const [key, value] of this.store.entries()) {
|
||||||
|
if (this.basePath && !key.startsWith(prefix)) continue;
|
||||||
|
rows.push({ key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRows = rows.filter((row) => {
|
||||||
|
const strippedKey = this.stripBasePath(row.key);
|
||||||
|
return !strippedKey.includes(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRows.map((row) => ({
|
||||||
|
key: this.stripBasePath(row.key),
|
||||||
|
value: decodeExtendedJson(row.value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<any> {
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
const encodedValue = this.store.get(fullKey);
|
||||||
|
if (encodedValue === undefined) return null;
|
||||||
|
|
||||||
|
return decodeExtendedJson(encodedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
this.store.delete(fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
if (!this.basePath) {
|
||||||
|
this.store.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${this.basePath}.`;
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const key of this.store.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToDelete) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child(key: string): InMemoryStorage {
|
||||||
|
return new InMemoryStorage(this.store, this.getFullKey(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { colors, logo } from '../theme.js';
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
||||||
|
import { getMnemonicsDir } from '../../utils/paths.js';
|
||||||
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
||||||
import { encodeBip39Mnemonic } from '@bitauth/libauth';
|
import { encodeBip39Mnemonic } from '@bitauth/libauth';
|
||||||
|
|
||||||
@@ -39,20 +41,26 @@ interface MnemonicFileEntry {
|
|||||||
* Focus sections the user can tab between.
|
* Focus sections the user can tab between.
|
||||||
* When saved wallets exist the file list is shown first.
|
* When saved wallets exist the file list is shown first.
|
||||||
*/
|
*/
|
||||||
type FocusSection = 'files' | 'input' | 'button';
|
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL,
|
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
|
||||||
* and converts the entropy back to a BIP39 English mnemonic phrase.
|
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
|
||||||
*/
|
*/
|
||||||
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
||||||
const cwd = process.cwd();
|
const dirs = [getMnemonicsDir(), process.cwd()];
|
||||||
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-'));
|
const seenBasenames = new Set<string>();
|
||||||
const entries: MnemonicFileEntry[] = [];
|
const entries: MnemonicFileEntry[] = [];
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
const filenames = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.startsWith('mnemonic-'));
|
||||||
for (const filename of filenames) {
|
for (const filename of filenames) {
|
||||||
|
if (seenBasenames.has(filename)) continue;
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim();
|
const content = fs.readFileSync(path.join(dir, filename), 'utf-8').trim();
|
||||||
const parsed = BCHMnemonicURL.fromURL(content);
|
const parsed = BCHMnemonicURL.fromURL(content);
|
||||||
const raw = parsed.toObject();
|
const raw = parsed.toObject();
|
||||||
|
|
||||||
@@ -64,10 +72,12 @@ function loadMnemonicFiles(): MnemonicFileEntry[] {
|
|||||||
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
|
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
|
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
|
||||||
|
seenBasenames.add(filename);
|
||||||
} catch {
|
} catch {
|
||||||
// Skip files that can't be parsed
|
// Skip files that can't be parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +101,9 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
||||||
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
||||||
|
|
||||||
|
/** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */
|
||||||
|
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
|
||||||
|
|
||||||
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
||||||
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
|
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
|
||||||
|
|
||||||
@@ -104,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
* The ordered list of focusable sections (files section only when entries exist).
|
* The ordered list of focusable sections (files section only when entries exist).
|
||||||
*/
|
*/
|
||||||
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||||
? ['files', 'input', 'button']
|
? ['files', 'input', 'saveCheckbox', 'button']
|
||||||
: ['input', 'button'];
|
: ['input', 'saveCheckbox', 'button'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a status message with the given type.
|
* Shows a status message with the given type.
|
||||||
@@ -118,7 +131,8 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
/**
|
/**
|
||||||
* Shared wallet initialization handler used by both manual entry and file selection.
|
* Shared wallet initialization handler used by both manual entry and file selection.
|
||||||
*/
|
*/
|
||||||
const doInitialize = useCallback(async (seed: string) => {
|
const doInitialize = useCallback(
|
||||||
|
async (seed: string, options?: { saveMnemonic?: boolean }) => {
|
||||||
showStatus('Initializing wallet...', 'loading');
|
showStatus('Initializing wallet...', 'loading');
|
||||||
setStatus('Initializing wallet...');
|
setStatus('Initializing wallet...');
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
@@ -126,20 +140,37 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await initializeWallet(seed);
|
await initializeWallet(seed);
|
||||||
|
|
||||||
showStatus('Wallet initialized successfully!', 'success');
|
let statusText = 'Wallet initialized successfully!';
|
||||||
|
if (options?.saveMnemonic) {
|
||||||
|
try {
|
||||||
|
const savedAs = createMnemonicFile(getMnemonicsDir(), seed);
|
||||||
|
setMnemonicFiles(loadMnemonicFiles());
|
||||||
|
statusText = `Wallet initialized! Mnemonic saved as ${savedAs}`;
|
||||||
|
} catch (saveErr) {
|
||||||
|
const saveMsg =
|
||||||
|
saveErr instanceof Error ? saveErr.message : String(saveErr);
|
||||||
|
statusText = `Wallet initialized, but could not save mnemonic: ${saveMsg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(statusText, 'success');
|
||||||
setStatus('Wallet ready');
|
setStatus('Wallet ready');
|
||||||
setSeedPhrase('');
|
setSeedPhrase('');
|
||||||
|
setSaveMnemonicChecked(false);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('wallet');
|
navigate('wallet');
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
||||||
showStatus(message, 'error');
|
showStatus(message, 'error');
|
||||||
setStatus('Initialization failed');
|
setStatus('Initialization failed');
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [initializeWallet, navigate, showStatus, setStatus]);
|
},
|
||||||
|
[initializeWallet, navigate, showStatus, setStatus],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles manual seed phrase submission with validation.
|
* Handles manual seed phrase submission with validation.
|
||||||
@@ -158,8 +189,8 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await doInitialize(seed);
|
await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
|
||||||
}, [seedPhrase, doInitialize, showStatus]);
|
}, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles selecting a mnemonic file from the list.
|
* Handles selecting a mnemonic file from the list.
|
||||||
@@ -186,6 +217,14 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Space or Enter toggles "save mnemonic" when that row is focused
|
||||||
|
if (focusedSection === 'saveCheckbox') {
|
||||||
|
if (_input === ' ' || key.return) {
|
||||||
|
setSaveMnemonicChecked((v) => !v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Arrow keys inside the file list
|
// Arrow keys inside the file list
|
||||||
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
|
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
|
||||||
if (key.upArrow) {
|
if (key.upArrow) {
|
||||||
@@ -319,6 +358,32 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
paddingX={1}
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={
|
||||||
|
focusedSection === 'saveCheckbox' ? colors.focus : colors.borderMuted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
color={focusedSection === 'saveCheckbox' ? colors.focus : colors.text}
|
||||||
|
bold={focusedSection === 'saveCheckbox'}
|
||||||
|
>
|
||||||
|
{saveMnemonicChecked ? '[x] ' : '[ ] '}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.text}>Save this mnemonic</Text>
|
||||||
|
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
|
||||||
|
</Box>
|
||||||
|
{focusedSection === 'saveCheckbox' && (
|
||||||
|
<Box marginTop={0} paddingX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Space / Enter: toggle
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status message */}
|
{/* Status message */}
|
||||||
<Box marginTop={1} height={1}>
|
<Box marginTop={1} height={1}>
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
@@ -345,7 +410,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={2}>
|
<Box marginTop={2}>
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
Tab: navigate sections • Enter: submit • Esc: back
|
Tab: navigate • Enter: submit, load wallet, or toggle save • Space: toggle save • Esc: back
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -94,10 +94,7 @@ export function InputsSelectStep({
|
|||||||
// Create a map of the utxoID to suitable resource
|
// Create a map of the utxoID to suitable resource
|
||||||
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
|
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
|
||||||
for (const outputIdentifier of outputIdentifiers) {
|
for (const outputIdentifier of outputIdentifiers) {
|
||||||
const suitableResources = await invitation.findSuitableResources({
|
const suitableResources = await invitation.findSuitableResources();
|
||||||
|
|
||||||
outputIdentifier,
|
|
||||||
});
|
|
||||||
for (const suitableResource of suitableResources) {
|
for (const suitableResource of suitableResources) {
|
||||||
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
|
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/utils/paths.ts
Normal file
70
src/utils/paths.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
|
||||||
|
* User-provided paths (templates, invitation JSON) stay relative to cwd.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base config directory. Created on first access.
|
||||||
|
*/
|
||||||
|
export function getConfigDir(): string {
|
||||||
|
const dir = join(homedir(), ".config", "xo-cli");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory for mnemonic wallet files (mnemonic-*).
|
||||||
|
*/
|
||||||
|
export function getMnemonicsDir(): string {
|
||||||
|
const dir = join(getConfigDir(), "mnemonics");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory for engine DB and invitation storage SQLite files.
|
||||||
|
*/
|
||||||
|
export function getDataDir(): string {
|
||||||
|
const dir = join(getConfigDir(), "data");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File storing the last-used mnemonic reference for `-m` omission.
|
||||||
|
*/
|
||||||
|
export function getWalletConfigPath(): string {
|
||||||
|
return join(getConfigDir(), ".wallet");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a mnemonic reference to an absolute path.
|
||||||
|
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
|
||||||
|
*
|
||||||
|
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||||
|
* @returns Absolute path to the mnemonic file
|
||||||
|
* @throws If no matching file exists
|
||||||
|
*/
|
||||||
|
export function resolveMnemonicFilePath(mnemonicRef: string): string {
|
||||||
|
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
||||||
|
return mnemonicRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
||||||
|
if (existsSync(relativeToCwd)) {
|
||||||
|
return relativeToCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inMnemonics = join(getMnemonicsDir(), basename(mnemonicRef));
|
||||||
|
if (existsSync(inMnemonics)) {
|
||||||
|
return inMnemonics;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
86
tests/cli/commands/handler-contracts.test.ts
Normal file
86
tests/cli/commands/handler-contracts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||||
|
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||||
|
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||||
|
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import {
|
||||||
|
createBaseCommandDeps,
|
||||||
|
createCommandDeps,
|
||||||
|
createMockIO,
|
||||||
|
} from "../mocks/command";
|
||||||
|
|
||||||
|
const fakeApp = {
|
||||||
|
engine: {},
|
||||||
|
invitations: [],
|
||||||
|
unreserveAllResources: async () => 0,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
describe("command handler contracts", () => {
|
||||||
|
test("mnemonic throws and prints help for missing subcommand", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleMnemonicCommand(createBaseCommandDeps(io), [], {}),
|
||||||
|
).rejects.toThrow(CommandError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleMnemonicCommand(createBaseCommandDeps(io), [], {});
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe("mnemonic.subcommand.missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(capture.out.join("\n")).toContain("Usage:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("template throws for missing subcommand", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleTemplateCommand(createCommandDeps(fakeApp, io), [], {}),
|
||||||
|
).rejects.toThrow(CommandError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleTemplateCommand(createCommandDeps(fakeApp, io), [], {});
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe("template.subcommand.missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(capture.out.join("\n")).toContain("Usage:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receive throws for missing args", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleReceiveCommand(createCommandDeps(fakeApp, io), [], {}),
|
||||||
|
).rejects.toThrow(CommandError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleReceiveCommand(createCommandDeps(fakeApp, io), [], {});
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe("receive.arguments.missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(capture.out.join("\n")).toContain("Usage:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource throws for unknown subcommand", async () => {
|
||||||
|
const { io } = createMockIO();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {}),
|
||||||
|
).rejects.toThrow(CommandError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {});
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1341
tests/cli/commands/invitation.test.ts
Normal file
1341
tests/cli/commands/invitation.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
146
tests/cli/commands/mnemonic.test.ts
Normal file
146
tests/cli/commands/mnemonic.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { DEFAULT_SEED } from "../mocks/engine";
|
||||||
|
|
||||||
|
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import { createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command";
|
||||||
|
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
|
||||||
|
|
||||||
|
type TestCase = {
|
||||||
|
inputs: string[];
|
||||||
|
options?: Record<string, string>;
|
||||||
|
shouldThrow: boolean;
|
||||||
|
expectedEvent?: string;
|
||||||
|
expectedData?: Record<string, unknown>;
|
||||||
|
logs?: LogExpectation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCases: TestCase[] = [
|
||||||
|
// Successful creation of a mnemonic file
|
||||||
|
{
|
||||||
|
inputs: ["create"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
|
||||||
|
},
|
||||||
|
logs: [{ out: "Mnemonic file created" }],
|
||||||
|
},
|
||||||
|
// Successfully creating a mnemonic file with a custom filename
|
||||||
|
{
|
||||||
|
inputs: ["create"],
|
||||||
|
options: { output: "custom-filename" },
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
savedAs: "custom-filename",
|
||||||
|
},
|
||||||
|
logs: [{ out: "custom-filename" }],
|
||||||
|
},
|
||||||
|
// Successfully listing mnemonic files
|
||||||
|
{
|
||||||
|
inputs: ["list"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
count: expect.toSatisfy((count: number) => count >= 1),
|
||||||
|
},
|
||||||
|
logs: [{ out: "mnemonic-test" }],
|
||||||
|
},
|
||||||
|
// Successfully exposing a mnemonic file
|
||||||
|
{
|
||||||
|
inputs: ["expose", "mnemonic-test"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
mnemonic: DEFAULT_SEED,
|
||||||
|
},
|
||||||
|
logs: [{ out: DEFAULT_SEED }],
|
||||||
|
},
|
||||||
|
// Successfully importing a mnemonic file
|
||||||
|
{
|
||||||
|
inputs: ["import", ...DEFAULT_SEED.split(" ")],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
|
||||||
|
},
|
||||||
|
logs: [{ out: "Mnemonic file created" }],
|
||||||
|
},
|
||||||
|
// Failure to import a mnemonic file due to missing arguments
|
||||||
|
{
|
||||||
|
inputs: ["import"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "mnemonic.import.seed_missing",
|
||||||
|
},
|
||||||
|
// Failure to expose a mnemonic file due to missing arguments
|
||||||
|
{
|
||||||
|
inputs: ["expose"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "mnemonic.expose.file_missing",
|
||||||
|
},
|
||||||
|
// Failure to expose a mnemonic file due to unknown mnemonic file
|
||||||
|
{
|
||||||
|
inputs: ["expose", "unknown-mnemonic-file"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "mnemonic.expose.file_not_found",
|
||||||
|
},
|
||||||
|
// Missing sub-command
|
||||||
|
{
|
||||||
|
inputs: [],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "mnemonic.subcommand.missing",
|
||||||
|
},
|
||||||
|
// Unknown sub-command
|
||||||
|
{
|
||||||
|
inputs: ["unknown"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "mnemonic.subcommand.unknown",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("mnemonic commands", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-mnemonic-tests-"));
|
||||||
|
|
||||||
|
// Write a single test mnemonic file to the temp directory
|
||||||
|
writeFileSync(
|
||||||
|
path.join(tempDir, "mnemonic-test"),
|
||||||
|
BCHMnemonicURL.fromSeed(DEFAULT_SEED).toURL(),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testCases)("mnemonic command: $inputs", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
if (shouldThrow) {
|
||||||
|
try {
|
||||||
|
await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
||||||
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
expectLogs(spies, logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
114
tests/cli/commands/receive.test.ts
Normal file
114
tests/cli/commands/receive.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine";
|
||||||
|
import { type Engine } from "@xo-cash/engine";
|
||||||
|
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||||
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
|
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command";
|
||||||
|
|
||||||
|
type TestCase = {
|
||||||
|
name: string;
|
||||||
|
inputs: string[];
|
||||||
|
options?: Record<string, string>;
|
||||||
|
shouldThrow: boolean;
|
||||||
|
expectedEvent?: string;
|
||||||
|
expectedData?: Record<string, unknown>;
|
||||||
|
logs?: LogExpectation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCases: TestCase[] = [
|
||||||
|
// Successful address generation with template name and output identifier
|
||||||
|
{
|
||||||
|
name: "generates address with template name and output",
|
||||||
|
inputs: ["Wallet (P2PKH)", "receiveOutput"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
|
||||||
|
},
|
||||||
|
logs: [{ out: "bitcoincash:q" }],
|
||||||
|
},
|
||||||
|
// Successful address generation with role specified
|
||||||
|
{
|
||||||
|
name: "generates address with template name, output, and role",
|
||||||
|
inputs: ["Wallet (P2PKH)", "receiveOutput", "receiver"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
|
||||||
|
},
|
||||||
|
logs: [{ out: "bitcoincash:q" }],
|
||||||
|
},
|
||||||
|
// Missing all required arguments
|
||||||
|
{
|
||||||
|
name: "throws when no arguments provided",
|
||||||
|
inputs: [],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "receive.arguments.missing",
|
||||||
|
},
|
||||||
|
// Missing output identifier
|
||||||
|
{
|
||||||
|
name: "throws when output identifier missing",
|
||||||
|
inputs: ["Wallet (P2PKH)"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "receive.arguments.missing",
|
||||||
|
},
|
||||||
|
// Unknown template
|
||||||
|
{
|
||||||
|
name: "throws when template not found",
|
||||||
|
inputs: ["unknown-template", "receiveOutput"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.resolve.not_found",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("receive command", () => {
|
||||||
|
let engine: Engine;
|
||||||
|
let app: AppService;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
engine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
|
app = await createMockAppService(engine);
|
||||||
|
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await engine.stop();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
|
||||||
|
if (shouldThrow) {
|
||||||
|
try {
|
||||||
|
await handleReceiveCommand(createCommandDeps(app, io), inputs, options ?? {});
|
||||||
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleReceiveCommand(createCommandDeps(app, io), inputs, options ?? {});
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
expectLogs(spies, logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
279
tests/cli/commands/resource.test.ts
Normal file
279
tests/cli/commands/resource.test.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, reserveResource } from "../mocks/engine";
|
||||||
|
import { type Engine } from "@xo-cash/engine";
|
||||||
|
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||||
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
|
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command";
|
||||||
|
|
||||||
|
type TestCase = {
|
||||||
|
name: string;
|
||||||
|
inputs: string[];
|
||||||
|
options?: Record<string, string>;
|
||||||
|
shouldThrow: boolean;
|
||||||
|
expectedEvent?: string;
|
||||||
|
expectedData?: Record<string, unknown>;
|
||||||
|
logs?: LogExpectation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCases: TestCase[] = [
|
||||||
|
// List commands (no resources in empty wallet)
|
||||||
|
{
|
||||||
|
name: "list returns empty count when no resources",
|
||||||
|
inputs: ["list"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
logs: [{ out: "No resources found" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list reserved returns empty count when no reserved resources",
|
||||||
|
inputs: ["list", "reserved"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
logs: [{ out: "No resources found" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list all returns empty count when no resources",
|
||||||
|
inputs: ["list", "all"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
logs: [{ out: "No resources found" }],
|
||||||
|
},
|
||||||
|
// Unreserve-all with no resources
|
||||||
|
{
|
||||||
|
name: "unreserve-all returns zero count when no reserved resources",
|
||||||
|
inputs: ["unreserve-all"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
logs: [{ out: "No reserved resources" }],
|
||||||
|
},
|
||||||
|
// Error cases
|
||||||
|
{
|
||||||
|
name: "throws when no subcommand provided",
|
||||||
|
inputs: [],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "resource.subcommand.missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when unknown subcommand provided",
|
||||||
|
inputs: ["unknown-subcommand"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "resource.subcommand.unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when unreserve called without outpoint",
|
||||||
|
inputs: ["unreserve"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "resource.unreserve.outpoint_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when unreserve called with invalid outpoint format (no colon)",
|
||||||
|
inputs: ["unreserve", "invalid-outpoint"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "resource.unreserve.outpoint_invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when unreserve called with invalid outpoint format (no vout)",
|
||||||
|
inputs: ["unreserve", "abc123:"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "resource.unreserve.outpoint_invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when unreserve called with non-existent UTXO",
|
||||||
|
inputs: ["unreserve", "0000000000000000000000000000000000000000000000000000000000000000:0"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "resource.unreserve.utxo_missing",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("resource command", () => {
|
||||||
|
let engine: Engine;
|
||||||
|
let app: AppService;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
engine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
|
app = await createMockAppService(engine);
|
||||||
|
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await engine.stop();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
|
||||||
|
if (shouldThrow) {
|
||||||
|
try {
|
||||||
|
await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {});
|
||||||
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {});
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
expectLogs(spies, logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resource command with populated data", () => {
|
||||||
|
let engine: Engine;
|
||||||
|
let app: AppService;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
engine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
app = await createMockAppService(engine);
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await engine.stop();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list returns count when resources exist", async () => {
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 50000 });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 25000 });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expectLogs(spies, [{ out: "Total resources: 2" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list shows total satoshis", async () => {
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 50000 });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 25000 });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||||
|
|
||||||
|
expectLogs(spies, [{ out: "Total satoshis: 75000" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list excludes reserved resources by default", async () => {
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 50000 });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
||||||
|
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||||
|
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list reserved shows only reserved resources", async () => {
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 50000 });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "reserved"], {});
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expectLogs(spies, [{ out: "reserved for inv-123" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list all shows both reserved and unreserved", async () => {
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 50000 });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
||||||
|
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "all"], {});
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unreserve releases a reserved UTXO", async () => {
|
||||||
|
const resource = await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expectLogs(spies, [{ out: "Unreserved" }, { out: "was reserved for inv-123" }]);
|
||||||
|
|
||||||
|
const resources = await engine.listUnspentOutputsData();
|
||||||
|
const target = resources.find(
|
||||||
|
r => r.outpointTransactionHash === resource.outpointTransactionHash,
|
||||||
|
);
|
||||||
|
expect(target?.reservedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unreserve reports when UTXO is not reserved", async () => {
|
||||||
|
const resource = await addFakeResource(engine, { valueSatoshis: 25000 });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expectLogs(spies, [{ out: "UTXO is not reserved" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unreserve-all releases all reserved UTXOs", async () => {
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 50000 });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
||||||
|
await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
const result = await handleResourceCommand(createCommandDeps(app, io), ["unreserve-all"], {});
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
|
||||||
|
|
||||||
|
const resources = await engine.listUnspentOutputsData();
|
||||||
|
const reserved = resources.filter(r => r.reservedBy);
|
||||||
|
expect(reserved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list displays outpoint information", async () => {
|
||||||
|
const resource = await addFakeResource(engine, { valueSatoshis: 12345 });
|
||||||
|
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||||
|
|
||||||
|
expectLogs(spies, [
|
||||||
|
{ out: resource.outpointTransactionHash },
|
||||||
|
{ out: "12345 sats" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
tests/cli/commands/template.test.ts
Normal file
235
tests/cli/commands/template.test.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine";
|
||||||
|
import { type Engine } from "@xo-cash/engine";
|
||||||
|
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh";
|
||||||
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
|
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command";
|
||||||
|
|
||||||
|
type TestCase = {
|
||||||
|
name: string;
|
||||||
|
inputs: string[];
|
||||||
|
options?: Record<string, string>;
|
||||||
|
shouldThrow: boolean;
|
||||||
|
expectedEvent?: string;
|
||||||
|
expectedData?: Record<string, unknown>;
|
||||||
|
logs?: LogExpectation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCases: TestCase[] = [
|
||||||
|
// List command
|
||||||
|
{
|
||||||
|
name: "list returns count of imported templates",
|
||||||
|
inputs: ["list"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
logs: [{ out: "Wallet (P2PKH)" }],
|
||||||
|
},
|
||||||
|
// List by category
|
||||||
|
{
|
||||||
|
name: "list action returns actions for template",
|
||||||
|
inputs: ["list", "action", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
logs: [{ out: "receive" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list output returns outputs for template",
|
||||||
|
inputs: ["list", "output", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
logs: [{ out: "receiveOutput" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list variable returns variables for template",
|
||||||
|
inputs: ["list", "variable", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
logs: [{ out: "ownerKey" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list transaction returns transactions for template",
|
||||||
|
inputs: ["list", "transaction", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list lockingscript returns locking scripts for template",
|
||||||
|
inputs: ["list", "lockingscript", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
},
|
||||||
|
// Inspect command
|
||||||
|
{
|
||||||
|
name: "inspect action returns action details",
|
||||||
|
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "receive"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inspect output returns output details",
|
||||||
|
inputs: ["inspect", "output", p2pkhTemplateIdentifier, "receiveOutput"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inspect variable returns variable details",
|
||||||
|
inputs: ["inspect", "variable", p2pkhTemplateIdentifier, "ownerKey"],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
},
|
||||||
|
// Error cases - subcommand
|
||||||
|
{
|
||||||
|
name: "throws when no subcommand provided",
|
||||||
|
inputs: [],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.subcommand.missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when unknown subcommand provided",
|
||||||
|
inputs: ["unknown-subcommand"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.subcommand.unknown",
|
||||||
|
},
|
||||||
|
// Error cases - import
|
||||||
|
{
|
||||||
|
name: "throws when import called without file",
|
||||||
|
inputs: ["import"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.import.file_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when import called with non-existent file",
|
||||||
|
inputs: ["import", "non-existent-file.json"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.import.file_not_found",
|
||||||
|
},
|
||||||
|
// Error cases - list category
|
||||||
|
{
|
||||||
|
name: "throws when list category called without template identifier",
|
||||||
|
inputs: ["list", "action"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.list.identifier_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when list category called with unknown template",
|
||||||
|
inputs: ["list", "action", "unknown-template"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.list.not_found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when list called with unknown category",
|
||||||
|
inputs: ["list", "unknown-category", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.list.category_unknown",
|
||||||
|
},
|
||||||
|
// Error cases - inspect
|
||||||
|
{
|
||||||
|
name: "throws when inspect called without all arguments",
|
||||||
|
inputs: ["inspect", "action"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.inspect.arguments_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when inspect called with unknown template",
|
||||||
|
inputs: ["inspect", "action", "unknown-template", "receive"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.resolve.not_found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when inspect called with unknown action",
|
||||||
|
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "unknown-action"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.inspect.action_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when inspect called with unknown category",
|
||||||
|
inputs: ["inspect", "unknown-category", p2pkhTemplateIdentifier, "field"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.inspect.category_unknown",
|
||||||
|
},
|
||||||
|
// Error cases - set-default
|
||||||
|
{
|
||||||
|
name: "throws when set-default called without all arguments",
|
||||||
|
inputs: ["set-default", "template"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.default.arguments_missing",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("template command", () => {
|
||||||
|
let engine: Engine;
|
||||||
|
let app: AppService;
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
engine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
|
app = await createMockAppService(engine);
|
||||||
|
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await engine.stop();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
|
||||||
|
if (shouldThrow) {
|
||||||
|
try {
|
||||||
|
await handleTemplateCommand(createCommandDeps(app, io), inputs, options ?? {});
|
||||||
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleTemplateCommand(createCommandDeps(app, io), inputs, options ?? {});
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs) {
|
||||||
|
expectLogs(spies, logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import imports template from file", async () => {
|
||||||
|
const templatePath = path.join(tempDir, "test-template.json");
|
||||||
|
writeFileSync(templatePath, JSON.stringify(p2pkhTemplate));
|
||||||
|
|
||||||
|
const { io } = createMockIO();
|
||||||
|
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handleTemplateCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["import", "test-template.json"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.templateFile).toBe("test-template.json");
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tests/cli/integration/entry.test.ts
Normal file
28
tests/cli/integration/entry.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const runCli = (args: string[]) => {
|
||||||
|
const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx");
|
||||||
|
const cliPath = path.resolve(process.cwd(), "src/cli/index.ts");
|
||||||
|
|
||||||
|
return spawnSync(tsxPath, [cliPath, ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cli entry boundary behavior", () => {
|
||||||
|
test("returns non-zero for incomplete mnemonic invocation", () => {
|
||||||
|
const result = runCli(["mnemonic"]);
|
||||||
|
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stdout).toContain("Usage:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns zero for mnemonic list invocation", () => {
|
||||||
|
const result = runCli(["mnemonic", "list"]);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
tests/cli/mnemonic.test.ts
Normal file
192
tests/cli/mnemonic.test.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createMnemonicSeed,
|
||||||
|
createMnemonicFile,
|
||||||
|
resolveMnemonicFilePath,
|
||||||
|
loadMnemonic,
|
||||||
|
listMnemonicFiles,
|
||||||
|
} from "../../src/cli/mnemonic";
|
||||||
|
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||||
|
|
||||||
|
const TEST_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||||
|
|
||||||
|
describe("mnemonic utilities", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = path.join(tmpdir(), `xo-cli-mnemonic-utils-test-${Date.now()}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createMnemonicSeed", () => {
|
||||||
|
test("generates a valid BIP39 mnemonic", () => {
|
||||||
|
const mnemonic = createMnemonicSeed();
|
||||||
|
|
||||||
|
expect(typeof mnemonic).toBe("string");
|
||||||
|
const words = mnemonic.split(" ");
|
||||||
|
expect(words.length).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates unique mnemonics on each call", () => {
|
||||||
|
const mnemonic1 = createMnemonicSeed();
|
||||||
|
const mnemonic2 = createMnemonicSeed();
|
||||||
|
|
||||||
|
expect(mnemonic1).not.toBe(mnemonic2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createMnemonicFile", () => {
|
||||||
|
test("creates a mnemonic file with auto-generated name", () => {
|
||||||
|
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||||
|
|
||||||
|
expect(filename).toMatch(/^mnemonic-page$/);
|
||||||
|
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a mnemonic file with custom name", () => {
|
||||||
|
const filename = createMnemonicFile(tempDir, TEST_SEED, "my-wallet");
|
||||||
|
|
||||||
|
expect(filename).toBe("my-wallet");
|
||||||
|
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes valid BCHMnemonicURL format", () => {
|
||||||
|
const filename = createMnemonicFile(tempDir, TEST_SEED, "test-wallet");
|
||||||
|
const content = readFileSync(path.join(tempDir, filename), "utf8");
|
||||||
|
|
||||||
|
expect(content).toMatch(/^bch-mnemonic:/);
|
||||||
|
const parsed = BCHMnemonicURL.fromURL(content);
|
||||||
|
expect(parsed).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sanitizes filename to basename only", () => {
|
||||||
|
const filename = createMnemonicFile(tempDir, TEST_SEED, "../../../evil-path");
|
||||||
|
|
||||||
|
expect(filename).toBe("evil-path");
|
||||||
|
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
|
||||||
|
expect(existsSync(path.join(tempDir, "../../../evil-path"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when mnemonic is empty", () => {
|
||||||
|
expect(() => createMnemonicFile(tempDir, "")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMnemonicFilePath", () => {
|
||||||
|
test("resolves absolute path when file exists", () => {
|
||||||
|
const filePath = path.join(tempDir, "mnemonic-absolute");
|
||||||
|
writeFileSync(filePath, "test");
|
||||||
|
|
||||||
|
const resolved = resolveMnemonicFilePath(tempDir, filePath);
|
||||||
|
expect(resolved).toBe(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves path relative to cwd when file exists", () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
|
||||||
|
const resolved = resolveMnemonicFilePath("/nonexistent", "mnemonic-relative");
|
||||||
|
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves from mnemonicsDir when file exists there", () => {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-test"), "test");
|
||||||
|
|
||||||
|
const resolved = resolveMnemonicFilePath(tempDir, "mnemonic-test");
|
||||||
|
expect(resolved).toBe(path.join(tempDir, "mnemonic-test"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when file not found anywhere", () => {
|
||||||
|
expect(() => resolveMnemonicFilePath(tempDir, "nonexistent-file")).toThrow(
|
||||||
|
/Mnemonic file not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips path components and looks up basename in mnemonicsDir", () => {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
|
||||||
|
|
||||||
|
const resolved = resolveMnemonicFilePath(tempDir, "some/path/mnemonic-basename");
|
||||||
|
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadMnemonic", () => {
|
||||||
|
test("loads mnemonic from file", () => {
|
||||||
|
createMnemonicFile(tempDir, TEST_SEED, "test-load");
|
||||||
|
|
||||||
|
const loaded = loadMnemonic(tempDir, "test-load");
|
||||||
|
expect(loaded).toBe(TEST_SEED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads mnemonic from absolute path", () => {
|
||||||
|
const filePath = path.join(tempDir, "mnemonic-absolute-load");
|
||||||
|
createMnemonicFile(tempDir, TEST_SEED, "mnemonic-absolute-load");
|
||||||
|
|
||||||
|
const loaded = loadMnemonic(tempDir, filePath);
|
||||||
|
expect(loaded).toBe(TEST_SEED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when file not found", () => {
|
||||||
|
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(/Mnemonic file not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when file contains invalid data", () => {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-invalid"), "not a valid mnemonic url");
|
||||||
|
|
||||||
|
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listMnemonicFiles", () => {
|
||||||
|
test("returns empty array when no mnemonic files exist", () => {
|
||||||
|
const files = listMnemonicFiles(tempDir);
|
||||||
|
expect(files).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists only files starting with 'mnemonic-'", () => {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-one"), "test");
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-two"), "test");
|
||||||
|
writeFileSync(path.join(tempDir, "other-file"), "test");
|
||||||
|
writeFileSync(path.join(tempDir, "wallet.json"), "test");
|
||||||
|
|
||||||
|
const files = listMnemonicFiles(tempDir);
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files).toContain("mnemonic-one");
|
||||||
|
expect(files).toContain("mnemonic-two");
|
||||||
|
expect(files).not.toContain("other-file");
|
||||||
|
expect(files).not.toContain("wallet.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns sorted or consistent ordering", () => {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-zebra"), "test");
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-alpha"), "test");
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-beta"), "test");
|
||||||
|
|
||||||
|
const files = listMnemonicFiles(tempDir);
|
||||||
|
expect(files).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("round-trip", () => {
|
||||||
|
test("create and load preserves mnemonic exactly", () => {
|
||||||
|
const original = createMnemonicSeed();
|
||||||
|
createMnemonicFile(tempDir, original, "roundtrip-test");
|
||||||
|
|
||||||
|
const loaded = loadMnemonic(tempDir, "roundtrip-test");
|
||||||
|
expect(loaded).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
162
tests/cli/mocks/command.ts
Normal file
162
tests/cli/mocks/command.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { vi, expect, type Mock } from "vitest";
|
||||||
|
import type {
|
||||||
|
BaseCommandDependencies,
|
||||||
|
CommandDependencies,
|
||||||
|
CommandIO,
|
||||||
|
CommandPaths,
|
||||||
|
} from "../../../src/cli/commands/types";
|
||||||
|
import type { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured CLI IO buffers used by tests.
|
||||||
|
*/
|
||||||
|
export type MockIOCapture = {
|
||||||
|
out: string[];
|
||||||
|
err: string[];
|
||||||
|
verbose: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spy functions for each IO channel.
|
||||||
|
*/
|
||||||
|
export type MockIOSpies = {
|
||||||
|
out: Mock;
|
||||||
|
err: Mock;
|
||||||
|
verbose: Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete mock IO result including the IO adapter, capture buffers, and spies.
|
||||||
|
*/
|
||||||
|
export type MockIO = {
|
||||||
|
io: CommandIO;
|
||||||
|
capture: MockIOCapture;
|
||||||
|
spies: MockIOSpies;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines an expected log message for assertion.
|
||||||
|
* At least one of out, err, or verbose should be specified.
|
||||||
|
*/
|
||||||
|
export type LogExpectation = {
|
||||||
|
/** Expected substring (or exact match if exact=true) in io.out */
|
||||||
|
out?: string;
|
||||||
|
/** Expected substring (or exact match if exact=true) in io.err */
|
||||||
|
err?: string;
|
||||||
|
/** Expected substring (or exact match if exact=true) in io.verbose */
|
||||||
|
verbose?: string;
|
||||||
|
/** If true, match the string exactly instead of using contains (default: false) */
|
||||||
|
exact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a command IO adapter that records every message using vi.fn() spies.
|
||||||
|
* This enables vitest's built-in matchers like toHaveBeenCalledWith.
|
||||||
|
*/
|
||||||
|
export const createMockIO = (): MockIO => {
|
||||||
|
const capture: MockIOCapture = {
|
||||||
|
out: [],
|
||||||
|
err: [],
|
||||||
|
verbose: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const outSpy = vi.fn((message: string) => {
|
||||||
|
capture.out.push(message);
|
||||||
|
});
|
||||||
|
const errSpy = vi.fn((message: string) => {
|
||||||
|
capture.err.push(message);
|
||||||
|
});
|
||||||
|
const verboseSpy = vi.fn((message: string) => {
|
||||||
|
capture.verbose.push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
const io: CommandIO = {
|
||||||
|
out: outSpy,
|
||||||
|
err: errSpy,
|
||||||
|
verbose: verboseSpy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
io,
|
||||||
|
capture,
|
||||||
|
spies: {
|
||||||
|
out: outSpy,
|
||||||
|
err: errSpy,
|
||||||
|
verbose: verboseSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the expected log messages were printed to the appropriate IO channels.
|
||||||
|
* @param spies - The mock IO spies from createMockIO
|
||||||
|
* @param logs - Array of log expectations to validate
|
||||||
|
*/
|
||||||
|
export const expectLogs = (spies: MockIOSpies, logs: LogExpectation[]): void => {
|
||||||
|
for (const log of logs) {
|
||||||
|
if (log.out !== undefined) {
|
||||||
|
if (log.exact) {
|
||||||
|
expect(spies.out).toHaveBeenCalledWith(log.out);
|
||||||
|
} else {
|
||||||
|
expect(spies.out).toHaveBeenCalledWith(expect.stringContaining(log.out));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (log.err !== undefined) {
|
||||||
|
if (log.exact) {
|
||||||
|
expect(spies.err).toHaveBeenCalledWith(log.err);
|
||||||
|
} else {
|
||||||
|
expect(spies.err).toHaveBeenCalledWith(expect.stringContaining(log.err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (log.verbose !== undefined) {
|
||||||
|
if (log.exact) {
|
||||||
|
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
|
||||||
|
} else {
|
||||||
|
expect(spies.verbose).toHaveBeenCalledWith(expect.stringContaining(log.verbose));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates mock paths for testing.
|
||||||
|
* @param tempDir - Optional temp directory to use as base for all paths
|
||||||
|
*/
|
||||||
|
export const createMockPaths = (tempDir?: string): CommandPaths => {
|
||||||
|
const base = tempDir ?? "/tmp/xo-cli-test";
|
||||||
|
return {
|
||||||
|
mnemonicsDir: base,
|
||||||
|
dataDir: base,
|
||||||
|
walletConfigPath: `${base}/.wallet`,
|
||||||
|
workingDir: base,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates base command dependencies for commands that do not require the app.
|
||||||
|
* @param io - Command IO adapter
|
||||||
|
* @param paths - Optional custom paths (defaults to mock paths)
|
||||||
|
*/
|
||||||
|
export const createBaseCommandDeps = (
|
||||||
|
io: CommandIO,
|
||||||
|
paths?: CommandPaths,
|
||||||
|
): BaseCommandDependencies => ({
|
||||||
|
io,
|
||||||
|
paths: paths ?? createMockPaths(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates command dependencies for app-backed command handlers.
|
||||||
|
* @param app - App service instance
|
||||||
|
* @param io - Command IO adapter
|
||||||
|
* @param paths - Optional custom paths (defaults to mock paths)
|
||||||
|
*/
|
||||||
|
export const createCommandDeps = (
|
||||||
|
app: AppService,
|
||||||
|
io: CommandIO,
|
||||||
|
paths?: CommandPaths,
|
||||||
|
): CommandDependencies => ({
|
||||||
|
app,
|
||||||
|
io,
|
||||||
|
paths: paths ?? createMockPaths(),
|
||||||
|
});
|
||||||
7
tests/cli/mocks/electrum-service.ts
Normal file
7
tests/cli/mocks/electrum-service.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class MockElectrumService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
tests/cli/mocks/engine.ts
Normal file
164
tests/cli/mocks/engine.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
import { createStorageAdapter, State, StorageType, type UnspentOutputData } from "@xo-cash/state";
|
||||||
|
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
|
||||||
|
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||||
|
|
||||||
|
import { binToHex, sha256 } from "@bitauth/libauth";
|
||||||
|
import { AppService } from "../../../src/services/app";
|
||||||
|
import { InMemoryStorage } from "../../../src/services/storage";
|
||||||
|
import { MockElectrumService } from "./electrum-service";
|
||||||
|
|
||||||
|
export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a fake resource (UTXO) in tests.
|
||||||
|
*/
|
||||||
|
export type FakeResourceOptions = {
|
||||||
|
/** Transaction hash of the outpoint. Auto-generated if not provided. */
|
||||||
|
outpointTransactionHash?: string;
|
||||||
|
/** Index of the outpoint in the transaction. Defaults to 0. */
|
||||||
|
outpointIndex?: number;
|
||||||
|
/** Value in satoshis. Defaults to 10000. */
|
||||||
|
valueSatoshis?: number;
|
||||||
|
/** Template identifier. Defaults to "test-template". */
|
||||||
|
templateIdentifier?: string;
|
||||||
|
/** Output identifier from the template. Defaults to "receiveOutput". */
|
||||||
|
outputIdentifier?: string;
|
||||||
|
/** Locking bytecode for this output. Defaults to a placeholder. */
|
||||||
|
lockingBytecode?: string;
|
||||||
|
/** Block height where the UTXO was mined. Defaults to 800000. */
|
||||||
|
minedAtHeight?: number;
|
||||||
|
/** Invitation identifier that reserves this output. Undefined means unreserved. */
|
||||||
|
reservedBy?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random 64-character hex string representing a transaction hash.
|
||||||
|
*/
|
||||||
|
export const randomTxHash = (): string => {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a fake resource (UTXO) to the engine's state for testing purposes.
|
||||||
|
* @param engine - The engine instance to add the resource to.
|
||||||
|
* @param options - Options for the fake resource. All fields have sensible defaults.
|
||||||
|
* @returns The created UnspentOutputData object.
|
||||||
|
*/
|
||||||
|
export const addFakeResource = async (
|
||||||
|
engine: Engine,
|
||||||
|
options: FakeResourceOptions = {},
|
||||||
|
): Promise<UnspentOutputData> => {
|
||||||
|
const resource: UnspentOutputData = {
|
||||||
|
status: "confirmed",
|
||||||
|
selectable: true,
|
||||||
|
privacy: false,
|
||||||
|
templateIdentifier: options.templateIdentifier ?? "test-template",
|
||||||
|
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
||||||
|
outpointIndex: options.outpointIndex ?? 0,
|
||||||
|
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||||
|
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||||
|
valueSatoshis: options.valueSatoshis ?? 10000,
|
||||||
|
lockingBytecode: options.lockingBytecode ?? "76a914000000000000000000000000000000000000000088ac",
|
||||||
|
reservedBy: options.reservedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
await engine.state.storeUnspentOutputData(resource);
|
||||||
|
return resource;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves a resource for a specific invitation.
|
||||||
|
* @param engine - The engine instance.
|
||||||
|
* @param outpointTransactionHash - The transaction hash of the UTXO to reserve.
|
||||||
|
* @param outpointIndex - The output index of the UTXO to reserve.
|
||||||
|
* @param invitationIdentifier - The invitation identifier to reserve for.
|
||||||
|
*/
|
||||||
|
export const reserveResource = async (
|
||||||
|
engine: Engine,
|
||||||
|
outpointTransactionHash: string,
|
||||||
|
outpointIndex: number,
|
||||||
|
invitationIdentifier: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await engine.state.executeBulkUnspentOutputReservation(
|
||||||
|
[{ outpointTransactionHash, outpointIndex }],
|
||||||
|
true,
|
||||||
|
invitationIdentifier,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unreserves a resource from a specific invitation.
|
||||||
|
* @param engine - The engine instance.
|
||||||
|
* @param outpointTransactionHash - The transaction hash of the UTXO to unreserve.
|
||||||
|
* @param outpointIndex - The output index of the UTXO to unreserve.
|
||||||
|
* @param invitationIdentifier - The invitation identifier to unreserve from.
|
||||||
|
*/
|
||||||
|
export const unreserveResource = async (
|
||||||
|
engine: Engine,
|
||||||
|
outpointTransactionHash: string,
|
||||||
|
outpointIndex: number,
|
||||||
|
invitationIdentifier: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await engine.state.executeBulkUnspentOutputReservation(
|
||||||
|
[{ outpointTransactionHash, outpointIndex }],
|
||||||
|
false,
|
||||||
|
invitationIdentifier,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock engine instance with a given seed. Uses the in-memory storage and blockchain provider.
|
||||||
|
* @param seed - The seed to use for the engine.
|
||||||
|
* @returns A mock engine instance.
|
||||||
|
*/
|
||||||
|
export const createMockEngine = async (seed: string) => {
|
||||||
|
// Create the in-memory storage adapter.
|
||||||
|
const storage = await createStorageAdapter({
|
||||||
|
storageType: StorageType.INMEMORY,
|
||||||
|
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the storage adapter.
|
||||||
|
await storage.initialize();
|
||||||
|
|
||||||
|
// Create the state instance.
|
||||||
|
const state = new State(storage);
|
||||||
|
|
||||||
|
// Create the in-memory blockchain provider.
|
||||||
|
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||||
|
await blockchainProvider.initialize({
|
||||||
|
applicationIdentifier: "xo-cli-tests",
|
||||||
|
electrumOptions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the blockchain monitor instance.
|
||||||
|
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||||
|
await blockchainMonitor.initializeEventListeners();
|
||||||
|
|
||||||
|
// Create the engine instance.
|
||||||
|
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
||||||
|
await engine.initializeStateSync();
|
||||||
|
|
||||||
|
return engine;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMockAppService = async (engine: Engine) => {
|
||||||
|
const storage = await InMemoryStorage.create();
|
||||||
|
|
||||||
|
const electrum = new MockElectrumService();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
syncServerUrl: "http://localhost:3000",
|
||||||
|
engineConfig: {
|
||||||
|
databasePath: "test-data",
|
||||||
|
databaseFilename: "xo-wallet.db",
|
||||||
|
},
|
||||||
|
invitationStoragePath: "test-invitations.db",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AppService(engine, storage, config, electrum);
|
||||||
|
};
|
||||||
1395
tests/cli/mocks/template-p2pkh.ts
Normal file
1395
tests/cli/mocks/template-p2pkh.ts
Normal file
File diff suppressed because it is too large
Load Diff
149
tests/cli/paths.test.ts
Normal file
149
tests/cli/paths.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir, tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getConfigDir,
|
||||||
|
getMnemonicsDir,
|
||||||
|
getDataDir,
|
||||||
|
getWalletConfigPath,
|
||||||
|
resolveMnemonicFilePath,
|
||||||
|
} from "../../src/utils/paths";
|
||||||
|
|
||||||
|
describe("paths utilities", () => {
|
||||||
|
describe("getConfigDir", () => {
|
||||||
|
test("returns path under ~/.config/xo-cli", () => {
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
|
||||||
|
expect(configDir).toBe(path.join(homedir(), ".config", "xo-cli"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates the directory if it does not exist", () => {
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
|
||||||
|
expect(existsSync(configDir)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMnemonicsDir", () => {
|
||||||
|
test("returns path under config dir", () => {
|
||||||
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
|
||||||
|
expect(mnemonicsDir).toBe(path.join(homedir(), ".config", "xo-cli", "mnemonics"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates the directory if it does not exist", () => {
|
||||||
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
|
||||||
|
expect(existsSync(mnemonicsDir)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDataDir", () => {
|
||||||
|
test("returns path under config dir", () => {
|
||||||
|
const dataDir = getDataDir();
|
||||||
|
|
||||||
|
expect(dataDir).toBe(path.join(homedir(), ".config", "xo-cli", "data"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates the directory if it does not exist", () => {
|
||||||
|
const dataDir = getDataDir();
|
||||||
|
|
||||||
|
expect(existsSync(dataDir)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWalletConfigPath", () => {
|
||||||
|
test("returns .wallet file path under config dir", () => {
|
||||||
|
const walletConfigPath = getWalletConfigPath();
|
||||||
|
|
||||||
|
expect(walletConfigPath).toBe(path.join(homedir(), ".config", "xo-cli", ".wallet"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMnemonicFilePath (global)", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = path.join(tmpdir(), `xo-cli-paths-test-${Date.now()}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves absolute path when file exists", () => {
|
||||||
|
const filePath = path.join(tempDir, "mnemonic-test");
|
||||||
|
writeFileSync(filePath, "test");
|
||||||
|
|
||||||
|
const resolved = resolveMnemonicFilePath(filePath);
|
||||||
|
expect(resolved).toBe(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves path relative to cwd when file exists", () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
||||||
|
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
|
||||||
|
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves from global mnemonics dir when file exists there", () => {
|
||||||
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(testFile, "test");
|
||||||
|
const resolved = resolveMnemonicFilePath("mnemonic-global-test");
|
||||||
|
expect(resolved).toBe(testFile);
|
||||||
|
} finally {
|
||||||
|
if (existsSync(testFile)) {
|
||||||
|
rmSync(testFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when file not found anywhere", () => {
|
||||||
|
expect(() => resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz")).toThrow(
|
||||||
|
/Mnemonic file not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not resolve absolute path if file does not exist", () => {
|
||||||
|
const nonExistentPath = "/nonexistent/path/mnemonic-test";
|
||||||
|
expect(() => resolveMnemonicFilePath(nonExistentPath)).toThrow(
|
||||||
|
/Mnemonic file not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path hierarchy", () => {
|
||||||
|
test("mnemonics dir is under config dir", () => {
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
|
||||||
|
expect(mnemonicsDir.startsWith(configDir)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("data dir is under config dir", () => {
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
const dataDir = getDataDir();
|
||||||
|
|
||||||
|
expect(dataDir.startsWith(configDir)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wallet config is under config dir", () => {
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
const walletConfig = getWalletConfigPath();
|
||||||
|
|
||||||
|
expect(walletConfig.startsWith(configDir)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user