From 5ff82c2c49e3f70e77d1f122836573ac920b22da Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Tue, 14 Jul 2026 11:29:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20bdrive=20url=20=E2=80=94=20interna?= =?UTF-8?q?l,=20permission-walled=20links=20agents=20share=20when=20they?= =?UTF-8?q?=20create=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New command printing a file's hub viewer URL: sign-in + project-org membership required to open (the internal counterpart to bdrive share's public links). Computed locally from the mount config — hub origin + project id from the remote, path segments percent-encoded with literal slashes; unsynced paths (ignored, or outside a --shared scope) are refused so nobody gets handed a 404. --sync pushes first so a just-created file's link resolves immediately; no arg = project home. The plugin docs now instruct agents to include this link in their reply whenever they create a shareable artifact (.md/.html/.csv/...) in the shared folder, reserving bdrive share for people outside the hub: SKILL.md command map + 'Share what you make' guidance, install.md root pointer template + payoff step, README, CLAUDE.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- CLAUDE.md | 2 +- README.md | 19 +++++- cmd/bdrive/main.go | 1 + cmd/bdrive/url.go | 109 +++++++++++++++++++++++++++++++ cmd/bdrive/url_test.go | 95 +++++++++++++++++++++++++++ plugin/commands/install.md | 8 ++- plugin/skills/beardrive/SKILL.md | 5 +- 7 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 cmd/bdrive/url.go create mode 100644 cmd/bdrive/url_test.go diff --git a/CLAUDE.md b/CLAUDE.md index f127060..195a23c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,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 a React + TypeScript app (`webapp/frontend/`, Vite; runtime deps only react, react-dom, @tanstack/react-query) 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 (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal) 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. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//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 pluggable `MetaStore` of 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 (the `ReadRepo` alone is batch-oriented — one flush, one write). 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`, `logout`, `init`, `stop`, `sync`, `status`, `log`, `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 ` 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 `, 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`, `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 ` 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 `, 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 c578c3d..5b4b57b 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s | `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 url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally | | `bdrive share ` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) | | `bdrive sync [folder]` | Run one sync cycle now. `--note ` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires | | `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking; idempotent (`--agent` overrides detection) | @@ -266,8 +267,22 @@ and their pushes wait (offline semantics) until allowed. ### Sharing files by URL -Any synced file can be shared with a public link — hand someone the URL -and they see the file, no account needed: +For teammates, every synced file already has an internal link — the hub +viewer URL, gated by sign-in and the project's org membership: + +```console +$ bdrive url wiki/report.html +https://drive.example.com/p-1a2b3c4d/wiki/report.html +``` + +It's computed locally (no network), always shows the latest synced +content, and is the link agents should drop in their replies when they +create an artifact in the shared folder (`--sync` pushes first so a +just-created file resolves immediately). + +For people **outside** the hub, any synced file can instead be shared +with a public link — hand someone the URL and they see the file, no +account needed: ```console $ bdrive share wiki/report.html diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index 3624ada..f818623 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -34,6 +34,7 @@ everything keeps working offline; changes sync when the remote is reachable.`, logoutCmd(), initCmd(), shareCmd(), + urlCmd(), stopCmd(), syncCmd(), readLogCmd(), diff --git a/cmd/bdrive/url.go b/cmd/bdrive/url.go new file mode 100644 index 0000000..0ba8075 --- /dev/null +++ b/cmd/bdrive/url.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/runbear-io/beardrive/internal/syncer" +) + +// urlCmd prints a file's internal hub link — the viewer URL colleagues with +// project access open after signing in. The counterpart to `bdrive share`: +// share mints a PUBLIC link, url points at the permission-walled viewer. +func urlCmd() *cobra.Command { + var doSync bool + c := &cobra.Command{ + Use: "url [path]", + Short: "Print a file's internal hub link (sign-in + membership required to view)", + Long: `Print the hub viewer URL for a file or folder in a bdrive project — the +link to hand teammates: it requires signing in to the hub and membership in +the project's organization, and always shows the latest synced content. +With no path (or "."), prints the project's home page URL. + +This is the internal counterpart to "bdrive share": share mints a public +URL anyone can open; url points at the permission-walled viewer. + +The link resolves once the file has synced — the daemon usually pushes +within seconds of saving; --sync pushes right now instead of waiting. + +Computed locally from the folder's config; no network unless --sync.`, + Example: ` bdrive url wiki/report.md + bdrive url wiki/report.md --sync # push first, so the link works immediately + bdrive url wiki/ # the folder's listing + bdrive url # the project's home page`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + target := "." + if len(args) == 1 { + target = args[0] + } + abs, err := filepath.Abs(target) + if err != nil { + return err + } + // findProject walks up from a directory; start at the target + // itself only when it is one. + start := filepath.Dir(abs) + if fi, err := os.Stat(abs); err == nil && fi.IsDir() { + start = abs + } + root, proj, err := findProject(start) + if err != nil { + return err + } + rel, err := filepath.Rel(root, abs) + if err != nil || strings.HasPrefix(rel, "..") { + return fmt.Errorf("%s is outside the project at %s", abs, root) + } + rel = filepath.ToSlash(rel) + if rel != "." { + // A link to something the project doesn't sync would 404 for + // everyone — refuse it here instead. + filter, err := syncer.LoadFilter(root, proj.Include) + if err != nil { + return err + } + if filter.Skip(rel) { + return fmt.Errorf("%s is not synced (ignored, or outside the project's shared scope)", rel) + } + } + server, projectID, err := splitHubRemote(proj.Remote) + if err != nil { + return err + } + if doSync { + sess, _, err := openSession(cmd.Context(), root, true) + if err != nil { + return err + } + defer closeSession(sess) + if _, err := sess.Cycle(cmd.Context()); err != nil { + return err + } + } + link := server + "/" + projectID + if rel != "." { + link += "/" + encodePathSegments(rel) + } + fmt.Fprintln(cmd.OutOrStdout(), link) + return nil + }, + } + c.Flags().BoolVar(&doSync, "sync", false, "run a sync first so a just-created file is pushed and the link resolves immediately") + return c +} + +// encodePathSegments percent-encodes each path segment while keeping the +// "/" separators literal, matching the viewer's routing (no %2F). +func encodePathSegments(p string) string { + segs := strings.Split(p, "/") + for i, s := range segs { + segs[i] = url.PathEscape(s) + } + return strings.Join(segs, "/") +} diff --git a/cmd/bdrive/url_test.go b/cmd/bdrive/url_test.go new file mode 100644 index 0000000..557acfd --- /dev/null +++ b/cmd/bdrive/url_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/runbear-io/beardrive/internal/config" +) + +// bdrive url computes the permission-walled viewer link locally: hub origin +// + project id from the mount's remote, path segments percent-encoded with +// literal "/" separators, unsynced paths refused. +func TestURLCommand(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + folder := t.TempDir() + folder, _ = filepath.EvalSymlinks(folder) + if _, err := config.SaveProject(folder, config.Project{ + Volume: "wiki", + Remote: "https://hub.example.com/p/p-12345678", + }); err != nil { + t.Fatal(err) + } + os.MkdirAll(filepath.Join(folder, "wiki notes"), 0o755) + os.WriteFile(filepath.Join(folder, "wiki notes", "a report.md"), []byte("x"), 0o644) + os.WriteFile(filepath.Join(folder, ".bdriveignore"), []byte("drafts/\n"), 0o644) + + run := func(args ...string) (string, error) { + c := urlCmd() + var out bytes.Buffer + c.SetOut(&out) + c.SetArgs(args) + err := c.Execute() + return strings.TrimSpace(out.String()), err + } + + // A file: segments encoded, slashes literal. + got, err := run(filepath.Join(folder, "wiki notes", "a report.md")) + if err != nil { + t.Fatal(err) + } + want := "https://hub.example.com/p-12345678/wiki%20notes/a%20report.md" + if got != want { + t.Fatalf("url = %q, want %q", got, want) + } + + // The project root: the home page. + if got, err = run(folder); err != nil || got != "https://hub.example.com/p-12345678" { + t.Fatalf("root url = %q, %v", got, err) + } + + // An ignored path is refused — the link would 404 for everyone. + if _, err = run(filepath.Join(folder, "drafts", "wip.md")); err == nil || !strings.Contains(err.Error(), "not synced") { + t.Fatalf("ignored path: err = %v, want 'not synced'", err) + } + + // Outside the project entirely. + if _, err = run(filepath.Join(t.TempDir(), "elsewhere.md")); err == nil { + t.Fatal("outside path should error") + } +} + +// A --shared style mount (include list) refuses links outside the scope. +func TestURLCommandIncludeScope(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + folder := t.TempDir() + folder, _ = filepath.EvalSymlinks(folder) + if _, err := config.SaveProject(folder, config.Project{ + Volume: "wiki", + Remote: "https://hub.example.com/p/p-12345678", + Include: []string{"wiki/"}, + }); err != nil { + t.Fatal(err) + } + os.MkdirAll(filepath.Join(folder, "wiki"), 0o755) + os.WriteFile(filepath.Join(folder, "wiki", "a.md"), []byte("x"), 0o644) + os.WriteFile(filepath.Join(folder, "code.go"), []byte("x"), 0o644) + + run := func(arg string) (string, error) { + c := urlCmd() + var out bytes.Buffer + c.SetOut(&out) + c.SetArgs([]string{arg}) + err := c.Execute() + return strings.TrimSpace(out.String()), err + } + if got, err := run(filepath.Join(folder, "wiki", "a.md")); err != nil || got != "https://hub.example.com/p-12345678/wiki/a.md" { + t.Fatalf("in-scope = %q, %v", got, err) + } + if _, err := run(filepath.Join(folder, "code.go")); err == nil || !strings.Contains(err.Error(), "not synced") { + t.Fatalf("out-of-scope: err = %v, want 'not synced'", err) + } +} diff --git a/plugin/commands/install.md b/plugin/commands/install.md index 997fa14..cd72457 100644 --- a/plugin/commands/install.md +++ b/plugin/commands/install.md @@ -71,7 +71,9 @@ this (adapt the folder name; create the file if missing): propagate to everyone within seconds and every change is tracked (who, when, which device). Read `wiki/AGENTS.md` before working there. Put shareable artifacts — reports, notes, plans — in `wiki/` so the team -sees them; never secrets (`bdrive share wiki/` mints public URLs). +sees them, and include the file's internal link in your reply +(`bdrive url wiki/` — teammates sign in to view). Never put +secrets here (`bdrive share wiki/` mints fully public URLs). ``` Point at the synced `AGENTS.md` rather than duplicating its conventions — @@ -114,4 +116,6 @@ that wasn't detected: `bdrive hooks install --agent claude,codex,gemini,hermes`. Run `bdrive status` and confirm the daemon is running and pending is 0. Then tell the user what was set up, and demonstrate the payoff: if they have (or you just generated) an HTML/PDF/markdown artifact in the synced -folder, run `bdrive share ` and hand them the URL. +folder, run `bdrive url ` and hand them the teammate link (sign-in +required — safe by default); mention `bdrive share ` exists for +fully public links when someone outside the hub needs it. diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 215d159..2dc1ec1 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -24,6 +24,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing | 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). **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. | +| Link a synced file for teammates | `bdrive url ` — 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 ` — 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 the two-file agent orientation (synced `/AGENTS.md` map + repo-root pointer), and registers agent sync hooks via `bdrive hooks install` (pull at turn start, push after edits, session-note stamping — for every detected platform, not just Claude) | | 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=` | @@ -97,7 +98,9 @@ Devices connecting the same project (by name or id) converge through the hub. A Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. **Hubs are invite-only by default** — the safe posture for a public URL. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/`); the teammate opens it and creates an account through the link (invites bootstrap signup even when public self-signup is closed), and is in. An admin can instead open self-service signup with a gate (admin approval, or allowed-domains + email verification) under **Admin → Signup & access** / the config's `auth` block. -A hub stores its metadata (accounts, projects, orgs, invites, shares, devices — never files or journals, which stay in object storage) in a database chosen by the config's `database` block: `{"driver":"file"}` (default, JSON under `$BDRIVE_HOME`), `{"driver":"sqlite","dsn":"…/hub.db"}`, or `{"driver":"postgres","dsn":"postgres://…"}` for a managed Postgres such as Supabase. file/sqlite are single-writer; Postgres backs multiple instances. If a teammate's `bdrive init --project ` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall. +A hub stores its metadata (accounts, projects, orgs, invites, shares, devices — never files or journals, which stay in object storage) in a database chosen by the config's `database` block: `{"driver":"file"}` (default, JSON under `$BDRIVE_HOME`), `{"driver":"sqlite","dsn":"…/hub.db"}`, or `{"driver":"postgres","dsn":"postgres://…"}` for a managed Postgres such as Supabase. file/sqlite are single-writer; Postgres backs multiple instances. If a teammate's `bdrive init --project ` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall; internal links (`bdrive url`) stay behind it — prefer them for teammates, and reserve `bdrive share` for people outside the hub. + +**Share what you make**: whenever you create a shareable artifact in the synced folder — a report, plan, analysis, or export (.md, .html, .csv, .pdf, …) — get its internal link with `bdrive url ` and include it in your reply. The sync hooks push within seconds so the link resolves almost immediately; use `bdrive url --sync` when the reader will click right away. Never mint a public `bdrive share` link for this unless the user asks for one. ### Renames and moves