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/README.md b/README.md index 12dd76f..ab36460 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 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 b5d0da3..8a6b2a9 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,16 +1,13 @@ 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, DEFAULT_RELAY_URLS, getConnectionSecretPath, getPendingConnectionRelayPath, getPendingConnectionSecretPath, handleError, - saveConnectionSecret, - testAndLogConnection, } from "../utils.js"; import { generateSecretKey, getPublicKey } from "nostr-tools"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; @@ -86,9 +83,7 @@ export function registerAuthCommand(program: Command) { } const relayUrls = - options.relayUrl.length > 0 - ? options.relayUrl - : DEFAULT_RELAY_URLS; + options.relayUrl.length > 0 ? options.relayUrl : DEFAULT_RELAY_URLS; const secret = bytesToHex(generateSecretKey()); const pubkey = getPublicKey(hexToBytes(secret)); @@ -99,7 +94,7 @@ export function registerAuthCommand(program: Command) { pubkey, ).toString(); - const dir = join(homedir(), ".alby-cli"); + const dir = getAlbyCliDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } @@ -118,9 +113,7 @@ export function registerAuthCommand(program: Command) { const retryCmd = walletName ? `npx @getalby/cli get-balance --wallet-name ${walletName}` : `npx @getalby/cli get-balance`; - console.log( - `\nOnce approved, run any command, e.g.:\n ${retryCmd}`, - ); + console.log(`\nOnce approved, run any command, e.g.:\n ${retryCmd}`); }); }, ); 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 468ffa6..ca3b0e7 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 ab5f9f4..1125a95 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,7 @@ import { chmodSync, existsSync, mkdirSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -17,6 +18,10 @@ export const DEFAULT_RELAY_URLS = [ "wss://relay2.getalby.com", ]; +export function getAlbyCliDir() { + return join(homedir(), ".alby-cli"); +} + function sanitizeWalletName(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, "_"); } @@ -25,21 +30,80 @@ 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( @@ -48,7 +112,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 }); }