Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/worktrees/fix-already-connected-msg
Submodule fix-already-connected-msg added at 488978
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 4 additions & 11 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
Expand All @@ -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 });
}
Expand All @@ -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}`);
});
},
);
Expand Down
16 changes: 16 additions & 0 deletions src/commands/list-wallets.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -105,5 +106,6 @@ registerDiscoverCommand(program);
program.commandsGroup("Setup:");
registerAuthCommand(program);
registerConnectCommand(program);
registerListWalletsCommand(program);

program.parse();
97 changes: 97 additions & 0 deletions src/test/list-wallets.test.ts
Original file line number Diff line number Diff line change
@@ -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<ListWalletsOutput>("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<ListWalletsOutput>("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<ListWalletsOutput>("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<ListWalletsOutput>("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<ListWalletsOutput>("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<ListWalletsOutput>("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<ListWalletsOutput>("list-wallets", { HOME: testHome });
expect(result.success).toBe(true);
expect(result.output.wallets).toEqual([]);
});
});
72 changes: 68 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
Expand All @@ -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, "_");
}
Expand All @@ -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<string | null, "connected" | "pending">();

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(
Expand All @@ -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 });
}
Expand Down
Loading