mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
1ac128db2c
commit
fafb7fe24d
@@ -1,5 +1,4 @@
|
||||
/bdrive
|
||||
/bdrive-web
|
||||
/dist/
|
||||
.DS_Store
|
||||
.omc
|
||||
|
||||
+1
-14
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
|
||||
statusCmd(),
|
||||
logCmd(),
|
||||
remoteCmd(),
|
||||
webCmd(),
|
||||
whoamiCmd(),
|
||||
daemonCmd(),
|
||||
versionCmd(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user