fix(cli): onboarding friction — headless login fallback, --version, init next steps, labeled authorship (#39)

BEA-7 launch-critical set from the onboarding audit:

- login: shells without a TTY auto-fall back to the device-code flow
  (agents/CI/SSH no longer hang on the browser callback); a failed
  browser open also falls back, and the waiting state hints --device
- bdrive --version now works (cobra root Version), same output as
  bdrive version
- init prints a next-steps block: daemon auto-sync note, the
  'bdrive init --project p-xxx' connect command for teammates,
  bdrive log / bdrive share
- authorship: the daemon re-reads settings.json every tick so a
  login/logout/account switch is reflected in op authorship instead
  of stamping a stale identity forever; whoami now shows the
  signed-in account and labels the git/OS author as the signed-out
  fallback

Docs updated in README, plugin SKILL.md, and web/docs reference/cli.md.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-22 09:35:48 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent a00f7cd1a9
commit 34afef17cc
7 changed files with 59 additions and 12 deletions
+3 -2
View File
@@ -135,7 +135,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` for headless; 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 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 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 — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
@@ -148,7 +148,8 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
| `bdrive whoami` | Device identity used in change tracking |
| `bdrive whoami` | Signed-in account and device identity used in change tracking |
| `bdrive version` | Print the version (also `bdrive --version`) |
## Project files
+12 -1
View File
@@ -156,7 +156,18 @@ the folder was renamed or moved.`,
if shared != "" {
fmt.Printf(" syncing: ./%s only\n", shared)
}
return startSync(cmd.Context(), folder, proj, foreground, 3*time.Second, 10*time.Second)
if err := startSync(cmd.Context(), folder, proj, foreground, 3*time.Second, 10*time.Second); err != nil {
return err
}
fmt.Printf(`
done the daemon now keeps this folder in sync automatically.
next steps:
connect another device or teammate: bdrive init --project %s
see who changed what: bdrive log
share a file by public URL: bdrive share <file>
`, p.ID)
return nil
},
}
c.Flags().StringVar(&projectID, "project", "", "connect an existing project by id (p-xxxxxxxx)")
+20 -1
View File
@@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/config"
@@ -163,6 +164,13 @@ func runLogin(server string, cfg serverConfig, useDevice bool) error {
if loginPath == "" {
loginPath = "/auth/cli"
}
// Headless shells (agents, CI, SSH) can't complete the loopback-callback
// flow — the browser would open nowhere and the CLI would hang. Fall back
// to the device-code flow automatically instead of waiting.
if !useDevice && !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) {
fmt.Println("no interactive terminal detected — using the device-code sign-in flow")
useDevice = true
}
var token string
var user serverUser
var err error
@@ -170,6 +178,10 @@ func runLogin(server string, cfg serverConfig, useDevice bool) error {
token, user, err = deviceCodeLogin(server)
} else {
token, user, err = browserLogin(server, loginPath)
if errors.Is(err, errNoBrowser) {
fmt.Println("could not open a browser — switching to the device-code sign-in flow")
token, user, err = deviceCodeLogin(server)
}
}
if err != nil {
return err
@@ -238,6 +250,10 @@ func deviceName() string {
return "cli"
}
// errNoBrowser signals that the loopback flow can't proceed because no local
// browser could be opened; the caller falls back to the device-code flow.
var errNoBrowser = errors.New("no local browser")
// browserLogin runs the loopback-callback flow.
func browserLogin(server, loginPath string) (string, serverUser, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
@@ -272,8 +288,11 @@ func browserLogin(server, loginPath string) (string, serverUser, error) {
fmt.Println("opening your browser to sign in (sign up there if you don't have an account):")
fmt.Println(" " + loginURL)
if err := openBrowser(loginURL); err != nil {
fmt.Println("could not open a browser — open the URL above manually, or rerun with --device")
// The loopback callback only works from a browser on this machine, so
// a URL the user pastes elsewhere would dead-end — use the code flow.
return "", serverUser{}, errNoBrowser
}
fmt.Println("waiting for the browser sign-in… (no browser here? Ctrl-C and run `bdrive login --device`)")
select {
case code := <-codeCh:
+13 -1
View File
@@ -28,7 +28,9 @@ Cloud). Every change is journaled — you can always see which device and
author changed which file, and when. Files are real files on disk, so
everything keeps working offline; changes sync when the remote is reachable.`,
SilenceUsage: true,
Version: version,
}
root.SetVersionTemplate("beardrive {{.Version}}\n")
root.AddCommand(
loginCmd(),
logoutCmd(),
@@ -77,7 +79,17 @@ func whoamiCmd() *cobra.Command {
}
fmt.Printf("device id: %s\n", dev.ID)
fmt.Printf("device name: %s\n", dev.Name)
fmt.Printf("author: %s\n", dev.Author)
if settings, _ := config.LoadSettings(); settings.Email != "" {
who := settings.Email
if settings.Name != "" {
who = settings.Name + " <" + settings.Email + ">"
}
fmt.Printf("account: %s (from `bdrive login`; changes are attributed to this)\n", who)
fmt.Printf("author: %s (git/OS fallback, used only when signed out)\n", dev.Author)
} else {
fmt.Printf("account: not signed in — changes are attributed to the author below (run `bdrive login`)\n")
fmt.Printf("author: %s (detected from git config / OS user)\n", dev.Author)
}
fmt.Printf("beardrive home: %s\n", home)
return nil
},
+4 -2
View File
@@ -134,8 +134,6 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
}
defer os.Remove(PidPath(volDir))
settings, _ := config.LoadSettings()
log.Printf("daemon started: folder=%s mount=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
folder, proj.ID, proj.Volume, proj.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
@@ -191,6 +189,10 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
}
}
// Re-read settings each tick too, so a login/logout/account switch
// after the daemon started is reflected in op authorship — otherwise
// a long-lived daemon stamps every change with a stale identity.
settings, _ := config.LoadSettings()
sess := &syncer.Session{Folder: folder, MountID: proj.ID, Store: st, Device: dev, Account: settings}
if doRemote {
sess.Backend = be
+4 -2
View File
@@ -23,7 +23,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
| 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); `--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 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 out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The device token stays valid server-side until it expires — revoke it from the hub's device list to be sure. |
| 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. Put generated reports in the shared folder, sync, then share. |
@@ -484,12 +484,14 @@ History is content-addressed — overwritten and deleted files are still in the
```
device id: d380dea58598
device name: macbook
author: snow@runbear.io
account: Snow <snow@runbear.io> (from `bdrive login`; changes are attributed to this)
author: snow@runbear.io (git/OS fallback, used only when signed out)
beardrive home: /Users/snow/.bdrive
```
- **device id** — random 12-hex, generated on first run, persisted to `~/.bdrive/device.json`.
- **device name** — hostname (without `.local`).
- **account** — the signed-in hub account; changes are attributed to it in `bdrive log` and hub history. When signed out, the author fallback is used instead.
- **author**`git config user.email` if present, else `$USER@<hostname>`.
To change name/author, edit `~/.bdrive/device.json` and restart the daemon (`bdrive stop`/`bdrive init`).
+3 -3
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` for headless. 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 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 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. Interactive on a TTY; flags (`--name`, `--project`, `--shared`, `--yes`) for scripts. Re-run to resume |
| `bdrive stop [folder]` | Stop syncing. Files stay on disk; `bdrive init` resumes |
@@ -22,8 +22,8 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer, uploads, multi-project sync hub |
| `bdrive whoami` | Device identity used in change tracking |
| `bdrive version` | Version |
| `bdrive whoami` | Signed-in account and device identity used in change tracking |
| `bdrive version` | Version (also `bdrive --version`) |
## Notes on a few