* feat(auth): ask before signing a terminal in as whoever the browser is `bdrive login` opened /auth/cli and the browser bounced straight back with a code. Whoever the browser happened to be signed in as is who the terminal became — silently. That is frequently not the account the user meant: a personal login left open in the default browser, a teammate's session on a shared machine. The mistake surfaces much later, as a synced folder full of commits authored by the wrong person, which is far more work to undo than one click would have been. The device flow already got this right in #83 — it names the account, offers to switch, and says what approving grants. The browser flow said nothing at all, for the same outcome: a token that acts as you. So /auth/cli now confirms first. GET renders the page (who you would be signing in as, a Switch account link that comes back to this same pending sign-in, what is asking, and where it is waiting); POST is what mints the code and redirects to the loopback listener. A GET therefore grants nothing, so a link someone else got you to open can no longer mint a code on your behalf. whoBlock loses its pendingGrant parameter and renders only the identity half. What is asking differs per flow — a device has a name and an OS, a CLI on this computer has a loopback port — so each page now renders its own rows through a small helper instead of whoBlock pretending to a shape neither quite fits. The CLI's own wording follows: "waiting for you to approve the sign-in in your browser", since being signed in already is no longer the whole story. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * docs: the browser sign-in confirms first, and says whose account it grants README, the CLI reference, and the self-hosting auth page all described the old behaviour — sign in and the page bounces a code straight to the terminal. They also read as though only `--device` had an approval step. Both flows now confirm; say so, and say why it matters (the browser session is often not the account the user meant). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * feat(auth): one web step for a first sign-in, not two The confirmation page fixed the wrong-account problem and created a smaller one: a user with no browser session now saw two pages on their first `bdrive init` — sign in, then approve — where the sign-in had already settled the only question the second page asks. So authenticating *for* a pending CLI sign-in now counts as approving it. The login and signup pages carry a line saying a terminal is waiting and that the account used here is the one it will act as, which is where that consent is made informed; reaching the callback then needs no second click. The marker is server-side, bound to the exact pending sign-in, single use, and two minutes long, so it can only ever skip the page it was granted for and only once. It cannot be forged: setting it requires authenticating as that account, and anyone who could do that could click Approve anyway. An existing session still gets the page — that is the case where the browser may be signed in as someone the user did not intend, which is the whole reason it exists. Net effect: exactly one web interaction either way. The device flow keeps its explicit approval. Its page names a machine that isn't this one, along with the OS and address it came from — information no login form can convey, about a grant to somewhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * fix(auth): keep `bdrive login` on one line in the approval hint It wrapped mid-phrase into two separate code boxes, which reads as two commands rather than one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * refactor(auth): one approval page for both sign-in flows The two flows ask the same question — shall this thing act as you? — and had two copies of the page asking it, differing in three strings. They had already started drifting: a wrapping fix went into the CLI copy only, leaving the device page able to break `bdrive login --device` across two code boxes. A page whose whole purpose is consistent disclosure is a bad place to keep two of everything. So pageAuth owns the shape (session check, redirect to login, whoBlock, rows, the Approve form, the note) and each flow supplies an authRequest describing what differs: how the request is identified, what is asking, and what approving does. Two asymmetries are now explicit rather than accidental. freshAuthSkips is true only for the local flow — signing in and approving are the same act when the terminal is on this machine, and are not when the token goes to another one. live() reports whether the request still exists, because the device flow's link expires while the CLI flow carries its whole request in the URL and has nothing to expire. detail is a function, not a slice: the device rows come off the pending grant, which only exists after live() has found it. No test changed. The pages render byte-identically — same sha256 for all three CLI screenshots before and after — and the device flow was driven end to end against a real hub, approving a real `bdrive login --device`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * feat(auth): both sign-in flows always ask you to approve Consistency between the two flows is worth more than the click it saves. Letting a sign-in count as its own approval made the local flow one step and the device flow two, so the same product asked for consent in two different shapes depending on which machine you were on — and the shape that skipped it was the one where the page had something to tell you. So the fresh-auth marker is gone: sign in, then approve, on both flows. That drops a map, two methods, a descriptor field, and a branch in pageAuth — the unified handler now has exactly one path through it. A first `bdrive init` on a fresh machine is two web pages again. That is the deliberate trade: the approval page is where a user sees which account a machine is about to act as, and nothing shortcuts it. The sign-in page keeps the line saying a terminal is waiting. It no longer carries the consent — the next page does — so it is there to explain why a password prompt appeared at all. TestBothFlowsAlwaysAskToApprove replaces the one-step test and runs the same assertions over both flows as subtests: no session sends you to sign in carrying the request, signing in returns to the request without granting, the approval page is there every time, and only the POST grants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs.beardrive.ai
The public product documentation: CLI, sync model, self-hosting. Astro + Starlight, static output, Pagefind search.
npm install
npm run dev # http://localhost:4321
npm run build # -> dist/
npm run preview
Why this is a standalone site
Unlike the hub frontend (internal/webapp/static) and the cloud landing page
(cloud/internal/landing/dist), this is not embedded into a Go binary:
- Docs change far more often than the binary. Embedding them would mean a Go rebuild and redeploy to fix a typo.
- A Pagefind search index has no business shipping inside every self-hoster's install.
It lives in the OSS repo because that is what it documents. "Edit this page" resolves to something an outside contributor can open a PR against.
Design tokens
scripts/tokens.mjs reads the @theme block in
internal/webapp/frontend/src/tw.css — the source of truth for the BearDrive
palette — and emits src/styles/tokens.gen.css. That file is generated,
gitignored, and regenerated by npm run dev and npm run build, so the palette
cannot drift. src/styles/custom.css maps Starlight's --sl-color-* variables
onto it and invents no colors of its own.
(The cloud landing page can't do this — it sits in a different Go module and
keeps a copy of the tokens, policed by its own check-tokens.mjs.)
Adding a page
Drop a .md file under src/content/docs/<section>/ with title and
description frontmatter, then add its slug to the sidebar in
astro.config.mjs. Sidebar order is explicit, not alphabetical.
Write description for every page: it is the meta description, the search
result snippet, and what llms.txt shows.
llms.txt
starlight-llms-txt generates /llms.txt, /llms-small.txt, and
/llms-full.txt at build time.
Convention puts llms.txt at the root domain, not a docs subdomain — so
beardrive.ai/llms.txt should redirect or proxy to docs.beardrive.ai/llms.txt.
That redirect lives in the cloud landing page and is the one cross-repo
coordination point this split introduces.
Structure
The sidebar order in astro.config.mjs is the recommended path, and the
recommended path is agent-first:
- Start here — what it is, set up with your agent, first hour. No
brew installappears in this group. - Working with agents — the workflows the product exists for.
- Manual setup (optional) — the CLI route: install, set up by hand, skills and hooks in detail. Same destination, more steps; one click away, never on the critical path.
- Use cases — job-shaped titles ("Share work across your team's agents"),
persona named in the first line and in the
description. These pages ROUTE: who it's for, what you get, the one setup difference, links out. The moment one starts teaching a feature, it links to the guide that owns it instead. - Self-hosting, Reference, Concepts — unchanged in intent.
Keep new onboarding content out of Manual. If a page teaches someone how to get started, it belongs in Start here and should say what to ask an agent, not what to type.
Deploying
Static output in dist/. Any static host works; build command npm run build,
output directory dist, project root web/docs.
Redirects
The docs were reorganized around the agent-first path, so three old URLs moved:
| Old | New |
|---|---|
/start/install |
/manual/install/ |
/start/quickstart |
/manual/setup-by-hand/ |
/guides/connect-an-agent |
/start/setup/ |
astro.config.mjs declares these, which in a static build emits meta-refresh
pages — fine for humans, weak for search engines. Real 301s belong in the host.
Firebase Hosting (simplest static option on GCP — CDN, TLS, and custom domains included):
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*"],
"redirects": [
{ "source": "/start/install", "destination": "/manual/install/", "type": 301 },
{ "source": "/start/quickstart", "destination": "/manual/setup-by-hand/", "type": 301 },
{ "source": "/guides/connect-an-agent", "destination": "/start/setup/", "type": 301 }
]
}
}
Cloud Storage behind an external Application Load Balancer: put the rules in the URL map, which redirects before the bucket is ever reached.
gcloud compute url-maps edit docs-url-map # pathMatchers[].pathRules[]:
# - paths: ["/start/install"]
# urlRedirect:
# pathRedirect: "/manual/install/"
# redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
# stripQuery: false
Whichever host wins, keep the Astro redirects block as well: it is the
portable fallback, and it keeps local npm run preview honest.
Note that the build reads a file outside web/docs (the token source), so
the host must check out the whole repository rather than just this
subdirectory.