@getalby/cli 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { spawn } from "child_process";
3
+ import { createServer } from "http";
4
+ // Stand up a local HTTP mock for the Lendaswap API endpoints the CLI hits
5
+ // during validation. The CLI spawns a fresh subprocess per test, so the
6
+ // `LENDASWAP_API_URL` env var (consumed in src/lendaswap/swap.ts) points it
7
+ // at this mock instead of api.satora.io. We only need `/tokens` and
8
+ // `/swap-pairs` — pair validation runs before wallet load, so tests never
9
+ // reach the swap-creation endpoints.
10
+ //
11
+ // IMPORTANT: we use async `spawn`, not the shared `execSync`-based runCli
12
+ // helper. `execSync` blocks the event loop, which would prevent this
13
+ // in-process mock from accepting the subprocess's TCP connection — fetch
14
+ // would just time out.
15
+ let server;
16
+ let mockUrl = "";
17
+ const MOCK_TOKENS = {
18
+ btc_tokens: [],
19
+ evm_tokens: [
20
+ {
21
+ chain: "42161",
22
+ decimals: 6,
23
+ name: "USD Coin",
24
+ symbol: "USDC",
25
+ token_id: "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
26
+ },
27
+ {
28
+ chain: "42161",
29
+ decimals: 6,
30
+ name: "Tether USD",
31
+ symbol: "USDT",
32
+ token_id: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9",
33
+ },
34
+ ],
35
+ };
36
+ // Only the Lightning → 42161 (Arbitrum) pair is enabled in the mock. So
37
+ // USDC/USDT on Arbitrum resolve; the same symbols on any other chain are
38
+ // rejected as unsupported.
39
+ const MOCK_SWAP_PAIRS = {
40
+ pairs: [
41
+ {
42
+ fee_percentage: 0.0025,
43
+ max_sats: 100_000_000,
44
+ min_sats: 1000,
45
+ source: "Lightning",
46
+ target: "42161",
47
+ },
48
+ ],
49
+ };
50
+ beforeAll(async () => {
51
+ server = createServer((req, res) => {
52
+ res.setHeader("Content-Type", "application/json");
53
+ if (req.url === "/tokens") {
54
+ res.end(JSON.stringify(MOCK_TOKENS));
55
+ }
56
+ else if (req.url === "/swap-pairs") {
57
+ res.end(JSON.stringify(MOCK_SWAP_PAIRS));
58
+ }
59
+ else {
60
+ res.statusCode = 404;
61
+ res.end(JSON.stringify({ error: "not found" }));
62
+ }
63
+ });
64
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
65
+ const addr = server.address();
66
+ mockUrl = `http://127.0.0.1:${addr.port}`;
67
+ });
68
+ afterAll(() => new Promise((resolve) => server.close(() => resolve())));
69
+ function runCliAsync(args) {
70
+ return new Promise((resolve) => {
71
+ const child = spawn("node", ["build/index.js", ...args.split(" ")], {
72
+ env: {
73
+ ...process.env,
74
+ // Wallet load always fails — exposes the validation gates as the
75
+ // only thing that can succeed or fail before that point.
76
+ HOME: "/tmp/nonexistent-alby-cli-test-home",
77
+ NWC_URL: "",
78
+ LENDASWAP_API_URL: mockUrl,
79
+ },
80
+ });
81
+ let stdout = "";
82
+ let stderr = "";
83
+ child.stdout.on("data", (d) => (stdout += d.toString()));
84
+ child.stderr.on("data", (d) => (stderr += d.toString()));
85
+ child.on("close", (code) => {
86
+ const raw = code === 0 ? stdout : stderr || stdout || "{}";
87
+ try {
88
+ resolve({ success: code === 0, output: JSON.parse(raw) });
89
+ }
90
+ catch {
91
+ resolve({ success: code === 0, output: { error: raw } });
92
+ }
93
+ });
94
+ });
95
+ }
96
+ describe("pay-crypto validation", () => {
97
+ describe("unsupported currency/network combination", () => {
98
+ test("unknown currency is rejected and lists supported pairs", async () => {
99
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency XYZ --network arbitrum");
100
+ expect(result.success).toBe(false);
101
+ expect(result.output.error).toContain("Unsupported currency/network combination");
102
+ expect(result.output.error).toContain("USDC on Arbitrum");
103
+ });
104
+ test("USDC on ethereum is rejected (chain not in swap-pairs)", async () => {
105
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC --network ethereum");
106
+ expect(result.success).toBe(false);
107
+ expect(result.output.error).toContain("Unsupported currency/network combination");
108
+ expect(result.output.error).toContain("USDC on Arbitrum");
109
+ });
110
+ });
111
+ describe("malformed EVM address", () => {
112
+ test("completely non-hex string is rejected", async () => {
113
+ const result = await runCliAsync("pay-crypto notanaddress --amount 10 --currency USDC --network arbitrum");
114
+ expect(result.success).toBe(false);
115
+ expect(result.output.error).toContain("address does not look valid");
116
+ });
117
+ test("too-short hex with 0x prefix is rejected", async () => {
118
+ const result = await runCliAsync("pay-crypto 0xabc --amount 10 --currency USDC --network arbitrum");
119
+ expect(result.success).toBe(false);
120
+ expect(result.output.error).toContain("address does not look valid");
121
+ });
122
+ test("40-char hex without 0x prefix is rejected", async () => {
123
+ const result = await runCliAsync("pay-crypto 000000000000000000000000000000000000dead --amount 10 --currency USDC --network arbitrum");
124
+ expect(result.success).toBe(false);
125
+ expect(result.output.error).toContain("address does not look valid");
126
+ });
127
+ });
128
+ describe("invalid amount", () => {
129
+ test("--amount 0 is rejected", async () => {
130
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 0 --currency USDC --network arbitrum");
131
+ expect(result.success).toBe(false);
132
+ expect(result.output.error).toContain("Amount must be a positive number");
133
+ });
134
+ test("--amount -1 is rejected", async () => {
135
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount -1 --currency USDC --network arbitrum");
136
+ expect(result.success).toBe(false);
137
+ expect(result.output.error).toContain("Amount must be a positive number");
138
+ });
139
+ test("--amount abc (NaN) is rejected", async () => {
140
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount abc --currency USDC --network arbitrum");
141
+ expect(result.success).toBe(false);
142
+ expect(result.output.error).toContain("Amount must be a positive number");
143
+ });
144
+ // Unit-suffixed input must not be truncated to its leading digits
145
+ // (Number("123usd") is NaN, unlike parseFloat which would yield 123).
146
+ test("--amount 123usd is rejected", async () => {
147
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 123usd --currency USDC --network arbitrum");
148
+ expect(result.success).toBe(false);
149
+ expect(result.output.error).toContain("Amount must be a positive number");
150
+ });
151
+ });
152
+ describe("missing required options", () => {
153
+ test("missing --currency is rejected", async () => {
154
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --network arbitrum");
155
+ expect(result.success).toBe(false);
156
+ expect(result.output.error).toContain("--currency");
157
+ });
158
+ test("missing --network is rejected", async () => {
159
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC");
160
+ expect(result.success).toBe(false);
161
+ expect(result.output.error).toContain("--network");
162
+ });
163
+ });
164
+ describe("happy-path validation", () => {
165
+ test("valid USDC/arbitrum inputs get past validation and fail only at wallet load", async () => {
166
+ // The mocked supported list includes USDC on 42161 (Arbitrum), so
167
+ // findSupportedPair succeeds. With the wallet env disabled, the only
168
+ // error left is "No wallet connection found" from getClient() —
169
+ // proof that amount, address, and pair gates all accepted the input.
170
+ const result = await runCliAsync("pay-crypto 0x000000000000000000000000000000000000dead --amount 10 --currency USDC --network arbitrum");
171
+ expect(result.success).toBe(false);
172
+ expect(result.output.error).toContain("No wallet connection found");
173
+ });
174
+ });
175
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, test, expect, beforeAll } from "vitest";
2
+ import { createTestWallet, runCli } from "./helpers.js";
3
+ describe("receive command — validation", () => {
4
+ test("--description without --amount is rejected", () => {
5
+ const result = runCli(`receive --description "hi"`);
6
+ expect(result.success).toBe(false);
7
+ expect(result.output.error).toContain("--description requires --amount");
8
+ });
9
+ test("--currency without --amount is rejected", () => {
10
+ const result = runCli(`receive --currency USD`);
11
+ expect(result.success).toBe(false);
12
+ expect(result.output.error).toContain("--currency requires --amount");
13
+ });
14
+ test("--amount 0 is rejected at parse time", () => {
15
+ const result = runCli(`receive --amount 0`);
16
+ expect(result.success).toBe(false);
17
+ expect(result.output.error).toContain("Amount must be a positive number");
18
+ });
19
+ test("--amount abc (NaN) is rejected at parse time", () => {
20
+ const result = runCli(`receive --amount abc`);
21
+ expect(result.success).toBe(false);
22
+ expect(result.output.error).toContain("Amount must be a positive number");
23
+ });
24
+ test("--amount without --currency is rejected", () => {
25
+ const result = runCli(`receive --amount 100 --network lightning`);
26
+ expect(result.success).toBe(false);
27
+ expect(result.output.error).toContain("--currency");
28
+ });
29
+ test("--amount --currency BTC without --unit is rejected", () => {
30
+ const result = runCli(`receive --amount 100 --currency BTC --network lightning`);
31
+ expect(result.success).toBe(false);
32
+ expect(result.output.error).toContain("--unit");
33
+ });
34
+ test("--unit on a fiat currency is rejected", () => {
35
+ const result = runCli(`receive --amount 5 --currency USD --unit sats --network lightning`);
36
+ expect(result.success).toBe(false);
37
+ expect(result.output.error).toContain("--unit is not valid");
38
+ });
39
+ test("a chain network is rejected (invoices are lightning-only)", () => {
40
+ const result = runCli(`receive --amount 10 --currency USDC --network arbitrum`);
41
+ expect(result.success).toBe(false);
42
+ expect(result.output.error).toContain("lightning");
43
+ });
44
+ });
45
+ describe("receive command — live integration", () => {
46
+ let wallet;
47
+ beforeAll(async () => {
48
+ wallet = await createTestWallet();
49
+ }, 60000);
50
+ test("receive (no amount) returns the wallet's lightning address", () => {
51
+ const result = runCli(`-c "${wallet.nwcUrl}" receive`);
52
+ expect(result.success).toBe(true);
53
+ expect(result.output.lightning_address).toBe(wallet.lightningAddress);
54
+ });
55
+ test("receive --amount --currency BTC --unit sats returns a BOLT-11 invoice", () => {
56
+ const result = runCli(`-c "${wallet.nwcUrl}" receive --amount 100 --currency BTC --unit sats --network lightning`);
57
+ expect(result.success).toBe(true);
58
+ expect(result.output.invoice).toMatch(/^lnbc/i);
59
+ expect(result.output.amount_in_sats).toBe(100);
60
+ });
61
+ test("receive --unit BTC converts to sats", () => {
62
+ const result = runCli(`-c "${wallet.nwcUrl}" receive --amount 0.000001 --currency BTC --unit BTC --network lightning`);
63
+ expect(result.success).toBe(true);
64
+ expect(result.output.amount_in_sats).toBe(100);
65
+ });
66
+ test("receive --amount --currency USD resolves fiat to sats", () => {
67
+ const result = runCli(`-c "${wallet.nwcUrl}" receive --amount 5 --currency USD --network lightning`);
68
+ expect(result.success).toBe(true);
69
+ expect(result.output.invoice).toMatch(/^lnbc/i);
70
+ expect(result.output.amount_in_sats).toBeGreaterThan(0);
71
+ expect(result.output.fiat).toEqual({ amount: 5, currency: "USD" });
72
+ });
73
+ test("receive --amount --description produces an invoice", () => {
74
+ const result = runCli(`-c "${wallet.nwcUrl}" receive --amount 100 --currency BTC --unit sats --network lightning --description "test"`);
75
+ expect(result.success).toBe(true);
76
+ expect(result.output.invoice).toMatch(/^lnbc/i);
77
+ expect(result.output.amount_in_sats).toBe(100);
78
+ });
79
+ });
@@ -9,7 +9,7 @@ export async function discover(params) {
9
9
  url.searchParams.set("health", params.health);
10
10
  if (params.sort)
11
11
  url.searchParams.set("sort", params.sort);
12
- // Filter to BTC (Lightning) services server-side
12
+ // Filter to BTC (lightning) services server-side
13
13
  url.searchParams.set("payment_asset", "BTC");
14
14
  url.searchParams.set("limit", String(requestedLimit));
15
15
  const controller = new AbortController();
@@ -18,7 +18,7 @@ export async function fetch402(client, params) {
18
18
  const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS;
19
19
  const result = await fetch402Lib(params.url, requestOptions, {
20
20
  wallet: client,
21
- maxAmount: maxAmountSats || undefined,
21
+ maxAmount: maxAmountSats,
22
22
  });
23
23
  const responseContent = await result.text();
24
24
  if (!result.ok) {
package/build/utils.js CHANGED
@@ -1,8 +1,15 @@
1
1
  import { NWAClient, NWCClient } from "@getalby/sdk";
2
2
  import { getInfo } from "./tools/nwc/get_info.js";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ export const DEFAULT_RELAY_URLS = [
7
+ "wss://relay.getalby.com",
8
+ "wss://relay2.getalby.com",
9
+ ];
10
+ export function getAlbyCliDir() {
11
+ return join(homedir(), ".alby-cli");
12
+ }
6
13
  function sanitizeWalletName(name) {
7
14
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
8
15
  }
@@ -10,23 +17,70 @@ export function getConnectionSecretPath(name) {
10
17
  const filename = name
11
18
  ? `connection-secret-${sanitizeWalletName(name)}.key`
12
19
  : "connection-secret.key";
13
- return join(homedir(), ".alby-cli", filename);
20
+ return join(getAlbyCliDir(), filename);
14
21
  }
15
22
  export function getPendingConnectionSecretPath(name) {
16
23
  const filename = name
17
24
  ? `pending-connection-secret-${sanitizeWalletName(name)}.key`
18
25
  : "pending-connection-secret.key";
19
- return join(homedir(), ".alby-cli", filename);
26
+ return join(getAlbyCliDir(), filename);
20
27
  }
21
28
  export function getPendingConnectionRelayPath(name) {
22
29
  const filename = name
23
30
  ? `pending-connection-relay-${sanitizeWalletName(name)}.txt`
24
31
  : "pending-connection-relay.txt";
25
- return join(homedir(), ".alby-cli", filename);
32
+ return join(getAlbyCliDir(), filename);
33
+ }
34
+ /**
35
+ * List configured wallets by scanning ~/.alby-cli for connection secret files.
36
+ * Never reads or returns secret contents - only wallet names and status.
37
+ */
38
+ export function listWallets() {
39
+ const dir = getAlbyCliDir();
40
+ let files;
41
+ try {
42
+ files = readdirSync(dir);
43
+ }
44
+ catch (error) {
45
+ const err = error;
46
+ if (err.code === "ENOENT")
47
+ return [];
48
+ throw err;
49
+ }
50
+ // Map of wallet name (null for default) -> status. Connected takes precedence
51
+ // over pending so a re-authed wallet still shows as usable.
52
+ const wallets = new Map();
53
+ const patterns = [
54
+ { regex: /^connection-secret(?:-(.+))?\.key$/, status: "connected" },
55
+ { regex: /^pending-connection-secret(?:-(.+))?\.key$/, status: "pending" },
56
+ ];
57
+ for (const file of files) {
58
+ for (const { regex, status } of patterns) {
59
+ const match = file.match(regex);
60
+ if (!match)
61
+ continue;
62
+ const name = match[1] ?? null;
63
+ if (status === "connected" || !wallets.has(name)) {
64
+ wallets.set(name, wallets.get(name) === "connected" ? "connected" : status);
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ return [...wallets.entries()]
70
+ .map(([name, status]) => ({
71
+ name,
72
+ isDefault: name === null,
73
+ status,
74
+ }))
75
+ .sort((a, b) => {
76
+ if (a.isDefault !== b.isDefault)
77
+ return a.isDefault ? -1 : 1;
78
+ return (a.name ?? "").localeCompare(b.name ?? "");
79
+ });
26
80
  }
27
81
  export function saveConnectionSecret(path, secret, verbose) {
28
82
  const alreadyExists = existsSync(path);
29
- const dir = join(homedir(), ".alby-cli");
83
+ const dir = getAlbyCliDir();
30
84
  if (!existsSync(dir)) {
31
85
  mkdirSync(dir, { recursive: true });
32
86
  }
@@ -43,16 +97,20 @@ export async function testAndLogConnection(client) {
43
97
  const info = await getInfo(client);
44
98
  console.log(`Connected to ${info.alias || "wallet"} (${info.network || "unknown network"})`);
45
99
  }
46
- export async function completePendingConnection(pendingSecretPath, connectionSecretPath, relayUrl, verbose, pendingRelayPath) {
100
+ export async function completePendingConnection(pendingSecretPath, connectionSecretPath, relayUrls, verbose, pendingRelayPath) {
47
101
  const secret = readFileSync(pendingSecretPath, "utf-8").trim();
48
- const DEFAULT_RELAY = "wss://relay.getalby.com/v1";
49
- if (!relayUrl && pendingRelayPath && existsSync(pendingRelayPath)) {
50
- relayUrl = readFileSync(pendingRelayPath, "utf-8").trim();
102
+ if ((!relayUrls || relayUrls.length === 0) &&
103
+ pendingRelayPath &&
104
+ existsSync(pendingRelayPath)) {
105
+ relayUrls = readFileSync(pendingRelayPath, "utf-8")
106
+ .split("\n")
107
+ .map((line) => line.trim())
108
+ .filter((line) => line.length > 0);
51
109
  }
52
- const resolvedRelay = relayUrl ?? DEFAULT_RELAY;
110
+ const resolvedRelays = relayUrls && relayUrls.length > 0 ? relayUrls : DEFAULT_RELAY_URLS;
53
111
  const nwaClient = new NWAClient({
54
112
  appSecretKey: secret,
55
- relayUrls: [resolvedRelay],
113
+ relayUrls: resolvedRelays,
56
114
  requestMethods: [],
57
115
  });
58
116
  return new Promise((resolve, reject) => {
@@ -117,7 +175,11 @@ export async function getClient(program) {
117
175
  }
118
176
  }
119
177
  if (!connectionSecret) {
120
- throw new Error("No connection secret provided. Pass -c <secret or file path>, set NWC_URL, use --wallet-name <name>, or create ~/.alby-cli/connection-secret.key");
178
+ throw new Error("No wallet connection found. Run 'auth' or 'connect' first to set up a wallet:\n" +
179
+ " npx @getalby/cli auth <wallet-url> # e.g. https://my.albyhub.com\n" +
180
+ ' npx @getalby/cli connect "nostr+walletconnect://..."\n' +
181
+ "\n" +
182
+ "Already have a connection secret? Pass -c <secret or file path>, set NWC_URL, use --wallet-name <name>, or create ~/.alby-cli/connection-secret.key");
121
183
  }
122
184
  // Auto-detect: if it doesn't start with the protocol, treat as file path
123
185
  if (!connectionSecret.startsWith("nostr+walletconnect://")) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@getalby/cli",
3
3
  "description": "CLI for Nostr Wallet Connect (NIP-47) with a few additional useful lightning tools",
4
4
  "repository": "https://github.com/getAlby/cli.git",
5
- "version": "0.6.1",
5
+ "version": "0.8.0",
6
6
  "type": "module",
7
7
  "main": "build/index.js",
8
8
  "bin": {
@@ -36,8 +36,9 @@
36
36
  "node": ">=20"
37
37
  },
38
38
  "dependencies": {
39
- "@getalby/lightning-tools": "^8.0.0",
40
- "@getalby/sdk": "^7.0.0",
39
+ "@getalby/lightning-tools": "^8.1.1",
40
+ "@getalby/sdk": "^8.0.1",
41
+ "@lendasat/lendaswap-sdk-pure": "^0.2.38",
41
42
  "@noble/hashes": "^2.0.1",
42
43
  "commander": "^14.0.3",
43
44
  "nostr-tools": "^2.23.3"