@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/LICENSE +21 -0
- package/README.md +188 -0
- package/package.json +32 -0
- package/src/browser.ts +360 -0
- package/src/cli.ts +289 -0
- package/src/commands/balance.ts +128 -0
- package/src/commands/currencies.ts +49 -0
- package/src/commands/expenses.ts +543 -0
- package/src/commands/friends.ts +156 -0
- package/src/commands/groups.ts +627 -0
- package/src/commands/me.ts +45 -0
- package/src/commands/payments.ts +167 -0
- package/src/index.ts +11 -0
- package/src/request.ts +108 -0
- package/src/shared.ts +183 -0
- package/src/types.ts +101 -0
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>;
|