Sidebar order is now Start here -> Working with agents -> Manual setup (optional) -> Use cases -> Self-hosting -> Reference -> Concepts. README and CLAUDE.md carry the group order and the rule for what belongs in each, so both move with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
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 web 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.)
The repo ships one binary: cmd/bdrive — the CLI, the sync daemon, and the web server (bdrive web: viewer, uploads, multi-project sync hub).
Commands
go build ./... # build everything
go test ./... # run all tests
go test ./internal/syncer -run TestConflict -v # run a single test
go vet ./... # vet
go build -o bdrive ./cmd/bdrive # build the binary (gitignored at repo root)
# web frontend (internal/webapp/frontend — only needed when changing frontend/src):
npm run build # rebuild the committed assets in internal/webapp/static (run from frontend/)
npm run e2e # Playwright suite against the seeded e2e hub (starts itself on :8993)
./check-dist.sh # verify committed static/ matches frontend/src (run before releases)
There is no Makefile, linter config, or CI config in-repo. 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.
Architecture
Data flows in two hops; the local volume store is the pivot:
working folder ←scan/materialize→ volume store (~/.bdrive/volumes/<vol>) ←push/pull→ object store
(real files) blobs/ + journal/ + state + sync s3:// gs:// file://
Package roles (internal/):
journal— the core data model. Every change is anOp(put/delete) in a per-device append-only JSONL log.Lessdefines the total order(lamport, time, device, seq);Replayfolds 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— theBackendinterface (Put/Get/List/Exists) withfile://,s3://,gs://, andhttps://implementations (https://syncs through abdrive webserver's/api/storeAPI — the client device holds no storage credentials).PutSigneris the optional presign capability (S3/GCS). Remote layout:blobs/<sha256>+journal/<device>.jsonlunder the URL prefix.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 insyncer.gofirst.ignore.goholds the path filter (.bdriveignorerules + the.bdriveinclude 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.login 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.jsoneach 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).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) holdsconfig.jsonwith the mount id + volume/remote/include; nothing is keyed by the folder path, so renames/moves are free —ResolveMountself-heals the registry path, and the volume store lives at~/.bdrive/volumes/<mount-id>/..bdrive/is never synced and holds no credentials.webapp— thebdrive webserver, in two modes. Single-volume:Sourceis aDirSource(plain folder from disk) orRemoteSource(folds journals into a file tree with per-file provenance). Hub:Root+Projectshost many projects on one storage root, each under<root>/<project-id>/viaremote.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-backedorgs.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/projectslists 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 mirroringAuthProvider— CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships onlyUnlimitedQuota, managed deployments swap the provider. Renders markdown (goldmark + Obsidian[[wikilinks]]). With--uploadit accepts writes: browser uploads (upload.go— direct-to-storage via presigned URLs when the backend implementsremote.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 — thehttps://remote backend (remote/http.go) is its client; journals are never presigned, only immutable blobs. Frontend is a React + TypeScript app (webapp/frontend/, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied intosrc/components/ui, themed from the BearDrive tokens insrc/tw.css; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose built output is committed atwebapp/static/— thego:embed statictarget — so plaingo buildneeds no Node; after anyfrontend/srcchange runnpm run buildthere and commit the newstatic/(frontend/check-dist.shverifies freshness; e2e suite:npm run e2e— Playwright against the seeded harness ine2e_serve_test.go, port 8993). It learns everything from/api/config(+/api/projectsin 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>/{insights|history|install|settings}[/<path>],VIEW_ROUTESinrouter.ts) 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 routerfrontend/src/nav.ts+router.ts(deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen);Server.frontendservesindex.htmlas the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashedassets/*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. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch thedangerouslySetInnerHTMLsubtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). Read heat (reads.go): aReadLedger(hub-only, nil = off, configreadsblock) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows pastretention_days— viewer file/render/download = human (recorded via the project id theproj()resolver stashes in the request context),/s/*hits = share, device-reported reads (POST /api/p/<id>/reads) = agent;/store/*replication and history/blobviews are NEVER reads.GET /api/p/<id>/heat?prefix=&days=returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings for members and an admin/org-owner Insights quadrant (reads × staleness). Hub metadata persistence (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggableMetaStoreof typed repos (db.go): the service structs (BuiltinAuth,OrgDB,ProjectDB,ShareDB,DeviceRegistry,ReadLedger) keep their in-memory maps + logic and persist each change as one record through a repo (theReadRepoalone is batch-oriented — one flush, one write). Two backends —db_file.go(the historical JSON files, still the zero-dep default, reached via theOpen*(path)constructors) anddb_sql.go(onedatabase/sqlimpl over pure-Go drivers:modernc.org/sqlitelocally,jackc/pgxfor Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes).web.go'sdatabaseconfig ({driver:file|sqlite|postgres, dsn}) selects it; file is default and untouched.db_conformance_test.goruns the same service ops against every backend.
cmd/bdrive/ is a thin cobra CLI over these packages (login, logout, init, stop, sync, status, log, url, web, whoami, daemon, version — mnt/umnt/remote 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 <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 --shared <dir>, 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/<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. 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).
Invariants — do not break these
- 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.LessorReplaychanges 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. Cycleruns under the volume flock — the daemon and one-shot CLI commands (bdrive sync) coexist through it.- Errors during pull/push degrade to
Result.Offlinerather 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.
Claude Code plugin
plugin/ is a Claude Code plugin (skill + /beardrive:install + /beardrive:init + /beardrive:status commands + turn-boundary sync hooks). /beardrive:install (plugin/commands/install.md) is the team onboarding flow: binary, login, init, a consent-gated two-file agent orientation (synced <shared>/AGENTS.md map + repo-root AGENTS.md/CLAUDE.md pointer — see SKILL.md "Teaching agents the folder"), and project-level hooks in .claude/settings.json (blocking pull at UserPromptSubmit — which, via bdrive sync --hook, also injects the project's gated-link formula as additionalContext so agents append path [🔗](hub link) to every synced path they mention — async push on PostToolUse Write/Edit) so teammates without the plugin still sync, published via the marketplace manifest at .claude-plugin/marketplace.json (/plugin marketplace add runbear-io/beardrive). The canonical skill lives at plugin/skills/beardrive/SKILL.md; .claude/skills/beardrive is a symlink to it. The hook script plugin/scripts/beardrive-sync.sh (and the inline project-level hook commands) must stay a fast no-op for folders without a .bdrive/ dir — it runs on every turn in every project.
Docs site (web/docs)
web/docs/ is the public documentation at docs.beardrive.ai — Astro 7 + Starlight (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:embeded: 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, skills and 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. Use cases (after Manual setup) holds job-shaped pages — "Share work across your team's agents", "Turn a personal brain into a company brain" — with the persona named in the first line and the description, not in the title; they route into the guides and never re-teach a feature. 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.
Docs to keep in sync
README.mdandplugin/skills/beardrive/SKILL.mdboth document CLI behavior, flags, output formats, and the on-disk layout. When changing CLI commands, flags, output, or layout, update both — the skill is what makes Claude Code beardrive-aware for end users and must match the actual binary.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 theself-hosting/pages are the ones that go stale.