Skip to content
Open
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
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 75 additions & 10 deletions src/commands/reconnect.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,111 @@
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 <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,
})),
});
})();

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;
}
Expand All @@ -50,6 +114,7 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command {
snaptrade,
user,
existingConnectionId,
connectionType,
});
});
}
10 changes: 7 additions & 3 deletions src/utils/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,24 @@ export async function handleConnect({
user,
existingConnectionId,
brokerSlug,
connectionType = "trade-if-available",
connectionType,
}: {
snaptrade: SnaptradeClient;
user: User;
existingConnectionId?: string;
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) ||
Expand Down
27 changes: 26 additions & 1 deletion src/utils/selectAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
77 changes: 74 additions & 3 deletions test/accounts-select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading