@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.
package/src/cli.ts ADDED
@@ -0,0 +1,289 @@
1
+ import {
2
+ browseCollection,
3
+ hasInteractiveBrowser,
4
+ hasInteractivePager,
5
+ isBrowserPresentation,
6
+ isListPresentation,
7
+ pageWithLess,
8
+ } from "./browser";
9
+ import { balancePresenters, parseBalance } from "./commands/balance";
10
+ import { currencyPresenters, parseCurrencies } from "./commands/currencies";
11
+ import {
12
+ expensePresenters,
13
+ mergeExpenseBody,
14
+ parseExpenses,
15
+ } from "./commands/expenses";
16
+ import { friendPresenters, parseFriends } from "./commands/friends";
17
+ import { groupPresenters, parseGroups } from "./commands/groups";
18
+ import { mePresenters, parseMe } from "./commands/me";
19
+ import { parsePayments, paymentPresenters } from "./commands/payments";
20
+ import { DEFAULT_API_URL, REQUEST_TIMEOUT_MS, request } from "./request";
21
+ import { asArray, asRecord } from "./shared";
22
+ import {
23
+ CliFailure,
24
+ type CliRuntime,
25
+ type CommandParser,
26
+ type OutputMode,
27
+ type Presentation,
28
+ type Presenter,
29
+ type RequestCommand,
30
+ } from "./types";
31
+
32
+ const ROOT_HELP = `Usage: banana [--json | --raw] <command>
33
+
34
+ Commands:
35
+ me Show the authenticated user
36
+ balance Show the aggregate balance
37
+ balance users Show balances by user
38
+ balances Show balances by user
39
+ currencies [list] List currencies
40
+ friends [list] List friends
41
+ groups [list] List groups
42
+ groups create Create a group
43
+ groups get <group-id> Show a group
44
+ groups members <group-id> List group members
45
+ groups activities <group-id>
46
+ List group activities
47
+ expenses list List the authenticated user's expenses
48
+ expenses add Add an expense
49
+ expenses get <expense-id> Show an expense
50
+ expenses edit <expense-id> Edit an expense
51
+ payments add Add a payment
52
+ payments get <payment-id> Show a payment
53
+
54
+ Output:
55
+ --json Print curated operational JSON
56
+ --raw Print the complete API response as JSON
57
+
58
+ Environment:
59
+ BANANASPLIT_TOKEN Required bearer session token
60
+ BANANASPLIT_API_URL API base URL (default: ${DEFAULT_API_URL})`;
61
+
62
+ const COMMANDS: Record<string, CommandParser> = {
63
+ balance: parseBalance,
64
+ currencies: parseCurrencies,
65
+ expenses: parseExpenses,
66
+ friends: parseFriends,
67
+ groups: parseGroups,
68
+ me: parseMe,
69
+ payments: parsePayments,
70
+ };
71
+
72
+ const PRESENTERS: Record<Presentation, Presenter> = {
73
+ ...mePresenters,
74
+ ...balancePresenters,
75
+ ...currencyPresenters,
76
+ ...friendPresenters,
77
+ ...groupPresenters,
78
+ ...expensePresenters,
79
+ ...paymentPresenters,
80
+ };
81
+
82
+ function parseOutputFlags(args: string[]) {
83
+ const commandArgs = args.filter((arg) => arg !== "--json" && arg !== "--raw");
84
+ const json = args.includes("--json");
85
+ const raw = args.includes("--raw");
86
+ if (json && raw) {
87
+ throw new CliFailure("usage", "Choose only one of --json or --raw");
88
+ }
89
+ return {
90
+ args: commandArgs,
91
+ mode: (raw ? "raw" : json ? "json" : "human") as OutputMode,
92
+ };
93
+ }
94
+
95
+ function parseCommand(args: string[]) {
96
+ if (
97
+ args.length === 0 ||
98
+ args[0] === "--help" ||
99
+ args[0] === "-h" ||
100
+ args[0] === "help"
101
+ ) {
102
+ return { kind: "help" as const, text: ROOT_HELP };
103
+ }
104
+ if (args[0] === "balances") args = ["balance", "users", ...args.slice(1)];
105
+ const [name, ...rest] = args;
106
+ const parser = COMMANDS[name];
107
+ if (!parser) throw new CliFailure("usage", ROOT_HELP);
108
+ return parser(rest);
109
+ }
110
+
111
+ function serializeFailure(error: unknown, mode: OutputMode) {
112
+ const failure =
113
+ error instanceof CliFailure
114
+ ? error
115
+ : new CliFailure(
116
+ "network",
117
+ error instanceof Error ? error.message : String(error),
118
+ );
119
+ const json = JSON.stringify({
120
+ error: {
121
+ type: failure.type,
122
+ ...(failure.status === undefined ? {} : { status: failure.status }),
123
+ message: failure.message,
124
+ ...(failure.body === undefined ? {} : { body: failure.body }),
125
+ },
126
+ });
127
+ return {
128
+ exitCode: failure.type === "usage" ? 2 : 1,
129
+ output:
130
+ mode === "human"
131
+ ? `Error: ${failure.message}`
132
+ : mode === "raw" &&
133
+ failure.type === "api" &&
134
+ failure.body !== undefined &&
135
+ failure.body !== null
136
+ ? JSON.stringify(failure.body)
137
+ : json,
138
+ };
139
+ }
140
+
141
+ export async function runCli(
142
+ args: string[],
143
+ runtime: CliRuntime = {},
144
+ ): Promise<number> {
145
+ const stdout = runtime.stdout ?? console.log;
146
+ const stderr = runtime.stderr ?? console.error;
147
+ const outputMode: OutputMode = args.includes("--json")
148
+ ? "json"
149
+ : args.includes("--raw")
150
+ ? "raw"
151
+ : "human";
152
+
153
+ try {
154
+ const output = parseOutputFlags(args);
155
+ const command = parseCommand(output.args);
156
+ if (command.kind === "help") {
157
+ stdout(command.text);
158
+ return 0;
159
+ }
160
+
161
+ const browserPresentation = isBrowserPresentation(command.presentation)
162
+ ? command.presentation
163
+ : undefined;
164
+ const pager =
165
+ output.mode === "human" && isListPresentation(command.presentation)
166
+ ? (runtime.pager ??
167
+ (runtime.stdout === undefined &&
168
+ !browserPresentation &&
169
+ hasInteractivePager()
170
+ ? pageWithLess
171
+ : undefined))
172
+ : undefined;
173
+ const browser =
174
+ output.mode === "human" && browserPresentation
175
+ ? (runtime.browser ??
176
+ (runtime.stdout === undefined && hasInteractiveBrowser()
177
+ ? browseCollection
178
+ : undefined))
179
+ : undefined;
180
+ const hasExplicitLimit = output.args.some(
181
+ (arg) => arg === "--limit" || arg.startsWith("--limit="),
182
+ );
183
+ if (
184
+ (pager || browser) &&
185
+ (command.presentation === "group-list" ||
186
+ command.presentation === "expense-list" ||
187
+ command.presentation === "friend-list") &&
188
+ !hasExplicitLimit
189
+ ) {
190
+ command.query?.delete("l");
191
+ }
192
+
193
+ const requestRuntime = {
194
+ fetch: runtime.fetch ?? globalThis.fetch,
195
+ timeoutMs: runtime.timeoutMs ?? REQUEST_TIMEOUT_MS,
196
+ };
197
+ const env = runtime.env ?? process.env;
198
+ if (command.mergeExpense !== undefined) {
199
+ const current = await request(
200
+ {
201
+ kind: "request",
202
+ path: command.mergeExpense,
203
+ presentation: "expense",
204
+ },
205
+ requestRuntime,
206
+ env,
207
+ );
208
+ command.body = mergeExpenseBody(
209
+ current,
210
+ asRecord(command.body) as Record<string, unknown>,
211
+ );
212
+ }
213
+ let body = await request(command, requestRuntime, env);
214
+ if (output.mode !== "raw" && command.presentation === "expense-updated") {
215
+ // PUT answers with a flat row, so re-read the expense for its
216
+ // paidBy / group / category / splits expansions.
217
+ body = await request(
218
+ {
219
+ kind: "request",
220
+ path: command.path,
221
+ presentation: "expense-updated",
222
+ },
223
+ requestRuntime,
224
+ env,
225
+ );
226
+ }
227
+ if (output.mode !== "raw" && command.presentation === "group") {
228
+ const members = await request(
229
+ {
230
+ kind: "request",
231
+ path: `${command.path}/members`,
232
+ presentation: "members",
233
+ },
234
+ requestRuntime,
235
+ env,
236
+ );
237
+ body = { ...asRecord(body), memberCount: asArray(members).length };
238
+ }
239
+
240
+ if (output.mode === "raw") {
241
+ stdout(JSON.stringify(body));
242
+ return 0;
243
+ }
244
+
245
+ const presenter = PRESENTERS[command.presentation];
246
+ const clean = presenter.clean(body);
247
+ if (output.mode === "json") {
248
+ stdout(JSON.stringify(clean));
249
+ return 0;
250
+ }
251
+
252
+ const human = presenter.format(clean);
253
+ const browserPresenter = presenter.browser;
254
+ const loadDetail = async (item: Record<string, unknown>) => {
255
+ if (!browserPresenter) {
256
+ throw new CliFailure("api", "Details are unavailable for this item");
257
+ }
258
+ const detailCommand: RequestCommand = {
259
+ kind: "request",
260
+ path: browserPresenter.detailPath(command, item),
261
+ presentation: command.presentation,
262
+ };
263
+ const detail = await request(detailCommand, requestRuntime, env);
264
+ return browserPresenter.formatDetail(item, detail);
265
+ };
266
+ const loadPage = command.presentation === "expense-list"
267
+ ? async (cursor: string) => {
268
+ const query = new URLSearchParams(command.query);
269
+ query.set("cursor", cursor);
270
+ return presenter.clean(await request(
271
+ { ...command, query }, requestRuntime, env,
272
+ ));
273
+ }
274
+ : undefined;
275
+ if (
276
+ (!browser ||
277
+ !browserPresentation ||
278
+ !(await browser(browserPresentation, clean, loadDetail, loadPage))) &&
279
+ (!pager || !(await pager(human)))
280
+ ) {
281
+ stdout(human);
282
+ }
283
+ return 0;
284
+ } catch (error) {
285
+ const failure = serializeFailure(error, outputMode);
286
+ stderr(failure.output);
287
+ return failure.exitCode;
288
+ }
289
+ }
@@ -0,0 +1,128 @@
1
+ import {
2
+ asArray,
3
+ asRecord,
4
+ cleanUserSummary,
5
+ currencyCode,
6
+ display,
7
+ encodedDetailId,
8
+ formatCard,
9
+ humanAmount,
10
+ numeric,
11
+ wantsHelp,
12
+ } from "../shared";
13
+ import { CliFailure, type ParsedCommand, type Presenter } from "../types";
14
+
15
+ const HELP = "Usage: banana balance [users]";
16
+
17
+ export function parseBalance(args: string[]): ParsedCommand {
18
+ if (wantsHelp(args)) return { kind: "help", text: HELP };
19
+ if (args.length === 0) {
20
+ return { kind: "request", path: "/balance", presentation: "balance" };
21
+ }
22
+ if (args.length === 1 && args[0] === "users") {
23
+ return {
24
+ kind: "request",
25
+ path: "/balance/users",
26
+ presentation: "balance-users",
27
+ };
28
+ }
29
+ throw new CliFailure("usage", HELP);
30
+ }
31
+
32
+ function cleanBalanceUsers(body: unknown) {
33
+ return asArray(body).map((value) => {
34
+ const entry = asRecord(value);
35
+ return {
36
+ user: cleanUserSummary(entry.user),
37
+ balance: numeric(entry.balance),
38
+ totalOwed: numeric(entry.totalOwed),
39
+ totalOwing: numeric(entry.totalOwing),
40
+ };
41
+ });
42
+ }
43
+
44
+ function cleanBalanceDetail(body: unknown, item: Record<string, unknown>) {
45
+ const response = asRecord(body);
46
+ return {
47
+ user: cleanUserSummary(item.user),
48
+ balance: numeric(response.balance) ?? numeric(item.balance),
49
+ totalOwed: numeric(item.totalOwed),
50
+ totalOwing: numeric(item.totalOwing),
51
+ currency: currencyCode(response.currency),
52
+ breakdown: asArray(response.balanceByGroup).map((value) => {
53
+ const entry = asRecord(value);
54
+ const group = asRecord(entry.group);
55
+ return {
56
+ groupId: group.id ?? null,
57
+ groupName: entry.group == null ? "Direct" : group.name ?? null,
58
+ balance: numeric(entry.balance),
59
+ };
60
+ }),
61
+ };
62
+ }
63
+
64
+ export const balancePresenters = {
65
+ balance: {
66
+ clean(body) {
67
+ const response = asRecord(body);
68
+ return {
69
+ balance: numeric(response.balance),
70
+ totalOwed: numeric(response.totalOwed),
71
+ totalOwing: numeric(response.totalOwing),
72
+ };
73
+ },
74
+ format(body) {
75
+ const response = asRecord(body);
76
+ return [
77
+ `Balance: ${display(response.balance)}`,
78
+ `Owed: ${display(response.totalOwed)}`,
79
+ `Owing: ${display(response.totalOwing)}`,
80
+ ].join("\n");
81
+ },
82
+ },
83
+ "balance-users": {
84
+ clean: cleanBalanceUsers,
85
+ format(body) {
86
+ const items = asArray(body);
87
+ if (!items.length) return "No user balances.";
88
+ return [
89
+ "User balances",
90
+ ...items.map((value, index) => {
91
+ const entry = asRecord(value);
92
+ const user = asRecord(entry.user);
93
+ return formatCard(index, user.name, [
94
+ `User ID: ${display(user.id)}`,
95
+ `Balance: ${display(entry.balance)}`,
96
+ `Owed: ${display(entry.totalOwed)}`,
97
+ `Owing: ${display(entry.totalOwing)}`,
98
+ ]);
99
+ }),
100
+ ].join("\n\n");
101
+ },
102
+ browser: {
103
+ detailPath(_command, item) {
104
+ return `/users/${encodedDetailId(asRecord(item.user).id)}/balances`;
105
+ },
106
+ formatDetail(item, body) {
107
+ const detail = asRecord(cleanBalanceDetail(body, item));
108
+ const breakdown = asArray(detail.breakdown);
109
+ return [
110
+ `Balance: ${humanAmount(detail.balance, detail.currency)}`,
111
+ `Owed: ${humanAmount(detail.totalOwed, detail.currency)}`,
112
+ `Owing: ${humanAmount(detail.totalOwing, detail.currency)}`,
113
+ `User ID: ${display(asRecord(detail.user).id)}`,
114
+ "",
115
+ "Balance breakdown",
116
+ ...(breakdown.length
117
+ ? breakdown.map((value) => {
118
+ const entry = asRecord(value);
119
+ return `${display(entry.groupName)}: ${humanAmount(entry.balance, detail.currency)}${
120
+ entry.groupId ? ` · ${String(entry.groupId)}` : ""
121
+ }`;
122
+ })
123
+ : ["—"]),
124
+ ].join("\n");
125
+ },
126
+ },
127
+ },
128
+ } satisfies Record<"balance" | "balance-users", Presenter>;
@@ -0,0 +1,49 @@
1
+ import {
2
+ asArray,
3
+ asRecord,
4
+ display,
5
+ formatCard,
6
+ parseOptions,
7
+ requirePositionals,
8
+ wantsHelp,
9
+ } from "../shared";
10
+ import type { ParsedCommand, Presenter } from "../types";
11
+
12
+ const HELP = "Usage: banana currencies [list]";
13
+
14
+ export function parseCurrencies(args: string[]): ParsedCommand {
15
+ if (args[0] === "list") args = args.slice(1);
16
+ if (wantsHelp(args)) return { kind: "help", text: HELP };
17
+ const { positionals } = parseOptions(args);
18
+ requirePositionals(positionals, 0, HELP);
19
+ return {
20
+ kind: "request",
21
+ path: "/currencies",
22
+ presentation: "currency-list",
23
+ };
24
+ }
25
+
26
+ export const currencyPresenters = {
27
+ "currency-list": {
28
+ clean: (body) => asArray(body),
29
+ format(body) {
30
+ const currencies = asArray(body);
31
+ if (!currencies.length) return "No currencies.";
32
+ return [
33
+ "Currencies",
34
+ ...currencies.map((value, index) => {
35
+ const currency = asRecord(value);
36
+ return formatCard(index, currency.name, [
37
+ `ID: ${display(currency.id)}`,
38
+ `Code: ${display(currency.code)}`,
39
+ `Symbol: ${display(currency.symbol)}`,
40
+ `Type: ${display(currency.type)}`,
41
+ `Decimals: ${display(currency.decimals)}`,
42
+ `Rate to base: ${display(currency.exchangeRateToBase)}`,
43
+ `Updated: ${display(currency.updatedAt)}`,
44
+ ]);
45
+ }),
46
+ ].join("\n\n");
47
+ },
48
+ },
49
+ } satisfies Record<"currency-list", Presenter>;