diff --git a/CLAUDE.md b/CLAUDE.md index 9cef551..18e38bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Package roles (`internal/`): - **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes//`. `.bdrive/` is never synced and holds no credentials. - **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve, and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`) keep their in-memory maps + logic and persist each change as one record through a repo. Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. -`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `init`, `stop`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared `, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning. +`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`) — switching hubs is `bdrive login ` then re-`init`. The per-folder remote (`bdrive remote set`) accepts object storage (`s3://`/`gs://`/`file://`) or a hub (`https://`), distinct from the device's logged-in server. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared `, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning. Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p//history?path=|prefix=` (newest first, device-registry join) and `GET /api/p//blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into). diff --git a/README.md b/README.md index 27b3d0a..488d4ac 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s | Command | Description | |---|---| -| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` for headless; default server beardrive.ai) | +| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` for headless; default server beardrive.ai). Switch hubs with `bdrive login ` | +| `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) | | `bdrive share ` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) | @@ -232,7 +233,10 @@ cd ~/some-project && bdrive init # once per project `bdrive login` signs the device in and remembers the server (`settings.json` under the bdrive home; bare `bdrive login` defaults to beardrive.ai — -`--status` shows the current server and account). `bdrive init` then, per +`--status` shows the current server and account). To move to a **different +hub**, run `bdrive login ` and then re-run `bdrive init` in each +folder to connect it to a project there; `bdrive logout` signs out entirely. +`bdrive init` then, per project, walks you through it on a terminal: **create a new project or connect an existing one** (picked from the server's list), and **sync the whole folder or only a shared subfolder** (e.g. `./shared`). Every question diff --git a/cmd/bdrive/cmds.go b/cmd/bdrive/cmds.go index a2cccbc..0dc6a0f 100644 --- a/cmd/bdrive/cmds.go +++ b/cmd/bdrive/cmds.go @@ -198,8 +198,23 @@ func remoteCmd() *cobra.Command { } set := &cobra.Command{ Use: "set ", - Short: "Set the remote (s3://bucket/prefix, gs://bucket/prefix, file:///path, https://bdrive-server)", - Args: cobra.ExactArgs(2), + Short: "Set where a folder syncs (object storage, or a bdrive hub)", + Long: `Point a folder at where it syncs. Two kinds of remote: + + Object storage — sync straight to a bucket you own (you hold the creds): + s3://bucket/prefix gs://bucket/prefix file:///abs/path + + A bdrive hub — sync through a bdrive web server that holds the storage + credentials for you (sign in first with "bdrive login "): + https://your-hub.example.com + +Switching hubs is usually done with "bdrive login " then "bdrive init", +which wires the folder to a project on that hub for you. Use "remote set" for +object storage, or to re-point a folder by hand.`, + Example: ` bdrive remote set ./notes s3://acme-bdrive/notes + bdrive remote set ./notes gs://acme-bdrive/notes + bdrive remote set ./notes https://drive.example.com`, + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { folder, err := absFolder(args[:1]) if err != nil { diff --git a/cmd/bdrive/login.go b/cmd/bdrive/login.go index fb325a8..5e3f708 100644 --- a/cmd/bdrive/login.go +++ b/cmd/bdrive/login.go @@ -106,6 +106,56 @@ With no argument the remembered server is used, or ` + config.DefaultServer + `. return c } +func logoutCmd() *cobra.Command { + var forget bool + c := &cobra.Command{ + Use: "logout", + Short: "Sign this device out (clear the saved token)", + Long: `Clear this device's saved sign-in — the token and account — so it is no +longer authenticated to the bdrive server. The remembered server is kept so +"bdrive login" re-authenticates to it; pass --forget to clear that too. + +To switch to a different server, just run "bdrive login ". +Your synced folders are untouched; this only affects this device's session.`, + Example: ` bdrive logout # sign out, keep the server remembered + bdrive logout --forget # sign out and forget the server`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + settings, err := config.LoadSettings() + if err != nil { + return err + } + if settings.Token == "" && settings.Email == "" && !(forget && settings.Server != "") { + fmt.Println("already signed out") + return nil + } + who, server := settings.Email, settings.Server + settings.Token, settings.Email, settings.Name = "", "", "" + if forget { + settings.Server = "" + } + if err := config.SaveSettings(settings); err != nil { + return err + } + switch { + case who != "" && server != "" && !forget: + fmt.Printf("signed out %s from %s\n", who, server) + case who != "": + fmt.Printf("signed out %s\n", who) + default: + fmt.Println("signed out") + } + if forget { + fmt.Println("forgot the remembered server (run `bdrive login ` to set a new one)") + } + fmt.Println("note: the device token stays valid on the server until it expires — revoke it from the hub's device list if needed") + return nil + }, + } + c.Flags().BoolVar(&forget, "forget", false, "also forget the remembered server") + return c +} + // runLogin executes the sign-in flow against a server known to require auth // and persists server + token + account to settings. func runLogin(server string, cfg serverConfig, useDevice bool) error { diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index fef1c97..6048420 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -31,6 +31,7 @@ everything keeps working offline; changes sync when the remote is reachable.`, } root.AddCommand( loginCmd(), + logoutCmd(), initCmd(), shareCmd(), stopCmd(), diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 91f93d0..2776a22 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -21,7 +21,8 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing | Change history | `bdrive log [] [-p path] [-n N]` | | Show / set remote | `bdrive remote []` · `bdrive remote set ` | | This device's identity | `bdrive whoami` | -| Sign this device in (once per device) | `bdrive login [url]` — bare form uses the remembered server or beardrive.ai. 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). | +| Sign this device in (once per device) | `bdrive login [url]` — bare form uses the remembered server or beardrive.ai. 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 `, 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. | | Share a synced file publicly by URL | `bdrive share ` — 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 ` to manage. Put generated reports in the shared folder, sync, then share. | | Set up a project for a Claude Code team | `/beardrive:install` — installs the CLI, signs in, runs init (whole/shared folder), offers a CLAUDE.md section about the shared folder, and registers project-level hooks (blocking pull at prompt-submit, async push after Write/Edit) in `.claude/settings.json` | | Per-file / folder change history in the web UI | History button (file versions or project feed) and per-folder ⌚ — each entry: account, time, device (name/OS/IP), view/download of that exact version. API: `GET /api/p//history?path=\|prefix=`, `GET /api/p//blob?sha=` |