**BearDrive** is the product name; **`bdrive`** is its CLI binary (file conventions: the `.bdrive/` settings directory and `.bdriveignore` at the project root, `~/.bdrive` home, `BDRIVE_HOME`). BearDrive is a Go CLI that mounts any folder as a synced volume: contents sync across devices and teammates through a **`bdrive serve` hub**, with accounts, per-file change history, and offline support. Clients are storage-blind — they sync through the hub over `https://` and never hold storage credentials; the hub owns the object store (S3, GCS, S3-compatible, or a plain directory) and devices converge through append-only journals in it. (Direct client-to-bucket sync without a hub is no longer supported; the object-storage backends exist only as the hub's own storage.)
There is no Makefile or linter config in-repo. CI is three GitHub Actions workflows: `ci.yml` (build/vet/test on Linux and macOS), `bump-cloud.yml` (pins the new OSS commit in the private cloud repo), and `docs.yml` (builds `web/docs` on PRs, deploys it to the `beardrive-docs` Cloudflare Pages project — docs.beardrive.ai — on pushes to main; see `web/docs/README.md`). Releases run `goreleaser release` on a tagged commit (see `.goreleaser.yaml`); the version is injected via `-ldflags "-X main.version=..."` into `cmd/bdrive/main.go`. The frontend's built assets are **committed** (go:embed needs them in the module), so `go build` and `go install` never require Node — but a release tag must not ship a stale `internal/webapp/static`: run `frontend/check-dist.sh` first.
When testing the CLI manually, set `BDRIVE_HOME=/some/tmp/dir` to relocate all beardrive state (device identity, mount registry, volume stores) away from the real `~/.bdrive`.
- **`journal`** — the core data model. Every change is an `Op` (`put`/`delete`) in a per-device append-only JSONL log. `Less` defines the total order `(lamport, time, device, seq)`; `Replay` folds all ops into the volume state, last-writer-wins per path. Everything else is machinery around this.
- **`store`** — a volume's local on-disk state: content-addressed blob store (`blobs/<aa>/<sha256>`), per-device journal copies, the per-mount materialization cache (`state-<mount-id>.json`, size+mtime fingerprints for cheap change detection), sync state (lamport clock + push cursor), and the exclusive flock that serializes cycles.
- **`remote`** — the `Backend` interface (Put/Get/List/Exists) with `file://`, `s3://`, `gs://`, and `https://` implementations (`https://` syncs through a `bdrive serve` server's `/api/store` API — the client device holds no storage credentials). `PutSigner` is the optional presign capability (S3/GCS). Remote layout: `blobs/<sha256>` + `journal/<device>.jsonl` under the URL prefix. The `https://` wire is gzipped (`compress.go`) — **transport only**: hashes, storage and the journal format are always over the uncompressed bytes, `Compressible` skips already-compressed content, and the presigned direct-to-storage leg stays raw. Pull needs no negotiation (net/http sends `Accept-Encoding: gzip` and inflates transparently, so `do` must never set that header); push compresses only when `sign()` answered `accept_encoding`, which an older hub does not.
- **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely.
- **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log`/`daemon.lock` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). **Liveness is the flock on `daemon.lock`, never the pidfile**: the pidfile outlives its process (it lives in `$BDRIVE_HOME`, which survives reboots), so a recycled pid used to read as a live daemon — making `status` lie and `Start` a silent no-op, which broke the one documented recovery. The kernel drops the flock when the holder dies, including at reboot and on a crash, and holding the lock also makes two daemons on one mount impossible (two writers of one journal). **The pid `stop` signals is written inside the lock file and cleared with it** — `daemon.pid` is display-only, since nothing binds its contents to the lock holder and a `kill -9`'d daemon leaves it behind for a recycled pid to inherit. A mid-run change to the folder's `remote` is not followed: the daemon exits cleanly and the next bdrive command in the folder starts one for whatever the config then says.
- **`autostart`** — the login registration that undoes a reboot: one unit per machine, user-level, running `bdrive resume` — macOS `~/Library/LaunchAgents/ai.beardrive.daemon.plist` (`RunAtLoad`, deliberately no `KeepAlive` since the job exits); Linux `$XDG_CONFIG_HOME/systemd/user/beardrive.service` (`Type=oneshot`, no `Restart=`) plus the `default.target.wants` symlink that `systemctl --user enable` would create, since systemd ignores a unit nothing wants. Linux also requires systemd to be booted (`/run/systemd/system`, i.e. `sd_booted`) — otherwise `Install` returns `ErrUnsupported` rather than writing a file nothing would ever read (Alpine/runit, WSL1, slim containers), which starts a daemon for every enrolled, unpaused mount — so mounts added later need no re-registration and `bdrive stop` still means stay stopped. `init` installs it (`--no-autostart` skips). Writing the file is the whole job: no `launchctl` shell-out, so a test or a packaging script can't register a real login item as a side effect, and launchd loads it at the next login anyway. Windows uses a per-user `HKCU\...\CurrentVersion\Run` value (`golang.org/x/sys/windows/registry`) — no admin, no COM (a Startup `.lnk` would need it), no `schtasks`, and it shows up in Task Manager's Startup tab where a user can disable it. Its tests exist but have **never been executed** (written and compile-checked from macOS); they run the first time the suite runs on Windows. **`GOOS=windows go build ./...` does not pass yet** and this package is not the reason: `internal/store`'s `Lock` uses `syscall.Flock` and `internal/daemon` uses `syscall.Kill`/`Setsid`, all unix-only — a Windows port means `LockFileEx` and a kill story for a platform with no SIGTERM, which is a separate change against the sync invariants. `autostart_other.go` (`!darwin && !linux && !windows`) covers the BSDs with `ErrUnsupported`; shared bits (`writeIfDifferent`, `selfPath`) live in the tag-free `autostart.go`.
- **`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/<mount-id>/`. `.bdrive/` is never synced and holds no credentials.
- **`webapp`** — the `bdrive serve` 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 `<root>/<project-id>/` 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/<token>`), 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/<id>/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. That proxy gzips what it answers and inflates what it is sent — the inflate sits ABOVE `spool` (the sha, the op count and the billed size are all properties of the plaintext) and is bounded by `maxInflatedPut`, since a compressed body severs the one-wire-byte-one-disk-byte relationship that made `spool` safe unbounded; `RecordUsage` charges uncompressed, `RecordEgress` compressed. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied into `src/components/ui`, themed from the BearDrive tokens in `src/tw.css`; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). 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 (`/<project-id>/<path>` in hub mode, `/<path>` in volume mode, `/join/<token>` for invites — no `#`, slashes stay literal). **Every user-facing page owns a URL path**: new surfaces are view routes (`/<project-id>/{dashboard|history|install|settings}[/<path>]`, `VIEW_ROUTES` in `router.ts`; renamed segments live in `LEGACY_VIEWS` and are normalized away on arrival) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution; the shell also carries `nosniff` + `frame-ancestors 'none'`, and `immutable` is set only after an asset is found (a miss under `assets/` must not pin the shell in a shared cache for a year). Rendered markdown is tr
`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `resume`, `autostart`, `read-log`, `serve`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `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` revokes this device's token on the hub (`DELETE /api/auth/token`, authenticated by the token itself) and then clears the saved token+account, keeping the remembered server unless `--forget`; a revocation it could not reach the hub for is reported, never swallowed. Switching hubs is `bdrive login <new-url>` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (`--name/--project/--only/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks in each platform's USER config (`~/.claude/settings.json` and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; `Install` also migrates away hooks older versions wrote into projects), and starts sync via `startSync`; re-running it resumes — including after a folder move. **A mount is always exactly the folder named** — there is no re-rooting flag. Syncing only part of a mount is `--only wiki,docs`, which writes a bdrive-managed block of `.bdriveignore` negation rules (`cmd/bdrive/scopefile.go`; `bdrive scope add/rm` edits the same block) rather than a second scope mechanism: the old `Include` list in `config.json` is legacy — still honored, never written. Because the rules live in the synced `.bdriveignore`, scope is team-wide, which is why `sync --prune` refuses when `!` rules are present (it would strip everything outside the scope from the hub for everyone; `bdrive forget <path>` is the per-path tool). `init` also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. `bdrive serve -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/<token>` 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. **Any provider must call the binder handed to `AuthProvider.UseDeviceBinder` at every point it mints a CLI token, and refuse the login when it errors**: `store.go:ownJournal` refuses a journal write unless the device id is bound to the caller's account, for every provider, and `DeviceRegistry.Bind` is the only thing that binds. That hook was a field on `BuiltinAuth` wired behind `if a, ok := s.Auth.(*BuiltinAuth); ok`, so a hub running a managed provider bound nothing and refused *every* push from *every* device forever while login, permissions and blob uploads all read healthy — it is on the interface now so a provider that ignores it does not compile. The hub cannot bind on the provider's behalf: binding must be reachable only from a completed authentication (a device token that could reach a bind would let a stolen credential squat a teammate's id), and `Authenticate` reports who a request is, never which credential class it presented. 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/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/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/<token>` 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).
- **Each device writes only its own journal.** This is the whole concurrency story: no locking service is needed because no object ever has two writers. Never write to another device's journal file or remote key.
- **Blobs are pushed before the journal** (`syncer.push`), so a peer never sees an op whose content is missing. Preserve this ordering.
- **Scan happens before pull** in `Cycle`, so local edits are journaled (and content captured) before remote state can overwrite the working folder.
- **Replay must stay deterministic.** Any change to `journal.Less` or `Replay` changes what every device converges to.
- **Materialize never clobbers dirty files**: a file whose size/mtime differs from the state cache changed mid-cycle and is left for the next scan.
- **All state files are written atomically** (temp file + rename, see `store.WriteFileAtomic`). Temp files are prefixed `.bdrive-tmp-` and ignored by the scanner.
- **The agent hook guard stays pure shell.** It runs on every session and every tool call on the machine, so it must never spawn `bdrive` (or anything else) outside a BearDrive project — a couple of `stat`s and at most one `grep` of `mounts.json`.
- **A daemon's liveness is its `daemon.lock` flock, never its pidfile.** Anything asking "is the daemon running" goes through `daemon.Running`; a pid is for display and for signalling only.
- Errors during pull/push degrade to `Result.Offline` rather than failing the cycle; unreadable/vanished files during scan are skipped and retried next cycle. Follow this "never break sync, retry next cycle" posture.
## Testing conventions
The real coverage is the integration tests in `internal/syncer/syncer_test.go`: each test builds multiple simulated devices (`newDevice`) syncing through a shared `file://` remote (`sharedRemote`), then drives explicit `cycle()` calls to test convergence, offline operation, and concurrent-edit conflicts. Extend these when touching sync behavior — a new sync feature without a multi-device test is untested where it matters.
`sandbox/` is a disposable Linux container to run a scenario in **when it needs one** — an environment, not a test suite (`./sandbox/run.sh`, which cross-builds the binary and takes `BDRIVE_SRC=<other checkout>` to test a branch without disturbing your tree). It provides a hub, a seeded account, browserless sign-in (`bdrive-signin`), Claude Code, and a `$HOME` that is thrown away, so `bdrive init` writes its device identity and its agent hooks somewhere other than yours. Reach for it only when a Go test cannot do the job: a real `claude` session with the real permission classifier, Linux-only paths like the systemd user unit, or a reboot simulated by killing processes while the filesystem survives. Everything deterministic and machine-local belongs in `internal/webapp/cli_e2e_test.go` (which already isolates `HOME` and drives the real binary) or `internal/syncer` — **if it doesn't need a conversation or an OS, it's a Go test.** The two scripts it ships (`onboarding.sh`, `daemon-linux.sh`) are the scenarios that cannot live anywhere else; don't grow a suite in here.
`web/docs/` is the public documentation at docs.beardrive.ai — Astro 7 + [Starlight](https://starlight.astro.build) (Starlight requires Astro ^7; the cloud landing is still on Astro 5 — separate projects, so they upgrade independently), static output, Pagefind search, `llms.txt` via `starlight-llms-txt`. Unlike the hub frontend (`internal/webapp/static`) and the cloud landing page (`cloud/internal/landing/dist`) it is **not**`go:embed`ed: docs change far more often than the binary, and a search index has no business shipping in every self-hoster's install. It deploys on its own from `dist/`, so `go build` never touches it. Sidebar order is explicit in `astro.config.mjs` — a new page under `src/content/docs/` is invisible until listed there, and every page needs a `description` (meta description, search snippet, and `llms.txt` line). **The sidebar order is the recommended path, and that path is agent-first**: `Start here` (what it is → set up with your agent → your first hour) never mentions installing a binary; the CLI route lives in `Manual setup (optional)` (install the CLI, set up by hand, hooks in detail) — same destination, one click away, never on the critical path. New onboarding content belongs in `Start here` and should say what to ask an agent, not what to type. Job-shaped "use case" pages — the ones aimed at someone deciding rather than someone building — belong on the marketing site (beardrive.ai/use-cases), not here; the docs sidebar ends with a `More` group of off-site links (use cases, blog, GitHub) and the header logo points at beardrive.ai via the `SiteTitle` override in `src/components/`, since Starlight has no config for that link. Moved URLs keep `redirects` entries in `astro.config.mjs` (static builds emit meta-refresh; real 301s live in the host config, see `web/docs/README.md`). **Guides are agent-workflow docs, not CLI tutorials** (`Working with agents`: shared agent memory, artifacts/links, read heat, scoping); command-by-command detail belongs in `reference/cli.md`. Design tokens are **generated, never copied**: `scripts/tokens.mjs` reads the `@theme` block in `internal/webapp/frontend/src/tw.css` and emits the gitignored `src/styles/tokens.gen.css`, which `src/styles/custom.css` maps onto Starlight's `--sl-color-*` — so the palette cannot drift and there is no checker to maintain (contrast the cloud landing, which keeps a copy policed by its own `check-tokens.mjs`). Because the build reads that file *outside*`web/docs`, a deploy host must check out the whole repo, not the subdirectory. Note `llms.txt` convention wants the root domain, so `beardrive.ai/llms.txt` should point at the docs subdomain — that redirect lives in the cloud landing and is the one cross-repo coordination point.
Every PR body starts with a `## TL;DR` section: at most 5 bullet points, one short informal sentence each — fewer is better. Lead with the user-visible symptom/outcome, not the mechanism; include a known-gap bullet if the PR knowingly leaves one. Detail sections follow after.
`architecture/` holds mermaid diagrams covering every application package in the repo: `overview.md` (system diagram — the package map and how the pieces connect), `cli-sync.md` (the CLI and sync engine — `cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,autostart}`), `webapp-server.md` (the `bdrive serve` server — `internal/webapp` + `internal/remote`), and `webapp-frontend.md` (the hub's React SPA — `internal/webapp/frontend/src`). Every code change lands in exactly one detail diagram's scope; `overview.md` changes only when packages appear/disappear or the cross-piece wiring changes (`web/docs` and the private `cloud/` repo are deliberately out of scope). Before `gh pr create`: if the branch changes types or relationships drawn in any of them (new/removed types, new seams, changed fields/implements/ownership in those packages), update the affected diagram and commit it on the branch, then add an "Architecture changes" section to the PR description with, per changed diagram: (1) a sentence naming exactly which types/relationships changed and how, (2) **one** consolidated diff diagram per changed file — invoke the `mermaid-diff-diagram` skill, which marks additions (✅) and removals (❌, struck through) in place on a single flowchart, instead of pasting a Before block and an After block. Feed it the merge-base diagram as *before* and the branch's as *after*, and keep it an *excerpt* containing only the affected classes and their immediate relationships — never the full diagram. The committed diagram file stays the full current state and stays `classDiagram`; the diff excerpt lives only in the PR description. No structural change → no section, and append `# skip-diagram-check` to the `gh pr create` command to satisfy the pre-PR hook.
-`README.md` documents CLI behavior, flags, output formats, and the on-disk layout. When changing CLI commands, flags, output, or layout, update it.
-`INSTALL_FOR_AGENTS.md` (repo root) is the URL-addressable agent onboarding runbook and the source of truth for that flow — the canonical two-line paste prompt (README, docs, and the hub's `ConnectGuide.tsx` all point at its raw URL) tells any agent to fetch and follow it. When the CLI's init/login/hooks steps change, update it too.
-`web/docs/src/content/docs/` is the third surface: it restates the same CLI reference, hub config, and self-hosting knobs for end users. A CLI or config change lands there too — `reference/cli.md`, `reference/hub-config.md`, `reference/project-files.md`, and the `self-hosting/` pages are the ones that go stale.