diff --git a/.gitignore b/.gitignore index a76c44f..66e9dff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /bdrive -/bdrive-web /dist/ .DS_Store .omc diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f12c6fa..65a3a5f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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] diff --git a/CLAUDE.md b/CLAUDE.md index 7d83a33..6a7885a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-.pid` and log `daemon-.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 diff --git a/README.md b/README.md index c51f1bf..7ad8552 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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: diff --git a/cmd/bdrive-web/main.go b/cmd/bdrive-web/main.go deleted file mode 100644 index 1691a46..0000000 --- a/cmd/bdrive-web/main.go +++ /dev/null @@ -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" -} diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index e6759c4..dad512b 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -36,6 +36,7 @@ everything keeps working offline; changes sync when the remote is reachable.`, statusCmd(), logCmd(), remoteCmd(), + webCmd(), whoamiCmd(), daemonCmd(), versionCmd(), diff --git a/cmd/bdrive/web.go b/cmd/bdrive/web.go new file mode 100644 index 0000000..00966e3 --- /dev/null +++ b/cmd/bdrive/web.go @@ -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" +} diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 823c0a9..af7b25c 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -23,6 +23,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: mounting, unm | Change history | `bdrive log [] [-p path] [-n N]` | | Show / set remote | `bdrive remote []` · `bdrive remote set ` | | This device's identity | `bdrive whoami` | +| Web viewer (read-only, rendered markdown) | `bdrive web [ \| ]` (serves cwd by default, `--addr :4173`) | `` is created if missing. Omitting it on `sync`/`status`/`log` defaults to the current working directory.