bravenlab 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,249 @@
1
- # Temporary Holding Version
1
+ # bravenlab
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `braven` command: **sign in once per machine** and let the CLI and the
4
+ Python SDK use the same identity.
5
+
6
+ This package owns no credential convention. The config file's path, its
7
+ version-1 schema and the OS keychain naming belong to
8
+ [`braven-identity`](../identity/README.md) (`packages/identity`), the contract
9
+ every surface mirrors — so a `braven login` here is the same login
10
+ `braven.login()` sees. The TypeScript implementation lives in
11
+ [`@braven/identity-store`](../identity-store/README.md), which this CLI and
12
+ `braven-agent` share; the Python package stays the reference.
13
+
14
+ Nothing in this package ever prints, logs or writes a secret: secrets live in
15
+ the OS keychain and are handed only to the store.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npx bravenlab --help
21
+ npx bravenlab agent --folder ./my-data-folder --project-id <id>
22
+ ```
23
+
24
+ The package installs two names for one bin: `npx bravenlab` (the package name)
25
+ and `braven` once it is installed globally. The old package name
26
+ `braven-cli@0.1.0` is superseded by `bravenlab`; the OAuth client id stays
27
+ `braven-cli` on the wire.
28
+
29
+ `braven` and `braven-agent` publish together from a `v*` tag
30
+ (`.github/workflows/release-npm.yml`); the tag must equal both manifests
31
+ (`scripts/check-release-tag.ts`), and the release drops the `workspace:` ranges
32
+ npm cannot resolve (`scripts/prune-workspace-deps.ts`) because the build inlines
33
+ those packages.
34
+
35
+ The workflow **stages** the release (`npm stage publish`) rather than publishing
36
+ it: a maintainer approves the staged version with 2FA — `npm stage approve
37
+ <stage-id>`, or the **Staged Packages** tab on npmjs.com — and only then does
38
+ `0.1.0` become installable. A brand-new name gets a public `0.0.0-stage`
39
+ placeholder while its real version waits.
40
+
41
+ The published artifact is a bundle, not the source tree: `braven` is
42
+ `dist/bin.js`, a plain ESM file with `#!/usr/bin/env node`, its third-party
43
+ dependencies external and declared in `dependencies`. `files` ships `dist/` and
44
+ this README only; source maps embed their sources, so a stack trace still points
45
+ into the TypeScript without publishing `src/`.
46
+
47
+ ```bash
48
+ nub run --filter bravenlab build # esbuild → dist/bin.js + dist/{cli,auth}.js
49
+ node dist/bin.js --help # exactly what an install runs
50
+ npm pack --dry-run # dist/ + README.md
51
+ nub exec -F braven braven --help # the workspace bin, after a build
52
+ nub run --filter bravenlab test # builds in `pretest`, then the suite
53
+ ```
54
+
55
+ `src/bin.test.ts` spawns `dist/bin.js` with an empty home directory, so a bundle
56
+ that drifts from the source (or loses its shebang or a runtime dependency) fails
57
+ the suite rather than a user. Development still runs the source directly — Node
58
+ 24 strips its types — but `braven agent` resolves the `braven-agent` package, so
59
+ build the agent once first:
60
+
61
+ ```bash
62
+ nub run --filter braven-agent build
63
+ node --experimental-strip-types src/bin.ts --help
64
+ ```
65
+
66
+ ## Commands
67
+
68
+ | Command | What it does |
69
+ | --- | --- |
70
+ | `braven login [--origin URL] [--web-origin URL]` | Opens the browser, waits for the signed-in session token on a loopback listener, proves it with `GET {origin}/api/auth/get-session`, stores it in the keychain as `kind="session"`, makes the account current, then prints whoami. **This is the whole sign-in: no password is typed into the terminal.** |
71
+ | `braven login --watcher` | Uses the sign-in the Braven app already made on this machine: the app (and the agent it runs) and the CLI share one store, so this only adopts the account and makes it current — no browser, no keychain prompt. Refuses when the app never signed in to the resolved server. |
72
+ | `braven login --api [--key bvn_api_…] [--email EMAIL]` | Stores a long-lived API key as `kind="apiKey"`; the key comes from `--key` or `$BRAVEN_API_KEY`. For CI, scripts and machines that cannot open a browser. |
73
+ | `braven login --device [--client-id ID]` | Better-auth's device authorization grant: prints a user code and verification URL, opens it, polls `/api/auth/device/token` on the server's interval, then proves and stores the session token exactly as the loopback flow does. For headless machines and machines without a reliable loopback callback. |
74
+ | `braven push <file> [--override] [--path PATH] [--project-id ID]` | Uploads one local file through `POST {origin}/files/ingest` with `X-Project-Id` and the stored credential as a bearer. `--path` defaults to the file's basename; `--override` reuses that path's Experiment/File instead of deduping or adding a sibling. |
75
+ | `braven key add [--origin URL] [--email EMAIL] [--label LABEL] <bvn_api_...>` | Stores a long-lived API key as `kind="apiKey"`. The owner comes from `--email`, or from `GET {origin}/api/auth/get-session` with the key as a bearer. |
76
+ | `braven whoami` | The effective identity: origin, email, kind, label. **Never the secret.** Honours `BRAVEN_SESSION_TOKEN` / `BRAVEN_API_KEY` and says "signed in from the environment" when one is in play. |
77
+ | `braven config` / `braven config show` | The stored accounts, which one is `current`, and the resolved `company` / `project` defaults. |
78
+ | `braven config set company\|project <value>` | Writes one default. |
79
+ | `braven config use <email>` | Switches `current`, wherever that account's origin is. |
80
+ | `braven logout [--email EMAIL]` | Forgets that account — keychain secret included (default: the current one), rotating `current` to a survivor. A stored session is revoked server-side; a stored API key is only forgotten. |
81
+ | `braven agent [agent flags]` | Runs the headless watch daemon (`braven-agent`) with the arguments handed over verbatim. Same program, same flags — `braven agent --help` prints the daemon's usage. |
82
+
83
+ `--origin` beats `$BRAVEN_API_URL`, which beats the hosted
84
+ `https://api.bravenlab.com`. A trailing slash is normalised away, so origins
85
+ never produce a doubled path. `--web-origin` beats `$BRAVEN_WEB_URL`, which
86
+ beats `https://bravenlab.com`.
87
+
88
+ ## Three ways in
89
+
90
+ `braven login` picks the flow with a flag: `--watcher` (the running app's
91
+ sign-in), nothing/`--browser` (the loopback handoff), or `--api` (a stored key).
92
+ All three end in the same identity store, so `whoami`, `push`, `agent` and the
93
+ Python SDK see the same account.
94
+
95
+ ## How `login` works
96
+
97
+ 1. Bind an `http.Server` on `127.0.0.1` with **port 0**, and read the real
98
+ ephemeral port from `HttpServer.address` (narrowed to an inet address).
99
+ 2. `GET /?token=<session token>` resolves an Effect `Deferred` and answers
100
+ `<h1>Authenticated. You can close this window.</h1>`; every other request is
101
+ `400`. The listener is loopback-only and lives in the command's scope, so it
102
+ closes on success, on the timeout, and on Ctrl-C.
103
+ 3. Open `{webOrigin}/cli-login?port=<port>` with the `open` package. The web app
104
+ redirects the better-auth session token to the loopback URL.
105
+ 4. Wait five minutes (`Effect.timeoutOrElse`). On timeout the listener closes
106
+ and the CLI fails with one actionable line.
107
+ 5. With the token, call `GET {origin}/api/auth/get-session` with
108
+ `Authorization: Bearer <token>`. Only a `200` whose body has `user.email`
109
+ becomes an account; any other answer fails loudly and stores nothing.
110
+
111
+ ## How `login --device` works
112
+
113
+ The same identity, without the token-in-a-URL handoff. It needs better-auth's
114
+ `deviceAuthorization()` plugin on the API — this repo configures it, along with
115
+ the `deviceCode` table and the `/device` approval page.
116
+
117
+ 1. `POST {origin}/api/auth/device/code` with `{client_id, scope}`. The client
118
+ id is `--client-id`, `$BRAVEN_CLIENT_ID`, or `braven-cli`; the scope is
119
+ `openid profile email`.
120
+ 2. Print the `user_code` and open
121
+ `verification_uri_complete ?? verification_uri` through the same `Browser`
122
+ seam.
123
+ 3. Poll `POST {origin}/api/auth/device/token` with the RFC 8628 grant
124
+ (`grant_type`, `device_code`, `client_id`) on a `Schedule` seeded from the
125
+ server's `interval`. `authorization_pending` keeps polling; `slow_down` adds
126
+ five seconds to every subsequent interval. `expired_token`,
127
+ `access_denied` and `invalid_grant` stop with one actionable line.
128
+ 4. The whole wait is bounded by `expires_in` (30 minutes when omitted) through
129
+ `Effect.timeoutOrElse`.
130
+ 5. On success `access_token` is a better-auth session token: prove it with
131
+ `GET /api/auth/get-session`, store `{origin, kind: "session", secret, email}`
132
+ and print whoami. Nothing is stored on expiry, denial or failure.
133
+
134
+ ## How `push` works
135
+
136
+ `files.ingest` is plan 015's one ingest wire. It is mounted as a plain
137
+ multipart route so scripts share one write path with the oRPC procedure the
138
+ webapp uses:
139
+
140
+ ```
141
+ POST {origin}/files/ingest
142
+ Authorization: Bearer <session token or API key>
143
+ X-Project-Id: <project>
144
+ Content-Type: multipart/form-data
145
+ ```
146
+
147
+ The body carries `path`, `override` (`"true"` / `"false"`) and the bytes as
148
+ `file`. `path` defaults to the file's basename; the server takes its basename
149
+ as the `filename` that keys the Experiment and File. Both doors end at
150
+ `saveUpload`, so hashing, dedup, versioning and `--override` have one
151
+ implementation (`apps/api/src/workerRoutes.ts`). Re-pushing the same bytes never
152
+ makes a second File in that Experiment; `--override` reuses the path's existing
153
+ Experiment instead of creating a sibling.
154
+
155
+ The project comes from `--project-id`, `$BRAVEN_PROJECT_ID`, or the store's
156
+ `defaults.project` resolved to an id through `GET /projects` (narrowed by
157
+ `defaults.company` when both are set). An unknown or ambiguous project name
158
+ fails with exit 2 instead of guessing; a server refusal is one line and exit 1.
159
+
160
+ ## How `logout` works
161
+
162
+ The stored secret is read **before** the account is removed, so the command can
163
+ revoke it. Only a stored `session` is revoked — `POST
164
+ {origin}/api/auth/sign-out` with the token as a bearer; a `200` or a `401` both
165
+ mean it is dead. A `kind="apiKey"` account is only forgotten locally and stays
166
+ valid until it is revoked under **Settings → API keys**.
167
+
168
+ The keychain secret and config row then go, and `current` rotates to a survivor
169
+ (or none). Because the local account is already gone when revocation runs, an
170
+ unreachable API is a warning — "the session stays valid until it expires" — not
171
+ a failure; there is nothing left to retry. With no stored account the command
172
+ exits 3.
173
+
174
+ ## Identity store
175
+
176
+ Config (never a secret), at the OS convention:
177
+
178
+ | OS | Path |
179
+ | --- | --- |
180
+ | macOS | `~/Library/Application Support/braven/config.json` |
181
+ | Linux | `${XDG_CONFIG_HOME:-~/.config}/braven/config.json` |
182
+ | Windows | `%APPDATA%\braven\config.json` |
183
+
184
+ ```json
185
+ {
186
+ "version": 1,
187
+ "current": "https://api.bravenlab.com|me@example.com",
188
+ "accounts": [
189
+ { "origin": "https://api.bravenlab.com", "email": "me@example.com", "kind": "session" }
190
+ ],
191
+ "defaults": { "company": "Gelion" },
192
+ "watcher": {}
193
+ }
194
+ ```
195
+
196
+ Writes are atomic (`config.json.tmp` + rename) and preserve `watcher` and the
197
+ other defaults. Secret, in the OS keychain via `@napi-rs/keyring` (macOS
198
+ Keychain, Windows Credential Manager, libsecret): service `braven`, account
199
+ `{origin}|{email}`. **No plaintext fallback**; a refused keychain surfaces as
200
+ one line and nothing is written.
201
+
202
+ Precedence for the secret is `env var > store`: `BRAVEN_SESSION_TOKEN` or
203
+ `BRAVEN_API_KEY` (with `BRAVEN_API_URL`) win and touch neither file nor
204
+ keychain. The origin is `flag > $BRAVEN_API_URL > hosted`; `--email` selects a
205
+ stored account.
206
+
207
+ ## Exit codes
208
+
209
+ | Code | Meaning |
210
+ | --- | --- |
211
+ | `0` | It worked. Help and `--version` too. |
212
+ | `1` | The command failed: a rejected session, an unreachable API, a keychain that refused, a corrupt config file. |
213
+ | `2` | The arguments cannot work: an unknown flag, a missing one, a key without the `bvn_api_` prefix, a key whose owner the probe could not prove. |
214
+ | `3` | Not signed in, or no stored account for the email asked about — so a script can `braven login` and retry. |
215
+
216
+ Failures print exactly one `braven: <what to do>` line on stderr.
217
+
218
+ ## Layout
219
+
220
+ ```
221
+ src/
222
+ bin.ts # entry point: `agent` dispatch, then Node services + runMain
223
+ cli.ts # the effect/cli command tree and the exit-code boundary
224
+ commands.ts # login (browser handoff), whoami, config, logout, key add
225
+ auth.ts # loopback listener, device grant, session lookup + revoke
226
+ files.ts # files.ingest multipart wire + project resolution
227
+ testing.ts # browser/sessions/HTTP doubles (the store's doubles come from
228
+ # @braven/identity-store/testing)
229
+ ```
230
+
231
+ The config file, its schema, the keychain (`Keychain`, `StoreConfig`,
232
+ `Environment`) and the `CliFailure` vocabulary live in
233
+ [`@braven/identity-store`](../identity-store/README.md), so the CLI and
234
+ `braven-agent` read one store instead of depending on each other. The seams the
235
+ CLI adds are `Browser`, `Sessions` and `HttpClient`. Tests replace all of them,
236
+ so the suite touches no real keychain, no real browser and no network except
237
+ the loopback listener the sign-in test deliberately starts.
238
+
239
+ `braven agent` is not an `effect/cli` command: `bin.ts` checks `argv[0]` first
240
+ and hands the remaining arguments to `runAgent` from `braven-agent`, so the
241
+ daemon's flags never have to be modelled twice.
242
+
243
+ ## Development
244
+
245
+ ```bash
246
+ nub run --filter bravenlab check-types
247
+ nub run --filter bravenlab test
248
+ nub run lint && nub run fmt
249
+ ```