From ee4f722d10b1b598c2f5de7ce5b0831384ae3fdd Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 25 May 2026 16:15:59 +0700 Subject: [PATCH 1/2] feat: add list-wallets command Closes #24 Adds a `list-wallets` command that scans ~/.alby-cli for configured wallets and reports their name and connection status. Useful for users managing multiple NWC wallets via --wallet-name. Only wallet names and status (connected/pending) are reported; secret file contents are never read or exposed. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++ src/commands/auth.ts | 5 +- src/commands/list-wallets.ts | 16 ++++++ src/index.ts | 2 + src/test/list-wallets.test.ts | 97 +++++++++++++++++++++++++++++++++++ src/utils.ts | 69 +++++++++++++++++++++++-- 6 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 src/commands/list-wallets.ts create mode 100644 src/test/list-wallets.test.ts diff --git a/README.md b/README.md index e1aaadd..132b474 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ npx @getalby/cli --wallet-name work get-balance npx @getalby/cli --wallet-name personal pay-invoice --invoice lnbc... ``` +List the wallets you've configured (names and connection status only, never the secrets): + +```bash +npx @getalby/cli list-wallets +``` + ### Connection secret resolution (in order of priority) 1. `--connection-secret` flag (value or path to file) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f8b5547..a3e390c 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,9 +1,8 @@ import { Command } from "commander"; import { NWCClient } from "@getalby/sdk"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { + getAlbyCliDir, getConnectionSecretPath, getPendingConnectionRelayPath, getPendingConnectionSecretPath, @@ -92,7 +91,7 @@ export function registerAuthCommand(program: Command) { pubkey, ).toString(); - const dir = join(homedir(), ".alby-cli"); + const dir = getAlbyCliDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } diff --git a/src/commands/list-wallets.ts b/src/commands/list-wallets.ts new file mode 100644 index 0000000..ba152cd --- /dev/null +++ b/src/commands/list-wallets.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { getAlbyCliDir, handleError, listWallets, output } from "../utils.js"; + +export function registerListWalletsCommand(program: Command) { + program + .command("list-wallets") + .description( + "List configured wallets (names and connection status only, no secrets)", + ) + .action(async () => { + await handleError(async () => { + const wallets = listWallets(); + output({ directory: getAlbyCliDir(), wallets }); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index f677d36..4cdbb43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { registerRequestInvoiceFromLightningAddressCommand } from "./commands/re import { registerFetch402Command } from "./commands/fetch.js"; import { registerConnectCommand } from "./commands/connect.js"; import { registerAuthCommand } from "./commands/auth.js"; +import { registerListWalletsCommand } from "./commands/list-wallets.js"; import { registerDiscoverCommand } from "./commands/discover.js"; const program = new Command(); @@ -105,5 +106,6 @@ registerDiscoverCommand(program); program.commandsGroup("Setup:"); registerAuthCommand(program); registerConnectCommand(program); +registerListWalletsCommand(program); program.parse(); diff --git a/src/test/list-wallets.test.ts b/src/test/list-wallets.test.ts new file mode 100644 index 0000000..042187a --- /dev/null +++ b/src/test/list-wallets.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { runCli } from "./helpers.js"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface WalletInfo { + name: string | null; + isDefault: boolean; + status: "connected" | "pending"; +} + +interface ListWalletsOutput { + directory: string; + wallets: WalletInfo[]; +} + +describe("list-wallets command", () => { + let testHome: string; + let albyDir: string; + + beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "alby-cli-test-")); + albyDir = join(testHome, ".alby-cli"); + }); + + afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); + }); + + function writeWallet(filename: string) { + mkdirSync(albyDir, { recursive: true }); + writeFileSync(join(albyDir, filename), "nostr+walletconnect://test"); + } + + test("returns empty list when no .alby-cli directory exists", () => { + const result = runCli("list-wallets", { HOME: testHome }); + expect(result.success).toBe(true); + expect(result.output.wallets).toEqual([]); + }); + + test("lists the default (unnamed) wallet", () => { + writeWallet("connection-secret.key"); + const result = runCli("list-wallets", { HOME: testHome }); + expect(result.success).toBe(true); + expect(result.output.wallets).toEqual([ + { name: null, isDefault: true, status: "connected" }, + ]); + }); + + test("lists named wallets sorted with default first", () => { + writeWallet("connection-secret.key"); + writeWallet("connection-secret-work.key"); + writeWallet("connection-secret-personal.key"); + const result = runCli("list-wallets", { HOME: testHome }); + expect(result.success).toBe(true); + expect(result.output.wallets).toEqual([ + { name: null, isDefault: true, status: "connected" }, + { name: "personal", isDefault: false, status: "connected" }, + { name: "work", isDefault: false, status: "connected" }, + ]); + }); + + test("reports pending connections", () => { + writeWallet("pending-connection-secret-test.key"); + const result = runCli("list-wallets", { HOME: testHome }); + expect(result.success).toBe(true); + expect(result.output.wallets).toEqual([ + { name: "test", isDefault: false, status: "pending" }, + ]); + }); + + test("connected status takes precedence over pending for the same wallet", () => { + writeWallet("connection-secret-dual.key"); + writeWallet("pending-connection-secret-dual.key"); + const result = runCli("list-wallets", { HOME: testHome }); + expect(result.success).toBe(true); + expect(result.output.wallets).toEqual([ + { name: "dual", isDefault: false, status: "connected" }, + ]); + }); + + test("does not reveal secret contents", () => { + writeWallet("connection-secret.key"); + const result = runCli("list-wallets", { HOME: testHome }); + expect(JSON.stringify(result.output)).not.toContain("nostr+walletconnect://"); + }); + + test("ignores unrelated files", () => { + mkdirSync(albyDir, { recursive: true }); + writeFileSync(join(albyDir, "pending-connection-relay-test.txt"), "wss://relay"); + writeFileSync(join(albyDir, "notes.txt"), "hello"); + const result = runCli("list-wallets", { HOME: testHome }); + expect(result.success).toBe(true); + expect(result.output.wallets).toEqual([]); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 3ddc15e..496542a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,7 @@ import { chmodSync, existsSync, mkdirSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -12,6 +13,10 @@ import { import { homedir } from "node:os"; import { join } from "node:path"; +export function getAlbyCliDir() { + return join(homedir(), ".alby-cli"); +} + function sanitizeWalletName(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, "_"); } @@ -20,21 +25,77 @@ export function getConnectionSecretPath(name?: string) { const filename = name ? `connection-secret-${sanitizeWalletName(name)}.key` : "connection-secret.key"; - return join(homedir(), ".alby-cli", filename); + return join(getAlbyCliDir(), filename); } export function getPendingConnectionSecretPath(name?: string) { const filename = name ? `pending-connection-secret-${sanitizeWalletName(name)}.key` : "pending-connection-secret.key"; - return join(homedir(), ".alby-cli", filename); + return join(getAlbyCliDir(), filename); } export function getPendingConnectionRelayPath(name?: string) { const filename = name ? `pending-connection-relay-${sanitizeWalletName(name)}.txt` : "pending-connection-relay.txt"; - return join(homedir(), ".alby-cli", filename); + return join(getAlbyCliDir(), filename); +} + +export interface WalletInfo { + /** Wallet name, or null for the default (unnamed) wallet. */ + name: string | null; + isDefault: boolean; + /** "connected" if a connection secret exists, "pending" if awaiting wallet approval. */ + status: "connected" | "pending"; +} + +/** + * List configured wallets by scanning ~/.alby-cli for connection secret files. + * Never reads or returns secret contents - only wallet names and status. + */ +export function listWallets(): WalletInfo[] { + const dir = getAlbyCliDir(); + let files: string[]; + try { + files = readdirSync(dir); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") return []; + throw err; + } + + // Map of wallet name (null for default) -> status. Connected takes precedence + // over pending so a re-authed wallet still shows as usable. + const wallets = new Map(); + + const patterns: { regex: RegExp; status: "connected" | "pending" }[] = [ + { regex: /^connection-secret(?:-(.+))?\.key$/, status: "connected" }, + { regex: /^pending-connection-secret(?:-(.+))?\.key$/, status: "pending" }, + ]; + + for (const file of files) { + for (const { regex, status } of patterns) { + const match = file.match(regex); + if (!match) continue; + const name = match[1] ?? null; + if (status === "connected" || !wallets.has(name)) { + wallets.set(name, wallets.get(name) === "connected" ? "connected" : status); + } + break; + } + } + + return [...wallets.entries()] + .map(([name, status]) => ({ + name, + isDefault: name === null, + status, + })) + .sort((a, b) => { + if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1; + return (a.name ?? "").localeCompare(b.name ?? ""); + }); } export function saveConnectionSecret( @@ -43,7 +104,7 @@ export function saveConnectionSecret( verbose: boolean, ) { const alreadyExists = existsSync(path); - const dir = join(homedir(), ".alby-cli"); + const dir = getAlbyCliDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } From 26a04d51f4bd97f2fabca0cfb8c093d8bc9f89b2 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Thu, 28 May 2026 14:04:45 +0700 Subject: [PATCH 2/2] chore: remove unused imports --- .claude/worktrees/fix-already-connected-msg | 1 + src/commands/auth.ts | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 160000 .claude/worktrees/fix-already-connected-msg diff --git a/.claude/worktrees/fix-already-connected-msg b/.claude/worktrees/fix-already-connected-msg new file mode 160000 index 0000000..488978c --- /dev/null +++ b/.claude/worktrees/fix-already-connected-msg @@ -0,0 +1 @@ +Subproject commit 488978ce21c8e1f9449434d3a3f398e0d939ee07 diff --git a/src/commands/auth.ts b/src/commands/auth.ts index fa56571..8a6b2a9 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -8,8 +8,6 @@ import { getPendingConnectionRelayPath, getPendingConnectionSecretPath, handleError, - saveConnectionSecret, - testAndLogConnection, } from "../utils.js"; import { generateSecretKey, getPublicKey } from "nostr-tools"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";