rename: sfs -> BearDrive everywhere; CLI becomes bdrive

Product and project are BearDrive; the CLI binary is bdrive (bdrive mnt,
bdrive sync, ...), the web viewer is bdrive-web. All conventions follow
the full name: .beardrive settings file, .beardriveignore, ~/.beardrive
home, BEARDRIVE_HOME, .beardrive-conflict-* / .beardrive-tmp-* files.
Plugin/skill/marketplace renamed to beardrive (/beardrive:mount).

Module path is now github.com/runbear-io/beardrive — merge only after
renaming the GitHub repo, or go install breaks.

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 15:02:40 -07:00
co-authored by Claude Fable 5
parent 7f098b3ff5
commit 1ac128db2c
37 changed files with 336 additions and 335 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "sfs",
"name": "beardrive",
"owner": { "name": "runbear", "email": "snow@runbear.io" },
"description": "BearDrive (sfs) — a synced file system for AI agents",
"description": "BearDrive (beardrive) — a synced file system for AI agents",
"plugins": [
{
"name": "sfs",
"name": "beardrive",
"source": "./plugin",
"description": "BearDrive: mount folders that stay in sync across devices through S3/GCS/any object store. Installs the sfs skill, /sfs:mount and /sfs:status commands, and turn-boundary sync hooks in one step.",
"description": "BearDrive: mount folders that stay in sync across devices through S3/GCS/any object store. Installs the beardrive skill, /beardrive:mount and /beardrive:status commands, and turn-boundary sync hooks in one step.",
"category": "workflow",
"tags": ["sync", "files", "workspace", "s3", "gcs", "agents"]
}
+1 -1
View File
@@ -4,7 +4,7 @@
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(go vet:*)",
"Bash(go run ./cmd/sfs:*)",
"Bash(go run ./cmd/bdrive:*)",
"Bash(gofmt:*)",
"Bash(go mod tidy)",
"Bash(go doc:*)",
+1
View File
@@ -0,0 +1 @@
../../plugin/skills/beardrive
-1
View File
@@ -1 +0,0 @@
../../plugin/skills/sfs
+2 -1
View File
@@ -1,4 +1,5 @@
/sfs
/bdrive
/bdrive-web
/dist/
.DS_Store
.omc
+9 -9
View File
@@ -1,15 +1,15 @@
# 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 (sfs, sfs-web).
# The product is BearDrive; the binaries keep their short names (bdrive, bdrive-web).
version: 2
project_name: beardrive
builds:
- id: sfs
main: ./cmd/sfs
binary: sfs
- id: beardrive
main: ./cmd/bdrive
binary: bdrive
env:
- CGO_ENABLED=0
goos:
@@ -20,9 +20,9 @@ builds:
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
- id: sfs-web
main: ./cmd/sfs-web
binary: sfs-web
- id: bdrive-web
main: ./cmd/bdrive-web
binary: bdrive-web
env:
- CGO_ENABLED=0
goos:
@@ -46,11 +46,11 @@ brews:
repository:
owner: runbear-io
name: homebrew-tap
homepage: https://github.com/runbear-io/sfs
homepage: https://github.com/runbear-io/beardrive
description: "BearDrive: a synced file system for AI agents — mount, sync, and track folders"
license: MIT
test: |
assert_match "sfs", shell_output("#{bin}/sfs version")
assert_match "beardrive", shell_output("#{bin}/bdrive version")
changelog:
sort: asc
+15 -15
View File
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
**BearDrive** is the product name; `sfs` is its CLI (and the historical project name — binary names, `.sfs`/`.sfsignore` files, and `~/.sfs` stay `sfs` deliberately). `sfs` 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.
**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/sfs` (the CLI + sync daemon) and `cmd/sfs-web` (a read-only web viewer for a remote).
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).
## Commands
@@ -15,19 +15,19 @@ go build ./... # build everything
go test ./... # run all tests
go test ./internal/syncer -run TestConflict -v # run a single test
go vet ./... # vet
go build -o sfs ./cmd/sfs # build the binary (gitignored at repo root)
go build -o bdrive ./cmd/bdrive # build the binary (gitignored at repo root)
```
There is no Makefile, linter config, or CI config in-repo. Releases run `goreleaser release` on a tagged commit (see `.goreleaser.yaml`); the version is injected via `-ldflags "-X main.version=..."` into `cmd/sfs/main.go`.
There is no Makefile, linter config, or CI config in-repo. Releases run `goreleaser release` on a tagged commit (see `.goreleaser.yaml`); the version is injected via `-ldflags "-X main.version=..."` into `cmd/bdrive/main.go`.
When testing the CLI manually, set `SFS_HOME=/some/tmp/dir` to relocate all sfs state (device identity, mount registry, volume stores) away from the real `~/.sfs`.
When testing the CLI manually, set `BEARDRIVE_HOME=/some/tmp/dir` to relocate all beardrive state (device identity, mount registry, volume stores) away from the real `~/.beardrive`.
## Architecture
Data flows in two hops; the local volume store is the pivot:
```
working folder ←scan/materialize→ volume store (~/.sfs/volumes/<vol>) ←push/pull→ object store
working folder ←scan/materialize→ volume store (~/.beardrive/volumes/<vol>) ←push/pull→ object store
(real files) blobs/ + journal/ + state + sync s3:// gs:// file://
```
@@ -36,12 +36,12 @@ Package roles (`internal/`):
- **`journal`** — the core data model. Every change is an `Op` (`put`/`delete`) in a per-device append-only JSONL log. `Less` defines the total order `(lamport, time, device, seq)`; `Replay` folds all ops into the volume state, last-writer-wins per path. Everything else is machinery around this.
- **`store`** — a volume's local on-disk state: content-addressed blob store (`blobs/<aa>/<sha256>`), per-device journal copies, the per-mount materialization cache (`state-<mountID>.json`, size+mtime fingerprints for cheap change detection), sync state (lamport clock + push cursor), and the exclusive flock that serializes cycles.
- **`remote`** — the `Backend` interface (Put/Get/List/Exists) with `file://`, `s3://`, `gs://` implementations. Remote layout: `blobs/<sha256>` + `journal/<device>.jsonl` under the URL prefix.
- **`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 (`.sfsignore` rules + the `.sfs` 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 `sfs remote set` / `umnt --forget` without restart.
- **`config`** — global state under `$SFS_HOME` (default `~/.sfs`): 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 `.sfs` 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 `sfs-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`.
- **`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`.
`cmd/sfs/` is a thin cobra CLI over these packages (`mnt`, `umnt`, `sync`, `status`, `log`, `remote`, `whoami`, `daemon`, `version`); `cmd/sfs-web/` wraps `webapp` with flags.
`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.
## Invariants — do not break these
@@ -50,8 +50,8 @@ Package roles (`internal/`):
- **Scan happens before pull** in `Cycle`, so local edits are journaled (and content captured) before remote state can overwrite the working folder.
- **Replay must stay deterministic.** Any change to `journal.Less` or `Replay` changes what every device converges to.
- **Materialize never clobbers dirty files**: a file whose size/mtime differs from the state cache changed mid-cycle and is left for the next scan.
- **All state files are written atomically** (temp file + rename, see `store.WriteFileAtomic`). Temp files are prefixed `.sfs-tmp-` and ignored by the scanner.
- **`Cycle` runs under the volume flock** — the daemon and one-shot CLI commands (`sfs sync`) coexist through it.
- **All state files are written atomically** (temp file + rename, see `store.WriteFileAtomic`). Temp files are prefixed `.beardrive-tmp-` and ignored by the scanner.
- **`Cycle` runs under the volume flock** — the daemon and one-shot CLI commands (`bdrive sync`) coexist through it.
- Errors during pull/push degrade to `Result.Offline` rather than failing the cycle; unreadable/vanished files during scan are skipped and retried next cycle. Follow this "never break sync, retry next cycle" posture.
## Testing conventions
@@ -60,8 +60,8 @@ The real coverage is the integration tests in `internal/syncer/syncer_test.go`:
## Claude Code plugin
`plugin/` is a Claude Code plugin (skill + `/sfs:mount` + `/sfs:status` commands + turn-boundary sync hooks), published via the marketplace manifest at `.claude-plugin/marketplace.json` (`/plugin marketplace add runbear-io/sfs`). The canonical skill lives at `plugin/skills/sfs/SKILL.md`; `.claude/skills/sfs` is a symlink to it. The hook script `plugin/scripts/sfs-sync.sh` must stay a fast no-op for folders without a `.sfs` file — it runs on every turn in every project.
`plugin/` is a Claude Code plugin (skill + `/beardrive:mount` + `/beardrive:status` commands + turn-boundary sync hooks), published via the marketplace manifest at `.claude-plugin/marketplace.json` (`/plugin marketplace add runbear-io/beardrive`). The canonical skill lives at `plugin/skills/beardrive/SKILL.md`; `.claude/skills/beardrive` is a symlink to it. The hook script `plugin/scripts/beardrive-sync.sh` must stay a fast no-op for folders without a `.beardrive` file — it runs on every turn in every project.
## Docs to keep in sync
- `README.md` and `plugin/skills/sfs/SKILL.md` both document CLI behavior, flags, output formats, and the on-disk layout. When changing CLI commands, flags, output, or layout, update both — the skill is what makes Claude Code sfs-aware for end users and must match the actual binary.
- `README.md` and `plugin/skills/beardrive/SKILL.md` both document CLI behavior, flags, output formats, and the on-disk layout. When changing CLI commands, flags, output, or layout, update both — the skill is what makes Claude Code beardrive-aware for end users and must match the actual binary.
+54 -54
View File
@@ -3,7 +3,7 @@
**BearDrive** mounts any folder as a synced volume: its contents stay
synchronized across all your devices through cloud object storage, every
change is tracked (who, when, on which device), and everything keeps
working offline. The CLI is `sfs` — short for synced file system.
working offline. The CLI is `bdrive`.
It is built for AI agent workflows — give your agents on every machine the
same `~/agent-workspace`, and notes, plans, memory files, and artifacts
@@ -11,7 +11,7 @@ follow them everywhere, with a full audit trail of which agent or human
changed what.
```console
$ sfs mnt ./workspace --remote s3://my-bucket/workspace
$ bdrive mnt ./workspace --remote s3://my-bucket/workspace
mounted /Users/snow/workspace
volume: workspace
remote: s3://my-bucket/workspace
@@ -22,19 +22,19 @@ mounted /Users/snow/workspace
On another machine:
```console
$ sfs mnt ./workspace --remote s3://my-bucket/workspace
$ bdrive mnt ./workspace --remote s3://my-bucket/workspace
# … the same files appear, and stay in sync from now on
```
## Features
- **Mount anywhere** — `sfs mnt ./folder` turns any folder into a synced
- **Mount anywhere** — `bdrive mnt ./folder` turns any folder into a synced
volume. Files are *real files on disk*: every tool, editor, and agent can
use them with zero integration work.
- **Multi-device sync** — devices converge through a shared remote. Each
device only writes its own append-only journal, so no locking service or
server is needed — any object store works.
- **Change tracking** — `sfs log` shows which device and author changed
- **Change tracking** — `bdrive log` shows which device and author changed
which file, when. Content is stored content-addressed, so history is
never lost, even for overwritten or deleted files.
- **Cloud-provider agnostic** — Amazon S3 (`s3://`), Google Cloud Storage
@@ -45,52 +45,52 @@ $ sfs mnt ./workspace --remote s3://my-bucket/workspace
reachable again.
- **Conflict-safe** — concurrent edits resolve deterministically
(last-writer-wins), and the losing version is preserved as a
`name.sfs-conflict-<device>-<time>` file. Nothing is silently dropped.
- **Selective sync** — a gitignore-style `.sfsignore` opts files out, and an
optional `include` list in the folder's `.sfs` settings narrows sync to
`name.beardrive-conflict-<device>-<time>` file. Nothing is silently dropped.
- **Selective sync** — a gitignore-style `.beardriveignore` opts files out, and an
optional `include` list in the folder's `.beardrive` settings narrows sync to
chosen paths.
- **macOS & Linux.**
## Install
```sh
brew install runbear-io/tap/beardrive # macOS (and Linuxbrew); installs the `sfs` CLI
brew install runbear-io/tap/beardrive # macOS (and Linuxbrew); installs the `bdrive` CLI
```
or from source:
```sh
go install github.com/runbear-io/sfs/cmd/sfs@latest
go install github.com/runbear-io/beardrive/cmd/bdrive@latest
```
## Quick start
```sh
# 1. Mount a folder, syncing through S3 (or gs://, or file://)
sfs mnt ./notes --remote s3://my-bucket/notes
bdrive mnt ./notes --remote s3://my-bucket/notes
# 2. Work normally — create, edit, delete files with any tool.
echo "remember this" > notes/memory.md
# 3. On every other device, mount the same remote:
sfs mnt ./notes --remote s3://my-bucket/notes
bdrive mnt ./notes --remote s3://my-bucket/notes
# See what changed, who changed it, and from which device
sfs log ./notes
bdrive log ./notes
# Check sync state and the daemon
sfs status
bdrive status
# Sync on demand (the daemon also syncs automatically)
sfs sync ./notes
bdrive sync ./notes
# Stop syncing (files stay on disk; mount again any time)
sfs umnt ./notes
bdrive umnt ./notes
```
### Credentials
sfs uses each provider's standard credential chain — nothing sfs-specific:
beardrive uses each provider's standard credential chain — nothing beardrive-specific:
| Remote | Credentials |
|---|---|
@@ -102,31 +102,31 @@ sfs uses each provider's standard credential chain — nothing sfs-specific:
| Command | Description |
|---|---|
| `sfs mnt <folder> [--remote URL]` | Mount a folder as a synced volume and start the sync daemon |
| `sfs umnt <folder>` | Stop syncing (`--forget` also unregisters the mount) |
| `sfs sync [folder]` | Run one sync cycle now |
| `sfs status [folder]` | Mounts, daemon state, pending changes |
| `sfs log [folder] [-p path] [-n N]` | Change history: author, device, time, file |
| `sfs remote [folder]` / `sfs remote set <folder> <url>` | Show / set the cloud remote |
| `sfs whoami` | Device identity used in change tracking |
| `bdrive mnt <folder> [--remote URL]` | Mount a folder as a synced volume and start the sync daemon |
| `bdrive umnt <folder>` | Stop syncing (`--forget` also unregisters the mount) |
| `bdrive sync [folder]` | Run one sync cycle now |
| `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 whoami` | Device identity used in change tracking |
## Project files
Each mounted folder carries its own settings, so configuration travels with
the project:
- **`.sfs`** — the folder's settings (JSON): `volume`, `remote`, and an
optional `include` list. Written by `sfs mnt`, safe to hand-edit (a running
- **`.beardrive`** — the folder's settings (JSON): `volume`, `remote`, and an
optional `include` list. Written by `bdrive mnt`, safe to hand-edit (a running
daemon picks changes up automatically). Never synced — remotes are
device-specific. Copy a folder containing `.sfs` to another machine and
plain `sfs mnt <folder>` reuses its volume and remote.
- **`.sfsignore`** — gitignore-style opt-out list at the mount root. Syncs
device-specific. Copy a folder containing `.beardrive` to another machine and
plain `bdrive mnt <folder>` reuses its volume and remote.
- **`.beardriveignore`** — gitignore-style opt-out list at the mount root. Syncs
like a normal file, so every device shares the same rules. Supports `#`
comments, `*`, `**`, `?`, trailing `/` for directories, leading `/` (or any
`/`) for root-anchoring, and `!` to re-include.
```jsonc
// .sfs
// .beardrive
{ "volume": "notes", "remote": "s3://my-bucket/notes", "include": ["docs/", "*.md"] }
```
@@ -135,53 +135,53 @@ already-synced file, the file stops syncing but is deleted nowhere.
## Web viewer
`sfs-web` serves a read-only website for a folder or an sfs 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
sfs-web # serve the current directory
sfs-web ./notes # serve a folder from disk
sfs-web s3://my-bucket/workspace # serve an sfs 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
system — on an sfs mount the daemon keeps those files fresh, so this is
system — on a BearDrive mount the daemon keeps those files fresh, so this is
the simplest way to run it in production (and needs no cloud credentials
on the serving machine). Pointing it at a remote instead reads the object
store directly — no mount, daemon, or local sfs state — and each file
store directly — no mount, daemon, or local beardrive state — and each file
shows who changed it last, from which device, and when: the same
provenance as `sfs log`.
provenance as `bdrive log`.
Flags: `--addr` (default `:4173`), `--volume` (display name), `--refresh`
(listing cache, default `10s`), `--dir` / `--remote` (explicit forms of
the positional argument).
Install alongside sfs, or from source:
Install alongside beardrive, or from source:
```sh
go install github.com/runbear-io/sfs/cmd/sfs-web@latest
go install github.com/runbear-io/beardrive/cmd/bdrive-web@latest
```
## Claude Code plugin
Install sfs support in Claude Code with two commands:
Install beardrive support in Claude Code with two commands:
```
/plugin marketplace add runbear-io/sfs
/plugin install sfs@sfs
/plugin marketplace add runbear-io/beardrive
/plugin install beardrive@beardrive
```
The plugin sets up everything at once:
- **`/sfs:mount [folder] [remote]`** — one command that installs sfs if
needed, mounts the folder (daemon + `.sfs` config), and verifies the sync.
`/sfs:status` diagnoses problems.
- **`/beardrive:mount [folder] [remote]`** — one command that installs beardrive if
needed, mounts the folder (daemon + `.beardrive` config), and verifies the sync.
`/beardrive:status` diagnoses problems.
- **Turn-boundary sync hooks**, registered automatically: a blocking pull
when you send a message (Claude always reads fresh files) and an async
push when the turn ends. The hook no-ops instantly in folders that aren't
sfs mounts, so it's safe globally.
- **The `sfs` skill** ([plugin/skills/sfs](plugin/skills/sfs/SKILL.md)),
beardrive mounts, so it's safe globally.
- **The `beardrive` skill** ([plugin/skills/beardrive](plugin/skills/beardrive/SKILL.md)),
covering mount/unmount/sync, backends and credentials, selective sync, and
troubleshooting. Working in a clone of this repo picks the same skill up
automatically via `.claude/skills/`.
@@ -190,7 +190,7 @@ The plugin sets up everything at once:
```
working folder ←materialize/scan→ local volume store ←push/pull→ object store
(real files) ~/.sfs/volumes/<vol> s3:// gs:// file://
(real files) ~/.beardrive/volumes/<vol> s3:// gs:// file://
├─ blobs/ content-addressed (sha256)
├─ journal/ one append-only op log per device
├─ state.json what's materialized
@@ -211,18 +211,18 @@ working folder ←materialize/scan→ local volume store ←push/pull→ obj
- A per-mount **daemon** scans the folder every few seconds (cheap
size+mtime check) and exchanges with the remote every ~10s — or
immediately after local edits. Tune with --scan-interval and
--remote-interval on `sfs mnt`.
--remote-interval on `bdrive mnt`.
### What sfs does not sync
### What beardrive does not sync
`.git` directories (per-file LWW would corrupt repositories), `.DS_Store`,
the `.sfs` settings file, its own temp files, and anything excluded by
`.sfsignore` or omitted from an `include` list. Empty directories are not
the `.beardrive` settings file, its own temp files, and anything excluded by
`.beardriveignore` or omitted from an `include` list. Empty directories are not
tracked (like git).
## Roadmap
- `sfs restore <path>@<time>` — restore any file from history (all content
- `beardrive restore <path>@<time>` — restore any file from history (all content
is already retained)
- FUSE/NFS mount mode for lazy-loading huge volumes
- Journal compaction & blob GC policies
@@ -237,7 +237,7 @@ go test ./...
The integration tests in `internal/syncer` simulate multiple devices syncing
through a `file://` remote, including offline operation and concurrent-edit
conflicts. Set `SFS_HOME` to relocate all sfs state (used heavily in tests).
conflicts. Set `BEARDRIVE_HOME` to relocate all beardrive state (used heavily in tests).
## License
+13 -13
View File
@@ -1,20 +1,20 @@
// sfs-web serves a read-only website for a folder or an sfs remote: browse
// 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 an sfs
// - a local folder, served straight from disk (the default — on a beardrive
// mount the daemon keeps it fresh, so this is the simplest deployment);
// - an sfs remote, read directly from the object store with per-file
// - a beardrive remote, read directly from the object store with per-file
// provenance from the journals — no mount, daemon, or local state needed.
//
// Examples:
//
// sfs-web # serve the current directory
// sfs-web ./notes # serve a folder
// sfs-web s3://bucket/prefix # serve an sfs remote
// sfs-web --remote gs://bucket/prefix --addr :8080
// 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 (
@@ -29,12 +29,12 @@ import (
"strings"
"time"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/webapp"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/webapp"
)
func main() {
remoteURL := flag.String("remote", "", "sfs remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
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)")
@@ -51,7 +51,7 @@ func main() {
}
}
if *remoteURL != "" && *dir != "" {
fmt.Fprintln(os.Stderr, "usage: sfs-web [folder | remote-url] [--addr :4173] (--remote and --dir are mutually exclusive)")
fmt.Fprintln(os.Stderr, "usage: bdrive-web [folder | remote-url] [--addr :4173] (--remote and --dir are mutually exclusive)")
os.Exit(2)
}
if *remoteURL == "" && *dir == "" {
@@ -97,7 +97,7 @@ func main() {
if strings.HasPrefix(shown, ":") {
shown = "localhost" + shown
}
fmt.Printf("sfs-web serving %s\n volume: %s\n url: http://%s\n", display, name, 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)
@@ -113,5 +113,5 @@ func volumeName(remoteURL string) string {
return u.Host
}
}
return "sfs"
return "beardrive"
}
+8 -8
View File
@@ -7,10 +7,10 @@ import (
"github.com/spf13/cobra"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/daemon"
"github.com/runbear-io/sfs/internal/journal"
"github.com/runbear-io/sfs/internal/syncer"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/daemon"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/syncer"
)
func syncCmd() *cobra.Command {
@@ -56,12 +56,12 @@ func statusCmd() *cobra.Command {
}
mi, ok := mounts[folder]
if !ok {
return fmt.Errorf("%s is not an sfs mount", folder)
return fmt.Errorf("%s is not a beardrive mount", folder)
}
mounts = map[string]config.MountInfo{folder: mi}
}
if len(mounts) == 0 {
fmt.Println("no sfs mounts (create one with `sfs mnt <folder>`)")
fmt.Println("no beardrive mounts (create one with `bdrive mnt <folder>`)")
return nil
}
dev, err := config.LoadDevice()
@@ -76,7 +76,7 @@ func statusCmd() *cobra.Command {
}
first = false
if eff, _, found, err := config.EffectiveMount(folder); err == nil && found {
mi = eff // .sfs project file wins over the registry
mi = eff // .beardrive project file wins over the registry
}
fmt.Printf("%s\n", folder)
fmt.Printf(" volume: %s\n", mi.Volume)
@@ -228,7 +228,7 @@ func remoteCmd() *cobra.Command {
return err
}
fmt.Printf("remote of %s set to %s\n", folder, raw)
fmt.Println("run `sfs sync` to sync now (a running daemon picks it up automatically)")
fmt.Println("run `bdrive sync` to sync now (a running daemon picks it up automatically)")
return nil
},
}
+8 -8
View File
@@ -6,10 +6,10 @@ import (
"os"
"path/filepath"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/store"
"github.com/runbear-io/sfs/internal/syncer"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
func absFolder(args []string) (string, error) {
@@ -20,8 +20,8 @@ func absFolder(args []string) (string, error) {
return filepath.Abs(arg)
}
// mustMount resolves a folder's settings: the .sfs project file wins over
// the global registry, so a folder that carries its own .sfs works even
// mustMount resolves a folder's settings: the .beardrive project file wins over
// the global registry, so a folder that carries its own .beardrive works even
// before it is registered on this device.
func mustMount(folder string) (config.MountInfo, error) {
mi, _, found, err := config.EffectiveMount(folder)
@@ -29,7 +29,7 @@ func mustMount(folder string) (config.MountInfo, error) {
return mi, err
}
if !found {
return mi, fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
return mi, fmt.Errorf("%s is not a beardrive mount (run `bdrive mnt %s` first)", folder, folder)
}
if mi.Volume == "" {
mi.Volume = filepath.Base(folder)
@@ -92,7 +92,7 @@ func printCycle(res *syncer.Result) {
fmt.Printf(" local changes: %d\n", res.LocalOps)
fmt.Printf(" pulled changes: %d\n", res.PulledOps)
if res.Conflicts > 0 {
fmt.Printf(" conflicts: %d (preserved as *.sfs-conflict-* files)\n", res.Conflicts)
fmt.Printf(" conflicts: %d (preserved as *.beardrive-conflict-* files)\n", res.Conflicts)
}
fmt.Printf(" files updated: %d\n", res.Materialized)
switch {
+8 -8
View File
@@ -1,4 +1,4 @@
// sfs is a syncing file system for AI agents: mount a folder, and its
// bdrive is the BearDrive CLI: mount a folder, and its
// contents stay synchronized across devices through cloud object storage,
// with full per-file change history and offline support.
package main
@@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/beardrive/internal/config"
)
// version is set at release time via -ldflags "-X main.version=...".
@@ -17,12 +17,12 @@ var version = "0.1.0-dev"
func main() {
root := &cobra.Command{
Use: "sfs",
Use: "bdrive",
Short: "BearDrive: a synced file system for AI agents",
Long: `sfs the BearDrive CLI. A mountable, offline-first, synced file
Long: `bdrive the BearDrive CLI. A mountable, offline-first, synced file
system for AI agents.
Mount any folder and sfs keeps it synchronized across your devices through
Mount any folder and BearDrive keeps it synchronized across your devices through
cloud object storage (Amazon S3, Google Cloud Storage, or a plain shared
directory). Every change is journaled you can always see which device and
author changed which file, and when. Files are real files on disk, so
@@ -48,9 +48,9 @@ everything keeps working offline; changes sync when the remote is reachable.`,
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the sfs version",
Short: "Print the bdrive version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("sfs", version)
fmt.Println("beardrive", version)
},
}
}
@@ -71,7 +71,7 @@ func whoamiCmd() *cobra.Command {
fmt.Printf("device id: %s\n", dev.ID)
fmt.Printf("device name: %s\n", dev.Name)
fmt.Printf("author: %s\n", dev.Author)
fmt.Printf("sfs home: %s\n", home)
fmt.Printf("beardrive home: %s\n", home)
return nil
},
}
+17 -17
View File
@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/daemon"
"github.com/runbear-io/sfs/internal/store"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/daemon"
"github.com/runbear-io/beardrive/internal/store"
)
func mntCmd() *cobra.Command {
@@ -20,17 +20,17 @@ func mntCmd() *cobra.Command {
c := &cobra.Command{
Use: "mnt <folder>",
Aliases: []string{"mount"},
Short: "Mount a folder as a synced sfs volume",
Long: `Mount a folder as a synced sfs volume.
Short: "Mount a folder as a synced beardrive volume",
Long: `Mount a folder as a synced beardrive volume.
Existing files in the folder are imported into the volume. If a remote is
configured (--remote, or previously via "sfs remote set"), the volume syncs
configured (--remote, or previously via "bdrive remote set"), the volume syncs
with it and with every other device mounting the same remote. A background
daemon keeps the folder in sync until "sfs umnt".`,
Example: ` sfs mnt ./notes
sfs mnt ./notes --remote s3://my-bucket/notes
sfs mnt ./notes --remote gs://my-bucket/notes
sfs mnt ./shared --remote file:///Volumes/nas/sfs/shared`,
daemon keeps the folder in sync until "bdrive umnt".`,
Example: ` bdrive mnt ./notes
bdrive mnt ./notes --remote s3://my-bucket/notes
bdrive mnt ./notes --remote gs://my-bucket/notes
bdrive mnt ./shared --remote file:///Volumes/nas/beardrive/shared`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
@@ -41,7 +41,7 @@ daemon keeps the folder in sync until "sfs umnt".`,
return err
}
// Settings resolution: flags win, then the folder's .sfs file,
// Settings resolution: flags win, then the folder's .beardrive file,
// then the global registry. The result is written back to both,
// so the project file travels with the folder and the registry
// knows which mounts are active on this device.
@@ -112,10 +112,10 @@ daemon keeps the folder in sync until "sfs umnt".`,
if mi.Remote != "" {
fmt.Printf(" remote: %s\n", mi.Remote)
} else {
fmt.Printf(" remote: (none — local only; set one with `sfs remote set %s <url>`)\n", folder)
fmt.Printf(" remote: (none — local only; set one with `bdrive remote set %s <url>`)\n", folder)
}
fmt.Printf(" device: %s (%s) as %s\n", dev.Name, dev.ID, dev.Author)
fmt.Printf(" config: %s (volume/remote/include; add a .sfsignore next to it to exclude paths)\n",
fmt.Printf(" config: %s (volume/remote/include; add a .beardriveignore next to it to exclude paths)\n",
filepath.Join(folder, config.ProjectFile))
printCycle(res)
@@ -145,10 +145,10 @@ func umntCmd() *cobra.Command {
Aliases: []string{"umount", "unmount"},
Short: "Stop syncing a mounted folder",
Long: `Stop the sync daemon for a folder. Files stay on disk and the volume's
history is kept; "sfs mnt" the folder again to resume syncing.
history is kept; "bdrive mnt" the folder again to resume syncing.
With --forget the folder is also removed from the mount registry (local
volume data under ~/.sfs/volumes is still kept).`,
volume data under ~/.beardrive/volumes is still kept).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
@@ -181,7 +181,7 @@ volume data under ~/.sfs/volumes is still kept).`,
if err := config.SaveMounts(mounts); err != nil {
return err
}
fmt.Printf("forgot mount %s (volume %q kept under ~/.sfs/volumes)\n", folder, mi.Volume)
fmt.Printf("forgot mount %s (volume %q kept under ~/.beardrive/volumes)\n", folder, mi.Volume)
}
return nil
},
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/runbear-io/sfs
module github.com/runbear-io/beardrive
go 1.25.8
+6 -6
View File
@@ -1,5 +1,5 @@
// Package config manages sfs's global state under the sfs home directory
// (default ~/.sfs, overridable with $SFS_HOME): the device identity and the
// Package config manages beardrive's global state under the beardrive home directory
// (default ~/.beardrive, overridable with $BEARDRIVE_HOME): the device identity and the
// registry of mounted folders.
package config
@@ -15,16 +15,16 @@ import (
"strings"
)
// Home returns the sfs home directory ($SFS_HOME or ~/.sfs).
// Home returns the beardrive home directory ($BEARDRIVE_HOME or ~/.beardrive).
func Home() (string, error) {
if h := os.Getenv("SFS_HOME"); h != "" {
if h := os.Getenv("BEARDRIVE_HOME"); h != "" {
return h, nil
}
uh, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(uh, ".sfs"), nil
return filepath.Join(uh, ".beardrive"), nil
}
// Device identifies this machine and its operator in journals.
@@ -160,7 +160,7 @@ func writeJSON(path string, v any) error {
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".sfs-tmp-*")
tmp, err := os.CreateTemp(filepath.Dir(path), ".beardrive-tmp-*")
if err != nil {
return err
}
+8 -8
View File
@@ -8,23 +8,23 @@ import (
)
// ProjectFile is the name of the per-folder settings file at the mount root.
// It travels with the project (copy the folder, `sfs mnt .`, and the same
// It travels with the project (copy the folder, `bdrive mnt .`, and the same
// volume/remote apply) but is never synced — remotes and credentials setups
// are often device-specific, and syncing it would let one device silently
// repoint another.
const ProjectFile = ".sfs"
const ProjectFile = ".beardrive"
// Project holds the settings stored in <folder>/.sfs.
// Project holds the settings stored in <folder>/.beardrive.
type Project struct {
Volume string `json:"volume,omitempty"`
Remote string `json:"remote,omitempty"`
// Include optionally narrows what syncs: when non-empty, only paths
// matching one of these patterns (gitignore-style, same syntax as
// .sfsignore) are scanned and materialized.
// .beardriveignore) are scanned and materialized.
Include []string `json:"include,omitempty"`
}
// LoadProject reads <folder>/.sfs; ok is false if the file does not exist.
// LoadProject reads <folder>/.beardrive; ok is false if the file does not exist.
func LoadProject(folder string) (Project, bool, error) {
var p Project
data, err := os.ReadFile(filepath.Join(folder, ProjectFile))
@@ -40,14 +40,14 @@ func LoadProject(folder string) (Project, bool, error) {
return p, true, nil
}
// SaveProject writes <folder>/.sfs.
// SaveProject writes <folder>/.beardrive.
func SaveProject(folder string, p Project) error {
return writeJSON(filepath.Join(folder, ProjectFile), p)
}
// EffectiveMount resolves a folder's mount settings: the project file wins
// over the global registry, so hand-edits to .sfs (or a folder copied with
// its .sfs) take effect without re-registering. Found reports whether the
// over the global registry, so hand-edits to .beardrive (or a folder copied with
// its .beardrive) take effect without re-registering. Found reports whether the
// folder is known at all (registered or carrying a project file).
func EffectiveMount(folder string) (mi MountInfo, proj Project, found bool, err error) {
mounts, err := LoadMounts()
+8 -8
View File
@@ -20,10 +20,10 @@ import (
"syscall"
"time"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/store"
"github.com/runbear-io/sfs/internal/syncer"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
// Daemons are per mount, not per volume: one volume may be mounted at
@@ -106,7 +106,7 @@ func Stop(volDir, mountID string) (bool, error) {
return true, nil
}
// overlayProject applies the folder's .sfs settings on top of the registry
// overlayProject applies the folder's .beardrive settings on top of the registry
// entry; the project file wins so hand-edits take effect on the next tick.
func overlayProject(folder string, mi config.MountInfo) config.MountInfo {
proj, ok, err := config.LoadProject(folder)
@@ -123,7 +123,7 @@ func overlayProject(folder string, mi config.MountInfo) config.MountInfo {
}
// Run is the daemon main loop, executed in the foreground of the (usually
// detached) `sfs daemon run` process.
// detached) `bdrive daemon run` process.
func Run(folder string, scanInterval, remoteInterval time.Duration) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
@@ -134,7 +134,7 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
}
mi, ok := mounts[folder]
if !ok {
return fmt.Errorf("%s is not an sfs mount", folder)
return fmt.Errorf("%s is not a beardrive mount", folder)
}
mi = overlayProject(folder, mi)
volDir, err := config.VolumeDir(mi.Volume)
@@ -167,7 +167,7 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
var lastRemote time.Time
for {
// Pick up `sfs remote set`, .sfs edits, and `sfs umnt --forget`
// Pick up `bdrive remote set`, .beardrive edits, and `bdrive umnt --forget`
// without restarting.
if m, err := config.LoadMounts(); err == nil {
cur, ok := m[folder]
+1 -1
View File
@@ -1,4 +1,4 @@
// Package journal implements sfs's append-only operation log.
// Package journal implements beardrive's append-only operation log.
//
// Every change to a volume is recorded as an Op in a per-device JSONL
// journal. Journals are append-only and each device only ever writes its
+2 -2
View File
@@ -35,7 +35,7 @@ func (b *localBackend) Put(_ context.Context, key string, r io.Reader, _ int64)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".sfs-tmp-*")
tmp, err := os.CreateTemp(filepath.Dir(dst), ".beardrive-tmp-*")
if err != nil {
return err
}
@@ -60,7 +60,7 @@ func (b *localBackend) List(_ context.Context, prefix string) ([]Object, error)
if err != nil || d.IsDir() {
return nil
}
if strings.HasPrefix(d.Name(), ".sfs-tmp-") {
if strings.HasPrefix(d.Name(), ".beardrive-tmp-") {
return nil
}
rel, err := filepath.Rel(b.root, p)
+1 -1
View File
@@ -1,5 +1,5 @@
// Package remote abstracts the cloud object store a volume syncs through.
// sfs is provider-agnostic: any backend that can put/get/list immutable
// beardrive is provider-agnostic: any backend that can put/get/list immutable
// objects works. Built-in schemes:
//
// file:///abs/path local or network-drive directory (also used in tests)
+4 -4
View File
@@ -3,7 +3,7 @@
// a volume needs works offline; the remote is only used to exchange blobs and
// journals.
//
// Layout under <sfs home>/volumes/<volume>/:
// Layout under <beardrive home>/volumes/<volume>/:
//
// blobs/<aa>/<sha256> content-addressed file contents (immutable)
// journal/<device>.jsonl per-device op logs (own + cached copies of peers)
@@ -24,7 +24,7 @@ import (
"strings"
"syscall"
"github.com/runbear-io/sfs/internal/journal"
"github.com/runbear-io/beardrive/internal/journal"
)
type Store struct {
@@ -152,7 +152,7 @@ func (s *Store) AllOps() ([]journal.Op, error) {
// ---- materialized-state cache (state.json) ----
// CachedFile records what sfs last wrote to / observed in the working folder
// CachedFile records what beardrive last wrote to / observed in the working folder
// for a path. Size+MTimeNS make change detection cheap; Blob ties it back to
// content. The cache is per mount (one volume can be materialized into
// several folders, each with its own stat fingerprints).
@@ -241,7 +241,7 @@ func WriteJSONAtomic(path string, v any) error {
// WriteFileAtomic writes data via a temp file in the same directory + rename.
func WriteFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".sfs-tmp-*")
tmp, err := os.CreateTemp(filepath.Dir(path), ".beardrive-tmp-*")
if err != nil {
return err
}
+6 -6
View File
@@ -4,7 +4,7 @@ import (
"os"
"testing"
"github.com/runbear-io/sfs/internal/journal"
"github.com/runbear-io/beardrive/internal/journal"
)
func TestBlobRoundtrip(t *testing.T) {
@@ -12,18 +12,18 @@ func TestBlobRoundtrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
sum, n, err := s.PutBlobBytes([]byte("hello sfs"))
sum, n, err := s.PutBlobBytes([]byte("hello beardrive"))
if err != nil {
t.Fatal(err)
}
if n != 9 {
t.Fatalf("size = %d, want 9", n)
if n != int64(len("hello beardrive")) {
t.Fatalf("size = %d, want %d", n, len("hello beardrive"))
}
if !s.HasBlob(sum) {
t.Fatal("blob not stored")
}
// dedupe: same content, same sum, no error
sum2, _, err := s.PutBlobBytes([]byte("hello sfs"))
sum2, _, err := s.PutBlobBytes([]byte("hello beardrive"))
if err != nil || sum2 != sum {
t.Fatalf("dedupe failed: %v %v", sum2, err)
}
@@ -34,7 +34,7 @@ func TestBlobRoundtrip(t *testing.T) {
defer f.Close()
data := make([]byte, 16)
k, _ := f.Read(data)
if string(data[:k]) != "hello sfs" {
if string(data[:k]) != "hello beardrive" {
t.Fatalf("content mismatch: %q", data[:k])
}
}
+7 -7
View File
@@ -6,7 +6,7 @@ import (
"testing"
)
// Multi-device behavior of .sfsignore and the .sfs include list.
// Multi-device behavior of .beardriveignore and the .beardrive include list.
func TestIgnoredFilesDoNotSync(t *testing.T) {
be := sharedRemote(t)
@@ -23,7 +23,7 @@ func TestIgnoredFilesDoNotSync(t *testing.T) {
t.Fatalf("notes.md = %q", got)
}
if got := read(t, b.Folder, IgnoreFile); got != "*.secret\n" {
t.Fatalf(".sfsignore should sync like a normal file, got %q", got)
t.Fatalf(".beardriveignore should sync like a normal file, got %q", got)
}
if _, err := os.Stat(filepath.Join(b.Folder, "key.secret")); !os.IsNotExist(err) {
t.Fatal("ignored file must not reach other devices")
@@ -46,11 +46,11 @@ func TestNewlyIgnoredFileIsNotDeletedRemotely(t *testing.T) {
// op, so it stays on disk on every device.
write(t, a.Folder, IgnoreFile, "*.log\n")
res := cycle(t, a)
if res.LocalOps != 1 { // only the .sfsignore put, no delete for debug.log
t.Fatalf("LocalOps = %d, want 1 (the .sfsignore itself)", res.LocalOps)
if res.LocalOps != 1 { // only the .beardriveignore put, no delete for debug.log
t.Fatalf("LocalOps = %d, want 1 (the .beardriveignore itself)", res.LocalOps)
}
cycle(t, b)
cycle(t, b) // second cycle: filter from the pulled .sfsignore is active
cycle(t, b) // second cycle: filter from the pulled .beardriveignore is active
if read(t, a.Folder, "debug.log") != "lines" || read(t, b.Folder, "debug.log") != "lines" {
t.Fatal("newly ignored file must remain on disk everywhere")
}
@@ -61,7 +61,7 @@ func TestIncludeListLimitsSync(t *testing.T) {
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, ".sfs", `{"include": ["docs/"]}`)
write(t, a.Folder, ".beardrive", `{"include": ["docs/"]}`)
write(t, a.Folder, "docs/guide.md", "included")
write(t, a.Folder, "src/main.go", "excluded")
cycle(t, a)
@@ -70,7 +70,7 @@ func TestIncludeListLimitsSync(t *testing.T) {
if got := read(t, b.Folder, "docs/guide.md"); got != "included" {
t.Fatalf("docs/guide.md = %q", got)
}
for _, absent := range []string{"src/main.go", ".sfs"} {
for _, absent := range []string{"src/main.go", ".beardrive"} {
if _, err := os.Stat(filepath.Join(b.Folder, absent)); !os.IsNotExist(err) {
t.Fatalf("%s must not sync", absent)
}
+4 -4
View File
@@ -8,9 +8,9 @@ import (
)
// IgnoreFile is the per-folder opt-out list at the mount root. It uses a
// gitignore-style syntax and, unlike the .sfs settings file, syncs like any
// gitignore-style syntax and, unlike the .beardrive settings file, syncs like any
// other file so every device shares the same rules.
const IgnoreFile = ".sfsignore"
const IgnoreFile = ".beardriveignore"
// Filter decides which paths sync. A path syncs when it is not ignored and,
// if an include list is set, matches at least one include pattern.
@@ -31,8 +31,8 @@ type pattern struct {
negate bool
}
// loadFilter builds the filter for a folder from its .sfsignore (if any)
// plus the include list from the .sfs settings file.
// loadFilter builds the filter for a folder from its .beardriveignore (if any)
// plus the include list from the .beardrive settings file.
func loadFilter(folder string, include []string) (*Filter, error) {
f := &Filter{}
for _, line := range include {
+10 -10
View File
@@ -7,7 +7,7 @@
// journal (and their content captured in the blob store) before any remote
// state can overwrite the working folder. Concurrent edits resolve
// deterministically last-writer-wins; the losing local version is preserved
// as a "<name>.sfs-conflict-<device>-<time>" file that syncs like any other.
// as a "<name>.beardrive-conflict-<device>-<time>" file that syncs like any other.
package syncer
import (
@@ -23,10 +23,10 @@ import (
"strings"
"time"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/journal"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/store"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// Session ties a working folder to its volume store and (optionally) remote.
@@ -60,13 +60,13 @@ func (r *Result) Activity() bool {
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Materialized > 0
}
// config.ProjectFile (.sfs) never syncs: remotes are device-specific and
// config.ProjectFile (.beardrive) never syncs: remotes are device-specific and
// syncing it would let one device silently repoint another.
var ignoreNames = map[string]bool{".DS_Store": true, config.ProjectFile: true}
var ignoreDirs = map[string]bool{".git": true, ".sfs": true}
var ignoreDirs = map[string]bool{".git": true, ".beardrive": true}
func ignoredFile(name string) bool {
return ignoreNames[name] || strings.HasPrefix(name, ".sfs-tmp-")
return ignoreNames[name] || strings.HasPrefix(name, ".beardrive-tmp-")
}
// Cycle runs one full scan/sync/materialize pass under the volume lock.
@@ -392,7 +392,7 @@ func (s *Session) conflictCopies(myOps []journal.Op, pushed int64, pulled []jour
}
func conflictName(p, deviceName string, t time.Time) string {
return p + ".sfs-conflict-" + sanitize(deviceName) + "-" + t.UTC().Format("20060102T150405Z")
return p + ".beardrive-conflict-" + sanitize(deviceName) + "-" + t.UTC().Format("20060102T150405Z")
}
func sanitize(s string) string {
@@ -477,7 +477,7 @@ func (s *Session) writeFile(abs string, want journal.FileState) error {
return err
}
defer src.Close()
tmp, err := os.CreateTemp(filepath.Dir(abs), ".sfs-tmp-*")
tmp, err := os.CreateTemp(filepath.Dir(abs), ".beardrive-tmp-*")
if err != nil {
return err
}
+4 -4
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/store"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// newDevice simulates one device: its own folder, volume store, and identity,
@@ -185,7 +185,7 @@ func TestConcurrentEditConflictPreserved(t *testing.T) {
t.Fatal(err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".sfs-conflict-") {
if strings.Contains(e.Name(), ".beardrive-conflict-") {
all[read(t, folder, e.Name())] = true
}
}
+4 -4
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
// DirSource serves a plain local folder straight from disk — no sfs remote
// DirSource serves a plain local folder straight from disk — no bdrive remote
// or volume needed. Meant for debugging the webapp (and as a quick local
// markdown browser): the tree reflects the folder live, provenance is just
// file mtimes, and content streams from the filesystem.
@@ -18,8 +18,8 @@ type DirSource struct {
Root string
}
var skipNames = map[string]bool{".DS_Store": true, ".sfs": true}
var skipDirs = map[string]bool{".git": true, ".sfs": true}
var skipNames = map[string]bool{".DS_Store": true, ".beardrive": true}
var skipDirs = map[string]bool{".git": true, ".beardrive": true}
func (d *DirSource) Files(_ context.Context) (map[string]FileInfo, error) {
files := make(map[string]FileInfo)
@@ -37,7 +37,7 @@ func (d *DirSource) Files(_ context.Context) (map[string]FileInfo, error) {
}
return nil
}
if !e.Type().IsRegular() || skipNames[e.Name()] || strings.HasPrefix(e.Name(), ".sfs-tmp-") {
if !e.Type().IsRegular() || skipNames[e.Name()] || strings.HasPrefix(e.Name(), ".beardrive-tmp-") {
return nil
}
info, err := e.Info()
+1 -1
View File
@@ -29,7 +29,7 @@ func TestDirSourceServesFolder(t *testing.T) {
h := dirServer(t, map[string]string{
"README.md": "# Local",
"notes/plan.md": "content",
".sfs": `{"volume":"x"}`, // settings file must be hidden
".beardrive": `{"volume":"x"}`, // settings file must be hidden
".git/config": "noise", // .git must be skipped
})
+5 -5
View File
@@ -1,4 +1,4 @@
// Package webapp serves a read-only web view of an sfs remote: the volume's
// Package webapp serves a read-only web view of a beardrive remote: the volume's
// file tree reconstructed from the journals, rendered markdown, and file
// downloads. It talks straight to the object store — no local volume state,
// mount, or daemon is needed.
@@ -21,15 +21,15 @@ import (
"sync"
"time"
"github.com/runbear-io/sfs/internal/journal"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
//go:embed static
var staticFiles embed.FS
// Source supplies the file set and content the server renders. Two
// implementations: RemoteSource (an sfs remote, the normal mode) and
// implementations: RemoteSource (a beardrive remote, the normal mode) and
// DirSource (a plain local folder, for debugging without any remote).
type Source interface {
Files(ctx context.Context) (map[string]FileInfo, error)
@@ -81,7 +81,7 @@ func (s *Server) snapshot(ctx context.Context) (*snapshot, error) {
return s.snap, nil
}
// RemoteSource reads an sfs remote: it fetches every journal and folds the
// RemoteSource reads a beardrive remote: it fetches every journal and folds the
// ops into the current volume state (same total order as journal.Replay,
// but keeping author/device/time of the winning op per path).
type RemoteSource struct {
+3 -3
View File
@@ -13,11 +13,11 @@ import (
"testing"
"time"
"github.com/runbear-io/sfs/internal/journal"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
// fakeRemote builds an sfs remote layout (journal/<dev>.jsonl + blobs/<sha>)
// fakeRemote builds a beardrive remote layout (journal/<dev>.jsonl + blobs/<sha>)
// in a temp dir and returns a Server over it.
type fakeRemote struct {
t *testing.T
+2 -2
View File
@@ -1,4 +1,4 @@
/* sfs web viewer: file tree + obsidian-like markdown pane. No dependencies. */
/* beardrive web viewer: file tree + obsidian-like markdown pane. No dependencies. */
"use strict";
const $ = (id) => document.getElementById(id);
@@ -24,7 +24,7 @@ async function boot() {
const v = await getJSON("api/volume");
$("vault-name").textContent = v.volume || "BearDrive";
$("vault-remote").textContent = v.remote || "";
document.title = (v.volume || "sfs") + " — BearDrive";
document.title = (v.volume || "beardrive") + " — BearDrive";
} catch { /* non-fatal */ }
await refreshTree();
const p = decodeURIComponent(location.hash.slice(1));
+5 -5
View File
@@ -1,11 +1,11 @@
{
"name": "sfs",
"displayName": "BearDrive (sfs)",
"description": "BearDrive: a synced file system for AI agents. Mount folders that stay in sync across devices through S3/GCS/any object store, with automatic sync at turn boundaries and full change history. CLI: sfs.",
"name": "beardrive",
"displayName": "BearDrive",
"description": "BearDrive: a synced file system for AI agents. Mount folders that stay in sync across devices through S3/GCS/any object store, with automatic sync at turn boundaries and full change history. CLI: bdrive.",
"version": "0.1.0",
"author": { "name": "runbear", "url": "https://github.com/runbear-io" },
"homepage": "https://github.com/runbear-io/sfs",
"repository": "https://github.com/runbear-io/sfs",
"homepage": "https://github.com/runbear-io/beardrive",
"repository": "https://github.com/runbear-io/beardrive",
"license": "MIT",
"keywords": ["sync", "files", "workspace", "s3", "gcs", "memory", "agents"]
}
+9 -9
View File
@@ -1,28 +1,28 @@
---
description: Mount a folder as a synced sfs volume — one command sets up the sync daemon, the .sfs project config, and (via this plugin's hooks) automatic sync at every turn boundary
description: Mount a folder as a synced beardrive volume — one command sets up the sync daemon, the .beardrive project config, and (via this plugin's hooks) automatic sync at every turn boundary
argument-hint: [folder] [remote e.g. s3://bucket/prefix]
---
Mount a folder as a synced sfs volume. Arguments: `$ARGUMENTS` (optional folder, optional remote URL).
Mount a folder as a synced beardrive volume. Arguments: `$ARGUMENTS` (optional folder, optional remote URL).
Follow these steps:
1. **Check sfs is installed**: run `command -v sfs`. If missing, offer to install it (`brew install runbear-io/tap/sfs`, or `go install github.com/runbear-io/sfs/cmd/sfs@latest`) and wait for the user's choice before installing.
1. **Check the bdrive CLI is installed**: run `command -v bdrive`. If missing, offer to install it (`brew install runbear-io/tap/beardrive`, or `go install github.com/runbear-io/beardrive/cmd/bdrive@latest`) and wait for the user's choice before installing.
2. **Determine the folder**: first argument if given, otherwise the current directory. If the folder already contains a `.sfs` file, the volume and remote are already configured — just run `sfs mnt <folder>` and skip step 3.
2. **Determine the folder**: first argument if given, otherwise the current directory. If the folder already contains a `.beardrive` file, the volume and remote are already configured — just run `bdrive mnt <folder>` and skip step 3.
3. **Determine the remote**: second argument if given. If not given, ask the user which backend they want:
- `s3://bucket/prefix` — Amazon S3 or any S3-compatible store (R2/MinIO via `AWS_ENDPOINT_URL`)
- `gs://bucket/prefix` — Google Cloud Storage
- `file:///abs/path` — a plain shared directory (NAS, external drive)
- none — local-only for now (`sfs remote set` can add one later)
- none — local-only for now (`bdrive remote set` can add one later)
4. **Mount**: run `sfs mnt <folder> [--remote <url>]`. This registers the background sync daemon and writes the folder's settings to `<folder>/.sfs`.
4. **Mount**: run `bdrive mnt <folder> [--remote <url>]`. This registers the background sync daemon and writes the folder's settings to `<folder>/.beardrive`.
5. **Verify**: run `sfs status <folder>` and show the result. If the remote errored, consult the sfs skill's troubleshooting table (credentials are the usual cause).
5. **Verify**: run `bdrive status <folder>` and show the result. If the remote errored, consult the beardrive skill's troubleshooting table (credentials are the usual cause).
6. **Tell the user what's now active** (briefly):
- the daemon syncs continuously (every few seconds);
- this plugin's hooks also sync at every turn boundary — a blocking pull when they send a message, an async push when the turn ends — so Claude always works on fresh files;
- `.sfsignore` in the folder root excludes files (gitignore-style); an `"include"` list in `.sfs` narrows what syncs;
- `sfs log` shows who changed what, `sfs umnt` stops syncing.
- `.beardriveignore` in the folder root excludes files (gitignore-style); an `"include"` list in `.beardrive` narrows what syncs;
- `bdrive log` shows who changed what, `bdrive umnt` stops syncing.
+7 -7
View File
@@ -1,13 +1,13 @@
---
description: Show sfs sync status — mounts, daemon state, pending changes — and diagnose any sync problems
description: Show bdrive sync status — mounts, daemon state, pending changes — and diagnose any sync problems
argument-hint: [folder]
---
Show the sfs sync status. Argument: `$ARGUMENTS` (optional folder; default all mounts).
Show the bdrive sync status. Argument: `$ARGUMENTS` (optional folder; default all mounts).
1. Run `sfs status $ARGUMENTS` and show the output.
2. If anything looks wrong, diagnose using the sfs skill:
- `daemon: stopped` → restart with `sfs mnt <folder>`
- `pending` stuck above 0 → run `sfs sync <folder>` and read the error; it usually points at credentials or the remote
- changes not appearing from another device → run `sfs log <folder>` to see whether the ops arrived
1. Run `bdrive status $ARGUMENTS` and show the output.
2. If anything looks wrong, diagnose using the beardrive skill:
- `daemon: stopped` → restart with `bdrive mnt <folder>`
- `pending` stuck above 0 → run `bdrive sync <folder>` and read the error; it usually points at credentials or the remote
- changes not appearing from another device → run `bdrive log <folder>` to see whether the ops arrived
3. Summarize the state in one or two sentences.
+3 -3
View File
@@ -5,9 +5,9 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/sfs-sync.sh\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-sync.sh\"",
"timeout": 30,
"statusMessage": "sfs: pulling latest files"
"statusMessage": "beardrive: pulling latest files"
}
]
}
@@ -17,7 +17,7 @@
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/sfs-sync.sh\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/beardrive-sync.sh\"",
"async": true
}
]
@@ -1,10 +1,10 @@
#!/bin/sh
# Sync the current project if it is an sfs mount (has a .sfs settings file).
# Sync the current project if it is a beardrive mount (has a .beardrive settings file).
# Fast no-op otherwise, so this hook is safe on every turn in every project.
#
# Runs blocking on UserPromptSubmit (fresh files before Claude reads them)
# and async on Stop (push edits out without delaying the turn).
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
[ -f .sfs ] || exit 0
command -v sfs >/dev/null 2>&1 || exit 0
sfs sync . >/dev/null 2>&1 || true
[ -f .beardrive ] || exit 0
command -v bdrive >/dev/null 2>&1 || exit 0
bdrive sync . >/dev/null 2>&1 || true
@@ -1,28 +1,28 @@
---
name: sfs
description: Use sfs — a mountable, synced file system for AI agents. Mount any folder and it stays synced across devices through cloud object storage (S3, GCS, S3-compatible, or a shared directory) with full per-file change history and offline support. Use when the user wants to "mount a folder", "unmount", "sync now", "set up sfs cloud storage", "connect sfs to S3/GCS/R2/MinIO/NAS", "check sfs status", "see sfs logs", "see what changed", "who changed this file?", or troubleshoot a stuck sync.
name: beardrive
description: Use beardrive — a mountable, synced file system for AI agents. Mount any folder and it stays synced across devices through cloud object storage (S3, GCS, S3-compatible, or a shared directory) with full per-file change history and offline support. Use when the user wants to "mount a folder", "unmount", "sync now", "set up beardrive cloud storage", "connect beardrive to S3/GCS/R2/MinIO/NAS", "check bdrive status", "see beardrive logs", "see what changed", "who changed this file?", or troubleshoot a stuck sync.
---
# sfs — synced file system for AI agents (BearDrive)
# BearDrive — synced file system for AI agents
`sfs` is the CLI of **BearDrive**. It mounts any folder as a synced volume backed by an object store. Each mount runs a per-mount background daemon that scans for local changes and exchanges with the remote. Files on disk are always real files — every tool, editor, and agent works on them with no integration.
**BearDrive** (CLI: `bdrive`) mounts any folder as a synced volume backed by an object store. Each mount runs a per-mount background daemon that scans for local changes and exchanges with the remote. Files on disk are always real files — every tool, editor, and agent works on them with no integration.
Use this skill whenever the user is working with the `sfs` CLI: mounting, unmounting, syncing, configuring a remote, inspecting state, reading change history, or debugging.
Use this skill whenever the user is working with the `bdrive` CLI: mounting, unmounting, syncing, configuring a remote, inspecting state, reading change history, or debugging.
## Command map
| Action | Command |
|---|---|
| Mount, no remote yet | `sfs mnt <folder>` |
| Mount with a remote | `sfs mnt <folder> --remote <url>` |
| Mount in foreground (no background daemon) | `sfs mnt <folder> -f` |
| Stop the sync daemon | `sfs umnt <folder>` |
| Stop and forget the mount | `sfs umnt <folder> --forget` |
| One sync cycle now | `sfs sync [<folder>]` |
| Mounts + daemon + pending state | `sfs status [<folder>]` |
| Change history | `sfs log [<folder>] [-p path] [-n N]` |
| Show / set remote | `sfs remote [<folder>]` · `sfs remote set <folder> <url>` |
| This device's identity | `sfs whoami` |
| Mount, no remote yet | `bdrive mnt <folder>` |
| Mount with a remote | `bdrive mnt <folder> --remote <url>` |
| Mount in foreground (no background daemon) | `bdrive mnt <folder> -f` |
| Stop the sync daemon | `bdrive umnt <folder>` |
| Stop and forget the mount | `bdrive umnt <folder> --forget` |
| One sync cycle now | `bdrive sync [<folder>]` |
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| 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` |
`<folder>` is created if missing. Omitting it on `sync`/`status`/`log` defaults to the current working directory.
@@ -30,20 +30,20 @@ Use this skill whenever the user is working with the `sfs` CLI: mounting, unmoun
Two files at the mount root control a folder's sync behavior:
- **`.sfs`** — the folder's settings (JSON): `volume`, `remote`, and optional `include`. Written by `sfs mnt` / `sfs remote set`; safe to hand-edit (a running daemon picks changes up on its next tick). It is **never synced** — remotes are device-specific — and it travels with the folder: copy a folder containing `.sfs` to a new machine and plain `sfs mnt <folder>` reuses its volume and remote.
- **`.sfsignore`** — opt-out list, gitignore-style. **Syncs like a normal file**, so all devices share the same rules. Syntax subset: `#` comments, `*` within a segment, `**` across segments, `?`, trailing `/` for directories-only, a `/` elsewhere anchors to the mount root, `!` re-includes.
- **`.beardrive`** — the folder's settings (JSON): `volume`, `remote`, and optional `include`. Written by `bdrive mnt` / `bdrive remote set`; safe to hand-edit (a running daemon picks changes up on its next tick). It is **never synced** — remotes are device-specific — and it travels with the folder: copy a folder containing `.beardrive` to a new machine and plain `bdrive mnt <folder>` reuses its volume and remote.
- **`.beardriveignore`** — opt-out list, gitignore-style. **Syncs like a normal file**, so all devices share the same rules. Syntax subset: `#` comments, `*` within a segment, `**` across segments, `?`, trailing `/` for directories-only, a `/` elsewhere anchors to the mount root, `!` re-includes.
```jsonc
// .sfs
// .beardrive
{
"volume": "agent-workspace",
"remote": "s3://acme-sfs/agent-workspace",
"remote": "s3://acme-beardrive/agent-workspace",
"include": ["docs/", "notes/", "*.md"] // optional: sync ONLY these
}
```
```gitignore
# .sfsignore
# .beardriveignore
*.log
node_modules/
build/
@@ -54,7 +54,7 @@ Selective-sync semantics — important when advising users:
- A path syncs when it is **not ignored** and (if `include` is non-empty) **matches an include pattern**. Ignore beats include.
- Adding a pattern for an already-synced file makes this device **stop tracking it without deleting it anywhere** — the file stays on disk locally and on every other device. Deleting it locally after that does not propagate either.
- Because `.sfsignore` syncs, adding a rule on one device applies it everywhere on the next cycle.
- Because `.beardriveignore` syncs, adding a rule on one device applies it everywhere on the next cycle.
---
@@ -63,17 +63,17 @@ Selective-sync semantics — important when advising users:
### Mount flow
1. Pick a folder. New empty or with existing files — existing files are imported on the first cycle.
2. Decide on a remote (optional at mount time; configurable later via `sfs remote set`).
3. Run `sfs mnt`. sfs:
- writes the folder's settings to `<folder>/.sfs` and registers it in `~/.sfs/mounts.json`,
- opens/creates the volume under `~/.sfs/volumes/<volume>/`,
2. Decide on a remote (optional at mount time; configurable later via `bdrive remote set`).
3. Run `bdrive mnt`. beardrive:
- writes the folder's settings to `<folder>/.beardrive` and registers it in `~/.beardrive/mounts.json`,
- opens/creates the volume under `~/.beardrive/volumes/<volume>/`,
- runs an initial cycle (import locals; pull remote state if a remote is set),
- starts a background daemon (unless `-f`).
4. Verify with `sfs status <folder>`.
4. Verify with `bdrive status <folder>`.
### Important `sfs mnt` flags
### Important `bdrive mnt` flags
- `--remote, -r <url>``s3://bucket/prefix`, `gs://bucket/prefix`, or `file:///abs/path`. Can be set later via `sfs remote set`.
- `--remote, -r <url>``s3://bucket/prefix`, `gs://bucket/prefix`, or `file:///abs/path`. Can be set later via `bdrive remote set`.
- `--volume, -v <name>` — override volume name. Default: folder basename. The same volume name on another device + same remote = they sync.
- `--foreground, -f` — run the daemon in the foreground (for systemd/launchd/containers).
- `--scan-interval` (default `3s`) — local scan interval.
@@ -83,22 +83,22 @@ Selective-sync semantics — important when advising users:
```sh
# Machine A
sfs mnt ~/agent-workspace --remote s3://my-bucket/agent-workspace
bdrive mnt ~/agent-workspace --remote s3://my-bucket/agent-workspace
# Machine B, C, …
sfs mnt ~/agent-workspace --remote s3://my-bucket/agent-workspace
bdrive mnt ~/agent-workspace --remote s3://my-bucket/agent-workspace
```
The basename `agent-workspace` becomes the volume name on each device; they converge through the same remote prefix.
### Unmount
- `sfs umnt <folder>` — stop the daemon. Files stay on disk; local volume store under `~/.sfs/volumes/<volume>/` is kept. Re-mount any time to resume.
- `sfs umnt <folder> --forget` — also drop from the mount registry. Local volume data is still preserved; the user must `rm -rf ~/.sfs/volumes/<volume>/` to reclaim disk.
- `bdrive umnt <folder>` — stop the daemon. Files stay on disk; local volume store under `~/.beardrive/volumes/<volume>/` is kept. Re-mount any time to resume.
- `bdrive umnt <folder> --forget` — also drop from the mount registry. Local volume data is still preserved; the user must `rm -rf ~/.beardrive/volumes/<volume>/` to reclaim disk.
### On-demand sync
`sfs sync [<folder>]` runs a single cycle (scan → upload blobs+journal → pull remote journals → materialize). Useful to:
`bdrive sync [<folder>]` runs a single cycle (scan → upload blobs+journal → pull remote journals → materialize). Useful to:
- Push a change immediately instead of waiting for the next interval.
- Verify credentials and the remote end-to-end.
@@ -108,54 +108,54 @@ The basename `agent-workspace` becomes the volume name on each device; they conv
```sh
# Brand-new shared workspace
sfs mnt ~/agent-workspace --remote s3://acme-sfs/agent-workspace
bdrive mnt ~/agent-workspace --remote s3://acme-beardrive/agent-workspace
# Local-only first, add a remote later
sfs mnt ./notes
sfs remote set ./notes file:///Volumes/nas/sfs/notes
sfs sync ./notes
bdrive mnt ./notes
bdrive remote set ./notes file:///Volumes/nas/beardrive/notes
bdrive sync ./notes
# Pause syncing for the day
sfs umnt ~/agent-workspace
bdrive umnt ~/agent-workspace
# Drop a folder entirely (keeps local volume history)
sfs umnt ./notes --forget
bdrive umnt ./notes --forget
```
### What sfs does not sync
### What beardrive does not sync
`.git` directories, `.DS_Store`, the `.sfs` settings file, sfs's own temp files, empty directories, and anything excluded by `.sfsignore` or left out of an `include` list. Don't suggest mounting a folder where `.git` is the content the user expects synced — they want git, not sfs.
`.git` directories, `.DS_Store`, the `.beardrive` settings file, beardrive's own temp files, empty directories, and anything excluded by `.beardriveignore` or left out of an `include` list. Don't suggest mounting a folder where `.git` is the content the user expects synced — they want git, not beardrive.
---
## 2. Cloud storage setup
sfs uses each provider's standard credential chain — nothing sfs-specific.
beardrive uses each provider's standard credential chain — nothing beardrive-specific.
### Supported URL schemes
| Scheme | Backend | Example |
|---|---|---|
| `s3://bucket/prefix` | Amazon S3, or any S3-compatible store via `AWS_ENDPOINT_URL` | `s3://acme-sfs/agent-workspace` |
| `gs://bucket/prefix` | Google Cloud Storage | `gs://acme-sfs/agent-workspace` |
| `file:///abs/path` | Plain directory (local, NAS, Dropbox folder, …) | `file:///Volumes/nas/sfs/notes` |
| `s3://bucket/prefix` | Amazon S3, or any S3-compatible store via `AWS_ENDPOINT_URL` | `s3://acme-beardrive/agent-workspace` |
| `gs://bucket/prefix` | Google Cloud Storage | `gs://acme-beardrive/agent-workspace` |
| `file:///abs/path` | Plain directory (local, NAS, Dropbox folder, …) | `file:///Volumes/nas/beardrive/notes` |
`sfs remote set` validates the scheme and rejects anything else. The prefix can be multi-segment (`s3://bucket/team/agent/workspace`); sfs writes `blobs/` and `journal/` underneath it.
`bdrive remote set` validates the scheme and rejects anything else. The prefix can be multi-segment (`s3://bucket/team/agent/workspace`); beardrive writes `blobs/` and `journal/` underneath it.
### Setting the remote
```sh
# At mount time
sfs mnt ./workspace --remote s3://acme-sfs/workspace
bdrive mnt ./workspace --remote s3://acme-beardrive/workspace
# After mounting
sfs remote set ./workspace s3://acme-sfs/workspace
bdrive remote set ./workspace s3://acme-beardrive/workspace
# Inspect
sfs remote ./workspace
bdrive remote ./workspace
```
After `remote set`, run `sfs sync ./workspace` to push immediately. A running daemon picks up the change on its next interval.
After `remote set`, run `bdrive sync ./workspace` to push immediately. A running daemon picks up the change on its next interval.
### Amazon S3 (`s3://`)
@@ -177,8 +177,8 @@ Minimum IAM policy for one prefix:
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::acme-sfs",
"arn:aws:s3:::acme-sfs/agent-workspace/*"
"arn:aws:s3:::acme-beardrive",
"arn:aws:s3:::acme-beardrive/agent-workspace/*"
]
}]
}
@@ -194,14 +194,14 @@ export AWS_ENDPOINT_URL=https://<accountid>.r2.cloudflarestorage.com
export AWS_REGION=auto
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
sfs mnt ./workspace --remote s3://my-r2-bucket/workspace
bdrive mnt ./workspace --remote s3://my-r2-bucket/workspace
# MinIO
export AWS_ENDPOINT_URL=http://minio.local:9000
export AWS_REGION=us-east-1
export AWS_ACCESS_KEY_ID=minioadmin
export AWS_SECRET_ACCESS_KEY=minioadmin
sfs mnt ./workspace --remote s3://sfs/workspace
bdrive mnt ./workspace --remote s3://beardrive/workspace
```
Persist these in the user's shell rc or a systemd/launchd unit so the daemon also has them.
@@ -217,14 +217,14 @@ gcloud auth application-default login
# Service account
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
sfs mnt ./workspace --remote gs://acme-sfs/workspace
bdrive mnt ./workspace --remote gs://acme-beardrive/workspace
```
Service account needs `storage.objects.{get,list,create,delete}` on the bucket (`roles/storage.objectAdmin` bucket-scoped works).
### Shared directory (`file://`)
No credentials. Anything readable+writable by the user works — NAS, SMB, Dropbox/iCloud, external drive. Path must be absolute (`file:///Volumes/nas/sfs/notes`).
No credentials. Anything readable+writable by the user works — NAS, SMB, Dropbox/iCloud, external drive. Path must be absolute (`file:///Volumes/nas/beardrive/notes`).
Caveats:
@@ -242,7 +242,7 @@ Caveats:
A common layout — one bucket, one prefix per volume:
```
s3://acme-sfs/
s3://acme-beardrive/
├── agent-workspace/ # one volume
├── design-notes/ # another volume
└── research/ # another volume
@@ -251,7 +251,7 @@ s3://acme-sfs/
### Verifying a remote actually works
```sh
sfs sync ./workspace
bdrive sync ./workspace
```
A clean `synced /path (volume "workspace")` plus a non-error cycle summary means the chain, endpoint, and permissions all work. Common failures:
@@ -271,7 +271,7 @@ A clean `synced /path (volume "workspace")` plus a non-error cycle summary means
### Credentials and the background daemon
The daemon inherits the env of the `sfs mnt` invocation. If you set `AWS_PROFILE` in a one-off shell, mounted, and opened a new shell without it — the daemon is fine, but `sfs sync` from the fresh shell may fail credential lookup. For long-lived setups, put credentials in the shell rc or the launchd/systemd unit.
The daemon inherits the env of the `bdrive mnt` invocation. If you set `AWS_PROFILE` in a one-off shell, mounted, and opened a new shell without it — the daemon is fine, but `bdrive sync` from the fresh shell may fail credential lookup. For long-lived setups, put credentials in the shell rc or the launchd/systemd unit.
---
@@ -279,11 +279,11 @@ The daemon inherits the env of the `sfs mnt` invocation. If you set `AWS_PROFILE
Three observation surfaces:
1. **`sfs status`** — registry, daemon liveness, file count, pending push.
2. **`sfs log`** — per-file change history from the journals.
1. **`bdrive status`** — registry, daemon liveness, file count, pending push.
2. **`bdrive log`** — per-file change history from the journals.
3. **The daemon log file** — what the background syncer actually did, and any errors.
### `sfs status [<folder>]`
### `bdrive status [<folder>]`
With no argument: every registered mount. With a folder: narrows to that mount.
@@ -292,7 +292,7 @@ device: macbook (d380dea58598) as snow@runbear.io
/Users/snow/agent-workspace
volume: agent-workspace
remote: s3://acme-sfs/agent-workspace
remote: s3://acme-beardrive/agent-workspace
daemon: running (pid 55434)
files: 142 (3.4 MiB)
pending: 0 local change(s) not yet pushed
@@ -300,20 +300,20 @@ device: macbook (d380dea58598) as snow@runbear.io
Interpretation:
- **`device`** — this machine's identity (same as `sfs whoami`); appears in `sfs log`.
- **`device`** — this machine's identity (same as `bdrive whoami`); appears in `bdrive log`.
- **`volume`** — folders on other devices with the same volume name + same remote converge.
- **`remote`** — `(none — local only)` means changes are journaled locally but never leave the device.
- **`daemon`** — `running (pid N)` or `stopped`. If stopped, run `sfs mnt <folder>` to start a daemon, or `sfs sync <folder>` for a one-shot.
- **`daemon`** — `running (pid N)` or `stopped`. If stopped, run `bdrive mnt <folder>` to start a daemon, or `bdrive sync <folder>` for a one-shot.
- **`files`** — tracked file count and total bytes.
- **`pending`** — local journal ops not yet pushed. Should be 0 shortly after a successful sync. Stuck > 0 usually means broken remote/creds, stopped daemon, or a custom `--remote-interval`.
### `sfs log [<folder>]`
### `bdrive log [<folder>]`
```sh
sfs log ./workspace # last 50 ops
sfs log ./workspace -n 0 # all ops
sfs log ./workspace -p notes/ # paths under notes/
sfs log ./workspace -p notes/x.md # one file
bdrive log ./workspace # last 50 ops
bdrive log ./workspace -n 0 # all ops
bdrive log ./workspace -p notes/ # paths under notes/
bdrive log ./workspace -p notes/x.md # one file
```
Each line: `time kind path author on device-name (size) [note]`
@@ -326,55 +326,55 @@ Each line: `time kind path author on device-name (size) [note]`
Answers:
- "Who last changed file X?" → `sfs log <folder> -p <path> -n 1`.
- "What did device Y do?" → `sfs log <folder> -n 0` and filter by device name.
- "Did my edit cross over?" → run `sfs log` on the other device; the op appears once it has pulled the source device's journal.
- "Who last changed file X?" → `bdrive log <folder> -p <path> -n 1`.
- "What did device Y do?" → `bdrive log <folder> -n 0` and filter by device name.
- "Did my edit cross over?" → run `bdrive log` on the other device; the op appears once it has pulled the source device's journal.
History is content-addressed — overwritten and deleted files are still in the log, with blobs retained under `~/.sfs/volumes/<volume>/blobs/`.
History is content-addressed — overwritten and deleted files are still in the log, with blobs retained under `~/.beardrive/volumes/<volume>/blobs/`.
### `sfs whoami`
### `bdrive whoami`
```
device id: d380dea58598
device name: macbook
author: snow@runbear.io
sfs home: /Users/snow/.sfs
beardrive home: /Users/snow/.beardrive
```
- **device id** — random 12-hex, generated on first run, persisted to `~/.sfs/device.json`.
- **device id** — random 12-hex, generated on first run, persisted to `~/.beardrive/device.json`.
- **device name** — hostname (without `.local`).
- **author**`git config user.email` if present, else `$USER@<hostname>`.
To change name/author, edit `~/.sfs/device.json` and restart the daemon (`sfs umnt`/`sfs mnt`).
To change name/author, edit `~/.beardrive/device.json` and restart the daemon (`bdrive umnt`/`bdrive mnt`).
### The per-mount daemon log
```sh
# Volume contents (daemon pid + log files live here, one pair per mount)
ls ~/.sfs/volumes/<volume>/
ls ~/.beardrive/volumes/<volume>/
# Tail
tail -F ~/.sfs/volumes/<volume>/daemon-*.log
tail -F ~/.beardrive/volumes/<volume>/daemon-*.log
```
Useful when `pending` is stuck > 0, the daemon flips to `stopped` after a restart, or you changed credentials and want to confirm uptake.
### Diagnostic flow ("sfs doesn't seem to be working")
### Diagnostic flow ("beardrive doesn't seem to be working")
1. `sfs status` — folder listed? daemon `running`? `pending` stuck > 0?
2. `daemon: stopped``sfs mnt <folder>` to restart it.
3. `pending` stuck → `sfs sync <folder>` and read the cycle output. Errors here point at the remote — see the cloud-storage troubleshooting table above.
4. Sync succeeds but the other device doesn't see changes → `sfs sync` on the other device + `sfs log` to confirm the op crossed over.
5. Daemon keeps dying → tail `~/.sfs/volumes/<volume>/daemon-*.log` for the cause.
1. `bdrive status` — folder listed? daemon `running`? `pending` stuck > 0?
2. `daemon: stopped``bdrive mnt <folder>` to restart it.
3. `pending` stuck → `bdrive sync <folder>` and read the cycle output. Errors here point at the remote — see the cloud-storage troubleshooting table above.
4. Sync succeeds but the other device doesn't see changes → `bdrive sync` on the other device + `bdrive log` to confirm the op crossed over.
5. Daemon keeps dying → tail `~/.beardrive/volumes/<volume>/daemon-*.log` for the cause.
---
## On-disk layout
```
~/.sfs/
├── device.json # identity (sfs whoami)
├── mounts.json # mount registry (sfs status)
~/.beardrive/
├── device.json # identity (bdrive whoami)
├── mounts.json # mount registry (bdrive status)
└── volumes/<volume>/
├── blobs/ # content-addressed file content
├── journal/ # per-device append-only op logs
@@ -383,6 +383,6 @@ Useful when `pending` is stuck > 0, the daemon flips to `stopped` after a restar
└── daemon-<mountID>.pid/.log # daemon state + log, per mount
```
Don't suggest editing files under `volumes/` directly — sfs owns them. `device.json` and `mounts.json` are safe to inspect; `mounts.json` is safe to hand-edit if a mount entry needs surgery, but prefer `sfs umnt --forget` then `sfs mnt`.
Don't suggest editing files under `volumes/` directly — beardrive owns them. `device.json` and `mounts.json` are safe to inspect; `mounts.json` is safe to hand-edit if a mount entry needs surgery, but prefer `bdrive umnt --forget` then `bdrive mnt`.
Override the whole tree with `SFS_HOME=/path` (used heavily in tests and ephemeral environments).
Override the whole tree with `BEARDRIVE_HOME=/path` (used heavily in tests and ephemeral environments).