@bananasplitapp/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ import {
2
+ asRecord,
3
+ cleanUserSummary,
4
+ display,
5
+ formatCard,
6
+ humanAmount,
7
+ isoDate,
8
+ namedEntity,
9
+ numeric,
10
+ parseJsonBody,
11
+ parseOptions,
12
+ requiredString,
13
+ requirePositionals,
14
+ wantsHelp,
15
+ yesNo,
16
+ } from "../shared";
17
+ import { CliFailure, type ParsedCommand, type Presenter } from "../types";
18
+
19
+ const HELP = `Usage: banana payments add JSON
20
+ or: banana payments add --amount AMOUNT --currency-id ID
21
+ --from-user-id ID --to-user-id ID
22
+ --date YYYY-MM-DD|DD-MM-YYYY
23
+ [--group-id ID] [--description TEXT]
24
+ or: banana payments get <payment-id>`;
25
+ const GET_HELP = "Usage: banana payments get <payment-id>";
26
+
27
+ export function parsePayments(args: string[]): ParsedCommand {
28
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
29
+ return { kind: "help", text: HELP };
30
+ }
31
+ const [command, ...rest] = args;
32
+ if (command === "get") {
33
+ if (wantsHelp(rest)) return { kind: "help", text: GET_HELP };
34
+ const { positionals } = parseOptions(rest);
35
+ requirePositionals(positionals, 1, GET_HELP);
36
+ return {
37
+ kind: "request",
38
+ path: `/payments/${encodeURIComponent(positionals[0])}`,
39
+ presentation: "payment",
40
+ };
41
+ }
42
+ if (command !== "add") throw new CliFailure("usage", HELP);
43
+ if (wantsHelp(rest)) return { kind: "help", text: HELP };
44
+
45
+ const { positionals, values } = parseOptions(rest, {
46
+ amount: { type: "string" },
47
+ "currency-id": { type: "string" },
48
+ date: { type: "string" },
49
+ description: { type: "string" },
50
+ "from-user-id": { type: "string" },
51
+ "group-id": { type: "string" },
52
+ "to-user-id": { type: "string" },
53
+ });
54
+ const jsonBody = parseJsonBody(positionals, values, HELP);
55
+ if (jsonBody !== undefined) {
56
+ return {
57
+ kind: "request",
58
+ method: "POST",
59
+ path: "/payments",
60
+ presentation: "payment-created",
61
+ body: jsonBody,
62
+ };
63
+ }
64
+ requirePositionals(positionals, 0, HELP);
65
+
66
+ const groupId = values["group-id"] as string | undefined;
67
+ const description = values.description as string | undefined;
68
+ return {
69
+ kind: "request",
70
+ method: "POST",
71
+ path: "/payments",
72
+ presentation: "payment-created",
73
+ body: {
74
+ amount: requiredString(values.amount, "--amount", HELP),
75
+ currencyId: requiredString(values["currency-id"], "--currency-id", HELP),
76
+ fromUserId: requiredString(values["from-user-id"], "--from-user-id", HELP),
77
+ toUserId: requiredString(values["to-user-id"], "--to-user-id", HELP),
78
+ date: isoDate(values.date, HELP),
79
+ ...(groupId === undefined ? {} : { groupId }),
80
+ ...(description === undefined ? {} : { description }),
81
+ },
82
+ };
83
+ }
84
+
85
+ function cleanPayment(body: unknown) {
86
+ const payment = asRecord(body);
87
+ return {
88
+ id: payment.id ?? null,
89
+ description: payment.description ?? null,
90
+ amount: numeric(payment.amount),
91
+ currency: asRecord(payment.currency).code ?? payment.currencyId ?? null,
92
+ from: cleanUserSummary(payment.fromUser),
93
+ to: cleanUserSummary(payment.toUser),
94
+ group: payment.groupId
95
+ ? {
96
+ id: payment.groupId,
97
+ name: asRecord(payment.group).name ?? null,
98
+ }
99
+ : null,
100
+ date: payment.date ?? null,
101
+ isSettlement: payment.isSettlement === true,
102
+ createdAt: payment.createdAt ?? null,
103
+ };
104
+ }
105
+
106
+ function cleanCreatedPaymentItem(value: unknown) {
107
+ const payment = asRecord(value);
108
+ return {
109
+ id: payment.id ?? null,
110
+ description: payment.description ?? null,
111
+ amount: numeric(payment.amount),
112
+ currencyId: payment.currencyId ?? null,
113
+ fromUserId: payment.fromUserId ?? null,
114
+ toUserId: payment.toUserId ?? null,
115
+ groupId: payment.groupId ?? null,
116
+ date: payment.date ?? null,
117
+ timezone: payment.timezone ?? null,
118
+ isSettlement: payment.isSettlement === true,
119
+ usedOptimalSettlement: payment.usedOptimalSettlement === true,
120
+ };
121
+ }
122
+
123
+ export const paymentPresenters = {
124
+ payment: {
125
+ clean: cleanPayment,
126
+ format(body) {
127
+ const response = asRecord(body);
128
+ const group = asRecord(response.group);
129
+ return [
130
+ "Payment",
131
+ `Amount: ${humanAmount(response.amount, response.currency)}`,
132
+ `From: ${namedEntity(response.from)}`,
133
+ `To: ${namedEntity(response.to)}`,
134
+ `Group: ${response.group ? namedEntity(group) : "—"}`,
135
+ `Description: ${display(response.description)}`,
136
+ `Date: ${display(response.date)}`,
137
+ `Settlement: ${yesNo(response.isSettlement)}`,
138
+ `ID: ${display(response.id)}`,
139
+ ].join("\n");
140
+ },
141
+ },
142
+ "payment-created": {
143
+ clean(body) {
144
+ return Array.isArray(body)
145
+ ? body.map(cleanCreatedPaymentItem)
146
+ : cleanCreatedPaymentItem(body);
147
+ },
148
+ format(body) {
149
+ const payments = Array.isArray(body) ? body : [body];
150
+ return [
151
+ payments.length === 1
152
+ ? "Payment created"
153
+ : `${payments.length} payments created`,
154
+ ...payments.map((value, index) => {
155
+ const payment = asRecord(value);
156
+ return formatCard(index, payment.id, [
157
+ `Amount: ${humanAmount(payment.amount, payment.currencyId)}`,
158
+ `From: ${display(payment.fromUserId)}`,
159
+ `To: ${display(payment.toUserId)}`,
160
+ `Group ID: ${display(payment.groupId)}`,
161
+ `Date: ${display(payment.date)}`,
162
+ ]);
163
+ }),
164
+ ].join("\n\n");
165
+ },
166
+ },
167
+ } satisfies Record<"payment" | "payment-created", Presenter>;
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { runCli } from "./cli";
4
+
5
+ export { renderCollectionBrowser, renderGroupBrowser } from "./browser";
6
+ export { runCli } from "./cli";
7
+ export type { CliRuntime } from "./types";
8
+
9
+ if (import.meta.main) {
10
+ process.exitCode = await runCli(process.argv.slice(2));
11
+ }
package/src/request.ts ADDED
@@ -0,0 +1,108 @@
1
+ import {
2
+ CliFailure,
3
+ type Environment,
4
+ type RequestCommand,
5
+ type CliRuntime,
6
+ } from "./types";
7
+
8
+ export const DEFAULT_API_URL = "https://api.bananasplit.net";
9
+ export const REQUEST_TIMEOUT_MS = 15_000;
10
+
11
+ function readApiUrl(raw: string | undefined) {
12
+ let url: URL;
13
+ try {
14
+ url = new URL(raw || DEFAULT_API_URL);
15
+ } catch {
16
+ throw new CliFailure("config", "BANANASPLIT_API_URL must be a valid URL");
17
+ }
18
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
19
+ throw new CliFailure(
20
+ "config",
21
+ "BANANASPLIT_API_URL must use http or https",
22
+ );
23
+ }
24
+ url.search = "";
25
+ url.hash = "";
26
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
27
+ return url;
28
+ }
29
+
30
+ function responseMessage(response: Response, body: unknown) {
31
+ if (typeof body === "string" && body) return body;
32
+ if (
33
+ body &&
34
+ typeof body === "object" &&
35
+ "message" in body &&
36
+ typeof body.message === "string"
37
+ ) {
38
+ return body.message;
39
+ }
40
+ return `${response.status} ${response.statusText || "Request failed"}`;
41
+ }
42
+
43
+ async function readResponseBody(response: Response) {
44
+ const text = await response.text();
45
+ if (!text) return null;
46
+ try {
47
+ return JSON.parse(text) as unknown;
48
+ } catch {
49
+ return text;
50
+ }
51
+ }
52
+
53
+ export async function request(
54
+ command: RequestCommand,
55
+ runtime: Required<Pick<CliRuntime, "fetch" | "timeoutMs">>,
56
+ env: Environment,
57
+ ) {
58
+ const token = env.BANANASPLIT_TOKEN;
59
+ if (!token) throw new CliFailure("config", "BANANASPLIT_TOKEN is required");
60
+
61
+ const url = new URL(
62
+ command.path.replace(/^\//, ""),
63
+ readApiUrl(env.BANANASPLIT_API_URL),
64
+ );
65
+ command.query?.forEach((value, key) => url.searchParams.set(key, value));
66
+
67
+ let response: Response;
68
+ try {
69
+ response = await runtime.fetch(url, {
70
+ headers: {
71
+ accept: "application/json",
72
+ authorization: `Bearer ${token}`,
73
+ ...(command.body === undefined
74
+ ? {}
75
+ : { "content-type": "application/json" }),
76
+ "user-agent": "bananasplit-cli",
77
+ },
78
+ ...(command.method === undefined ? {} : { method: command.method }),
79
+ ...(command.body === undefined
80
+ ? {}
81
+ : { body: JSON.stringify(command.body) }),
82
+ signal: AbortSignal.timeout(runtime.timeoutMs),
83
+ });
84
+ } catch (error) {
85
+ const timedOut =
86
+ error instanceof Error &&
87
+ (error.name === "AbortError" || error.name === "TimeoutError");
88
+ throw new CliFailure(
89
+ "network",
90
+ timedOut
91
+ ? `Request timed out after ${runtime.timeoutMs}ms`
92
+ : error instanceof Error
93
+ ? error.message
94
+ : "Network request failed",
95
+ );
96
+ }
97
+
98
+ const body = await readResponseBody(response);
99
+ if (!response.ok) {
100
+ throw new CliFailure(
101
+ "api",
102
+ responseMessage(response, body),
103
+ response.status,
104
+ body,
105
+ );
106
+ }
107
+ return body;
108
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { parseArgs } from "node:util";
2
+ import { CliFailure, type OptionConfig } from "./types";
3
+
4
+ export const DEFAULT_LIST_LIMIT = 5;
5
+
6
+ export function parseOptions(args: string[], options: OptionConfig = {}) {
7
+ try {
8
+ return parseArgs({ args, options, allowPositionals: true, strict: true });
9
+ } catch (error) {
10
+ throw new CliFailure(
11
+ "usage",
12
+ error instanceof Error ? error.message : String(error),
13
+ );
14
+ }
15
+ }
16
+
17
+ export function wantsHelp(args: string[]) {
18
+ return args.includes("--help") || args.includes("-h");
19
+ }
20
+
21
+ export function requirePositionals(
22
+ positionals: string[],
23
+ count: number,
24
+ usage: string,
25
+ ) {
26
+ if (positionals.length !== count) throw new CliFailure("usage", usage);
27
+ }
28
+
29
+ export function positiveInteger(value: unknown, name: string) {
30
+ if (value === undefined) return undefined;
31
+ const parsed = Number(value);
32
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
33
+ throw new CliFailure("usage", `${name} must be a positive integer`);
34
+ }
35
+ return parsed.toString();
36
+ }
37
+
38
+ export function requiredString(value: unknown, name: string, usage: string) {
39
+ if (typeof value !== "string" || value.length === 0) {
40
+ throw new CliFailure("usage", `${name} is required\n${usage}`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ export function isoDate(value: unknown, usage: string) {
46
+ const input = requiredString(value, "--date", usage);
47
+ const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input);
48
+ const dmy = /^(\d{2})-(\d{2})-(\d{4})$/.exec(input);
49
+ const normalized = ymd
50
+ ? input
51
+ : dmy
52
+ ? `${dmy[3]}-${dmy[2]}-${dmy[1]}`
53
+ : "";
54
+ const date = new Date(`${normalized}T00:00:00.000Z`);
55
+
56
+ if (
57
+ !normalized ||
58
+ Number.isNaN(date.getTime()) ||
59
+ date.toISOString().slice(0, 10) !== normalized
60
+ ) {
61
+ throw new CliFailure(
62
+ "usage",
63
+ "--date must use YYYY-MM-DD or DD-MM-YYYY",
64
+ );
65
+ }
66
+ return date.toISOString();
67
+ }
68
+
69
+ export function repeatedStrings(value: unknown) {
70
+ if (value === undefined) return [];
71
+ return (Array.isArray(value) ? value : [value]).filter(
72
+ (item): item is string => typeof item === "string",
73
+ );
74
+ }
75
+
76
+ export function parseJsonBody(
77
+ positionals: string[],
78
+ values: Record<string, unknown>,
79
+ usage: string,
80
+ ) {
81
+ if (positionals.length === 0) return undefined;
82
+ if (positionals.length !== 1 || Object.keys(values).length !== 0) {
83
+ throw new CliFailure(
84
+ "usage",
85
+ `Pass one JSON object or use options, not both\n${usage}`,
86
+ );
87
+ }
88
+
89
+ let body: unknown;
90
+ try {
91
+ body = JSON.parse(positionals[0]);
92
+ } catch {
93
+ throw new CliFailure("usage", `JSON body must be a valid object\n${usage}`);
94
+ }
95
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
96
+ throw new CliFailure("usage", `JSON body must be a valid object\n${usage}`);
97
+ }
98
+ return body;
99
+ }
100
+
101
+ export function enumValue<const Values extends readonly string[]>(
102
+ value: unknown,
103
+ name: string,
104
+ values: Values,
105
+ ): Values[number] | undefined {
106
+ if (value === undefined) return undefined;
107
+ if (typeof value !== "string" || !values.includes(value)) {
108
+ throw new CliFailure("usage", `${name} must be one of: ${values.join(", ")}`);
109
+ }
110
+ return value;
111
+ }
112
+
113
+ export function appendQuery(
114
+ query: URLSearchParams,
115
+ name: string,
116
+ value: string | boolean | undefined,
117
+ ) {
118
+ if (value !== undefined) query.set(name, String(value));
119
+ }
120
+
121
+ export function asRecord(value: unknown): Record<string, unknown> {
122
+ return value && typeof value === "object" && !Array.isArray(value)
123
+ ? (value as Record<string, unknown>)
124
+ : {};
125
+ }
126
+
127
+ export function asArray(value: unknown) {
128
+ return Array.isArray(value) ? value : [];
129
+ }
130
+
131
+ export function numeric(value: unknown) {
132
+ if (typeof value === "number" && Number.isFinite(value)) return value;
133
+ if (
134
+ typeof value === "string" &&
135
+ value.trim() &&
136
+ Number.isFinite(Number(value))
137
+ ) {
138
+ return Number(value);
139
+ }
140
+ return null;
141
+ }
142
+
143
+ export function cleanUserSummary(value: unknown) {
144
+ const user = asRecord(value);
145
+ return { id: user.id ?? null, name: user.name ?? null };
146
+ }
147
+
148
+ export function currencyCode(value: unknown) {
149
+ return asRecord(value).code ?? null;
150
+ }
151
+
152
+ export function display(value: unknown) {
153
+ return value === null || value === undefined || value === ""
154
+ ? "—"
155
+ : String(value);
156
+ }
157
+
158
+ export function namedEntity(value: unknown) {
159
+ const entity = asRecord(value);
160
+ return `${display(entity.name)}${entity.id ? ` · ${String(entity.id)}` : ""}`;
161
+ }
162
+
163
+ export function humanAmount(amount: unknown, currency: unknown) {
164
+ return `${display(amount)}${currency ? ` ${String(currency)}` : ""}`;
165
+ }
166
+
167
+ export function yesNo(value: unknown) {
168
+ return value === true ? "yes" : "no";
169
+ }
170
+
171
+ export function formatCard(index: number, title: unknown, fields: string[]) {
172
+ return [
173
+ `${index + 1}. ${display(title)}`,
174
+ ...fields.map((field) => ` ${field}`),
175
+ ].join("\n");
176
+ }
177
+
178
+ export function encodedDetailId(value: unknown) {
179
+ if (typeof value !== "string" || !value) {
180
+ throw new CliFailure("api", "Details are unavailable for this item");
181
+ }
182
+ return encodeURIComponent(value);
183
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ export type ErrorType = "api" | "config" | "network" | "usage";
2
+ export type Environment = Record<string, string | undefined>;
3
+ export type OutputWriter = (value: string) => void;
4
+ export type OutputMode = "human" | "json" | "raw";
5
+ export type Pager = (value: string) => Promise<boolean>;
6
+ export type Presentation =
7
+ | "user"
8
+ | "balance"
9
+ | "balance-users"
10
+ | "currency-list"
11
+ | "friend-list"
12
+ | "group-list"
13
+ | "group"
14
+ | "members"
15
+ | "activities"
16
+ | "expense-list"
17
+ | "expense"
18
+ | "payment"
19
+ | "expense-updated"
20
+ | "expense-created"
21
+ | "payment-created"
22
+ | "group-created";
23
+ export type BrowserPresentation =
24
+ | "balance-users"
25
+ | "friend-list"
26
+ | "group-list"
27
+ | "expense-list"
28
+ | "members"
29
+ | "activities";
30
+ export type BrowserDetailLoader = (
31
+ item: Record<string, unknown>,
32
+ ) => Promise<string>;
33
+ export type BrowserPageLoader = (cursor: string) => Promise<unknown>;
34
+ export type Browser = (
35
+ presentation: BrowserPresentation,
36
+ body: unknown,
37
+ loadDetail: BrowserDetailLoader,
38
+ loadPage?: BrowserPageLoader,
39
+ ) => Promise<boolean>;
40
+ export type Fetch = (
41
+ input: RequestInfo | URL,
42
+ init?: RequestInit,
43
+ ) => Promise<Response>;
44
+
45
+ export interface CliRuntime {
46
+ browser?: Browser;
47
+ env?: Environment;
48
+ fetch?: Fetch;
49
+ pager?: Pager;
50
+ stderr?: OutputWriter;
51
+ stdout?: OutputWriter;
52
+ timeoutMs?: number;
53
+ }
54
+
55
+ export type RequestCommand = {
56
+ kind: "request";
57
+ path: string;
58
+ presentation: Presentation;
59
+ query?: URLSearchParams;
60
+ method?: "POST" | "PUT";
61
+ body?: unknown;
62
+ mergeExpense?: string;
63
+ };
64
+
65
+ export type HelpCommand = {
66
+ kind: "help";
67
+ text: string;
68
+ };
69
+
70
+ export type ParsedCommand = HelpCommand | RequestCommand;
71
+ export type CommandParser = (args: string[]) => ParsedCommand;
72
+
73
+ export type BrowserPresenter = {
74
+ detailPath: (
75
+ command: RequestCommand,
76
+ item: Record<string, unknown>,
77
+ ) => string;
78
+ formatDetail: (item: Record<string, unknown>, body: unknown) => string;
79
+ };
80
+
81
+ export type Presenter = {
82
+ clean: (body: unknown) => unknown;
83
+ format: (body: unknown) => string;
84
+ browser?: BrowserPresenter;
85
+ };
86
+
87
+ export class CliFailure extends Error {
88
+ constructor(
89
+ readonly type: ErrorType,
90
+ message: string,
91
+ readonly status?: number,
92
+ readonly body?: unknown,
93
+ ) {
94
+ super(message);
95
+ }
96
+ }
97
+
98
+ export type OptionConfig = Record<
99
+ string,
100
+ { type: "boolean" | "string"; short?: string; multiple?: boolean }
101
+ >;