diff --git a/README.md b/README.md index 291c9a0..ec509d3 100644 --- a/README.md +++ b/README.md @@ -122,28 +122,28 @@ Usage: snaptrade [options] [command] CLI tool to interact with SnapTrade API Options: - -V, --version output the version number - --useLastAccount Use the last selected account for account specific commands (default: false) - --verbose Enable verbose output (default: false) - -h, --help display help for command + -V, --version output the version number + --useLastAccount Use the last selected account for account specific commands (default: false) + --verbose Enable verbose output (default: false) + -h, --help display help for command Commands: - status Get current status of your SnapTrade authentication - brokers List all brokers available to connect - connect [options] Establish a new broker connection - reconnect [connectionId] Re-establish an existing disabled connection - disconnect [connectionId] Remove an existing broker connection - connections List all broker connections - accounts List all connected accounts - positions [options] List all positions for a given account - recent-orders List the most recent orders (within last 24 hours) for a given account - orders List all orders for a given account - instruments Get a list of available instruments from a broker - quote [symbols] Get the latest market quote - trade [options] Execute different types of trades (equity, options, crypto) - cancel-order [options] Cancel an existing order - profiles Manage SnapTrade CLI profiles - help [command] display help for command + status Get current status of your SnapTrade authentication + brokers List all brokers available to connect + connect [options] Establish a new broker connection + reconnect [options] [connectionId] Re-establish a disabled connection, or change an existing connection's access level + disconnect [connectionId] Remove an existing broker connection + connections List all broker connections + accounts List all connected accounts + positions [options] List all positions for a given account + recent-orders List the most recent orders (within last 24 hours) for a given account + orders List all orders for a given account + instruments Get a list of available instruments from a broker + quote [symbols] Get the latest market quote + trade [options] Execute different types of trades (equity, options, crypto) + cancel-order [options] Cancel an existing order + profiles Manage SnapTrade CLI profiles + help [command] display help for command ``` ## ☕️ Development diff --git a/src/commands/reconnect.ts b/src/commands/reconnect.ts index 8383376..24b8dd5 100644 --- a/src/commands/reconnect.ts +++ b/src/commands/reconnect.ts @@ -1,39 +1,99 @@ import { select } from "@inquirer/prompts"; +import chalk from "chalk"; import { Command } from "commander"; import type { SnaptradeClient } from "../utils/snaptradeClient.ts"; import { handleConnect } from "../utils/connect.ts"; import { loadOrRegisterUser } from "../utils/user.ts"; +const CONNECTION_TYPES = ["read", "trade"] as const; +type ConnectionType = (typeof CONNECTION_TYPES)[number]; + export function reconnectCommand(snaptrade: SnaptradeClient): Command { return new Command("reconnect") - .description("Re-establish an existing disabled connection") + .description( + "Re-establish a disabled connection, or change an existing connection's access level", + ) .argument("[connectionId]", "Connection ID to reconnect") - .action(async (connectionId: string | undefined) => { + .option( + "--connection-type ", + `Access level to reconnect with (${CONNECTION_TYPES.join(", ")}). Defaults to keeping the current level.`, + (value: string) => { + if (!CONNECTION_TYPES.includes(value as ConnectionType)) { + console.error( + `Invalid connection type. Allowed values are: ${CONNECTION_TYPES.join(", ")}`, + ); + process.exit(1); + } + return value as ConnectionType; + }, + ) + .action(async (connectionId: string | undefined, opts) => { const user = await loadOrRegisterUser(snaptrade); + const connectionType = opts.connectionType as ConnectionType | undefined; // Prompt for connection ID if not provided const existingConnectionId = await (async () => { if (connectionId) { + // The API rejects connectionType=trade for brokerages with no trade + // auth type, and there's no global handler to turn that into + // something readable, so check before launching the portal. + if (connectionType === "trade") { + const connection = ( + await snaptrade.connections.detailBrokerageAuthorization({ + ...user, + authorizationId: connectionId, + }) + ).data; + if (connection?.brokerage?.allows_trading === false) { + console.error( + `${connection.brokerage.display_name} does not support trading through SnapTrade.`, + ); + process.exit(1); + } + } return connectionId; } const connections = ( await snaptrade.connections.listBrokerageAuthorizations(user) ).data; - const disabled = connections.filter((conn) => conn.disabled); + // Disabled connections always need repair. Healthy ones are only worth + // offering when the requested access level differs from what they + // already have — and an upgrade additionally needs a brokerage that can + // actually trade. + const candidates = connections.filter((conn) => { + if (conn.disabled) { + return ( + connectionType !== "trade" || + conn.brokerage?.allows_trading !== false + ); + } + if (!connectionType || conn.type === connectionType) { + return false; + } + return ( + connectionType !== "trade" || + conn.brokerage?.allows_trading !== false + ); + }); - if (disabled.length === 0) { + if (candidates.length === 0) { return null; } - if (disabled.length === 1) { - return disabled[0].id; + if (candidates.length === 1) { + return candidates[0].id; } return select({ - message: "Select a connection to reconnect", - choices: disabled.map((conn) => ({ - name: `${conn.brokerage?.display_name}`, + message: + connectionType === "trade" + ? "Select a connection to upgrade to trading" + : connectionType === "read" + ? "Select a connection to make read-only" + : "Select a connection to reconnect", + choices: candidates.map((conn) => ({ + name: `${conn.brokerage?.display_name}${conn.disabled ? " (disabled)" : conn.type === "read" ? " (read-only)" : " (trade)"}`, value: conn.id, })), }); @@ -41,7 +101,11 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { if (!existingConnectionId) { console.log( - "No disabled connections found, therefore there's no need to reconnect.", + connectionType === "trade" + ? "No connections found that need upgrading to trading." + : connectionType === "read" + ? "No connections found that need changing to read-only." + : `No disabled connections found, therefore there's no need to reconnect. To give an existing connection trading access, run ${chalk.green("snaptrade reconnect --connection-type trade")}.`, ); return; } @@ -50,6 +114,7 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { snaptrade, user, existingConnectionId, + connectionType, }); }); } diff --git a/src/utils/connect.ts b/src/utils/connect.ts index 400e3ba..6b86ebe 100644 --- a/src/utils/connect.ts +++ b/src/utils/connect.ts @@ -8,7 +8,7 @@ export async function handleConnect({ user, existingConnectionId, brokerSlug, - connectionType = "trade-if-available", + connectionType, }: { snaptrade: SnaptradeClient; user: User; @@ -16,12 +16,16 @@ export async function handleConnect({ brokerSlug?: string; connectionType?: "read" | "trade-if-available" | "trade"; }) { + // On a reconnect an unspecified type means "keep what the connection already + // has" — the API preserves it — so only default for brand new connections. + const requestedConnectionType = + connectionType ?? (existingConnectionId ? undefined : "trade-if-available"); + const loginResponse = await snaptrade.authentication.loginSnapTradeUser({ ...user, reconnect: existingConnectionId, broker: brokerSlug, - // Don't modify connection type if reconnecting - connectionType: existingConnectionId ? undefined : connectionType, + connectionType: requestedConnectionType, }); if ( !("redirectURI" in loginResponse.data) || diff --git a/src/utils/selectAccount.ts b/src/utils/selectAccount.ts index b1fc55d..ef0e0df 100644 --- a/src/utils/selectAccount.ts +++ b/src/utils/selectAccount.ts @@ -104,8 +104,33 @@ export async function selectAccount({ ("disabled" in choice && choice.disabled), ) ) { + // Every account was rejected. The per-account reasons are already computed + // above, so report them instead of guessing at a remedy — the most common + // cause is a read-only connection, which `reconnect` alone will not fix. + const reasons = new Set( + choices.flatMap((choice) => + choice instanceof Separator || typeof choice.disabled !== "string" + ? [] + : [choice.disabled], + ), + ); + + console.error("No valid accounts available."); + for (const reason of reasons) { + console.error(` • ${reason}`); + } + if (reasons.has("Read-only connection")) { + console.error( + `\nGive an existing connection trading access with ${chalk.green(`snaptrade reconnect --connection-type trade`)}.`, + ); + } + if (reasons.has("Connection disabled")) { + console.error( + `\nRepair your disabled connections with ${chalk.green(`snaptrade reconnect`)}.`, + ); + } console.error( - `No valid accounts available. Connect an account with ${chalk.green(`snaptrade connect`)} or fix your disabled connections with ${chalk.green(`snaptrade reconnect`)}.`, + `\nRun ${chalk.green(`snaptrade connections`)} to review them, or ${chalk.green(`snaptrade connect`)} to add another.`, ); process.exit(1); } diff --git a/test/accounts-select.test.ts b/test/accounts-select.test.ts index edf8014..9cc8086 100644 --- a/test/accounts-select.test.ts +++ b/test/accounts-select.test.ts @@ -201,8 +201,79 @@ describe("account list and selection", () => { }), ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(1); - expect(stripAnsi(consoleOutput.error.join("\n"))).toContain( - "No valid accounts available. Connect an account with snaptrade connect", - ); + const output = stripAnsi(consoleOutput.error.join("\n")); + expect(output).toContain("No valid accounts available."); + expect(output).toContain("• Crypto trading not supported"); + expect(output).toContain("snaptrade connections"); + // Nothing here is disabled or read-only, so neither remedy should be offered. + expect(output).not.toContain("--connection-type trade"); + expect(output).not.toContain("Repair your disabled connections"); + }); + + it("points read-only rejections at the connection-type upgrade, not at reconnect-repair", async () => { + useIsolatedConfigHome(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + vi.doMock("../src/utils/user.ts", () => ({ + loadOrRegisterUser: vi.fn().mockResolvedValue({}), + })); + vi.doMock("../src/utils/accounts.ts", () => ({ + listAccountsByConnection: vi.fn().mockResolvedValue([ + { + connection: { + id: "read-only", + disabled: false, + type: "read", + brokerage: { name: "Alpaca", slug: "ALPACA" }, + }, + accounts: [ + { + id: "acct-read", + name: "Alpaca Margin", + institution_name: "Alpaca", + balance: { total: { amount: 1, currency: "USD" } }, + }, + ], + }, + { + connection: { + id: "no-mleg", + disabled: false, + type: "trade", + brokerage: { name: "Questrade", slug: "QUESTRADE" }, + }, + accounts: [ + { + id: "acct-qt", + name: "Individual Margin", + institution_name: "Questrade", + balance: { total: { amount: 2, currency: "CAD" } }, + }, + ], + }, + ]), + })); + vi.doMock("@inquirer/prompts", () => ({ + select: vi.fn(), + })); + const consoleOutput = captureConsole(); + + const { selectAccount } = await import("../src/utils/selectAccount.ts"); + + await expect( + selectAccount({ + snaptrade: createMockSnaptrade(), + useLastAccount: false, + context: "option_trade", + }), + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + const output = stripAnsi(consoleOutput.error.join("\n")); + expect(output).toContain("• Read-only connection"); + expect(output).toContain("• Option trading not supported"); + expect(output).toContain("snaptrade reconnect --connection-type trade"); + // The old message sent read-only users to disabled-connection repair. + expect(output).not.toContain("Repair your disabled connections"); }); }); diff --git a/test/reconnect-command.test.ts b/test/reconnect-command.test.ts new file mode 100644 index 0000000..2d9439b --- /dev/null +++ b/test/reconnect-command.test.ts @@ -0,0 +1,341 @@ +import { mkdirSync } from "fs"; +import { join } from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + captureConsole, + createMockSnaptrade, + parseCommand, + stripAnsi, + useIsolatedConfigHome, +} from "./helpers/cli.ts"; + +// A personal api-key profile short-circuits loadOrRegisterUser, so the command +// under test never reaches the registration prompt or the network. +async function usePersonalProfile() { + const configHome = useIsolatedConfigHome(); + mkdirSync(join(configHome, "snaptrade"), { recursive: true }); + const settings = await import("../src/utils/settings.ts"); + settings.saveProfile({ authMode: "apiKey", accountType: "personal" }); +} + +describe("reconnect command", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("upgrades an active read-only connection when --connection-type trade is passed", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-read", + type: "read", + disabled: false, + brokerage: { display_name: "Alpaca" }, + }, + ], + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "trade", + ]); + + expect(handleConnect).toHaveBeenCalledTimes(1); + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "conn-read", + connectionType: "trade", + }); + }); + + it("forwards an explicit connection id and type without listing connections", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.detailBrokerageAuthorization, + ).mockResolvedValue({ + data: { brokerage: { display_name: "Alpaca", allows_trading: true } }, + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "2e582a27-82cc-4453-8b75-6a93a37b2bc8", + "--connection-type", + "trade", + ]); + + expect( + snaptrade.connections.listBrokerageAuthorizations, + ).not.toHaveBeenCalled(); + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "2e582a27-82cc-4453-8b75-6a93a37b2bc8", + connectionType: "trade", + }); + }); + + it("ignores healthy read-only connections when no upgrade was requested", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-read", + type: "read", + disabled: false, + brokerage: { display_name: "Alpaca" }, + }, + ], + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), ["reconnect"]); + + expect(handleConnect).not.toHaveBeenCalled(); + const output = stripAnsi(consoleOutput.log.join("\n")); + expect(output).toContain("No disabled connections found"); + // The dead end that sent SNAP-9604's reporter in circles: say what to do next. + expect(output).toContain("snaptrade reconnect --connection-type trade"); + }); + + it("refuses to upgrade a brokerage that cannot trade, instead of a raw API error", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.detailBrokerageAuthorization, + ).mockResolvedValue({ + data: { + brokerage: { + display_name: "Interactive Brokers Flex", + allows_trading: false, + }, + }, + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await expect( + parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "b922c247-fe01-4a6e-b08b-48e76f96a8e8", + "--connection-type", + "trade", + ]), + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(handleConnect).not.toHaveBeenCalled(); + expect(stripAnsi(consoleOutput.error.join("\n"))).toContain( + "Interactive Brokers Flex does not support trading", + ); + }); + + it("skips trade-incapable brokerages when listing upgrade candidates", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-flex", + type: "read", + disabled: false, + brokerage: { + display_name: "Interactive Brokers Flex", + allows_trading: false, + }, + }, + { + id: "conn-alpaca", + type: "read", + disabled: false, + brokerage: { display_name: "Alpaca", allows_trading: true }, + }, + ], + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "trade", + ]); + + // Only one viable candidate, so it is selected without prompting. + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "conn-alpaca", + }); + }); + + it("offers active trade connections when downgrading to read", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-trade", + type: "trade", + disabled: false, + brokerage: { display_name: "Alpaca", allows_trading: true }, + }, + // Already read-only: nothing to change, so it must not be a candidate. + { + id: "conn-read", + type: "read", + disabled: false, + brokerage: { display_name: "Moomoo", allows_trading: true }, + }, + ], + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "read", + ]); + + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "conn-trade", + connectionType: "read", + }); + }); + + it("does not offer connections that already have the requested level", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-trade", + type: "trade", + disabled: false, + brokerage: { display_name: "Alpaca", allows_trading: true }, + }, + ], + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "trade", + ]); + + expect(handleConnect).not.toHaveBeenCalled(); + expect(stripAnsi(consoleOutput.log.join("\n"))).toContain( + "No connections found that need upgrading to trading.", + ); + }); + + it("rejects an unknown connection type", async () => { + await usePersonalProfile(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect: vi.fn() })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await expect( + parseCommand(reconnectCommand(createMockSnaptrade()), [ + "reconnect", + "--connection-type", + "trade-if-available", + ]), + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(stripAnsi(consoleOutput.error.join("\n"))).toContain( + "Allowed values are: read, trade", + ); + }); +}); + +describe("handleConnect connection type", () => { + beforeEach(() => { + // The reconnect suite above doMocks this module and the registration + // outlives resetModules, so drop it before exercising the real thing. + vi.doUnmock("../src/utils/connect.ts"); + vi.resetModules(); + }); + + async function callHandleConnect(args: Record) { + vi.doMock("open", () => ({ default: vi.fn() })); + vi.useFakeTimers(); + const loginSnapTradeUser = vi.fn().mockResolvedValue({ + data: { redirectURI: "https://app.snaptrade.com/portal?token=t" }, + }); + const snaptrade = createMockSnaptrade({ + authentication: { loginSnapTradeUser }, + } as never); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ data: [] }); + captureConsole(); + + const { handleConnect } = await import("../src/utils/connect.ts"); + await handleConnect({ snaptrade, user: {}, ...args }); + vi.clearAllTimers(); + vi.useRealTimers(); + + return loginSnapTradeUser.mock.calls[0][0] as Record; + } + + it("keeps the existing type when reconnecting without an explicit type", async () => { + const payload = await callHandleConnect({ + existingConnectionId: "conn-1", + }); + expect(payload.reconnect).toBe("conn-1"); + expect(payload.connectionType).toBeUndefined(); + }); + + it("forwards an explicit type on reconnect so read-only can be upgraded", async () => { + const payload = await callHandleConnect({ + existingConnectionId: "conn-1", + connectionType: "trade", + }); + expect(payload.connectionType).toBe("trade"); + }); + + it("still defaults new connections to trade-if-available", async () => { + const payload = await callHandleConnect({}); + expect(payload.connectionType).toBe("trade-if-available"); + }); +});