From b47962a4f51072153ee969d85a37a8f19a4446c6 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 13 Mar 2026 23:59:43 +0700 Subject: [PATCH 1/2] feat: add sync, backup mnemonic, change password, lightning address, adds verbs to commands --- README.md | 42 +++- package.json | 3 +- src/client.ts | 9 + src/commands/backup-mnemonic.ts | 29 +++ src/commands/change-password.ts | 35 ++++ src/commands/{balances.ts => get-balances.ts} | 4 +- ...gestions.ts => get-channel-suggestions.ts} | 2 +- src/commands/{health.ts => get-health.ts} | 2 +- src/commands/{info.ts => get-info.ts} | 2 +- .../{node-status.ts => get-node-status.ts} | 2 +- ...llet-address.ts => get-onchain-address.ts} | 2 +- src/commands/{apps.ts => list-apps.ts} | 4 +- .../{channels.ts => list-channels.ts} | 2 +- src/commands/{peers.ts => list-peers.ts} | 2 +- .../{transactions.ts => list-transactions.ts} | 8 +- ...r.ts => request-alby-lsp-channel-offer.ts} | 2 +- .../request-invoice-from-lightning-address.ts | 27 +++ .../{lsp-order.ts => request-lsp-order.ts} | 2 +- src/commands/sync.ts | 15 ++ src/index.ts | 58 +++--- src/test/e2e/backup-mnemonic.e2e.test.ts | 67 +++++++ src/test/e2e/change-password.e2e.test.ts | 174 ++++++++++++++++ src/test/e2e/channel-lifecycle.e2e.test.ts | 16 +- .../{ => mutinynet}/mutinynet-lsp.e2e.test.ts | 4 +- ...invoice-from-lightning-address.e2e.test.ts | 186 ++++++++++++++++++ src/test/e2e/stop.e2e.test.ts | 2 +- src/test/e2e/sync.e2e.test.ts | 65 ++++++ yarn.lock | 5 + 28 files changed, 709 insertions(+), 62 deletions(-) create mode 100644 src/commands/backup-mnemonic.ts create mode 100644 src/commands/change-password.ts rename src/commands/{balances.ts => get-balances.ts} (81%) rename src/commands/{channel-suggestions.ts => get-channel-suggestions.ts} (87%) rename src/commands/{health.ts => get-health.ts} (89%) rename src/commands/{info.ts => get-info.ts} (87%) rename src/commands/{node-status.ts => get-node-status.ts} (86%) rename src/commands/{wallet-address.ts => get-onchain-address.ts} (85%) rename src/commands/{apps.ts => list-apps.ts} (82%) rename src/commands/{channels.ts => list-channels.ts} (86%) rename src/commands/{peers.ts => list-peers.ts} (87%) rename src/commands/{transactions.ts => list-transactions.ts} (80%) rename src/commands/{channel-offer.ts => request-alby-lsp-channel-offer.ts} (87%) create mode 100644 src/commands/request-invoice-from-lightning-address.ts rename src/commands/{lsp-order.ts => request-lsp-order.ts} (94%) create mode 100644 src/commands/sync.ts create mode 100644 src/test/e2e/backup-mnemonic.e2e.test.ts create mode 100644 src/test/e2e/change-password.e2e.test.ts rename src/test/e2e/{ => mutinynet}/mutinynet-lsp.e2e.test.ts (98%) create mode 100644 src/test/e2e/mutinynet/request-invoice-from-lightning-address.e2e.test.ts create mode 100644 src/test/e2e/sync.e2e.test.ts diff --git a/README.md b/README.md index 5f5f235..5d2a510 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ If you're using [Alby Cloud](https://getalby.com/alby-hub): 4. The CLI now auto-connects to `https://my.albyhub.com` with the correct routing headers. Use `start`/`unlock` normally: ```bash npx @getalby/hub-cli start --password YOUR_PASSWORD --save - npx @getalby/hub-cli balances + npx @getalby/hub-cli get-balances ``` To override the hub name for a single invocation, set `ALBY_HUB_NAME` env var. @@ -140,7 +140,7 @@ npx @getalby/hub-cli get-health ```bash # Lightning + on-chain balances -npx @getalby/hub-cli balances +npx @getalby/hub-cli get-balances # Get an on-chain deposit address npx @getalby/hub-cli get-onchain-address @@ -200,6 +200,21 @@ npx @getalby/hub-cli pay-invoice ```bash # Stop the Lightning node (hub HTTP server keeps running) npx @getalby/hub-cli stop + +# Trigger a wallet sync (queued, may take up to a minute) +npx @getalby/hub-cli sync + +# Export wallet recovery phrase to a file (default: ~/.hub-cli/albyhub.recovery) +npx @getalby/hub-cli backup --password YOUR_PASSWORD + +# Export to a custom path +npx @getalby/hub-cli backup --password YOUR_PASSWORD --output /path/to/backup.recovery + +# Change the hub unlock password +npx @getalby/hub-cli change-password \ + --current-password YOUR_PASSWORD \ + --confirm-current-password YOUR_PASSWORD \ + --new-password NEW_PASSWORD ``` ### Payments @@ -211,6 +226,9 @@ npx @getalby/hub-cli pay-invoice lnbc... # Pay a zero-amount invoice, specifying the amount npx @getalby/hub-cli pay-invoice lnbc... --amount 1000 +# Pay a lightning address (user@domain), amount in satoshis +npx @getalby/hub-cli pay-lightning-address user@domain.com --amount 1000 + # Create an invoice npx @getalby/hub-cli make-invoice --amount 1000 --description "test" ``` @@ -269,7 +287,7 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo | Command | Description | Required Options | | --------------------- | ----------------------------- | ---------------- | -| `balances` | Lightning + on-chain balances | — | +| `get-balances` | Lightning + on-chain balances | — | | `get-onchain-address` | On-chain deposit address | — | ### Channels & Peers @@ -288,16 +306,20 @@ npx @getalby/hub-cli create-app --name "Isolated App" --isolated --unlock-passwo ### Node Management -| Command | Description | Required Options | -| ------- | --------------------------------------------------- | ---------------- | -| `stop` | Stop the Lightning node (HTTP server keeps running) | — | +| Command | Description | Required Options | +| ----------------- | --------------------------------------------------- | --------------------------------------------------------------------- | +| `stop` | Stop the Lightning node (HTTP server keeps running) | — | +| `sync` | Trigger a wallet sync | — | +| `backup` | Export wallet recovery phrase to a file | `--password` | +| `change-password` | Change the hub unlock password | `--current-password`, `--confirm-current-password`, `--new-password` | ### Payments -| Command | Description | Required Options | -| -------------- | ----------------------- | ---------------------- | -| `pay-invoice` | Pay a BOLT11 invoice | `` (argument) | -| `make-invoice` | Create a BOLT11 invoice | `--amount` | +| Command | Description | Required Options | +| ------------------------- | -------------------------------- | ----------------------------- | +| `pay-invoice` | Pay a BOLT11 invoice | `` (argument) | +| `pay-lightning-address` | Pay a lightning address | `
` (argument), `--amount` | +| `make-invoice` | Create a BOLT11 invoice | `--amount` | ### Transactions diff --git a/package.json b/package.json index 3d0e821..adbe330 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@getalby/hub-cli", "description": "CLI for managing Alby Hub - a self-custodial Lightning node", "repository": "https://github.com/getAlby/hub.git", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "build/index.js", "bin": { @@ -32,6 +32,7 @@ "author": "Alby contributors", "license": "Apache-2.0", "dependencies": { + "@getalby/lightning-tools": "^7.0.2", "commander": "^13.1.0" }, "devDependencies": { diff --git a/src/client.ts b/src/client.ts index 2cc2270..2424fbc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -35,6 +35,15 @@ export class HubClient { return this.handleResponse(res); } + async patch(path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "PATCH", + headers: this.headers(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return this.handleResponse(res); + } + async delete(path: string): Promise { const res = await fetch(`${this.baseUrl}${path}`, { method: "DELETE", diff --git a/src/commands/backup-mnemonic.ts b/src/commands/backup-mnemonic.ts new file mode 100644 index 0000000..49b7a4e --- /dev/null +++ b/src/commands/backup-mnemonic.ts @@ -0,0 +1,29 @@ +import { Command } from "commander"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { getClient, handleError, output } from "../utils.js"; + +const DEFAULT_BACKUP_DIR = join(homedir(), ".hub-cli"); +const DEFAULT_BACKUP_FILE = join(DEFAULT_BACKUP_DIR, "albyhub.recovery"); + +export function registerBackupMnemonicCommand(program: Command): void { + program + .command("backup-mnemonic") + .description("Export the wallet recovery phrase to a file") + .requiredOption("-p, --password ", "Unlock password") + .option("--output ", "Output file path", DEFAULT_BACKUP_FILE) + .action(async (opts: { password: string; output: string }) => { + await handleError(async () => { + const client = getClient(program); + const result = await client.post<{ mnemonic: string }>("/api/mnemonic", { + unlockPassword: opts.password, + }); + const filePath = resolve(opts.output); + const dir = filePath.substring(0, filePath.lastIndexOf("/")); + if (dir) mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, result.mnemonic, { encoding: "utf-8", mode: 0o600 }); + output({ success: true, file: filePath }); + }); + }); +} diff --git a/src/commands/change-password.ts b/src/commands/change-password.ts new file mode 100644 index 0000000..f25e4b9 --- /dev/null +++ b/src/commands/change-password.ts @@ -0,0 +1,35 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerChangePasswordCommand(program: Command): void { + program + .command("change-password") + .description("Change the hub unlock password") + .requiredOption("--current-password ", "Current unlock password") + .requiredOption( + "--confirm-current-password ", + "Confirm current unlock password", + ) + .requiredOption("--new-password ", "New unlock password") + .action( + async (opts: { + currentPassword: string; + confirmCurrentPassword: string; + newPassword: string; + }) => { + await handleError(async () => { + if (opts.currentPassword !== opts.confirmCurrentPassword) { + throw new Error( + "Current password and confirmation do not match", + ); + } + const client = getClient(program); + await client.patch("/api/unlock-password", { + currentUnlockPassword: opts.currentPassword, + newUnlockPassword: opts.newPassword, + }); + output({ success: true }); + }); + }, + ); +} diff --git a/src/commands/balances.ts b/src/commands/get-balances.ts similarity index 81% rename from src/commands/balances.ts rename to src/commands/get-balances.ts index 45bf823..1f0ed3c 100644 --- a/src/commands/balances.ts +++ b/src/commands/get-balances.ts @@ -2,9 +2,9 @@ import { Command } from "commander"; import { BalancesResponse } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerBalancesCommand(program: Command): void { +export function registerGetBalancesCommand(program: Command): void { program - .command("balances") + .command("get-balances") .description("Get Lightning and on-chain balances") .action(async () => { await handleError(async () => { diff --git a/src/commands/channel-suggestions.ts b/src/commands/get-channel-suggestions.ts similarity index 87% rename from src/commands/channel-suggestions.ts rename to src/commands/get-channel-suggestions.ts index 25e3c4f..2aa52c1 100644 --- a/src/commands/channel-suggestions.ts +++ b/src/commands/get-channel-suggestions.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { ChannelPeerSuggestion } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerChannelSuggestionsCommand(program: Command): void { +export function registerListChannelSuggestionsCommand(program: Command): void { program .command("get-channel-suggestions") .description( diff --git a/src/commands/health.ts b/src/commands/get-health.ts similarity index 89% rename from src/commands/health.ts rename to src/commands/get-health.ts index b7f23c1..26290e0 100644 --- a/src/commands/health.ts +++ b/src/commands/get-health.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { HealthResponse } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerHealthCommand(program: Command): void { +export function registerGetHealthCommand(program: Command): void { program .command("get-health") .description( diff --git a/src/commands/info.ts b/src/commands/get-info.ts similarity index 87% rename from src/commands/info.ts rename to src/commands/get-info.ts index 811dfc3..6904544 100644 --- a/src/commands/info.ts +++ b/src/commands/get-info.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { InfoResponse } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerInfoCommand(program: Command): void { +export function registerGetInfoCommand(program: Command): void { program .command("get-info") .description("Get hub status, version, and configuration") diff --git a/src/commands/node-status.ts b/src/commands/get-node-status.ts similarity index 86% rename from src/commands/node-status.ts rename to src/commands/get-node-status.ts index debbaf1..8e3fcb9 100644 --- a/src/commands/node-status.ts +++ b/src/commands/get-node-status.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { NodeStatus } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerNodeStatusCommand(program: Command): void { +export function registerGetNodeStatusCommand(program: Command): void { program .command("get-node-status") .description("Get Lightning node readiness status") diff --git a/src/commands/wallet-address.ts b/src/commands/get-onchain-address.ts similarity index 85% rename from src/commands/wallet-address.ts rename to src/commands/get-onchain-address.ts index 831ba1f..50db39f 100644 --- a/src/commands/wallet-address.ts +++ b/src/commands/get-onchain-address.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { getClient, handleError, output } from "../utils.js"; -export function registerWalletAddressCommand(program: Command): void { +export function registerGetWalletAddressCommand(program: Command): void { program .command("get-onchain-address") .description("Get an on-chain Bitcoin deposit address") diff --git a/src/commands/apps.ts b/src/commands/list-apps.ts similarity index 82% rename from src/commands/apps.ts rename to src/commands/list-apps.ts index 6624836..d3ceffe 100644 --- a/src/commands/apps.ts +++ b/src/commands/list-apps.ts @@ -2,9 +2,9 @@ import { Command } from "commander"; import { ListAppsResponse } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerAppsCommand(program: Command): void { +export function registerListAppsCommand(program: Command): void { program - .command("apps") + .command("list-apps") .description("List NWC app connections") .action(async () => { await handleError(async () => { diff --git a/src/commands/channels.ts b/src/commands/list-channels.ts similarity index 86% rename from src/commands/channels.ts rename to src/commands/list-channels.ts index 87d6d3b..1ff9d66 100644 --- a/src/commands/channels.ts +++ b/src/commands/list-channels.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { Channel } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerChannelsCommand(program: Command): void { +export function registerListChannelsCommand(program: Command): void { program .command("list-channels") .description("List Lightning channels") diff --git a/src/commands/peers.ts b/src/commands/list-peers.ts similarity index 87% rename from src/commands/peers.ts rename to src/commands/list-peers.ts index 46cc0ad..50c2e7f 100644 --- a/src/commands/peers.ts +++ b/src/commands/list-peers.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { PeerDetails } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerPeersCommand(program: Command): void { +export function registerListPeersCommand(program: Command): void { program .command("list-peers") .description("List connected Lightning peers") diff --git a/src/commands/transactions.ts b/src/commands/list-transactions.ts similarity index 80% rename from src/commands/transactions.ts rename to src/commands/list-transactions.ts index 99ca5bf..067a09d 100644 --- a/src/commands/transactions.ts +++ b/src/commands/list-transactions.ts @@ -2,11 +2,15 @@ import { Command } from "commander"; import { ListTransactionsResponse } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerTransactionsCommand(program: Command): void { +export function registerListTransactionsCommand(program: Command): void { program .command("list-transactions") .description("List payment history") - .option("--limit ", "Maximum number of transactions to return", "20") + .option( + "--limit ", + "Maximum number of transactions to return", + "20", + ) .option("--offset ", "Pagination offset", "0") .action(async (opts: { limit: string; offset: string }) => { await handleError(async () => { diff --git a/src/commands/channel-offer.ts b/src/commands/request-alby-lsp-channel-offer.ts similarity index 87% rename from src/commands/channel-offer.ts rename to src/commands/request-alby-lsp-channel-offer.ts index fae0c57..241372f 100644 --- a/src/commands/channel-offer.ts +++ b/src/commands/request-alby-lsp-channel-offer.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { LSPChannelOffer } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerChannelOfferCommand(program: Command): void { +export function registerRequestAlbyChannelOfferCommand(program: Command): void { program .command("request-alby-lsp-channel-offer") .description( diff --git a/src/commands/request-invoice-from-lightning-address.ts b/src/commands/request-invoice-from-lightning-address.ts new file mode 100644 index 0000000..c8ef8fb --- /dev/null +++ b/src/commands/request-invoice-from-lightning-address.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { LightningAddress } from "@getalby/lightning-tools"; +import { handleError, output } from "../utils.js"; + +export function registerRequestInvoiceFromLightningAddressCommand( + program: Command, +): void { + program + .command("request-invoice-from-lightning-address") + .description("Request an invoice from a lightning address") + .requiredOption("-a, --address ", "Lightning address") + .requiredOption("-s, --amount ", "Amount in satoshis", parseInt) + .option("--comment ", "Optional comment") + .action( + async (opts: { address: string; amount: number; comment?: string }) => { + await handleError(async () => { + const ln = new LightningAddress(opts.address); + await ln.fetch(); + const { paymentRequest, paymentHash } = await ln.requestInvoice({ + satoshi: opts.amount, + comment: opts.comment, + }); + output({ paymentRequest, paymentHash }); + }); + }, + ); +} diff --git a/src/commands/lsp-order.ts b/src/commands/request-lsp-order.ts similarity index 94% rename from src/commands/lsp-order.ts rename to src/commands/request-lsp-order.ts index 634ec57..1c510d3 100644 --- a/src/commands/lsp-order.ts +++ b/src/commands/request-lsp-order.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { LSPOrderResponse } from "../types.js"; import { getClient, handleError, output } from "../utils.js"; -export function registerLspOrderCommand(program: Command): void { +export function registerRequestLspOrderCommand(program: Command): void { program .command("request-lsp-order") .description( diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..64cbc7e --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,15 @@ +import { Command } from "commander"; +import { getClient, handleError, output } from "../utils.js"; + +export function registerSyncCommand(program: Command): void { + program + .command("sync") + .description("Trigger a wallet sync (queued, may take up to a minute)") + .action(async () => { + await handleError(async () => { + const client = getClient(program); + await client.post("/api/wallet/sync"); + output({ success: true, message: "Wallet sync queued. May take up to a minute." }); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index 8145324..f025a19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,33 +5,37 @@ import { registerSetupCommand } from "./commands/setup.js"; import { registerUnlockCommand } from "./commands/unlock.js"; import { registerStartCommand } from "./commands/start.js"; import { registerStopCommand } from "./commands/stop.js"; -import { registerInfoCommand } from "./commands/info.js"; -import { registerBalancesCommand } from "./commands/balances.js"; -import { registerChannelsCommand } from "./commands/channels.js"; -import { registerChannelSuggestionsCommand } from "./commands/channel-suggestions.js"; -import { registerChannelOfferCommand } from "./commands/channel-offer.js"; -import { registerLspOrderCommand } from "./commands/lsp-order.js"; -import { registerTransactionsCommand } from "./commands/transactions.js"; +import { registerGetInfoCommand } from "./commands/get-info.js"; +import { registerGetBalancesCommand } from "./commands/get-balances.js"; +import { registerListChannelsCommand } from "./commands/list-channels.js"; +import { registerListChannelSuggestionsCommand } from "./commands/get-channel-suggestions.js"; +import { registerRequestAlbyChannelOfferCommand } from "./commands/request-alby-lsp-channel-offer.js"; +import { registerRequestLspOrderCommand } from "./commands/request-lsp-order.js"; +import { registerListTransactionsCommand } from "./commands/list-transactions.js"; import { registerLookupTransactionCommand } from "./commands/lookup-transaction.js"; import { registerPayInvoiceCommand } from "./commands/pay-invoice.js"; import { registerMakeInvoiceCommand } from "./commands/make-invoice.js"; -import { registerAppsCommand } from "./commands/apps.js"; +import { registerListAppsCommand } from "./commands/list-apps.js"; import { registerCreateAppCommand } from "./commands/create-app.js"; -import { registerPeersCommand } from "./commands/peers.js"; -import { registerNodeStatusCommand } from "./commands/node-status.js"; -import { registerHealthCommand } from "./commands/health.js"; -import { registerWalletAddressCommand } from "./commands/wallet-address.js"; +import { registerListPeersCommand } from "./commands/list-peers.js"; +import { registerGetNodeStatusCommand } from "./commands/get-node-status.js"; +import { registerGetHealthCommand } from "./commands/get-health.js"; +import { registerGetWalletAddressCommand } from "./commands/get-onchain-address.js"; import { registerGetNodeConnectionInfoCommand } from "./commands/get-node-connection-info.js"; import { registerConnectPeerCommand } from "./commands/connect-peer.js"; import { registerOpenChannelCommand } from "./commands/open-channel.js"; import { registerCloseChannelCommand } from "./commands/close-channel.js"; +import { registerSyncCommand } from "./commands/sync.js"; +import { registerBackupMnemonicCommand } from "./commands/backup-mnemonic.js"; +import { registerChangePasswordCommand } from "./commands/change-password.js"; +import { registerRequestInvoiceFromLightningAddressCommand } from "./commands/request-invoice-from-lightning-address.js"; const program = new Command(); program .name("hub-cli") .description("CLI for managing Alby Hub - a self-custodial Lightning node") - .version("0.1.0") + .version("0.2.0") .option( "-u, --url ", "Hub URL", @@ -43,25 +47,29 @@ registerSetupCommand(program); registerUnlockCommand(program); registerStartCommand(program); registerStopCommand(program); -registerInfoCommand(program); -registerBalancesCommand(program); -registerChannelsCommand(program); -registerChannelSuggestionsCommand(program); -registerChannelOfferCommand(program); -registerLspOrderCommand(program); -registerTransactionsCommand(program); +registerGetInfoCommand(program); +registerGetBalancesCommand(program); +registerListChannelsCommand(program); +registerListChannelSuggestionsCommand(program); +registerRequestAlbyChannelOfferCommand(program); +registerRequestLspOrderCommand(program); +registerListTransactionsCommand(program); registerLookupTransactionCommand(program); registerPayInvoiceCommand(program); registerMakeInvoiceCommand(program); -registerAppsCommand(program); +registerListAppsCommand(program); registerCreateAppCommand(program); -registerPeersCommand(program); -registerNodeStatusCommand(program); -registerHealthCommand(program); -registerWalletAddressCommand(program); +registerListPeersCommand(program); +registerGetNodeStatusCommand(program); +registerGetHealthCommand(program); +registerGetWalletAddressCommand(program); registerGetNodeConnectionInfoCommand(program); registerConnectPeerCommand(program); registerOpenChannelCommand(program); registerCloseChannelCommand(program); +registerSyncCommand(program); +registerBackupMnemonicCommand(program); +registerChangePasswordCommand(program); +registerRequestInvoiceFromLightningAddressCommand(program); program.parse(); diff --git a/src/test/e2e/backup-mnemonic.e2e.test.ts b/src/test/e2e/backup-mnemonic.e2e.test.ts new file mode 100644 index 0000000..9f52be5 --- /dev/null +++ b/src/test/e2e/backup-mnemonic.e2e.test.ts @@ -0,0 +1,67 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { existsSync, statSync, unlinkSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { TEST_PASSWORD, spawnHub, runCommand, waitForInfo, killHub } from "./helpers"; + +const HUB_PORT = 18087; +const HUB_URL = `http://localhost:${HUB_PORT}`; +const BACKUP_FILE = join(tmpdir(), "hub-cli-e2e-backup.recovery"); + +let hubProcess: ChildProcess; +let token: string; + +beforeEach(async () => { + ({ hubProcess } = await spawnHub(HUB_PORT, "hub-cli-e2e-backup-")); + + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + token = JSON.parse(start.stdout).token; + + await waitForInfo(HUB_URL, (i) => i.running); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); + if (existsSync(BACKUP_FILE)) unlinkSync(BACKUP_FILE); +}); + +test("backup-mnemonic writes recovery file", { timeout: 60_000 }, async () => { + const result = runCommand([ + "--url", + HUB_URL, + "--token", + token, + "backup-mnemonic", + "--password", + TEST_PASSWORD, + "--output", + BACKUP_FILE, + ]); + expect(result.status).toBe(0); + const out = JSON.parse(result.stdout); + expect(out.success).toBe(true); + expect(out.file).toBe(BACKUP_FILE); + expect(existsSync(BACKUP_FILE)).toBe(true); + expect(statSync(BACKUP_FILE).size).toBeGreaterThan(0); + const contents = readFileSync(BACKUP_FILE, "utf-8").trim(); + expect(contents.split(/\s+/).length).toBe(12); +}); diff --git a/src/test/e2e/change-password.e2e.test.ts b/src/test/e2e/change-password.e2e.test.ts new file mode 100644 index 0000000..3a72bb7 --- /dev/null +++ b/src/test/e2e/change-password.e2e.test.ts @@ -0,0 +1,174 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + waitForInfo, + killHub, +} from "./helpers"; + +const HUB_PORT = 18088; +const HUB_URL = `http://localhost:${HUB_PORT}`; +const NEW_PASSWORD = "new-test-password-e2e"; + +let hubProcess: ChildProcess; + +beforeEach(async () => { + ({ hubProcess } = await spawnHub(HUB_PORT, "hub-cli-e2e-changepw-")); + + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + + await waitForInfo(HUB_URL, (i) => i.running); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test( + "change-password fails if confirmations do not match", + { timeout: 60_000 }, + async () => { + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + + const token = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + if (token.status !== 0) throw new Error(`unlock failed: ${token.stderr}`); + const { token: jwt } = JSON.parse(token.stdout) as { token: string }; + + const result = runCommand([ + "--url", + HUB_URL, + "--token", + jwt, + "change-password", + "--current-password", + TEST_PASSWORD, + "--confirm-current-password", + "wrong-confirmation", + "--new-password", + NEW_PASSWORD, + ]); + expect(result.status).toBe(1); + const out = JSON.parse(result.stdout); + expect(typeof out.error).toBe("string"); + expect(out.error).toEqual("Current password and confirmation do not match"); + }, +); + +test( + "change-password succeeds and new password works", + { timeout: 60_000 }, + async () => { + // avoid rate limit from beforeEach unlock calls + await new Promise((r) => setTimeout(r, 3000)); + + const token = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + if (token.status !== 0) throw new Error(`unlock failed: ${token.stderr}`); + const { token: jwt } = JSON.parse(token.stdout) as { token: string }; + + const changeResult = runCommand([ + "--url", + HUB_URL, + "--token", + jwt, + "change-password", + "--current-password", + TEST_PASSWORD, + "--confirm-current-password", + TEST_PASSWORD, + "--new-password", + NEW_PASSWORD, + ]); + expect(changeResult.status).toBe(0); + const out = JSON.parse(changeResult.stdout); + expect(out.success).toBe(true); + + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + + // old password should no longer work + const oldStart = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(oldStart.status).toBe(1); + + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + + // new password should work + const newStart = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + NEW_PASSWORD, + ]); + expect(newStart.status).toBe(0); + const newStartOut = JSON.parse(newStart.stdout); + expect(typeof newStartOut.token).toBe("string"); + + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + + // old password should no longer work + const oldUnlock = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + expect(oldUnlock.status).toBe(1); + + // avoid rate limit + await new Promise((r) => setTimeout(r, 3000)); + + // new password should work + const newUnlock = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + NEW_PASSWORD, + ]); + expect(newUnlock.status).toBe(0); + const newOut = JSON.parse(newUnlock.stdout); + expect(typeof newOut.token).toBe("string"); + }, +); diff --git a/src/test/e2e/channel-lifecycle.e2e.test.ts b/src/test/e2e/channel-lifecycle.e2e.test.ts index 41498eb..fe7ae13 100644 --- a/src/test/e2e/channel-lifecycle.e2e.test.ts +++ b/src/test/e2e/channel-lifecycle.e2e.test.ts @@ -343,7 +343,7 @@ test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { HUB_A_URL, "--token", tokenA, - "balances", + "get-balances", ]); expect(balancesBeforeResult.status).toBe(0); const balancesBeforeData = JSON.parse(balancesBeforeResult.stdout) as { @@ -369,7 +369,7 @@ test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { HUB_A_URL, "--token", tokenA, - "balances", + "get-balances", ]); expect(hubABalancesAfterResult.status).toBe(0); const hubABalancesAfterData = JSON.parse(hubABalancesAfterResult.stdout) as { @@ -394,7 +394,7 @@ test("sends sats from hub A to hub B", { timeout: 120_000 }, async () => { HUB_B_URL, "--token", tokenB, - "balances", + "get-balances", ]); const hubBBalancesAfterData = JSON.parse(hubBBalancesAfterResult.stdout) as { lightning: { totalSpendable: number }; @@ -427,7 +427,7 @@ test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { HUB_A_URL, "--token", tokenA, - "balances", + "get-balances", ]); expect(hubABalancesBeforeResult.status).toBe(0); const hubABalancesBeforeData = JSON.parse( @@ -442,7 +442,7 @@ test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { HUB_B_URL, "--token", tokenB, - "balances", + "get-balances", ]); expect(hubBBalancesBeforeResult.status).toBe(0); const hubBBalancesBeforeData = JSON.parse( @@ -470,7 +470,7 @@ test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { HUB_B_URL, "--token", tokenB, - "balances", + "get-balances", ]); expect(hubBBalancesAfterResult.status).toBe(0); const hubBBalancesAfterData = JSON.parse(hubBBalancesAfterResult.stdout) as { @@ -497,7 +497,7 @@ test("sends sats from hub B back to hub A", { timeout: 120_000 }, async () => { HUB_A_URL, "--token", tokenA, - "balances", + "get-balances", ]); expect(hubABalancesAfterResult.status).toBe(0); const hubABalancesAfterData = JSON.parse(hubABalancesAfterResult.stdout) as { @@ -570,7 +570,7 @@ test( HUB_A_URL, "--token", tokenA, - "balances", + "get-balances", ]); expect(balancesBeforeResult.status).toBe(0); const balancesBeforeData = JSON.parse(balancesBeforeResult.stdout) as { diff --git a/src/test/e2e/mutinynet-lsp.e2e.test.ts b/src/test/e2e/mutinynet/mutinynet-lsp.e2e.test.ts similarity index 98% rename from src/test/e2e/mutinynet-lsp.e2e.test.ts rename to src/test/e2e/mutinynet/mutinynet-lsp.e2e.test.ts index b0dc3a6..ae46915 100644 --- a/src/test/e2e/mutinynet-lsp.e2e.test.ts +++ b/src/test/e2e/mutinynet/mutinynet-lsp.e2e.test.ts @@ -10,8 +10,8 @@ import { killHub, waitForInfo, waitForChannels, -} from "./helpers.js"; -import type { ChannelPeerSuggestion, Transaction } from "../../types.js"; +} from "../helpers.js"; +import type { ChannelPeerSuggestion, Transaction } from "../../../types.js"; try { process.loadEnvFile(join(E2E_DIR, ".env")); diff --git a/src/test/e2e/mutinynet/request-invoice-from-lightning-address.e2e.test.ts b/src/test/e2e/mutinynet/request-invoice-from-lightning-address.e2e.test.ts new file mode 100644 index 0000000..d84734f --- /dev/null +++ b/src/test/e2e/mutinynet/request-invoice-from-lightning-address.e2e.test.ts @@ -0,0 +1,186 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import { join } from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { NWCClient } from "@getalby/sdk/nwc"; +import { + E2E_DIR, + TEST_PASSWORD, + spawnMutinynetHub, + runCommand, + killHub, + waitForInfo, + waitForChannels, +} from "../helpers.js"; +import type { ChannelPeerSuggestion, Transaction } from "../../../types.js"; + +type InvoiceResult = { + paymentRequest: string; + paymentHash: string; +}; + +try { + process.loadEnvFile(join(E2E_DIR, ".env")); +} catch { + // .env file not present — tests will be skipped +} + +const MUTINYNET_NWC_URL = process.env.MUTINYNET_NWC_URL; + +const HUB_PORT = 18089; +const HUB_LDK_PORT = 19739; +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; +let token: string; + +beforeAll(async () => { + if (!MUTINYNET_NWC_URL) return; + + ({ hubProcess } = await spawnMutinynetHub( + HUB_PORT, + "hub-cli-e2e-pay-addr-", + HUB_LDK_PORT, + )); + + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`Hub setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`Hub start failed: ${start.stderr}`); + token = JSON.parse(start.stdout).token; + + await waitForInfo(HUB_URL, (info) => info.running); + + // Open a channel via LSP so the hub can send payments + const suggestionsResult = runCommand([ + "--url", + HUB_URL, + "--token", + token, + "get-channel-suggestions", + ]); + if (suggestionsResult.status !== 0) + throw new Error("get-channel-suggestions failed"); + const suggestions = JSON.parse( + suggestionsResult.stdout, + ) as ChannelPeerSuggestion[]; + const lsp = suggestions.find( + (s) => + s.identifier === "megalith" && + s.network === "signet" && + s.paymentMethod === "lightning", + ); + if (!lsp) throw new Error("Megalith signet LSP not found in suggestions"); + + const orderResult = runCommand([ + "--url", + HUB_URL, + "--token", + token, + "request-lsp-order", + "--amount", + String(lsp.minimumChannelSize), + "--lsp-type", + lsp.type, + "--lsp-identifier", + lsp.identifier, + ]); + if (orderResult.status !== 0) throw new Error("request-lsp-order failed"); + const order = JSON.parse(orderResult.stdout) as { invoice: string }; + + const nwcClient = new NWCClient({ + nostrWalletConnectUrl: MUTINYNET_NWC_URL!, + }); + try { + await nwcClient.payInvoice({ invoice: order.invoice }); + } finally { + nwcClient.close(); + } + + await waitForChannels( + HUB_URL, + token, + (chs) => chs.some((c) => c.active), + 120_000, + ); + + // Fund the hub with sats so it has outbound liquidity to pay + const invoiceResult = runCommand([ + "--url", + HUB_URL, + "--token", + token, + "make-invoice", + "--amount", + "10000", + "--description", + "fund hub for request-invoice-from-lightning-address e2e", + ]); + if (invoiceResult.status !== 0) throw new Error("make-invoice failed"); + const { invoice } = JSON.parse(invoiceResult.stdout) as { invoice: string }; + + const fundClient = new NWCClient({ + nostrWalletConnectUrl: MUTINYNET_NWC_URL!, + }); + try { + await fundClient.payInvoice({ invoice }); + } finally { + fundClient.close(); + } +}, 180_000); + +afterAll(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test.skipIf(!MUTINYNET_NWC_URL)( + "request-invoice-from-lightning-address then pay-invoice pays pmlspm@getalby.com", + { timeout: 60_000 }, + async () => { + const invoiceResult = runCommand([ + "--url", + HUB_URL, + "--token", + token, + "request-invoice-from-lightning-address", + "--address", + "pmlspm@getalby.com", + "--amount", + "100", + ]); + expect(invoiceResult.status).toBe(0); + const { paymentRequest, paymentHash } = JSON.parse( + invoiceResult.stdout, + ) as InvoiceResult; + expect(typeof paymentRequest).toBe("string"); + expect(paymentRequest.startsWith("ln")).toBe(true); + expect(typeof paymentHash).toBe("string"); + + const payResult = runCommand([ + "--url", + HUB_URL, + "--token", + token, + "pay-invoice", + paymentRequest, + ]); + expect(payResult.status).toBe(0); + const tx = JSON.parse(payResult.stdout) as Transaction; + expect(tx.state).toBe("settled"); + expect(tx.type).toBe("outgoing"); + }, +); diff --git a/src/test/e2e/stop.e2e.test.ts b/src/test/e2e/stop.e2e.test.ts index 25e34c7..81d4ffc 100644 --- a/src/test/e2e/stop.e2e.test.ts +++ b/src/test/e2e/stop.e2e.test.ts @@ -112,7 +112,7 @@ test("stop fails without a token", { timeout: 60_000 }, async () => { expect(stop.status).toBe(1); const output = JSON.parse(stop.stdout); expect(typeof output.error).toBe("string"); - expect(output.error).toEqual("missing or malformed jwt"); + expect(output.error).toEqual("invalid or expired jwt"); }); test( diff --git a/src/test/e2e/sync.e2e.test.ts b/src/test/e2e/sync.e2e.test.ts new file mode 100644 index 0000000..d6456e3 --- /dev/null +++ b/src/test/e2e/sync.e2e.test.ts @@ -0,0 +1,65 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import type { ChildProcess } from "node:child_process"; +import { + TEST_PASSWORD, + spawnHub, + runCommand, + waitForInfo, + killHub, +} from "./helpers"; + +const HUB_PORT = 18086; +const HUB_URL = `http://localhost:${HUB_PORT}`; + +let hubProcess: ChildProcess; + +beforeEach(async () => { + ({ hubProcess } = await spawnHub(HUB_PORT, "hub-cli-e2e-sync-")); + + const setup = runCommand([ + "--url", + HUB_URL, + "setup", + "--password", + TEST_PASSWORD, + "--backend", + "LDK", + ]); + if (setup.status !== 0) throw new Error(`setup failed: ${setup.stderr}`); + + const start = runCommand([ + "--url", + HUB_URL, + "start", + "--password", + TEST_PASSWORD, + ]); + if (start.status !== 0) throw new Error(`start failed: ${start.stderr}`); + + await waitForInfo(HUB_URL, (i) => i.running); +}); + +afterEach(async () => { + if (hubProcess) await killHub(hubProcess); +}); + +test("sync queues a wallet sync", { timeout: 60_000 }, async () => { + // avoid rate limit from beforeEach unlock calls + await new Promise((r) => setTimeout(r, 3000)); + + const token = runCommand([ + "--url", + HUB_URL, + "unlock", + "--password", + TEST_PASSWORD, + ]); + if (token.status !== 0) throw new Error(`unlock failed: ${token.stderr}`); + const { token: jwt } = JSON.parse(token.stdout) as { token: string }; + + const result = runCommand(["--url", HUB_URL, "--token", jwt, "sync"]); + expect(result.status).toBe(0); + const out = JSON.parse(result.stdout); + expect(out.success).toBe(true); + expect(typeof out.message).toBe("string"); +}); diff --git a/yarn.lock b/yarn.lock index 205c4ad..cee18e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -137,6 +137,11 @@ resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-6.1.0.tgz#558b90a83b961cb6aa760e62de69f5b5ceeb2fe9" integrity sha512-rGurar9X4Gm+9xwoNYS8s9YLK7ZYqvbqv4KbHLYV0LEeB0HxZHRgmxblGqg+fYfp6iiYHx+edIgUpt9rS3VwFw== +"@getalby/lightning-tools@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-7.0.2.tgz#01e2b4594636d2e26f65233adfedb88b8fc5cc4c" + integrity sha512-3jzXk9QgeiuSTr4X1DSg3n/YYC5ju93ZPs+4tE/DIOJA6rbDaWZ8yVLF81neqtcHOrguQirBaM1MD6ZjYPIUHA== + "@getalby/sdk@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-7.0.0.tgz#6ab17f27bd9e762d383b70cfabd0cdeeded6bd53" From f33e363e2d5f25ad7bfefe068839d6926a703f66 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 14 Mar 2026 00:04:48 +0700 Subject: [PATCH 2/2] fix: error message assertion in test --- src/test/e2e/stop.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/e2e/stop.e2e.test.ts b/src/test/e2e/stop.e2e.test.ts index 81d4ffc..25e34c7 100644 --- a/src/test/e2e/stop.e2e.test.ts +++ b/src/test/e2e/stop.e2e.test.ts @@ -112,7 +112,7 @@ test("stop fails without a token", { timeout: 60_000 }, async () => { expect(stop.status).toBe(1); const output = JSON.parse(stop.stdout); expect(typeof output.error).toBe("string"); - expect(output.error).toEqual("invalid or expired jwt"); + expect(output.error).toEqual("missing or malformed jwt"); }); test(