@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BananaSplit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# BananaSplit CLI
|
|
2
|
+
|
|
3
|
+
Standalone access to the BananaSplit API for people and shell-enabled agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
macOS and Linux (no Bun or Node required):
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
curl -fsSL https://github.com/teotti/banana-cli/releases/latest/download/install.sh | sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Windows PowerShell (no Bun or Node required):
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
irm https://github.com/teotti/banana-cli/releases/latest/download/install.ps1 | iex
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install the TypeScript package with [Bun](https://bun.sh):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun install -g @bananasplitapp/cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Set `BANANA_INSTALL_DIR` to choose a different destination. To install a
|
|
26
|
+
specific standalone release instead of the latest:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
curl -fsSL https://github.com/teotti/banana-cli/releases/latest/download/install.sh \
|
|
30
|
+
| BANANA_VERSION=v0.1.0 sh
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
$env:BANANA_VERSION = "v0.1.0"
|
|
35
|
+
irm https://github.com/teotti/banana-cli/releases/latest/download/install.ps1 | iex
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Release downloads are verified against the published SHA-256 checksums before
|
|
39
|
+
an existing installation is replaced.
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
|
|
43
|
+
Set your BananaSplit session token:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
export BANANASPLIT_TOKEN="your-bearer-session-token"
|
|
47
|
+
# Optional; defaults to production:
|
|
48
|
+
export BANANASPLIT_API_URL="http://localhost:8080"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
Run `banana` from any directory:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
banana me
|
|
57
|
+
banana balance
|
|
58
|
+
banana balances
|
|
59
|
+
banana currencies
|
|
60
|
+
banana friends
|
|
61
|
+
banana groups
|
|
62
|
+
banana groups members GROUP_ID
|
|
63
|
+
banana groups activities GROUP_ID --search dinner
|
|
64
|
+
banana expenses list
|
|
65
|
+
banana expenses get EXPENSE_ID
|
|
66
|
+
banana payments get PAYMENT_ID
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
List expenses across the authenticated account, sorted by date or amount:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
banana expenses list --sort amount --direction desc --limit 10
|
|
73
|
+
banana expenses list --recurring --sort date --direction asc
|
|
74
|
+
banana expenses list --no-recurring --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Omit both recurring flags to include all expenses. In the interactive browser,
|
|
78
|
+
reaching the last item loads and appends the next page with the same sorting,
|
|
79
|
+
filtering, and page size. Outside the browser, use `--cursor CURSOR` with the
|
|
80
|
+
same options to retrieve the next page. JSON output includes `items`, `hasMore`, and
|
|
81
|
+
`nextCursor`. Each item includes `share`, the current user's share amount,
|
|
82
|
+
and `isRecurring`. Full splits are available through expense details.
|
|
83
|
+
|
|
84
|
+
Create groups, expenses, and payments with explicit API IDs:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
banana groups create --name "Lisbon trip" --currency-id CURRENCY_ID \
|
|
88
|
+
--type travel --member USER_ID
|
|
89
|
+
|
|
90
|
+
banana expenses add --title Dinner --amount 42 --currency-id CURRENCY_ID \
|
|
91
|
+
--paid-by-id USER_ID --date 2026-09-01 --group-id GROUP_ID \
|
|
92
|
+
--split-type custom --split USER_ID=22 --split FRIEND_ID=20
|
|
93
|
+
|
|
94
|
+
banana payments add --amount 20 --currency-id CURRENCY_ID \
|
|
95
|
+
--from-user-id USER_ID --to-user-id FRIEND_ID --date 2026-09-01 \
|
|
96
|
+
--description "Settle up"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Edit an expense in place. Only the fields you pass change; everything else
|
|
100
|
+
keeps its current value:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
banana expenses edit EXPENSE_ID --title "Chinese dinner"
|
|
104
|
+
banana expenses edit EXPENSE_ID --group-id GROUP_ID
|
|
105
|
+
banana expenses edit EXPENSE_ID --amount 60 --split USER_ID=30 --split FRIEND_ID=30
|
|
106
|
+
banana expenses edit EXPENSE_ID '{"description":null}'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Moving an expense into a group with `--group-id` clears its direct friendship
|
|
110
|
+
link; `--no-group` does the reverse. Changing `--amount` on an equal split
|
|
111
|
+
redistributes the splits automatically; any other split type needs matching
|
|
112
|
+
`--split` values.
|
|
113
|
+
|
|
114
|
+
Each create command also accepts its API body as one quoted JSON object:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
banana groups create '{"name":"Lisbon trip","currencyId":"CURRENCY_ID"}'
|
|
118
|
+
banana expenses add '{"title":"Dinner","amount":"42","currencyId":"CURRENCY_ID","paidById":"USER_ID","date":"2026-09-01","splits":[{"userId":"USER_ID","amount":"42"}]}'
|
|
119
|
+
banana payments add '{"amount":"20","currencyId":"CURRENCY_ID","fromUserId":"USER_ID","toUserId":"FRIEND_ID","date":"2026-09-01"}'
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Direct expenses require at least one `--split USER_ID=AMOUNT`. Group expenses
|
|
123
|
+
may omit `--split` to use the group's configured default split.
|
|
124
|
+
|
|
125
|
+
Commands print a clean, human-readable summary by default. Use `--json` for
|
|
126
|
+
the same operational fields as compact JSON, or `--raw` for the complete API
|
|
127
|
+
response:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
banana me
|
|
131
|
+
banana --json groups list --limit 10
|
|
132
|
+
banana groups get GROUP_ID --raw
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The output flag may appear before or after the command. `--json` and `--raw`
|
|
136
|
+
cannot be used together.
|
|
137
|
+
|
|
138
|
+
In an interactive terminal, `balance users`, `friends list`, `groups list`, `expenses list`,
|
|
139
|
+
`groups members`, and `groups activities` open searchable browsers; type to
|
|
140
|
+
filter, use the arrow keys to select an item, press Enter to open its details,
|
|
141
|
+
Esc to return, and `q` to quit. In a non-interactive command card, friend and
|
|
142
|
+
group lists fall back to five items and print a copyable `Next page` cursor
|
|
143
|
+
command. Expense lists also default to five items outside the browser and show
|
|
144
|
+
the next cursor. In the browser they use the API's default page size and load
|
|
145
|
+
more as you reach the end. Search filters all loaded expenses; press ↓ at the
|
|
146
|
+
end (or when no items match) to load another page. If loading fails, press ↓
|
|
147
|
+
to retry. Use `--limit N` to choose a page size.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
Install Bun, clone this repository, then install dependencies and expose
|
|
152
|
+
`banana` on your path:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
bun install
|
|
156
|
+
bun link
|
|
157
|
+
banana --help
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Run the checks with:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
bun test
|
|
164
|
+
bun run typecheck
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Releasing
|
|
168
|
+
|
|
169
|
+
The repository must be public and the `@bananasplitapp` npm organization must
|
|
170
|
+
exist before the first release. Store a temporary granular npm publishing
|
|
171
|
+
token with bypass 2FA as the `NPM_TOKEN` repository secret, update the version
|
|
172
|
+
in `package.json`, then push its matching stable tag:
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
git tag v0.1.0
|
|
176
|
+
git push origin v0.1.0
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The tag workflow tests all targets, publishes the GitHub release, and publishes
|
|
180
|
+
the npm package. After the first npm release, configure `release.yml` as the
|
|
181
|
+
package's trusted GitHub Actions publisher and remove `NPM_TOKEN`; subsequent
|
|
182
|
+
publishes use OIDC automatically.
|
|
183
|
+
|
|
184
|
+
Configuration, network, API, and usage errors are written to stderr. Human
|
|
185
|
+
commands print `Error: MESSAGE`, `--json` prints a structured error object, and
|
|
186
|
+
`--raw` prints the API error body when available.
|
|
187
|
+
Session tokens currently expire after 90 days; interactive login and stored
|
|
188
|
+
credentials are not included in this first version.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bananasplitapp/cli",
|
|
3
|
+
"description": "Command-line access to the BananaSplit API",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/teotti/banana-cli.git"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"bin": {
|
|
15
|
+
"banana": "./src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"bun": ">=1.4.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"banana": "bun run ./src/index.ts",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "^1.4.0",
|
|
30
|
+
"typescript": "^7.0.2"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync } from "node:fs";
|
|
3
|
+
import { emitKeypressEvents } from "node:readline";
|
|
4
|
+
import { asArray, asRecord, display } from "./shared";
|
|
5
|
+
import {
|
|
6
|
+
CliFailure,
|
|
7
|
+
type BrowserDetailLoader,
|
|
8
|
+
type BrowserPageLoader,
|
|
9
|
+
type BrowserPresentation,
|
|
10
|
+
type Presentation,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
const PAGER_PROMPT = "↑/↓ navigate · q quit";
|
|
14
|
+
const ANSI = {
|
|
15
|
+
bold: "\x1b[1m",
|
|
16
|
+
yellow: "\x1b[38;2;255;228;0m",
|
|
17
|
+
dim: "\x1b[2m",
|
|
18
|
+
green: "\x1b[32m",
|
|
19
|
+
reset: "\x1b[0m",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type BrowserDetailState =
|
|
23
|
+
| { status: "loading"; title: string }
|
|
24
|
+
| { status: "loaded"; title: string; value: string }
|
|
25
|
+
| { status: "error"; title: string; message: string };
|
|
26
|
+
|
|
27
|
+
export function hasInteractivePager() {
|
|
28
|
+
if (process.env.CI || spawnSync("less", ["--version"]).status !== 0) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const terminal = openSync("/dev/tty", "r+");
|
|
33
|
+
closeSync(terminal);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function pageWithLess(value: string) {
|
|
41
|
+
let terminal: number;
|
|
42
|
+
try {
|
|
43
|
+
terminal = openSync("/dev/tty", "r+");
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const pager = spawn("less", ["-X", "-Q", `-Ps${PAGER_PROMPT}`], {
|
|
50
|
+
stdio: ["pipe", terminal, terminal],
|
|
51
|
+
});
|
|
52
|
+
const exited = new Promise<boolean>((resolve) => {
|
|
53
|
+
pager.once("error", () => resolve(false));
|
|
54
|
+
pager.once("close", (code) => resolve(code === 0));
|
|
55
|
+
});
|
|
56
|
+
if (!pager.stdin) {
|
|
57
|
+
pager.kill();
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
pager.stdin.end(`${value}\n`);
|
|
61
|
+
return await exited;
|
|
62
|
+
} finally {
|
|
63
|
+
closeSync(terminal);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isListPresentation(presentation: Presentation) {
|
|
68
|
+
return (
|
|
69
|
+
presentation === "balance-users" ||
|
|
70
|
+
presentation === "currency-list" ||
|
|
71
|
+
presentation === "expense-list" ||
|
|
72
|
+
presentation === "friend-list" ||
|
|
73
|
+
presentation === "group-list" ||
|
|
74
|
+
presentation === "members" ||
|
|
75
|
+
presentation === "activities"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isBrowserPresentation(
|
|
80
|
+
presentation: Presentation,
|
|
81
|
+
): presentation is BrowserPresentation {
|
|
82
|
+
return (
|
|
83
|
+
presentation === "expense-list" ||
|
|
84
|
+
presentation === "balance-users" ||
|
|
85
|
+
presentation === "friend-list" ||
|
|
86
|
+
presentation === "group-list" ||
|
|
87
|
+
presentation === "members" ||
|
|
88
|
+
presentation === "activities"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function browserItems(presentation: BrowserPresentation, body: unknown) {
|
|
93
|
+
return asArray(
|
|
94
|
+
presentation === "group-list" ||
|
|
95
|
+
presentation === "friend-list" ||
|
|
96
|
+
presentation === "expense-list"
|
|
97
|
+
? asRecord(body).items
|
|
98
|
+
: body,
|
|
99
|
+
).map(asRecord);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function filterBrowserItems(
|
|
103
|
+
presentation: BrowserPresentation,
|
|
104
|
+
body: unknown,
|
|
105
|
+
query: string,
|
|
106
|
+
) {
|
|
107
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
108
|
+
return browserItems(presentation, body).filter((item) =>
|
|
109
|
+
JSON.stringify(item).toLowerCase().includes(normalizedQuery),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function browserItemTitle(
|
|
114
|
+
presentation: BrowserPresentation,
|
|
115
|
+
item: Record<string, unknown>,
|
|
116
|
+
) {
|
|
117
|
+
if (presentation === "balance-users" || presentation === "friend-list") {
|
|
118
|
+
return display(asRecord(item.user).name);
|
|
119
|
+
}
|
|
120
|
+
if (presentation === "group-list" || presentation === "members") {
|
|
121
|
+
return display(item.name);
|
|
122
|
+
}
|
|
123
|
+
return item.entity === "payment"
|
|
124
|
+
? `Payment: ${display(item.description)}`
|
|
125
|
+
: `Expense: ${display(item.title)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderCollectionBrowser(
|
|
129
|
+
presentation: BrowserPresentation,
|
|
130
|
+
body: unknown,
|
|
131
|
+
query = "",
|
|
132
|
+
selectedIndex = 0,
|
|
133
|
+
detail?: BrowserDetailState,
|
|
134
|
+
rows = Infinity,
|
|
135
|
+
pageStatus?: string,
|
|
136
|
+
) {
|
|
137
|
+
if (detail) {
|
|
138
|
+
return [
|
|
139
|
+
`${ANSI.bold}${ANSI.yellow}BANANA${ANSI.reset}`,
|
|
140
|
+
"",
|
|
141
|
+
`◆ ${ANSI.bold}${detail.title}${ANSI.reset}`,
|
|
142
|
+
` ${ANSI.dim}esc/← back · q quit${ANSI.reset}`,
|
|
143
|
+
"",
|
|
144
|
+
detail.status === "loading"
|
|
145
|
+
? `${ANSI.dim}Loading details…${ANSI.reset}`
|
|
146
|
+
: detail.status === "error"
|
|
147
|
+
? `Unable to load details: ${detail.message}`
|
|
148
|
+
: detail.value,
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const response = asRecord(body);
|
|
153
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
154
|
+
const allItems = browserItems(presentation, body);
|
|
155
|
+
const items = filterBrowserItems(presentation, body, query);
|
|
156
|
+
const activeIndex = Math.min(selectedIndex, Math.max(items.length - 1, 0));
|
|
157
|
+
const visibleCount = Math.max(1, rows - 13);
|
|
158
|
+
const start = Math.max(0, activeIndex - visibleCount + 1);
|
|
159
|
+
const label = {
|
|
160
|
+
"balance-users": "user balances",
|
|
161
|
+
"friend-list": "friends",
|
|
162
|
+
"group-list": "groups",
|
|
163
|
+
"expense-list": "expenses",
|
|
164
|
+
members: "group members",
|
|
165
|
+
activities: "group activities",
|
|
166
|
+
}[presentation];
|
|
167
|
+
const count = `${ANSI.green}${items.length}${
|
|
168
|
+
normalizedQuery ? `/${allItems.length}` : ""
|
|
169
|
+
}${ANSI.reset}`;
|
|
170
|
+
const lines = [
|
|
171
|
+
`${ANSI.bold}${ANSI.yellow}BANANA${ANSI.reset}`,
|
|
172
|
+
"",
|
|
173
|
+
`┌─ ${ANSI.yellow} ${label} ${ANSI.reset}`,
|
|
174
|
+
"│",
|
|
175
|
+
`◇ Found ${count} ${label}`,
|
|
176
|
+
"│",
|
|
177
|
+
`◆ ${ANSI.bold}Browse ${label}${ANSI.reset}`,
|
|
178
|
+
` Search: ${query}${ANSI.yellow}█${ANSI.reset}`,
|
|
179
|
+
` ${ANSI.dim}↑↓ move · type search · enter/→ details · esc/← clear · q quit${ANSI.reset}`,
|
|
180
|
+
"",
|
|
181
|
+
...(items.length
|
|
182
|
+
? items.slice(start, start + visibleCount).map(
|
|
183
|
+
(item, index) =>
|
|
184
|
+
`${start + index === activeIndex ? `${ANSI.yellow}› ●${ANSI.reset}` : " ○"} ${browserItemTitle(presentation, item)}`,
|
|
185
|
+
)
|
|
186
|
+
: [` ${ANSI.dim}No matching ${label}.${ANSI.reset}`]),
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
if (pageStatus) {
|
|
190
|
+
lines.push("", pageStatus);
|
|
191
|
+
} else if (
|
|
192
|
+
(presentation === "group-list" ||
|
|
193
|
+
presentation === "friend-list" ||
|
|
194
|
+
presentation === "expense-list") &&
|
|
195
|
+
response.hasMore
|
|
196
|
+
) {
|
|
197
|
+
lines.push(
|
|
198
|
+
"",
|
|
199
|
+
`${ANSI.dim}${presentation === "expense-list"
|
|
200
|
+
? "Reach the last item to load more expenses (↓ to continue)."
|
|
201
|
+
: `More ${label} are available from the API.`}${ANSI.reset}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function renderGroupBrowser(
|
|
208
|
+
body: unknown,
|
|
209
|
+
query = "",
|
|
210
|
+
selectedIndex = 0,
|
|
211
|
+
) {
|
|
212
|
+
return renderCollectionBrowser("group-list", body, query, selectedIndex);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function hasInteractiveBrowser() {
|
|
216
|
+
return Boolean(
|
|
217
|
+
!process.env.CI &&
|
|
218
|
+
process.stdin.isTTY &&
|
|
219
|
+
process.stdout.isTTY &&
|
|
220
|
+
typeof process.stdin.setRawMode === "function",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function browseCollection(
|
|
225
|
+
presentation: BrowserPresentation,
|
|
226
|
+
body: unknown,
|
|
227
|
+
loadDetail: BrowserDetailLoader,
|
|
228
|
+
loadPage?: BrowserPageLoader,
|
|
229
|
+
) {
|
|
230
|
+
if (!hasInteractiveBrowser()) return false;
|
|
231
|
+
|
|
232
|
+
const input = process.stdin;
|
|
233
|
+
const output = process.stdout;
|
|
234
|
+
const wasRaw = input.isRaw;
|
|
235
|
+
let query = "";
|
|
236
|
+
let selectedIndex = 0;
|
|
237
|
+
let detail: BrowserDetailState | undefined;
|
|
238
|
+
let detailGeneration = 0;
|
|
239
|
+
let finished = false;
|
|
240
|
+
let loadingPage = false;
|
|
241
|
+
let pageStatus: string | undefined;
|
|
242
|
+
|
|
243
|
+
emitKeypressEvents(input);
|
|
244
|
+
input.setRawMode(true);
|
|
245
|
+
input.resume();
|
|
246
|
+
output.write("\x1b[?1049h\x1b[?25l");
|
|
247
|
+
|
|
248
|
+
return await new Promise<boolean>((resolve) => {
|
|
249
|
+
const draw = () => {
|
|
250
|
+
if (finished) return;
|
|
251
|
+
output.write(
|
|
252
|
+
`\x1b[H\x1b[2J${renderCollectionBrowser(presentation, body, query, selectedIndex, detail, output.rows, pageStatus)}`,
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
const finish = () => {
|
|
256
|
+
finished = true;
|
|
257
|
+
detailGeneration++;
|
|
258
|
+
input.off("keypress", onKeypress);
|
|
259
|
+
process.off("SIGWINCH", draw);
|
|
260
|
+
input.setRawMode(wasRaw);
|
|
261
|
+
input.pause();
|
|
262
|
+
output.write("\x1b[?25h\x1b[?1049l");
|
|
263
|
+
resolve(true);
|
|
264
|
+
};
|
|
265
|
+
const openDetail = async (item: Record<string, unknown>) => {
|
|
266
|
+
const generation = ++detailGeneration;
|
|
267
|
+
const title = browserItemTitle(presentation, item);
|
|
268
|
+
detail = { status: "loading", title };
|
|
269
|
+
draw();
|
|
270
|
+
try {
|
|
271
|
+
const value = await loadDetail(item);
|
|
272
|
+
if (finished || generation !== detailGeneration) return;
|
|
273
|
+
detail = { status: "loaded", title, value };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (finished || generation !== detailGeneration) return;
|
|
276
|
+
detail = {
|
|
277
|
+
status: "error",
|
|
278
|
+
title,
|
|
279
|
+
message:
|
|
280
|
+
error instanceof CliFailure ? error.message : "Unexpected error",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
draw();
|
|
284
|
+
};
|
|
285
|
+
const loadNextPage = async () => {
|
|
286
|
+
const response = asRecord(body);
|
|
287
|
+
const cursor = response.nextCursor;
|
|
288
|
+
if (finished || loadingPage || !loadPage || !response.hasMore ||
|
|
289
|
+
typeof cursor !== "string" || !cursor) return;
|
|
290
|
+
loadingPage = true;
|
|
291
|
+
pageStatus = "Loading more expenses…";
|
|
292
|
+
draw();
|
|
293
|
+
try {
|
|
294
|
+
const page = asRecord(await loadPage(cursor));
|
|
295
|
+
if (finished) return;
|
|
296
|
+
if (page.hasMore && page.nextCursor === cursor) {
|
|
297
|
+
throw new CliFailure("api", "Expense pagination did not advance");
|
|
298
|
+
}
|
|
299
|
+
body = { ...page, items: [...asArray(response.items), ...asArray(page.items)] };
|
|
300
|
+
pageStatus = undefined;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (finished) return;
|
|
303
|
+
pageStatus = `Unable to load more expenses: ${error instanceof CliFailure
|
|
304
|
+
? error.message : "Unexpected error"}. Press ↓ to retry.`;
|
|
305
|
+
} finally {
|
|
306
|
+
loadingPage = false;
|
|
307
|
+
}
|
|
308
|
+
draw();
|
|
309
|
+
};
|
|
310
|
+
const onKeypress = (
|
|
311
|
+
text: string,
|
|
312
|
+
key: { ctrl?: boolean; meta?: boolean; name?: string },
|
|
313
|
+
) => {
|
|
314
|
+
const itemCount = filterBrowserItems(presentation, body, query).length;
|
|
315
|
+
if ((key.ctrl && key.name === "c") || key.name === "q") {
|
|
316
|
+
finish();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (detail) {
|
|
320
|
+
if (key.name === "escape" || key.name === "left") {
|
|
321
|
+
detailGeneration++;
|
|
322
|
+
detail = undefined;
|
|
323
|
+
draw();
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (key.name === "escape" || key.name === "left") {
|
|
328
|
+
query = "";
|
|
329
|
+
selectedIndex = 0;
|
|
330
|
+
} else if (key.name === "up") {
|
|
331
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
332
|
+
} else if (key.name === "down") {
|
|
333
|
+
selectedIndex = Math.min(Math.max(itemCount - 1, 0), selectedIndex + 1);
|
|
334
|
+
if (selectedIndex >= itemCount - 1) void loadNextPage();
|
|
335
|
+
} else if (key.name === "backspace" || key.name === "delete") {
|
|
336
|
+
query = query.slice(0, -1);
|
|
337
|
+
selectedIndex = 0;
|
|
338
|
+
} else if (key.name === "return" || key.name === "enter" || key.name === "right") {
|
|
339
|
+
const items = filterBrowserItems(presentation, body, query);
|
|
340
|
+
const selected = items[Math.min(selectedIndex, items.length - 1)];
|
|
341
|
+
if (selected) void openDetail(selected);
|
|
342
|
+
return;
|
|
343
|
+
} else if (
|
|
344
|
+
text &&
|
|
345
|
+
!key.ctrl &&
|
|
346
|
+
!key.meta &&
|
|
347
|
+
text >= " " &&
|
|
348
|
+
text !== "\x7f"
|
|
349
|
+
) {
|
|
350
|
+
query += text;
|
|
351
|
+
selectedIndex = 0;
|
|
352
|
+
}
|
|
353
|
+
draw();
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
input.on("keypress", onKeypress);
|
|
357
|
+
process.on("SIGWINCH", draw);
|
|
358
|
+
draw();
|
|
359
|
+
});
|
|
360
|
+
}
|