Merge pull request #23 from runbear-io/feat/internal-links

bdrive url: internal links agents share when they create files
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-14 11:55:52 -07:00
committed by GitHub
7 changed files with 233 additions and 6 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -2
View File
@@ -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 <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` 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
+1
View File
@@ -34,6 +34,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
logoutCmd(),
initCmd(),
shareCmd(),
urlCmd(),
stopCmd(),
syncCmd(),
readLogCmd(),
+109
View File
@@ -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, "/")
}
+95
View File
@@ -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)
}
}
+6 -2
View File
@@ -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/<file>` mints public URLs).
sees them, and include the file's internal link in your reply
(`bdrive url wiki/<file>` — teammates sign in to view). Never put
secrets here (`bdrive share wiki/<file>` 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 <file>` and hand them the URL.
folder, run `bdrive url <file>` and hand them the teammate link (sign-in
required — safe by default); mention `bdrive share <file>` exists for
fully public links when someone outside the hub needs it.
+4 -1
View File
@@ -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 <new-url>`, then re-run `bdrive init` in each folder. |
| Sign this device out | `bdrive logout` — clears the saved token + account (folders untouched); `--forget` also drops the remembered server. The device token stays valid server-side until it expires — revoke it from the hub's device list to be sure. |
| Link a synced file for teammates | `bdrive url <file>` — prints the file's hub viewer URL (sign-in + project membership required; always the latest content). Computed locally, no network; `--sync` pushes first so a just-created file's link resolves immediately; no arg = the project home page. **After creating a shareable artifact (.md/.html/.csv/report/plan) in the shared folder, include this link in your reply** so teammates can open it. |
| Share a synced file publicly by URL | `bdrive share <file>` — prints a link anyone can open (HTML renders as a page, markdown rendered, PDFs inline; sandboxed; always the latest content; no account needed). `--expires 24h` for self-destructing links; `--list` / `--revoke <token-or-url>` to manage. Put generated reports in the shared folder, sync, then share. |
| 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 `<shared>/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/<id>/history?path=\|prefix=`, `GET /api/p/<id>/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/<token>`); 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 <id>` 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 <id>` 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 <file>` and include it in your reply. The sync hooks push within seconds so the link resolves almost immediately; use `bdrive url <file> --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