mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
7f098b3ff5
commit
1ac128db2c
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:*)",
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../plugin/skills/beardrive
|
||||
@@ -1 +0,0 @@
|
||||
../../plugin/skills/sfs
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
/sfs
|
||||
/bdrive
|
||||
/bdrive-web
|
||||
/dist/
|
||||
.DS_Store
|
||||
.omc
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
module github.com/runbear-io/sfs
|
||||
module github.com/runbear-io/beardrive
|
||||
|
||||
go 1.25.8
|
||||
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user