feat(auth): one approval link for device sign-in, and a page that says what it grants (#83)

The headless flow printed a short code to retype into a bare "Approve"
form. Now `bdrive login --device` prints a single link — the token lives in
the path (/auth/device/<token>), so there is nothing to read off one screen
and type into another.

The page it opens is a consent page rather than a text field: it names the
account the device would act as, offers Switch account (logout now honors
?next, so you land back here), and shows the device name, OS, and the
address the server observed. That matters because this flow's weakness is a
stranger sending you their pending link; an anonymous "Approve" gives you
nothing to notice with. Approval is still a POST from the page, so a link
alone cannot grant, and SameSite=Lax keeps a cross-site form out.

Also aligns the /auth/* pages with the app's tokens, which had drifted:
card #0c0e10 vs --color-card #15171b, 8px controls vs --radius-ctl 7px,
hand-picked #ff9b91/#6fd699 vs --color-del/--color-add. The style block now
declares the tw.css tokens by name and every rule uses them.

Older CLIs still print /auth/device?code=…, so that shape 303s to the path
form; a pre-0.13 hub returning no verify_url still gets the old
type-the-code instruction from the CLI.


Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-30 10:41:27 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 2f68bbe92e
commit dfb9da3260
11 changed files with 198 additions and 70 deletions
+2 -2
View File
@@ -45,8 +45,8 @@ works, stop and tell the user.
## 2. Do not run a login command
`bdrive init` (step 3) signs the device in when there is no session, and
without a TTY it uses the device-code flow automatically: it prints a URL and
a short code for the user to approve in any browser. Pass the hub with
without a TTY it uses the device-code flow automatically: it prints one link
for the user to open in any browser and approve — no code to type. Pass the hub with
`--server <hub-url>` and init signs in *there* — so a hub this device has
never seen still needs no separate command.
+3 -3
View File
@@ -142,7 +142,7 @@ hub's own storage, never something a syncing client points at directly:
| Command | Description |
|---|---|
| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the code flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login <new-url>` |
| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the approval-link flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login <new-url>` |
| `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) |
| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; installs the agent skill, registers agent sync hooks in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume |
| `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) |
@@ -501,8 +501,8 @@ to set up BearDrive project <project-id> on <hub-url>. Ask me which folder to sy
```
The agent fetches [INSTALL_FOR_AGENTS.md](INSTALL_FOR_AGENTS.md) and follows
it: install the CLI, then one `bdrive init` — which signs in (device code
when there is no browser), installs the skill, registers the sync hooks and
it: install the CLI, then one `bdrive init` — which signs in (an approval
link when there is no local browser), installs the skill, registers the sync hooks and
prints the project link. The instructions live at that URL rather than
inside the prompt so they never go stale in someone's copy — and the agent
handles every deviation (already installed, no Homebrew, sign-in, wrong
+13 -4
View File
@@ -330,10 +330,13 @@ func exchangeCode(server, code string) (string, serverUser, error) {
return out.Token, out.User, nil
}
// deviceCodeLogin runs the headless flow: show a code, poll until the user
// approves it from any browser.
// deviceCodeLogin runs the headless flow: print one approval link, poll until
// the user opens it in a signed-in browser. The link carries the secret, so
// there is no code to read off this screen and type into another — and the
// page it opens names this device, so the approver can see what they're
// approving.
func deviceCodeLogin(server string) (string, serverUser, error) {
body, _ := json.Marshal(map[string]string{"device": deviceName()})
body, _ := json.Marshal(map[string]string{"device": deviceName(), "os": runtime.GOOS})
resp, err := initClient.Post(server+"/api/auth/device/start", "application/json", bytes.NewReader(body))
if err != nil {
return "", serverUser{}, err
@@ -351,7 +354,13 @@ func deviceCodeLogin(server string) (string, serverUser, error) {
if start.Interval <= 0 {
start.Interval = 2
}
fmt.Printf("on any signed-in browser, open:\n %s\nand approve code: %s\n", start.VerifyURL, start.Code)
// Older hubs (pre-0.13) hand back a short code and expect it typed into
// /auth/device; keep that instruction for them.
if start.VerifyURL == "" {
fmt.Printf("on any signed-in browser, open:\n %s/auth/device\nand approve code: %s\n", server, start.Code)
} else {
fmt.Printf("to finish signing in, open this link in any browser:\n %s\n", start.VerifyURL)
}
deadline := time.Now().Add(10 * time.Minute)
for time.Now().Before(deadline) {
+28 -5
View File
@@ -271,16 +271,26 @@ func TestDeviceCodeFlow(t *testing.T) {
h := srv.Handler()
cookie := signupAndSession(t, h, "dev@x.io", "Dev", "password1")
rec := do(t, h, "POST", "/api/auth/device/start", map[string]string{"device": "server-1"})
rec := do(t, h, "POST", "/api/auth/device/start", map[string]string{"device": "server-1", "os": "linux"})
if rec.Code != 200 {
t.Fatalf("start: %d %s", rec.Code, rec.Body)
}
var start struct {
Code string `json:"code"`
Code string `json:"code"`
VerifyURL string `json:"verify_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &start); err != nil || start.Code == "" {
t.Fatalf("start = %s (%v)", rec.Body, err)
}
// The link is the secret, so it must be a real token, and it must carry
// the code in the path — nobody types this in.
if len(start.Code) < 32 {
t.Fatalf("device code %q is too short to be a URL secret", start.Code)
}
if !strings.HasSuffix(start.VerifyURL, "/auth/device/"+start.Code) {
t.Fatalf("verify_url = %q, want .../auth/device/<code>", start.VerifyURL)
}
approve := "/auth/device/" + start.Code
// pending until approved
rec = do(t, h, "POST", "/api/auth/device/poll", map[string]string{"code": start.Code})
@@ -288,10 +298,23 @@ func TestDeviceCodeFlow(t *testing.T) {
t.Fatalf("poll before approve: %d %s", rec.Code, rec.Body)
}
// The approval page names the account being granted, offers a way off it,
// and says what is asking — approving is handing that box a token.
req := httptest.NewRequest("GET", approve, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
for _, want := range []string{"dev@x.io", "server-1", "linux", "Switch account", url.QueryEscape(approve)} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("approval page missing %q:\n%s", want, rec.Body)
}
}
if strings.Contains(rec.Body.String(), `name="code"`) {
t.Fatal("approval page still asks for a typed code")
}
// approve from a signed-in browser
form := url.Values{"code": {start.Code}}
req := httptest.NewRequest("POST", "/auth/device", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = httptest.NewRequest("POST", approve, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
+133 -42
View File
@@ -82,6 +82,8 @@ type pendingGrant struct {
kind string // "code" (CLI callback), "device" (poll flow), "reset"
user string // set once granted
device string // device flow: requested device name
os string // device flow: requested device's OS
ip string // device flow: where the request came from, as the server saw it
granted bool
expires time.Time
}
@@ -516,8 +518,9 @@ func (a *BuiltinAuth) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/signup", a.pageSignup)
mux.HandleFunc("GET /auth/logout", a.pageLogout)
mux.HandleFunc("GET /auth/cli", a.pageCLI)
mux.HandleFunc("GET /auth/device", a.pageDevice)
mux.HandleFunc("POST /auth/device", a.pageDevice)
mux.HandleFunc("GET /auth/device/{token}", a.pageDevice)
mux.HandleFunc("POST /auth/device/{token}", a.pageDevice)
mux.HandleFunc("GET /auth/device", a.pageDeviceLegacy)
mux.HandleFunc("GET /auth/verify", a.pageVerify)
mux.HandleFunc("GET /auth/reset", a.pageReset)
mux.HandleFunc("POST /auth/reset", a.pageReset)
@@ -607,31 +610,51 @@ func authPage(w http.ResponseWriter, title, body string) {
fmt.Fprintf(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>%s BearDrive</title>
<style>
/* Shares the app's token values so sign-in and the app read as one product. */
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",sans-serif;
background:#0a0b0d;color:#eef0f3;display:flex;justify-content:center;padding-top:13vh;margin:0;
/* The app's tokens, name for name, so sign-in and the app read as one
product. Source of truth: frontend/src/tw.css @theme keep the values
here identical to the token of the same name there. */
:root{--bg:#0a0b0d;--raise:#15171b;--surface:rgba(255,255,255,.03);--hovered:rgba(255,255,255,.06);
--line:rgba(255,255,255,.07);--line-2:rgba(255,255,255,.11);--text:#eef0f3;--dim:#9aa0a9;--faint:#868b93;
--honey:#f5a623;--honey-bright:#ffcf85;--on-honey:#1a1204;--add:#4cc38a;--del:#f26d6d;
--radius-ctl:7px;--radius-over:14px;
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
background:var(--bg);color:var(--text);display:flex;justify-content:center;padding:13vh 16px;margin:0;
letter-spacing:-.006em;-webkit-font-smoothing:antialiased}
.card{background:#0c0e10;border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:28px 30px;width:344px;
box-shadow:0 24px 70px -24px rgba(0,0,0,.7)}
.logo{width:30px;height:30px;display:grid;place-items:center;color:#f5a623;margin-bottom:16px}
.card{background:var(--raise);border:1px solid var(--line);border-radius:var(--radius-over);padding:28px 30px;
width:344px;max-width:100%%;box-sizing:border-box;box-shadow:0 24px 70px -24px rgba(0,0,0,.7)}
.logo{width:30px;height:30px;display:grid;place-items:center;color:var(--honey);margin-bottom:16px}
.logo svg{width:30px;height:30px;fill:currentColor}
h1{font-size:18px;font-weight:640;letter-spacing:-.02em;margin:0 0 18px}
label{display:block;font-size:12px;color:#9aa0a9;margin:14px 0 5px;font-weight:500}
input{width:100%%;box-sizing:border-box;height:38px;padding:0 12px;border-radius:8px;
border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);color:#eef0f3;font:inherit;font-size:14px;outline:none}
input:focus-visible{outline:2px solid #f5a623;outline-offset:1px;border-color:#f5a623}
button{margin-top:20px;width:100%%;height:40px;border:none;border-radius:8px;background:#f5a623;
color:#241704;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
button:hover{background:#ffcf85}
button:focus-visible{outline:2px solid #ffcf85;outline-offset:2px}
.err{color:#ff9b91;font-size:13px;margin:12px 0 0}
.msg{color:#6fd699;font-size:13px;margin:12px 0 0}
.alt{margin-top:16px;font-size:12.5px;color:#868b93}
.alt a{color:#ffcf85;text-decoration:none}
label{display:block;font-size:12px;color:var(--dim);margin:14px 0 5px;font-weight:500}
input{width:100%%;box-sizing:border-box;height:38px;padding:0 12px;border-radius:var(--radius-ctl);
border:1px solid var(--line-2);background:var(--surface);color:var(--text);font:inherit;font-size:14px;outline:none}
input:focus-visible{outline:2px solid var(--honey);outline-offset:1px;border-color:var(--honey)}
button{margin-top:20px;width:100%%;height:40px;border:none;border-radius:var(--radius-ctl);background:var(--honey);
color:var(--on-honey);font:inherit;font-size:14px;font-weight:600;cursor:pointer}
button:hover{background:var(--honey-bright)}
button:focus-visible{outline:2px solid var(--honey-bright);outline-offset:2px}
.err{color:var(--del);font-size:13px;margin:12px 0 0}
.msg{color:var(--add);font-size:13px;margin:12px 0 0}
.lede{margin:0;color:var(--dim);font-size:13px}
.alt{margin-top:16px;font-size:12.5px;color:var(--faint)}
.alt a{color:var(--honey-bright);text-decoration:none}
.alt a:hover{text-decoration:underline}
/* Device approval: who you'd be granting as, then what is asking. */
.who{display:flex;align-items:center;gap:12px;justify-content:space-between;margin:16px 0 4px;
padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-ctl);background:var(--surface)}
.who-id{min-width:0}
.who-l{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint)}
.who-id b{display:block;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who-sub{display:block;font-size:12px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who-swap{flex:none;font-size:12.5px;color:var(--honey-bright);text-decoration:none}
.who-swap:hover{text-decoration:underline}
.rows{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:14px 0 0;font-size:13px}
.rows dt{color:var(--faint)}
.rows dd{margin:0;font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
@media (max-width:900px){input{height:44px}button{height:44px}}
code{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);padding:2px 6px;border-radius:5px;
font-family:ui-monospace,Menlo,monospace}
code{background:var(--hovered);border:1px solid var(--line);padding:2px 6px;border-radius:5px;
font-family:var(--mono)}
</style></head><body><div class="card"><div class="logo"><svg viewBox="0 0 32 32" role="img" aria-label="BearDrive">` +
`<rect x="4" y="4" width="5.6" height="24"/><rect x="11.2" y="4" width="14.4" height="11.2"/>` +
`<rect x="11.2" y="16.8" width="16.8" height="11.2"/></svg></div><h1>%s</h1>%s</div></body></html>`,
@@ -699,7 +722,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
if a.AllowSignup || invited {
note := ""
if a.AllowSignup && len(a.AllowedDomains) > 0 {
note = ` <span style="color:#868b93">(` + html.EscapeString(a.domainList()) + ` only)</span>`
note = ` <span style="color:var(--faint)">(` + html.EscapeString(a.domainList()) + ` only)</span>`
}
label := "No account?"
if invited && !a.AllowSignup {
@@ -709,7 +732,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#9aa0a9">` + html.EscapeString(a.Brand) + `</p>`
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
%s<p class="alt"><a href="/auth/reset">Forgot password?</a></p>`,
@@ -775,7 +798,7 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
}
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#9aa0a9">` + html.EscapeString(a.Brand) + `</p>`
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s%s<button>Sign up</button></form>
<p class="alt">Have an account? <a href="/auth/login?next=%s">Sign in</a></p>`,
@@ -787,12 +810,19 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
errMsg, url.QueryEscape(next)))
}
// pageLogout ends the browser session. It honors ?next= so "switch account"
// on a page that needed one (device approval, an invite) lands back there as
// the new account instead of dumping the visitor at the hub root.
func (a *BuiltinAuth) pageLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
a.revokeToken(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
dest := "/auth/login"
if next := safeNext(r.FormValue("next")); next != "/" {
dest += "?next=" + url.QueryEscape(next)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// pageCLI completes `bdrive login`: once the browser has a session, mint a
@@ -819,27 +849,74 @@ func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, u.String(), http.StatusSeeOther)
}
// pageDevice is the headless-login approval page: the user types the code
// `bdrive login` printed.
// pageDevice is the headless-login approval page, reached by opening the link
// `bdrive login` printed: the token lives in the path, so there is no code to
// read off one screen and type into another. It names the account the device
// will act as (with a way to switch), and what is asking, because approving
// hands that machine a token that acts as you.
func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
user, ok := a.sessionUser(r)
if !ok {
http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
var msg string
g, ok := a.peekGrant("device", token)
if !ok {
authPage(w, "Link expired", `<p class="err">This sign-in link is invalid, already used, or older than 10 minutes.</p>
<p class="alt">Run <code>bdrive login --device</code> again for a fresh one.</p>`)
return
}
if r.Method == http.MethodPost {
code := strings.ToLower(strings.TrimSpace(r.FormValue("code")))
if a.grantDevice(code, user.ID) {
authPage(w, "Device connected", `<p class="msg">Done — you can close this tab. The terminal will finish logging in.</p>`)
if !a.grantDevice(token, user.ID) {
authPage(w, "Link expired", `<p class="err">This sign-in link expired while the page was open.</p>
<p class="alt">Run <code>bdrive login --device</code> again for a fresh one.</p>`)
return
}
msg = `<p class="err">Unknown or expired code.</p>`
authPage(w, "Device connected", fmt.Sprintf(`<p class="msg">%s can now sync as %s.</p>
<p class="alt">You can close this tab the terminal finishes on its own.</p>`,
html.EscapeString(orDash(g.device)), html.EscapeString(user.Email)))
return
}
code := r.URL.Query().Get("code")
authPage(w, "Connect a device", fmt.Sprintf(`<p>Enter the code shown by <code>bdrive login</code>:</p>
<form method="post">%s%s<button>Approve</button></form>`,
field("Code", "code", "text", code), msg))
authPage(w, "Connect a device", fmt.Sprintf(`<p class="lede">A device is asking to sign in to BearDrive.</p>
%s
<form method="post"><button>Approve</button></form>
<p class="alt">Approve this only if you just started a sign-in on that machine.</p>`,
whoBlock(user, g, r.URL.RequestURI())))
}
// whoBlock renders the two things the approver needs: who they'd be granting
// as (with an escape hatch back to this same page), and what is asking.
func whoBlock(user User, g pendingGrant, back string) string {
name := user.Name
if name == "" {
name = user.Email
}
return fmt.Sprintf(`<div class="who">
<div class="who-id"><span class="who-l">Signing in as</span><b>%s</b><span class="who-sub">%s</span></div>
<a class="who-swap" href="/auth/logout?next=%s">Switch account</a>
</div>
<dl class="rows"><dt>Device</dt><dd>%s</dd><dt>System</dt><dd>%s</dd><dt>Address</dt><dd>%s</dd></dl>`,
html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back)),
html.EscapeString(orDash(g.device)), html.EscapeString(orDash(g.os)), html.EscapeString(orDash(g.ip)))
}
func orDash(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
// pageDeviceLegacy forwards the pre-0.13 link shape (/auth/device?code=…),
// which older CLIs still print, to the path form.
func (a *BuiltinAuth) pageDeviceLegacy(w http.ResponseWriter, r *http.Request) {
code := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("code")))
if code == "" {
authPage(w, "Connect a device", `<p>Run <code>bdrive login --device</code> on the machine you want to connect; it prints a link to open here.</p>`)
return
}
http.Redirect(w, r, "/auth/device/"+url.PathEscape(code), http.StatusSeeOther)
}
// pageVerify activates an account from an email link, then either starts a
@@ -963,23 +1040,37 @@ func (a *BuiltinAuth) apiExchange(w http.ResponseWriter, r *http.Request) {
a.finishLogin(w, g.user, req.Device)
}
// apiDeviceStart begins the headless flow: the CLI shows the code, the user
// approves it at /auth/device, the CLI polls.
// apiDeviceStart begins the headless flow: the CLI prints the approval link,
// the user opens it in any signed-in browser, the CLI polls. The link itself
// is the secret (RFC 8628 calls this verification_uri_complete), so it is a
// full-length token, not something short enough to retype — nobody has to
// read a code off one screen and type it into another.
//
// The requesting device's name, OS, and address are recorded here so the
// approval page can show WHAT is being approved: this flow's weakness is that
// a stranger can send you their own pending link, and a page that just says
// "Approve" gives you nothing to notice with.
func (a *BuiltinAuth) apiDeviceStart(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
OS string `json:"os"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
code := randHex(4) // short enough to type
code := randHex(16)
a.mu.Lock()
a.pending[code] = pendingGrant{kind: "device", device: req.Device, expires: time.Now().Add(10 * time.Minute)}
a.pending[code] = pendingGrant{
kind: "device", device: req.Device, os: req.OS, ip: requestIP(r),
expires: time.Now().Add(10 * time.Minute),
}
a.mu.Unlock()
writeJSON(w, map[string]any{
// "code" keeps its wire name: it is what the CLI polls with, and
// older clients still print it.
"code": code,
"verify_url": requestBaseURL(r) + "/auth/device?code=" + code,
"verify_url": requestBaseURL(r) + "/auth/device/" + code,
"interval": 2,
})
}
+6 -6
View File
@@ -77,9 +77,9 @@ func newCLIEnv(t *testing.T) cliEnv {
t.Fatal(err)
}
t.Cleanup(func() { login.Process.Kill() })
code := waitForCode(t, logFile)
approve := waitForApprovalLink(t, logFile)
browser := signedInBrowser(t, hub.URL)
if _, err := browser.PostForm(hub.URL+"/auth/device", url.Values{"code": {code}}); err != nil {
if _, err := browser.PostForm(approve, nil); err != nil {
t.Fatal(err)
}
if err := login.Wait(); err != nil {
@@ -500,21 +500,21 @@ func hubProjects(t *testing.T, browser *http.Client, hubURL string) string {
return string(data)
}
var codeRe = regexp.MustCompile(`approve code: ([a-z0-9]+)`)
var approveRe = regexp.MustCompile(`(https?://\S+/auth/device/[a-f0-9]+)`)
// waitForCode polls the login command's output for the device code it prints.
func waitForCode(t *testing.T, logFile string) string {
func waitForApprovalLink(t *testing.T, logFile string) string {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
data, _ := os.ReadFile(logFile)
if m := codeRe.FindSubmatch(data); m != nil {
if m := approveRe.FindSubmatch(data); m != nil {
return string(m[1])
}
time.Sleep(100 * time.Millisecond)
}
data, _ := os.ReadFile(logFile)
t.Fatalf("login --device never printed a device code:\n%s", data)
t.Fatalf("login --device never printed an approval link:\n%s", data)
return ""
}
+2 -2
View File
@@ -29,7 +29,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Undo a change to a file | `bdrive restore <file> [<version>]` — writes an earlier version back as a NEW change (never rewrites history); no version = the previous one, `--list` shows them. Also in the hub's History view. Cannot un-create a file a run created (see below) |
| Move a project to a different hub (cloud ↔ self-hosted) | `bdrive export [<folder>]` writes a portable `.tar.gz` of the whole project — every device's journal and every blob, so full history and authorship travel. Then `bdrive login <other-hub>` and `bdrive import <archive>` recreates it there as a new project (`--name` overrides; target must be empty); connect folders with `bdrive init --project <id>`. Sync first so the export is complete. |
| This device's identity | `bdrive whoami` |
| Sign this device in (once per device) | `bdrive login [url]` — bare form targets BearDrive Cloud (beardrive.ai): signing up there auto-creates a free personal workspace, no questions asked. Self-hosting teams pass their hub URL instead. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless), and login falls back to that code flow automatically when there is no TTY (agent shells, CI) or no browser opens; `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login <new-url>`, then re-run `bdrive init` in each folder. |
| Sign this device in (once per device) | `bdrive login [url]` — bare form targets BearDrive Cloud (beardrive.ai): signing up there auto-creates a free personal workspace, no questions asked. Self-hosting teams pass their hub URL instead. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints one approval link to open in any signed-in browser (SSH/headless) — nothing to retype, and the page names the device asking — and login falls back to that flow automatically when there is no TTY (agent shells, CI) or no browser opens; `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login <new-url>`, then re-run `bdrive init` in each folder. |
| Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The token is only cleared locally: the hub still accepts it and there is no revoke yet. |
| Link a synced file for teammates | `bdrive url <file>` — prints the file's hub viewer URL (sign-in + project membership required; always the latest content). Computed locally, no network; `--sync` pushes first so a just-created file's link resolves immediately; no arg = the project home page. **After creating a shareable artifact (.md/.html/.csv/report/plan) in the shared folder, include this link in your reply** so teammates can open it. |
| Share a synced file publicly by URL | `bdrive share <file>` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke <token-or-url>` to manage. The hub's Share dialog can also set an expiry (24h / 7d / 30d) on an already-minted link — same token, same URL. Put generated reports in the shared folder, sync, then share. |
@@ -208,7 +208,7 @@ to set up BearDrive project <project-id> on <hub-url>. Ask me which folder to sy
The fetched instructions cover install and one `bdrive init --project
--server`, which signs in, installs the skill and registers the hooks. Sign-in uses `login
--device` because an agent is driving: a browser-callback sign-in is
invisible to it mid-turn, while the device flow yields a code and URL it can
invisible to it mid-turn, while the device flow yields one approval link it can
hand back in chat. The hub's project home page renders this prompt with the
URL and id filled in.
@@ -30,8 +30,8 @@ bdrive login https://your-hub
This opens your browser, and the terminal finishes on its own. On a headless or
SSH machine login falls back to the device-code flow automatically (no TTY, or
no browser can open): it prints a short code you approve from any signed-in
browser. `bdrive login --device` forces that flow.
no browser can open): it prints one link you open in any signed-in browser and
approve — nothing to retype. `bdrive login --device` forces that flow.
`bdrive login --status` shows the current server and account.
+1 -1
View File
@@ -9,7 +9,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| Command | Description |
|---|---|
| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the code flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the approval-link flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
| `bdrive logout` | Sign this device out — clear the saved token and account. `--forget` also drops the remembered server |
| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also installs the agent skill, registers agent sync hooks for detected platforms (`--no-hooks` skips the hooks only), and prints the project's hub link. Re-run to resume |
| `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes |
@@ -64,8 +64,12 @@ to the CLI's loopback listener and the terminal finishes on its own, storing a
long-lived per-device token that is revocable server-side.
On headless or SSH machines login falls back to the device-code flow
automatically (no TTY, or no browser can open): it prints a short code to
approve from any signed-in browser. `bdrive login --device` forces that flow.
automatically (no TTY, or no browser can open): it prints one approval link to
open in any signed-in browser. The link carries the request, so there is no code
to retype; the page it opens names the account you would be granting, the device
asking, its OS, and the address it came from, so an unexpected approval request
is visible rather than anonymous. Approving is a POST from that page — a link
alone can't grant. `bdrive login --device` forces that flow.
Every sync and every `bdrive init` then authenticates with that token. The hub's
device registry records per-device name, OS, account, and the IP the server
+2 -1
View File
@@ -54,7 +54,8 @@ to set up BearDrive project <project-id> on <hub-url>. Ask me which folder to sy
```
The agent fetches that page and works through it — install the CLI, sign in
with a device code, connect the project, register the sync hooks. You copy one
(it prints a link you approve in the browser), connect the project, register
the sync hooks. You copy one
thing; the agent handles every deviation — already installed, no Homebrew,
browser sign-in, wrong folder. On BearDrive Cloud, drop `on <hub-url>`
sign-in defaults to beardrive.ai.