mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
|
||||
logoutCmd(),
|
||||
initCmd(),
|
||||
shareCmd(),
|
||||
urlCmd(),
|
||||
stopCmd(),
|
||||
syncCmd(),
|
||||
readLogCmd(),
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user