feat: fold the web viewer into the CLI as bdrive web

One binary instead of two: cmd/bdrive-web becomes the `web` subcommand
(same flags and positional folder-or-URL argument). Measured cost of
carrying the webapp in the CLI: +1.4 MB on a ~56 MB binary (~2.4%) —
the cloud SDKs dominate either way. Drops the second goreleaser build
and the separate go install path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHEUaYfFHhmDvqLYw74Ehz
This commit is contained in:
Snow Lee
2026-07-07 16:06:26 -07:00
co-authored by Claude Fable 5
parent 1ac128db2c
commit fafb7fe24d
8 changed files with 125 additions and 145 deletions
-1
View File
@@ -1,5 +1,4 @@
/bdrive
/bdrive-web
/dist/
.DS_Store
.omc
+1 -14
View File
@@ -1,7 +1,7 @@
# Release automation: `goreleaser release` on a tagged commit builds
# macOS/Linux binaries and publishes the Homebrew formula to
# runbear-io/homebrew-tap, enabling `brew install runbear-io/tap/beardrive`.
# The product is BearDrive; the binaries keep their short names (bdrive, bdrive-web).
# The product is BearDrive; the binary keeps its short name (bdrive).
version: 2
project_name: beardrive
@@ -20,19 +20,6 @@ builds:
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
- id: bdrive-web
main: ./cmd/bdrive-web
binary: bdrive-web
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w
archives:
- formats: [tar.gz]
+3 -3
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**BearDrive** is the product name; **`bdrive`** is its CLI binary (file conventions use the full name: `.beardrive`, `.beardriveignore`, `~/.beardrive`, `BEARDRIVE_HOME`). BearDrive is a Go CLI that mounts any folder as a synced volume: contents sync across devices through cloud object storage (S3, GCS, S3-compatible, or a plain directory), with per-file change history and offline support. No server — devices converge through append-only journals in a dumb object store.
The repo ships two binaries from one Go module: `cmd/bdrive` (the CLI + sync daemon) and `cmd/bdrive-web` (a read-only web viewer for a remote).
The repo ships one binary: `cmd/bdrive` the CLI, the sync daemon, and the read-only web viewer (`bdrive web`).
## Commands
@@ -39,9 +39,9 @@ Package roles (`internal/`):
- **`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 (`.beardriveignore` rules + the `.beardrive` 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, pidfile `daemon-<mountID>.pid` and log `daemon-<mountID>.log` in the volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `mounts.json` each tick to pick up `bdrive remote set` / `umnt --forget` without restart.
- **`config`** — global state under `$BEARDRIVE_HOME` (default `~/.beardrive`): device identity (`device.json`), mount registry (`mounts.json`), `MountID()` (sha256 of the folder path — one volume can be mounted at several folders, and everything folder-specific is keyed by it). Also the per-folder `.beardrive` project file (`project.go`): volume/remote/include settings that live in the mounted folder itself, win over the registry (`EffectiveMount`), and are never synced.
- **`webapp`** — the `bdrive-web` server: a `Source` interface with two implementations — `DirSource` (serves a local folder straight from disk; the default when no remote is given) and `RemoteSource` (reads journals straight from the remote, no local store, folds them into a file tree with per-file provenance). Renders markdown (goldmark + Obsidian `[[wikilinks]]`), streams/downloads content. Frontend is dependency-free vanilla JS embedded via `go:embed static`.
- **`webapp`** — the `bdrive web` server: a `Source` interface with two implementations — `DirSource` (serves a local folder straight from disk; the default when no remote is given) and `RemoteSource` (reads journals straight from the remote, no local store, folds them into a file tree with per-file provenance). Renders markdown (goldmark + Obsidian `[[wikilinks]]`), streams/downloads content. Frontend is dependency-free vanilla JS embedded via `go:embed static`.
`cmd/bdrive/` is a thin cobra CLI over these packages (`mnt`, `umnt`, `sync`, `status`, `log`, `remote`, `whoami`, `daemon`, `version`); `cmd/bdrive-web/` wraps `webapp` with flags.
`cmd/bdrive/` is a thin cobra CLI over these packages (`mnt`, `umnt`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version`).
## Invariants — do not break these
+5 -10
View File
@@ -108,6 +108,7 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s
| `bdrive status [folder]` | Mounts, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: author, device, time, file |
| `bdrive remote [folder]` / `bdrive remote set <folder> <url>` | Show / set the cloud remote |
| `bdrive web [folder \| remote-url]` | Read-only web viewer (rendered markdown, downloads) |
| `bdrive whoami` | Device identity used in change tracking |
## Project files
@@ -135,14 +136,14 @@ already-synced file, the file stops syncing but is deleted nowhere.
## Web viewer
`bdrive-web` serves a read-only website for a folder or a BearDrive remote —
`bdrive web` serves a read-only website for a folder or a BearDrive remote —
browse folders and files, read markdown rendered Obsidian-style (including
`[[wikilinks]]`, task lists, and tables), and download any file.
```sh
bdrive-web # serve the current directory
bdrive-web ./notes # serve a folder from disk
bdrive-web s3://my-bucket/workspace # serve a BearDrive remote
bdrive web # serve the current directory
bdrive web ./notes # serve a folder from disk
bdrive web s3://my-bucket/workspace # serve a BearDrive remote
```
With no remote given it serves the folder straight from the local file
@@ -157,12 +158,6 @@ Flags: `--addr` (default `:4173`), `--volume` (display name), `--refresh`
(listing cache, default `10s`), `--dir` / `--remote` (explicit forms of
the positional argument).
Install alongside beardrive, or from source:
```sh
go install github.com/runbear-io/beardrive/cmd/bdrive-web@latest
```
## Claude Code plugin
Install beardrive support in Claude Code with two commands:
-117
View File
@@ -1,117 +0,0 @@
// bdrive-web serves a read-only website for a folder or a beardrive remote: browse
// folders and files, read rendered markdown (Obsidian-style, including
// [[wikilinks]]), and download any file.
//
// Two sources:
//
// - a local folder, served straight from disk (the default — on a beardrive
// mount the daemon keeps it fresh, so this is the simplest deployment);
// - a beardrive remote, read directly from the object store with per-file
// provenance from the journals — no mount, daemon, or local state needed.
//
// Examples:
//
// bdrive-web # serve the current directory
// bdrive-web ./notes # serve a folder
// bdrive-web s3://bucket/prefix # serve a beardrive remote
// bdrive-web --remote gs://bucket/prefix --addr :8080
package main
import (
"context"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/webapp"
)
func main() {
remoteURL := flag.String("remote", "", "bdrive remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
dir := flag.String("dir", "", "local folder to serve (default: current directory)")
addr := flag.String("addr", ":4173", "address to listen on")
volume := flag.String("volume", "", "volume display name (default: folder or remote basename)")
refresh := flag.Duration("refresh", 10*time.Second, "how long to cache the file listing")
flag.Parse()
// Positional argument: a URL selects remote mode, anything else is a
// folder. With nothing specified at all, serve the current directory.
if *remoteURL == "" && *dir == "" && flag.NArg() > 0 {
if arg := flag.Arg(0); strings.Contains(arg, "://") {
*remoteURL = arg
} else {
*dir = arg
}
}
if *remoteURL != "" && *dir != "" {
fmt.Fprintln(os.Stderr, "usage: bdrive-web [folder | remote-url] [--addr :4173] (--remote and --dir are mutually exclusive)")
os.Exit(2)
}
if *remoteURL == "" && *dir == "" {
*dir = "."
}
ctx := context.Background()
var src webapp.Source
var display, name string
if *dir != "" {
abs, err := filepath.Abs(*dir)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", abs)
os.Exit(1)
}
src = &webapp.DirSource{Root: abs}
display, name = abs, filepath.Base(abs)
} else {
be, err := remote.Open(ctx, *remoteURL)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
defer be.Close()
src = &webapp.RemoteSource{Backend: be}
display, name = *remoteURL, volumeName(*remoteURL)
}
if *volume != "" {
name = *volume
}
srv := &webapp.Server{
Source: src,
Remote: display,
Volume: name,
Refresh: *refresh,
}
shown := *addr
if strings.HasPrefix(shown, ":") {
shown = "localhost" + shown
}
fmt.Printf("bdrive-web serving %s\n volume: %s\n url: http://%s\n", display, name, shown)
if err := http.ListenAndServe(*addr, srv.Handler()); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func volumeName(remoteURL string) string {
if u, err := url.Parse(remoteURL); err == nil {
if base := path.Base(strings.Trim(u.Path, "/")); base != "" && base != "." {
return base
}
if u.Host != "" {
return u.Host
}
}
return "beardrive"
}
+1
View File
@@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
statusCmd(),
logCmd(),
remoteCmd(),
webCmd(),
whoamiCmd(),
daemonCmd(),
versionCmd(),
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/webapp"
)
func webCmd() *cobra.Command {
var remoteURL, dir, volume string
var addr string
var refresh time.Duration
c := &cobra.Command{
Use: "web [folder | remote-url]",
Short: "Serve a read-only web viewer for a folder or a remote",
Long: `Serve a read-only website: browse folders and files, read rendered
markdown (Obsidian-style, including [[wikilinks]]), and download any file.
Two sources:
- a local folder, served straight from disk (the default — on a mounted
folder the daemon keeps it fresh, so this is the simplest deployment);
- a BearDrive remote, read directly from the object store with per-file
provenance from the journals — no mount, daemon, or local state needed.`,
Example: ` bdrive web # serve the current directory
bdrive web ./notes # serve a folder
bdrive web s3://bucket/prefix # serve a remote
bdrive web --remote gs://bucket/prefix --addr :8080`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Positional argument: a URL selects remote mode, anything else
// is a folder. With nothing specified, serve the current dir.
if remoteURL == "" && dir == "" && len(args) > 0 {
if strings.Contains(args[0], "://") {
remoteURL = args[0]
} else {
dir = args[0]
}
}
if remoteURL != "" && dir != "" {
return fmt.Errorf("--remote and --dir are mutually exclusive")
}
if remoteURL == "" && dir == "" {
dir = "."
}
var src webapp.Source
var display, name string
if dir != "" {
abs, err := filepath.Abs(dir)
if err != nil {
return err
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return fmt.Errorf("%s is not a directory", abs)
}
src = &webapp.DirSource{Root: abs}
display, name = abs, filepath.Base(abs)
} else {
be, err := remote.Open(cmd.Context(), remoteURL)
if err != nil {
return err
}
defer be.Close()
src = &webapp.RemoteSource{Backend: be}
display, name = remoteURL, volumeName(remoteURL)
}
if volume != "" {
name = volume
}
srv := &webapp.Server{
Source: src,
Remote: display,
Volume: name,
Refresh: refresh,
}
shown := addr
if strings.HasPrefix(shown, ":") {
shown = "localhost" + shown
}
fmt.Printf("serving %s\n volume: %s\n url: http://%s\n", display, name, shown)
return http.ListenAndServe(addr, srv.Handler())
},
}
c.Flags().StringVarP(&remoteURL, "remote", "r", "", "remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
c.Flags().StringVar(&dir, "dir", "", "local folder to serve (default: current directory)")
c.Flags().StringVar(&addr, "addr", ":4173", "address to listen on")
c.Flags().StringVarP(&volume, "volume", "v", "", "volume display name (default: folder or remote basename)")
c.Flags().DurationVar(&refresh, "refresh", 10*time.Second, "how long to cache the file listing")
return c
}
func volumeName(remoteURL string) string {
if u, err := url.Parse(remoteURL); err == nil {
if base := path.Base(strings.Trim(u.Path, "/")); base != "" && base != "." {
return base
}
if u.Host != "" {
return u.Host
}
}
return "beardrive"
}
+1
View File
@@ -23,6 +23,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: mounting, unm
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
| Show / set remote | `bdrive remote [<folder>]` · `bdrive remote set <folder> <url>` |
| This device's identity | `bdrive whoami` |
| Web viewer (read-only, rendered markdown) | `bdrive web [<folder> \| <remote-url>]` (serves cwd by default, `--addr :4173`) |
`<folder>` is created if missing. Omitting it on `sync`/`status`/`log` defaults to the current working directory.