sfs v0.1: mountable synced file system for AI agents

- sfs mnt/umnt/sync/status/log/remote/whoami CLI (cobra)
- per-device append-only journals + content-addressed blob store
- deterministic lamport-ordered merge, LWW with conflict-copy preservation
- cloud-agnostic backends: S3, GCS, file:// (S3-compatible via AWS_ENDPOINT_URL)
- offline-first: real files on disk, journal locally, push on reconnect
- background sync daemon per mount with change tracking (device/author/time)
- macOS + Linux; Homebrew formula + goreleaser release pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
snow
2026-06-11 23:40:26 -07:00
co-authored by Claude Fable 5
commit 2caa09702f
24 changed files with 3273 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/sfs
/dist/
.DS_Store
+46
View File
@@ -0,0 +1,46 @@
# 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/sfs`.
version: 2
project_name: sfs
builds:
- id: sfs
main: ./cmd/sfs
binary: sfs
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: checksums.txt
brews:
- name: sfs
repository:
owner: runbear-io
name: homebrew-tap
homepage: https://github.com/runbear-io/sfs
description: "Synced file system for AI agents: mount, sync, and track folders"
license: MIT
test: |
assert_match "sfs", shell_output("#{bin}/sfs version")
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
@@ -0,0 +1,8 @@
{
"session_id": "a75ed46b-65f6-4b00-b5ee-f946ec2826ab",
"ended_at": "2026-06-12T06:15:19.362Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Runbear, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+162
View File
@@ -0,0 +1,162 @@
# sfs — a synced file system for AI agents
**sfs** 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.
It is built for AI agent workflows — give your agents on every machine the
same `~/agent-workspace`, and notes, plans, memory files, and artifacts
follow them everywhere, with a full audit trail of which agent or human
changed what.
```console
$ sfs mnt ./workspace --remote s3://my-bucket/workspace
mounted /Users/snow/workspace
volume: workspace
remote: s3://my-bucket/workspace
device: macbook (d380dea58598) as snow@runbear.io
daemon: running (pid 55434, scan 3s, remote sync 30s)
```
On another machine:
```console
$ sfs 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
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
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
(`gs://`), any S3-compatible store (MinIO, Cloudflare R2 via
`AWS_ENDPOINT_URL`), or a plain shared directory (`file://`, e.g. a NAS).
- **Offline-first** — the working folder is always fully usable with no
network. Changes are journaled locally and pushed when the remote becomes
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.
- **macOS & Linux.**
## Install
```sh
brew install runbear-io/tap/sfs # macOS (and Linuxbrew)
```
or from source:
```sh
go install github.com/runbear-io/sfs/cmd/sfs@latest
```
## Quick start
```sh
# 1. Mount a folder, syncing through S3 (or gs://, or file://)
sfs 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
# See what changed, who changed it, and from which device
sfs log ./notes
# Check sync state and the daemon
sfs status
# Sync on demand (the daemon also syncs automatically)
sfs sync ./notes
# Stop syncing (files stay on disk; mount again any time)
sfs umnt ./notes
```
### Credentials
sfs uses each provider's standard credential chain — nothing sfs-specific:
| Remote | Credentials |
|---|---|
| `s3://bucket/prefix` | `AWS_PROFILE`, `~/.aws/credentials`, env vars, IAM roles. S3-compatible stores via `AWS_ENDPOINT_URL`. |
| `gs://bucket/prefix` | Application Default Credentials (`gcloud auth application-default login`) or `GOOGLE_APPLICATION_CREDENTIALS`. |
| `file:///path` | none — any local or network-mounted directory |
## Commands
| 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 |
## How it works
```
working folder ←materialize/scan→ local volume store ←push/pull→ object store
(real files) ~/.sfs/volumes/<vol> s3:// gs:// file://
├─ blobs/ content-addressed (sha256)
├─ journal/ one append-only op log per device
├─ state.json what's materialized
└─ sync.json lamport clock + push cursor
```
- Every change becomes an **op** (`put`/`delete`) in this device's
append-only journal, stamped with a lamport clock, wall-clock time, device
ID, and author. File content goes into a content-addressed blob store.
- A **sync** uploads new blobs, then the journal; it downloads other
devices' journals and any blobs it's missing. Since each device writes
only its own journal, there are no concurrent writers per object and any
dumb object store suffices.
- The folder's state is a deterministic **replay** of all journals ordered
by `(lamport, time, device)` — every device converges to the same view.
Concurrent edits keep the last writer at the path; the loser is preserved
as a conflict-copy file by the device that detects the overlap.
- A per-mount **daemon** scans the folder every few seconds (cheap
size+mtime check) and exchanges with the remote every ~30s — or
immediately after local edits.
### What sfs does not sync
`.git` directories (per-file LWW would corrupt repositories), `.DS_Store`,
and its own temp files. Empty directories are not tracked (like git).
## Roadmap
- `sfs restore <path>@<time>` — restore any file from history (all content
is already retained)
- FUSE/NFS mount mode for lazy-loading huge volumes
- `.sfsignore` patterns
- Journal compaction & blob GC policies
- Per-path access scopes for multi-agent setups
## Development
```sh
go build ./...
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).
## License
MIT
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"fmt"
"net/url"
"time"
"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"
)
func syncCmd() *cobra.Command {
return &cobra.Command{
Use: "sync [folder]",
Short: "Sync a mounted folder with its remote now",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
sess, mi, err := openSession(cmd.Context(), folder, true)
if err != nil {
return err
}
defer closeSession(sess)
res, err := sess.Cycle(cmd.Context())
if err != nil {
return err
}
fmt.Printf("synced %s (volume %q)\n", folder, mi.Volume)
printCycle(res)
return nil
},
}
}
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status [folder]",
Short: "Show mount, sync, and daemon status",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
mounts, err := config.LoadMounts()
if err != nil {
return err
}
if len(args) > 0 {
folder, err := absFolder(args)
if err != nil {
return err
}
mi, ok := mounts[folder]
if !ok {
return fmt.Errorf("%s is not an sfs mount", folder)
}
mounts = map[string]config.MountInfo{folder: mi}
}
if len(mounts) == 0 {
fmt.Println("no sfs mounts (create one with `sfs mnt <folder>`)")
return nil
}
dev, err := config.LoadDevice()
if err != nil {
return err
}
fmt.Printf("device: %s (%s) as %s\n\n", dev.Name, dev.ID, dev.Author)
first := true
for folder, mi := range mounts {
if !first {
fmt.Println()
}
first = false
fmt.Printf("%s\n", folder)
fmt.Printf(" volume: %s\n", mi.Volume)
if mi.Remote != "" {
fmt.Printf(" remote: %s\n", mi.Remote)
} else {
fmt.Printf(" remote: (none — local only)\n")
}
vdir, err := config.VolumeDir(mi.Volume)
if err != nil {
return err
}
if pid, ok := daemon.Running(vdir); ok {
fmt.Printf(" daemon: running (pid %d)\n", pid)
} else {
fmt.Printf(" daemon: stopped\n")
}
sess, _, err := openSession(cmd.Context(), folder, false)
if err != nil {
continue
}
cache, err := sess.Store.LoadCache()
if err == nil {
var total int64
for _, c := range cache {
total += c.Size
}
fmt.Printf(" files: %d (%s)\n", len(cache), humanBytes(total))
}
st, err := sess.Store.LoadSync()
myOps, err2 := sess.Store.DeviceOps(dev.ID)
if err == nil && err2 == nil {
pending := int64(len(myOps)) - st.PushedOps
if pending < 0 {
pending = 0
}
fmt.Printf(" pending: %d local change(s) not yet pushed\n", pending)
}
}
return nil
},
}
}
func logCmd() *cobra.Command {
var limit int
var pathFilter string
c := &cobra.Command{
Use: "log [folder]",
Short: "Show change history: who changed which file, when, on which device",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
sess, _, err := openSession(cmd.Context(), folder, false)
if err != nil {
return err
}
entries, err := syncer.LogEntries(sess.Store, pathFilter, limit)
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Println("no history yet")
return nil
}
for _, op := range entries {
when := op.Time.Local().Format("2006-01-02 15:04:05")
kind := op.Kind
if kind == journal.KindPut {
kind = "put "
} else {
kind = "delete"
}
line := fmt.Sprintf("%s %s %-40s %s on %s", when, kind, op.Path, op.Author, op.DeviceName)
if op.Kind == journal.KindPut {
line += fmt.Sprintf(" (%s)", humanBytes(op.Size))
}
if op.Note != "" {
line += " [" + op.Note + "]"
}
fmt.Println(line)
}
return nil
},
}
c.Flags().IntVarP(&limit, "limit", "n", 50, "max entries to show (0 = all)")
c.Flags().StringVarP(&pathFilter, "path", "p", "", "only show history for this file or directory")
return c
}
func remoteCmd() *cobra.Command {
c := &cobra.Command{
Use: "remote [folder]",
Short: "Show or set the cloud remote of a mounted folder",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
mi, err := mustMount(folder)
if err != nil {
return err
}
if mi.Remote == "" {
fmt.Println("(none)")
} else {
fmt.Println(mi.Remote)
}
return nil
},
}
set := &cobra.Command{
Use: "set <folder> <url>",
Short: "Set the remote (s3://bucket/prefix, gs://bucket/prefix, file:///path)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args[:1])
if err != nil {
return err
}
raw := args[1]
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "s3" && u.Scheme != "gs" && u.Scheme != "file") {
return fmt.Errorf("invalid remote %q (want s3://bucket/prefix, gs://bucket/prefix, or file:///path)", raw)
}
mounts, err := config.LoadMounts()
if err != nil {
return err
}
mi, ok := mounts[folder]
if !ok {
return fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
}
mi.Remote = raw
mounts[folder] = mi
if err := config.SaveMounts(mounts); err != nil {
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)")
return nil
},
}
c.AddCommand(set)
return c
}
func daemonCmd() *cobra.Command {
c := &cobra.Command{
Use: "daemon",
Short: "Manage the background sync daemon",
Hidden: true,
}
var scanInterval, remoteInterval time.Duration
run := &cobra.Command{
Use: "run <folder>",
Short: "Run the sync daemon in the foreground (internal)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
return daemon.Run(folder, scanInterval, remoteInterval)
},
}
run.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "local scan interval")
run.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "remote sync interval")
c.AddCommand(run)
return c
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"fmt"
"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"
)
func absFolder(args []string) (string, error) {
arg := "."
if len(args) > 0 {
arg = args[0]
}
return filepath.Abs(arg)
}
func mustMount(folder string) (config.MountInfo, error) {
mounts, err := config.LoadMounts()
if err != nil {
return config.MountInfo{}, err
}
mi, ok := mounts[folder]
if !ok {
return mi, fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
}
return mi, nil
}
// openSession builds a syncer session for a mounted folder. When withRemote
// is set and the remote is unreachable, it degrades to offline with a warning
// rather than failing.
func openSession(ctx context.Context, folder string, withRemote bool) (*syncer.Session, config.MountInfo, error) {
mi, err := mustMount(folder)
if err != nil {
return nil, mi, err
}
dev, err := config.LoadDevice()
if err != nil {
return nil, mi, err
}
vdir, err := config.VolumeDir(mi.Volume)
if err != nil {
return nil, mi, err
}
st, err := store.Open(vdir)
if err != nil {
return nil, mi, err
}
sess := &syncer.Session{Folder: folder, Store: st, Device: dev}
if withRemote && mi.Remote != "" {
be, err := remote.Open(ctx, mi.Remote)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: remote unavailable, working offline: %v\n", err)
} else {
sess.Backend = be
}
}
return sess, mi, nil
}
func closeSession(sess *syncer.Session) {
if sess != nil && sess.Backend != nil {
sess.Backend.Close()
}
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
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(" files updated: %d\n", res.Materialized)
switch {
case res.Offline:
fmt.Printf(" remote: offline (%v)\n", res.OfflineErr)
case res.Pushed:
fmt.Printf(" remote: pushed\n")
}
}
+77
View File
@@ -0,0 +1,77 @@
// sfs is a syncing file system for AI agents: 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
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/runbear-io/sfs/internal/config"
)
// version is set at release time via -ldflags "-X main.version=...".
var version = "0.1.0-dev"
func main() {
root := &cobra.Command{
Use: "sfs",
Short: "A synced file system for AI agents",
Long: `sfs — a mountable, offline-first, synced file system for AI agents.
Mount any folder and sfs 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
everything keeps working offline; changes sync when the remote is reachable.`,
SilenceUsage: true,
}
root.AddCommand(
mntCmd(),
umntCmd(),
syncCmd(),
statusCmd(),
logCmd(),
remoteCmd(),
whoamiCmd(),
daemonCmd(),
versionCmd(),
)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the sfs version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("sfs", version)
},
}
}
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Show this device's identity used in change tracking",
RunE: func(cmd *cobra.Command, args []string) error {
dev, err := config.LoadDevice()
if err != nil {
return err
}
home, err := config.Home()
if err != nil {
return err
}
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)
return nil
},
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"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"
)
func mntCmd() *cobra.Command {
var remoteURL, volume string
var foreground bool
var scanInterval, remoteInterval time.Duration
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.
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
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`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return err
}
mounts, err := config.LoadMounts()
if err != nil {
return err
}
mi, exists := mounts[folder]
if exists {
if volume != "" && volume != mi.Volume {
return fmt.Errorf("%s is already mounted as volume %q", folder, mi.Volume)
}
} else {
v := volume
if v == "" {
v = filepath.Base(folder)
}
mi = config.MountInfo{Volume: v}
}
if remoteURL != "" {
mi.Remote = remoteURL
}
mounts[folder] = mi
if err := config.SaveMounts(mounts); err != nil {
return err
}
vdir, err := config.VolumeDir(mi.Volume)
if err != nil {
return err
}
if _, err := store.Open(vdir); err != nil {
return err
}
dev, err := config.LoadDevice()
if err != nil {
return err
}
// Initial cycle: import existing files, pull remote state.
sess, _, err := openSession(cmd.Context(), folder, true)
if err != nil {
return err
}
res, err := sess.Cycle(cmd.Context())
closeSession(sess)
if err != nil {
return err
}
fmt.Printf("mounted %s\n", folder)
fmt.Printf(" volume: %s\n", mi.Volume)
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(" device: %s (%s) as %s\n", dev.Name, dev.ID, dev.Author)
printCycle(res)
if foreground {
return daemon.Run(folder, scanInterval, remoteInterval)
}
pid, err := daemon.Start(folder, vdir, scanInterval, remoteInterval)
if err != nil {
return fmt.Errorf("start sync daemon: %w", err)
}
fmt.Printf(" daemon: running (pid %d, scan %s, remote sync %s)\n", pid, scanInterval, remoteInterval)
return nil
},
}
c.Flags().StringVarP(&remoteURL, "remote", "r", "", "remote to sync with (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
c.Flags().StringVarP(&volume, "volume", "v", "", "volume name (default: folder basename)")
c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground")
c.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "how often to scan the folder for local changes")
c.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "how often to sync with the remote")
return c
}
func umntCmd() *cobra.Command {
var forget bool
c := &cobra.Command{
Use: "umnt <folder>",
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.
With --forget the folder is also removed from the mount registry (local
volume data under ~/.sfs/volumes is still kept).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folder, err := absFolder(args)
if err != nil {
return err
}
mi, err := mustMount(folder)
if err != nil {
return err
}
vdir, err := config.VolumeDir(mi.Volume)
if err != nil {
return err
}
stopped, err := daemon.Stop(vdir)
if err != nil {
return err
}
if stopped {
fmt.Printf("stopped sync daemon for %s\n", folder)
} else {
fmt.Printf("no daemon running for %s\n", folder)
}
if forget {
mounts, err := config.LoadMounts()
if err != nil {
return err
}
delete(mounts, folder)
if err := config.SaveMounts(mounts); err != nil {
return err
}
fmt.Printf("forgot mount %s (volume %q kept under ~/.sfs/volumes)\n", folder, mi.Volume)
}
return nil
},
}
c.Flags().BoolVar(&forget, "forget", false, "also remove the folder from the mount registry")
return c
}
+77
View File
@@ -0,0 +1,77 @@
module github.com/runbear-io/sfs
go 1.25.8
require (
cloud.google.com/go/storage v1.62.3
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3
github.com/aws/smithy-go v1.27.2
github.com/spf13/cobra v1.10.2
google.golang.org/api v0.284.0
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+171
View File
@@ -0,0 +1,171 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc=
google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+166
View File
@@ -0,0 +1,166 @@
// Package config manages sfs's global state under the sfs home directory
// (default ~/.sfs, overridable with $SFS_HOME): the device identity and the
// registry of mounted folders.
package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
)
// Home returns the sfs home directory ($SFS_HOME or ~/.sfs).
func Home() (string, error) {
if h := os.Getenv("SFS_HOME"); h != "" {
return h, nil
}
uh, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(uh, ".sfs"), nil
}
// Device identifies this machine and its operator in journals.
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Author string `json:"author"`
}
// LoadDevice loads the device identity, creating one on first use.
func LoadDevice() (Device, error) {
home, err := Home()
if err != nil {
return Device{}, err
}
p := filepath.Join(home, "device.json")
if data, err := os.ReadFile(p); err == nil {
var d Device
if err := json.Unmarshal(data, &d); err == nil && d.ID != "" {
return d, nil
}
}
d := Device{ID: randID(), Name: hostname(), Author: detectAuthor()}
if err := os.MkdirAll(home, 0o755); err != nil {
return Device{}, err
}
if err := writeJSON(p, d); err != nil {
return Device{}, err
}
return d, nil
}
func randID() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "device000000"
}
return hex.EncodeToString(b)
}
func hostname() string {
h, _ := os.Hostname()
h = strings.TrimSuffix(h, ".local")
if h == "" {
h = "device"
}
return h
}
func detectAuthor() string {
if out, err := exec.Command("git", "config", "--get", "user.email").Output(); err == nil {
if s := strings.TrimSpace(string(out)); s != "" {
return s
}
}
u := os.Getenv("USER")
if u == "" {
if cu, err := user.Current(); err == nil {
u = cu.Username
}
}
if u == "" {
u = "unknown"
}
return u + "@" + hostname()
}
// MountInfo describes one mounted folder.
type MountInfo struct {
Volume string `json:"volume"`
Remote string `json:"remote,omitempty"`
}
func mountsPath() (string, error) {
home, err := Home()
if err != nil {
return "", err
}
return filepath.Join(home, "mounts.json"), nil
}
// LoadMounts returns the abs-folder → mount registry.
func LoadMounts() (map[string]MountInfo, error) {
p, err := mountsPath()
if err != nil {
return nil, err
}
out := map[string]MountInfo{}
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return nil, err
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}
func SaveMounts(m map[string]MountInfo) error {
p, err := mountsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return writeJSON(p, m)
}
// VolumeDir returns (and creates parents for) the local store dir of a volume.
func VolumeDir(volume string) (string, error) {
home, err := Home()
if err != nil {
return "", err
}
return filepath.Join(home, "volumes", volume), nil
}
func writeJSON(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".sfs-tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+212
View File
@@ -0,0 +1,212 @@
// Package daemon runs the per-mount background sync loop and manages its
// lifecycle (detached start, pidfile, graceful stop).
//
// The loop scans the working folder every scan-interval (cheap: size+mtime
// against the state cache) and talks to the remote every remote-interval —
// or immediately after local changes, so edits propagate quickly without
// hammering the object store.
package daemon
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"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"
)
func PidPath(volDir string) string { return filepath.Join(volDir, "daemon.pid") }
func LogPath(volDir string) string { return filepath.Join(volDir, "daemon.log") }
// Running reports the daemon pid for a volume if one is alive.
func Running(volDir string) (int, bool) {
data, err := os.ReadFile(PidPath(volDir))
if err != nil {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
return 0, false
}
if err := syscall.Kill(pid, 0); err != nil {
return 0, false
}
return pid, true
}
// Start launches a detached daemon for the folder (no-op if already running).
func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (int, error) {
if pid, ok := Running(volDir); ok {
return pid, nil
}
exe, err := os.Executable()
if err != nil {
return 0, err
}
logf, err := os.OpenFile(LogPath(volDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return 0, err
}
defer logf.Close()
cmd := exec.Command(exe, "daemon", "run", folder,
"--scan-interval", scanInterval.String(),
"--remote-interval", remoteInterval.String())
cmd.Stdout = logf
cmd.Stderr = logf
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return 0, err
}
pid := cmd.Process.Pid
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
return pid, err
}
return pid, cmd.Process.Release()
}
// Stop terminates the daemon for a volume and waits for it to exit.
func Stop(volDir string) (bool, error) {
pid, ok := Running(volDir)
if !ok {
os.Remove(PidPath(volDir))
return false, nil
}
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return false, err
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := syscall.Kill(pid, 0); err != nil {
os.Remove(PidPath(volDir))
return true, nil
}
time.Sleep(100 * time.Millisecond)
}
syscall.Kill(pid, syscall.SIGKILL)
os.Remove(PidPath(volDir))
return true, nil
}
// Run is the daemon main loop, executed in the foreground of the (usually
// detached) `sfs daemon run` process.
func Run(folder string, scanInterval, remoteInterval time.Duration) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
mounts, err := config.LoadMounts()
if err != nil {
return err
}
mi, ok := mounts[folder]
if !ok {
return fmt.Errorf("%s is not an sfs mount", folder)
}
volDir, err := config.VolumeDir(mi.Volume)
if err != nil {
return err
}
st, err := store.Open(volDir)
if err != nil {
return err
}
dev, err := config.LoadDevice()
if err != nil {
return err
}
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
return err
}
defer os.Remove(PidPath(volDir))
log.Printf("daemon started: folder=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
folder, mi.Volume, mi.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
var be remote.Backend
defer func() {
if be != nil {
be.Close()
}
}()
var lastRemote time.Time
for {
// Pick up `sfs remote set` / `sfs umnt --forget` without restarting.
if m, err := config.LoadMounts(); err == nil {
cur, ok := m[folder]
if !ok {
log.Printf("mount unregistered; exiting")
return nil
}
if cur.Remote != mi.Remote {
log.Printf("remote changed: %q -> %q", mi.Remote, cur.Remote)
if be != nil {
be.Close()
be = nil
}
lastRemote = time.Time{}
}
mi = cur
}
doRemote := mi.Remote != "" && time.Since(lastRemote) >= remoteInterval
if doRemote && be == nil {
b, err := remote.Open(ctx, mi.Remote)
if err != nil {
log.Printf("remote unavailable: %v", err)
doRemote = false
lastRemote = time.Now()
} else {
be = b
}
}
sess := &syncer.Session{Folder: folder, Store: st, Device: dev}
if doRemote {
sess.Backend = be
}
res, err := sess.Cycle(ctx)
switch {
case ctx.Err() != nil:
log.Printf("daemon stopping")
return nil
case err != nil:
log.Printf("cycle error: %v", err)
case res.Offline:
log.Printf("offline, will retry: %v", res.OfflineErr)
if be != nil {
be.Close()
be = nil
}
lastRemote = time.Now()
default:
if res.Activity() {
log.Printf("local+%d pulled+%d conflicts=%d files~%d pushed=%v",
res.LocalOps, res.PulledOps, res.Conflicts, res.Materialized, res.Pushed)
}
if doRemote {
lastRemote = time.Now()
}
if res.LocalOps > 0 && !doRemote {
lastRemote = time.Time{} // push local edits on the next tick
}
}
select {
case <-ctx.Done():
log.Printf("daemon stopping")
return nil
case <-time.After(scanInterval):
}
}
}
+139
View File
@@ -0,0 +1,139 @@
// Package journal implements sfs'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
// own journal, so syncing is conflict-free at the transport level: a sync
// uploads your journal and downloads everyone else's. The merged view of
// a volume is a deterministic replay of the union of all ops ordered by
// (lamport, time, device, seq) — every device converges to the same state.
package journal
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"sort"
"time"
)
const (
KindPut = "put"
KindDelete = "delete"
)
// Op is a single journaled file operation.
type Op struct {
Seq int64 `json:"seq"` // per-device sequence number, 1-based
Lamport int64 `json:"lamport"` // logical clock for cross-device ordering
Time time.Time `json:"time"`
Device string `json:"device"`
DeviceName string `json:"device_name,omitempty"`
Author string `json:"author,omitempty"`
Kind string `json:"kind"` // "put" or "delete"
Path string `json:"path"` // slash-separated, relative to volume root
Blob string `json:"blob,omitempty"` // sha256 hex of content (put only)
Size int64 `json:"size,omitempty"`
Mode uint32 `json:"mode,omitempty"` // permission bits
Note string `json:"note,omitempty"` // e.g. "conflict copy of <path>"
}
// Less defines the total order used to replay ops from many devices.
func Less(a, b Op) bool {
if a.Lamport != b.Lamport {
return a.Lamport < b.Lamport
}
if !a.Time.Equal(b.Time) {
return a.Time.Before(b.Time)
}
if a.Device != b.Device {
return a.Device < b.Device
}
return a.Seq < b.Seq
}
func Sort(ops []Op) {
sort.SliceStable(ops, func(i, j int) bool { return Less(ops[i], ops[j]) })
}
// FileState is the resolved state of one path after replay.
type FileState struct {
Blob string
Size int64
Mode uint32
}
// Replay folds a set of ops (from any number of devices) into the
// resulting volume state. Last writer wins per path under the total order.
func Replay(ops []Op) map[string]FileState {
sorted := append([]Op(nil), ops...)
Sort(sorted)
state := make(map[string]FileState)
for _, op := range sorted {
switch op.Kind {
case KindPut:
state[op.Path] = FileState{Blob: op.Blob, Size: op.Size, Mode: op.Mode}
case KindDelete:
delete(state, op.Path)
}
}
return state
}
// Parse decodes a JSONL journal.
func Parse(data []byte) ([]Op, error) {
var ops []Op
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var op Op
if err := json.Unmarshal(line, &op); err != nil {
return nil, fmt.Errorf("parse journal line %d: %w", len(ops)+1, err)
}
ops = append(ops, op)
}
if err := sc.Err(); err != nil {
return nil, err
}
return ops, nil
}
// ReadFile reads a journal file; a missing file is an empty journal.
func ReadFile(path string) ([]Op, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return Parse(data)
}
// Append appends ops to a journal file as JSONL.
func Append(path string, ops []Op) error {
if len(ops) == 0 {
return nil
}
var buf bytes.Buffer
for _, op := range ops {
b, err := json.Marshal(op)
if err != nil {
return err
}
buf.Write(b)
buf.WriteByte('\n')
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(buf.Bytes())
return err
}
+95
View File
@@ -0,0 +1,95 @@
package journal
import (
"os"
"path/filepath"
"testing"
"time"
)
func op(lamport int64, dev string, seq int64, kind, path, blob string) Op {
return Op{
Seq: seq, Lamport: lamport, Time: time.Unix(1000+lamport, 0).UTC(),
Device: dev, Kind: kind, Path: path, Blob: blob,
}
}
func TestReplayLastWriterWins(t *testing.T) {
ops := []Op{
op(3, "b", 1, KindPut, "a.txt", "v2"),
op(1, "a", 1, KindPut, "a.txt", "v1"),
op(2, "a", 2, KindPut, "b.txt", "x"),
}
state := Replay(ops)
if state["a.txt"].Blob != "v2" {
t.Fatalf("want v2, got %q", state["a.txt"].Blob)
}
if state["b.txt"].Blob != "x" {
t.Fatalf("want x, got %q", state["b.txt"].Blob)
}
}
func TestReplayDelete(t *testing.T) {
ops := []Op{
op(1, "a", 1, KindPut, "a.txt", "v1"),
op(2, "b", 1, KindDelete, "a.txt", ""),
}
if state := Replay(ops); len(state) != 0 {
t.Fatalf("expected empty state, got %v", state)
}
// delete then put resurrects
ops = append(ops, op(3, "a", 2, KindPut, "a.txt", "v3"))
if state := Replay(ops); state["a.txt"].Blob != "v3" {
t.Fatalf("expected v3 after resurrection")
}
}
func TestOrderTieBreak(t *testing.T) {
// same lamport + time: device id breaks the tie deterministically
a := op(5, "aaa", 1, KindPut, "f", "from-a")
b := op(5, "bbb", 1, KindPut, "f", "from-b")
a.Time = b.Time
if state := Replay([]Op{a, b}); state["f"].Blob != "from-b" {
t.Fatalf("want from-b (higher device id wins tie), got %q", state["f"].Blob)
}
if state := Replay([]Op{b, a}); state["f"].Blob != "from-b" {
t.Fatalf("order of input must not matter")
}
}
func TestAppendRead(t *testing.T) {
p := filepath.Join(t.TempDir(), "dev.jsonl")
ops := []Op{
op(1, "a", 1, KindPut, "x.txt", "blob1"),
op(2, "a", 2, KindDelete, "x.txt", ""),
}
if err := Append(p, ops[:1]); err != nil {
t.Fatal(err)
}
if err := Append(p, ops[1:]); err != nil {
t.Fatal(err)
}
got, err := ReadFile(p)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Blob != "blob1" || got[1].Kind != KindDelete {
t.Fatalf("roundtrip mismatch: %+v", got)
}
}
func TestReadMissingFile(t *testing.T) {
got, err := ReadFile(filepath.Join(t.TempDir(), "nope.jsonl"))
if err != nil || got != nil {
t.Fatalf("missing journal should be empty, got %v %v", got, err)
}
}
func TestParseSkipsBlankLines(t *testing.T) {
p := filepath.Join(t.TempDir(), "j.jsonl")
os.WriteFile(p, []byte("\n{\"seq\":1,\"kind\":\"put\",\"path\":\"a\"}\n\n"), 0o644)
got, err := ReadFile(p)
if err != nil || len(got) != 1 {
t.Fatalf("got %v %v", got, err)
}
}
+86
View File
@@ -0,0 +1,86 @@
package remote
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
gcs "cloud.google.com/go/storage"
"google.golang.org/api/iterator"
)
// gcsBackend stores objects in Google Cloud Storage using Application
// Default Credentials (gcloud auth application-default login, or a service
// account via GOOGLE_APPLICATION_CREDENTIALS).
type gcsBackend struct {
client *gcs.Client
bucket *gcs.BucketHandle
prefix string
}
func newGCS(ctx context.Context, bucket, prefix string) (*gcsBackend, error) {
if bucket == "" {
return nil, fmt.Errorf("gs remote needs a bucket: gs://bucket/prefix")
}
client, err := gcs.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("create GCS client: %w", err)
}
return &gcsBackend{client: client, bucket: client.Bucket(bucket), prefix: prefix}, nil
}
func (b *gcsBackend) key(key string) string {
if b.prefix == "" {
return key
}
return path.Join(b.prefix, key)
}
func (b *gcsBackend) Put(ctx context.Context, key string, r io.Reader, _ int64) error {
w := b.bucket.Object(b.key(key)).NewWriter(ctx)
if _, err := io.Copy(w, r); err != nil {
w.Close()
return err
}
return w.Close()
}
func (b *gcsBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return b.bucket.Object(b.key(key)).NewReader(ctx)
}
func (b *gcsBackend) List(ctx context.Context, prefix string) ([]Object, error) {
it := b.bucket.Objects(ctx, &gcs.Query{Prefix: b.key(prefix)})
strip := b.prefix
if strip != "" {
strip += "/"
}
var out []Object
for {
attrs, err := it.Next()
if errors.Is(err, iterator.Done) {
break
}
if err != nil {
return nil, err
}
out = append(out, Object{Key: strings.TrimPrefix(attrs.Name, strip), Size: attrs.Size})
}
return out, nil
}
func (b *gcsBackend) Exists(ctx context.Context, key string) (bool, error) {
_, err := b.bucket.Object(b.key(key)).Attrs(ctx)
if err == nil {
return true, nil
}
if errors.Is(err, gcs.ErrObjectNotExist) {
return false, nil
}
return false, err
}
func (b *gcsBackend) Close() error { return b.client.Close() }
+95
View File
@@ -0,0 +1,95 @@
package remote
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// localBackend stores objects in a plain directory. Useful for tests and for
// syncing through any mounted network drive.
type localBackend struct {
root string
}
func newLocal(root string) (*localBackend, error) {
if root == "" {
return nil, fmt.Errorf("file:// remote needs an absolute path")
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
return &localBackend{root: root}, nil
}
func (b *localBackend) path(key string) string {
return filepath.Join(b.root, filepath.FromSlash(key))
}
func (b *localBackend) Put(_ context.Context, key string, r io.Reader, _ int64) error {
dst := b.path(key)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".sfs-tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (b *localBackend) Get(_ context.Context, key string) (io.ReadCloser, error) {
return os.Open(b.path(key))
}
func (b *localBackend) List(_ context.Context, prefix string) ([]Object, error) {
var out []Object
err := filepath.WalkDir(b.root, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.HasPrefix(d.Name(), ".sfs-tmp-") {
return nil
}
rel, err := filepath.Rel(b.root, p)
if err != nil {
return nil
}
key := filepath.ToSlash(rel)
if !strings.HasPrefix(key, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
out = append(out, Object{Key: key, Size: info.Size()})
return nil
})
return out, err
}
func (b *localBackend) Exists(_ context.Context, key string) (bool, error) {
_, err := os.Stat(b.path(key))
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func (b *localBackend) Close() error { return nil }
+51
View File
@@ -0,0 +1,51 @@
// Package remote abstracts the cloud object store a volume syncs through.
// sfs 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)
// s3://bucket/prefix Amazon S3 (or S3-compatible via AWS_ENDPOINT_URL)
// gs://bucket/prefix Google Cloud Storage
//
// Remote layout: blobs/<sha256> for content, journal/<device>.jsonl for op
// logs. Each device writes only its own journal, so there are no concurrent
// writers per object and no server-side coordination is needed.
package remote
import (
"context"
"fmt"
"io"
"net/url"
"strings"
)
type Object struct {
Key string
Size int64
}
type Backend interface {
Put(ctx context.Context, key string, r io.Reader, size int64) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
List(ctx context.Context, prefix string) ([]Object, error)
Exists(ctx context.Context, key string) (bool, error)
Close() error
}
// Open creates a backend from a remote URL.
func Open(ctx context.Context, raw string) (Backend, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid remote %q: %w", raw, err)
}
switch u.Scheme {
case "file":
return newLocal(u.Path)
case "s3":
return newS3(ctx, u.Host, strings.Trim(u.Path, "/"))
case "gs":
return newGCS(ctx, u.Host, strings.Trim(u.Path, "/"))
default:
return nil, fmt.Errorf("unsupported remote scheme %q (supported: file://, s3://, gs://)", u.Scheme)
}
}
+108
View File
@@ -0,0 +1,108 @@
package remote
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// s3Backend stores objects in Amazon S3 (or any S3-compatible store via the
// standard AWS_ENDPOINT_URL / AWS_PROFILE environment configuration).
type s3Backend struct {
client *s3.Client
bucket string
prefix string
}
func newS3(ctx context.Context, bucket, prefix string) (*s3Backend, error) {
if bucket == "" {
return nil, fmt.Errorf("s3 remote needs a bucket: s3://bucket/prefix")
}
cfg, err := awsconfig.LoadDefaultConfig(ctx)
if err != nil {
return nil, fmt.Errorf("load AWS config: %w", err)
}
return &s3Backend{client: s3.NewFromConfig(cfg), bucket: bucket, prefix: prefix}, nil
}
func (b *s3Backend) key(key string) string {
if b.prefix == "" {
return key
}
return path.Join(b.prefix, key)
}
func (b *s3Backend) Put(ctx context.Context, key string, r io.Reader, size int64) error {
_, err := b.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
Body: r,
ContentLength: aws.Int64(size),
})
return err
}
func (b *s3Backend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := b.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
})
if err != nil {
return nil, err
}
return out.Body, nil
}
func (b *s3Backend) List(ctx context.Context, prefix string) ([]Object, error) {
full := b.key(prefix)
var out []Object
p := s3.NewListObjectsV2Paginator(b.client, &s3.ListObjectsV2Input{
Bucket: aws.String(b.bucket),
Prefix: aws.String(full),
})
strip := b.prefix
if strip != "" {
strip += "/"
}
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return nil, err
}
for _, o := range page.Contents {
key := strings.TrimPrefix(aws.ToString(o.Key), strip)
out = append(out, Object{Key: key, Size: aws.ToInt64(o.Size)})
}
}
return out, nil
}
func (b *s3Backend) Exists(ctx context.Context, key string) (bool, error) {
_, err := b.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
})
if err == nil {
return true, nil
}
var nf *types.NotFound
if errors.As(err, &nf) {
return false, nil
}
var ae smithy.APIError
if errors.As(err, &ae) && (ae.ErrorCode() == "NotFound" || ae.ErrorCode() == "404") {
return false, nil
}
return false, err
}
func (b *s3Backend) Close() error { return nil }
+255
View File
@@ -0,0 +1,255 @@
// Package store manages a volume's local on-disk state: a content-addressed
// blob store, the per-device journals, and small JSON state files. Everything
// a volume needs works offline; the remote is only used to exchange blobs and
// journals.
//
// Layout under <sfs home>/volumes/<volume>/:
//
// blobs/<aa>/<sha256> content-addressed file contents (immutable)
// journal/<device>.jsonl per-device op logs (own + cached copies of peers)
// state.json what is currently materialized in the folder
// sync.json lamport clock + push cursor
// lock flock guarding cycles
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"github.com/runbear-io/sfs/internal/journal"
)
type Store struct {
dir string
}
func Open(dir string) (*Store, error) {
for _, d := range []string{dir, filepath.Join(dir, "blobs"), filepath.Join(dir, "journal"), filepath.Join(dir, "tmp")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
return &Store{dir: dir}, nil
}
func (s *Store) Dir() string { return s.dir }
func (s *Store) tmpDir() string { return filepath.Join(s.dir, "tmp") }
// ---- blobs ----
func (s *Store) BlobPath(sum string) string {
return filepath.Join(s.dir, "blobs", sum[:2], sum)
}
func (s *Store) HasBlob(sum string) bool {
if len(sum) < 3 {
return false
}
_, err := os.Stat(s.BlobPath(sum))
return err == nil
}
// PutBlobReader streams r into the blob store, returning its sha256 and size.
func (s *Store) PutBlobReader(r io.Reader) (string, int64, error) {
tmp, err := os.CreateTemp(s.tmpDir(), "blob-*")
if err != nil {
return "", 0, err
}
defer os.Remove(tmp.Name())
h := sha256.New()
n, err := io.Copy(io.MultiWriter(tmp, h), r)
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err != nil {
return "", 0, err
}
sum := hex.EncodeToString(h.Sum(nil))
if s.HasBlob(sum) {
return sum, n, nil
}
dst := s.BlobPath(sum)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", 0, err
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return "", 0, err
}
return sum, n, nil
}
func (s *Store) PutBlobFile(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
return s.PutBlobReader(f)
}
func (s *Store) PutBlobBytes(b []byte) (string, int64, error) {
return s.PutBlobReader(strings.NewReader(string(b)))
}
func (s *Store) OpenBlob(sum string) (*os.File, error) {
return os.Open(s.BlobPath(sum))
}
// ---- journals ----
func (s *Store) JournalPath(device string) string {
return filepath.Join(s.dir, "journal", device+".jsonl")
}
func (s *Store) AppendOps(device string, ops []journal.Op) error {
return journal.Append(s.JournalPath(device), ops)
}
func (s *Store) DeviceOps(device string) ([]journal.Op, error) {
return journal.ReadFile(s.JournalPath(device))
}
// Devices lists device IDs that have a local journal copy.
func (s *Store) Devices() ([]string, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "journal"))
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".jsonl") {
out = append(out, strings.TrimSuffix(e.Name(), ".jsonl"))
}
}
sort.Strings(out)
return out, nil
}
// AllOps returns the union of every journal known locally.
func (s *Store) AllOps() ([]journal.Op, error) {
devs, err := s.Devices()
if err != nil {
return nil, err
}
var all []journal.Op
for _, d := range devs {
ops, err := s.DeviceOps(d)
if err != nil {
return nil, fmt.Errorf("journal %s: %w", d, err)
}
all = append(all, ops...)
}
return all, nil
}
// ---- materialized-state cache (state.json) ----
// CachedFile records what sfs last wrote to / observed in the working folder
// for a path. Size+MTimeNS make change detection cheap; Blob ties it back to
// content.
type CachedFile struct {
Blob string `json:"blob"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
MTimeNS int64 `json:"mtime_ns"`
}
func (s *Store) LoadCache() (map[string]CachedFile, error) {
out := map[string]CachedFile{}
if err := readJSON(filepath.Join(s.dir, "state.json"), &out); err != nil {
return nil, err
}
return out, nil
}
func (s *Store) SaveCache(c map[string]CachedFile) error {
return WriteJSONAtomic(filepath.Join(s.dir, "state.json"), c)
}
// ---- sync state (sync.json) ----
type SyncState struct {
Lamport int64 `json:"lamport"`
PushedOps int64 `json:"pushed_ops"` // how many of our own ops the remote has
}
func (s *Store) LoadSync() (SyncState, error) {
var st SyncState
if err := readJSON(filepath.Join(s.dir, "sync.json"), &st); err != nil {
return st, err
}
return st, nil
}
func (s *Store) SaveSync(st SyncState) error {
return WriteJSONAtomic(filepath.Join(s.dir, "sync.json"), st)
}
// ---- locking ----
// Lock takes an exclusive flock for the volume, serializing sync cycles
// between the daemon and one-shot commands. Blocks until acquired.
func (s *Store) Lock() (func() error, error) {
f, err := os.OpenFile(filepath.Join(s.dir, "lock"), os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() error {
defer f.Close()
return syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}, nil
}
// ---- small JSON helpers ----
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, v)
}
// WriteJSONAtomic writes v as JSON via temp-file + rename.
func WriteJSONAtomic(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return WriteFileAtomic(path, data, 0o644)
}
// 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-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), mode); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+111
View File
@@ -0,0 +1,111 @@
package store
import (
"os"
"testing"
"github.com/runbear-io/sfs/internal/journal"
)
func TestBlobRoundtrip(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
sum, n, err := s.PutBlobBytes([]byte("hello sfs"))
if err != nil {
t.Fatal(err)
}
if n != 9 {
t.Fatalf("size = %d, want 9", n)
}
if !s.HasBlob(sum) {
t.Fatal("blob not stored")
}
// dedupe: same content, same sum, no error
sum2, _, err := s.PutBlobBytes([]byte("hello sfs"))
if err != nil || sum2 != sum {
t.Fatalf("dedupe failed: %v %v", sum2, err)
}
f, err := s.OpenBlob(sum)
if err != nil {
t.Fatal(err)
}
defer f.Close()
data := make([]byte, 16)
k, _ := f.Read(data)
if string(data[:k]) != "hello sfs" {
t.Fatalf("content mismatch: %q", data[:k])
}
}
func TestJournalAndState(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
ops := []journal.Op{{Seq: 1, Lamport: 1, Device: "devA", Kind: journal.KindPut, Path: "f", Blob: "b"}}
if err := s.AppendOps("devA", ops); err != nil {
t.Fatal(err)
}
if err := s.AppendOps("devB", []journal.Op{{Seq: 1, Lamport: 2, Device: "devB", Kind: journal.KindDelete, Path: "f"}}); err != nil {
t.Fatal(err)
}
all, err := s.AllOps()
if err != nil || len(all) != 2 {
t.Fatalf("AllOps = %v, %v", all, err)
}
devs, _ := s.Devices()
if len(devs) != 2 {
t.Fatalf("Devices = %v", devs)
}
cache := map[string]CachedFile{"f": {Blob: "b", Size: 1, MTimeNS: 42}}
if err := s.SaveCache(cache); err != nil {
t.Fatal(err)
}
got, err := s.LoadCache()
if err != nil || got["f"].MTimeNS != 42 {
t.Fatalf("cache roundtrip: %v %v", got, err)
}
st := SyncState{Lamport: 7, PushedOps: 3}
if err := s.SaveSync(st); err != nil {
t.Fatal(err)
}
gotSt, err := s.LoadSync()
if err != nil || gotSt != st {
t.Fatalf("sync state roundtrip: %v %v", gotSt, err)
}
}
func TestLock(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
unlock, err := s.Lock()
if err != nil {
t.Fatal(err)
}
if err := unlock(); err != nil {
t.Fatal(err)
}
// re-acquirable after unlock
unlock2, err := s.Lock()
if err != nil {
t.Fatal(err)
}
unlock2()
}
func TestWriteFileAtomic(t *testing.T) {
p := t.TempDir() + "/x.json"
if err := WriteFileAtomic(p, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(p)
if err != nil || string(b) != "data" {
t.Fatalf("got %q %v", b, err)
}
}
+579
View File
@@ -0,0 +1,579 @@
// Package syncer drives a volume's sync cycle:
//
// scan → commit local ops → pull peer journals → preserve conflicts →
// materialize merged state → push blobs + own journal
//
// Scanning always happens before pulling, so local edits are committed to the
// 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.
package syncer
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"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"
)
// Session ties a working folder to its volume store and (optionally) remote.
type Session struct {
Folder string
Store *store.Store
Device config.Device
Backend remote.Backend // nil = work offline
}
// Result summarizes one sync cycle.
type Result struct {
LocalOps int // local changes committed to the journal
PulledOps int // ops received from other devices
Conflicts int // conflict copies created
Materialized int // files written/removed in the working folder
Pushed bool // own journal/blobs uploaded
Offline bool // remote configured but unreachable this cycle
OfflineErr error
}
func (r *Result) Activity() bool {
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Materialized > 0
}
var ignoreNames = map[string]bool{".DS_Store": true}
var ignoreDirs = map[string]bool{".git": true, ".sfs": true}
func ignoredFile(name string) bool {
return ignoreNames[name] || strings.HasPrefix(name, ".sfs-tmp-")
}
// Cycle runs one full scan/sync/materialize pass under the volume lock.
func (s *Session) Cycle(ctx context.Context) (*Result, error) {
unlock, err := s.Store.Lock()
if err != nil {
return nil, fmt.Errorf("lock volume: %w", err)
}
defer unlock()
res := &Result{}
cache, err := s.Store.LoadCache()
if err != nil {
return nil, fmt.Errorf("load state: %w", err)
}
st, err := s.Store.LoadSync()
if err != nil {
return nil, fmt.Errorf("load sync state: %w", err)
}
myOps, err := s.Store.DeviceOps(s.Device.ID)
if err != nil {
return nil, fmt.Errorf("read own journal: %w", err)
}
// 1. Scan the working folder and journal any local changes.
localOps, err := s.scan(cache, &st, int64(len(myOps)))
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
if len(localOps) > 0 {
if err := s.Store.AppendOps(s.Device.ID, localOps); err != nil {
return nil, fmt.Errorf("append journal: %w", err)
}
myOps = append(myOps, localOps...)
res.LocalOps = len(localOps)
}
// 2. Pull journals + blobs from other devices.
var pulled []journal.Op
if s.Backend != nil {
pulled, err = s.pull(ctx)
if err != nil {
res.Offline = true
res.OfflineErr = err
}
res.PulledOps = len(pulled)
for _, op := range pulled {
if op.Lamport > st.Lamport {
st.Lamport = op.Lamport
}
}
}
// 3. Preserve losing local edits as conflict copies.
if len(pulled) > 0 {
conflictOps, err := s.conflictCopies(myOps, st.PushedOps, pulled, &st)
if err != nil {
return nil, err
}
if len(conflictOps) > 0 {
if err := s.Store.AppendOps(s.Device.ID, conflictOps); err != nil {
return nil, fmt.Errorf("append conflict ops: %w", err)
}
myOps = append(myOps, conflictOps...)
res.Conflicts = len(conflictOps)
}
}
// 4. Materialize the merged state into the working folder.
all, err := s.Store.AllOps()
if err != nil {
return nil, fmt.Errorf("read journals: %w", err)
}
target := journal.Replay(all)
n, err := s.materialize(target, cache)
if err != nil {
return nil, fmt.Errorf("materialize: %w", err)
}
res.Materialized = n
// 5. Push our blobs and journal.
if s.Backend != nil && !res.Offline && int64(len(myOps)) > st.PushedOps {
if err := s.push(ctx, myOps, &st); err != nil {
res.Offline = true
res.OfflineErr = err
} else {
res.Pushed = true
}
}
if err := s.Store.SaveCache(cache); err != nil {
return nil, err
}
if err := s.Store.SaveSync(st); err != nil {
return nil, err
}
return res, nil
}
// scan diffs the working folder against the state cache and returns ops for
// every local change, storing new content in the blob store.
func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64) ([]journal.Op, error) {
seen := make(map[string]bool, len(cache))
var ops []journal.Op
nextOp := func(kind, rel string) journal.Op {
st.Lamport++
seqBase++
return journal.Op{
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
Kind: kind, Path: rel,
}
}
err := filepath.WalkDir(s.Folder, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // skip unreadable entries
}
rel, err := filepath.Rel(s.Folder, p)
if err != nil || rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
if d.IsDir() {
if ignoreDirs[d.Name()] {
return fs.SkipDir
}
return nil
}
if !d.Type().IsRegular() || ignoredFile(d.Name()) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
seen[rel] = true
size, mt := info.Size(), info.ModTime().UnixNano()
mode := uint32(info.Mode().Perm())
c, ok := cache[rel]
if ok && c.Size == size && c.MTimeNS == mt {
return nil // unchanged (cheap path)
}
sum, n, err := s.Store.PutBlobFile(p)
if err != nil {
return nil // file vanished or unreadable; next cycle
}
if ok && c.Blob == sum {
// content unchanged, just touched
c.Size, c.MTimeNS, c.Mode = n, mt, mode
cache[rel] = c
return nil
}
op := nextOp(journal.KindPut, rel)
op.Blob, op.Size, op.Mode = sum, n, mode
ops = append(ops, op)
cache[rel] = store.CachedFile{Blob: sum, Size: n, Mode: mode, MTimeNS: mt}
return nil
})
if err != nil {
return nil, err
}
for rel := range cache {
if !seen[rel] {
ops = append(ops, nextOp(journal.KindDelete, rel))
delete(cache, rel)
}
}
return ops, nil
}
// pull fetches journals that grew on the remote and any blobs we are missing
// for the new ops. Returns only the ops we had not seen before.
func (s *Session) pull(ctx context.Context) ([]journal.Op, error) {
objs, err := s.Backend.List(ctx, "journal/")
if err != nil {
return nil, err
}
var newOps []journal.Op
for _, o := range objs {
name := strings.TrimPrefix(o.Key, "journal/")
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, "/") {
continue
}
dev := strings.TrimSuffix(name, ".jsonl")
if dev == s.Device.ID {
continue
}
lp := s.Store.JournalPath(dev)
var localSize int64
if fi, err := os.Stat(lp); err == nil {
localSize = fi.Size()
}
if o.Size <= localSize && localSize > 0 {
continue
}
rc, err := s.Backend.Get(ctx, o.Key)
if err != nil {
return newOps, err
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return newOps, err
}
fresh, err := journal.Parse(data)
if err != nil {
continue // corrupt remote journal; ignore rather than break sync
}
prev, err := s.Store.DeviceOps(dev)
if err != nil {
return newOps, err
}
if len(fresh) <= len(prev) {
continue
}
if err := store.WriteFileAtomic(lp, data, 0o644); err != nil {
return newOps, err
}
newOps = append(newOps, fresh[len(prev):]...)
}
// Fetch content for new ops. Blobs are uploaded before journals on push,
// so anything referenced should exist.
for _, op := range newOps {
if op.Kind != journal.KindPut || op.Blob == "" || s.Store.HasBlob(op.Blob) {
continue
}
rc, err := s.Backend.Get(ctx, "blobs/"+op.Blob)
if err != nil {
return newOps, fmt.Errorf("fetch blob %s: %w", op.Blob[:12], err)
}
sum, _, err := s.Store.PutBlobReader(rc)
rc.Close()
if err != nil {
return newOps, err
}
if sum != op.Blob {
return newOps, fmt.Errorf("blob %s corrupt on remote (got %s)", op.Blob[:12], sum[:12])
}
}
return newOps, nil
}
// conflictCopies detects paths edited concurrently — we hold a not-yet-pushed
// op and just pulled a competing op for the same path. Last-writer-wins
// resolves the path itself deterministically; here the device that observed
// the concurrency preserves the losing version (ours or the pulled one) as a
// conflict-copy file so no content is silently dropped.
func (s *Session) conflictCopies(myOps []journal.Op, pushed int64, pulled []journal.Op, st *store.SyncState) ([]journal.Op, error) {
if pushed > int64(len(myOps)) {
pushed = int64(len(myOps))
}
unpushed := map[string]journal.Op{}
for _, op := range myOps[pushed:] {
unpushed[op.Path] = op // latest local op per path
}
pulledLatest := map[string]journal.Op{}
for _, op := range pulled {
if _, ok := unpushed[op.Path]; !ok {
continue
}
if prev, ok := pulledLatest[op.Path]; !ok || journal.Less(prev, op) {
pulledLatest[op.Path] = op
}
}
if len(pulledLatest) == 0 {
return nil, nil
}
all, err := s.Store.AllOps()
if err != nil {
return nil, err
}
state := journal.Replay(all)
seqBase := int64(len(myOps))
var out []journal.Op
for p, theirs := range pulledLatest {
mine := unpushed[p]
cur, exists := state[p]
mineWon := (mine.Kind == journal.KindPut && exists && cur.Blob == mine.Blob) ||
(mine.Kind == journal.KindDelete && !exists)
loser := mine
if mineWon {
loser = theirs
}
if loser.Kind != journal.KindPut || loser.Blob == "" {
continue // a lost delete needs no preservation
}
if exists && cur.Blob == loser.Blob {
continue // identical content; nothing actually lost
}
if !s.Store.HasBlob(loser.Blob) {
continue // content unavailable (partial pull); skip rather than fail
}
st.Lamport++
seqBase++
out = append(out, journal.Op{
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
Kind: journal.KindPut, Path: conflictName(p, loser.DeviceName, loser.Time),
Blob: loser.Blob, Size: loser.Size, Mode: loser.Mode,
Note: "conflict copy of " + p,
})
}
return out, nil
}
func conflictName(p, deviceName string, t time.Time) string {
return p + ".sfs-conflict-" + sanitize(deviceName) + "-" + t.UTC().Format("20060102T150405Z")
}
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
default:
return '-'
}
}, s)
}
// materialize applies the merged state to the working folder, never
// clobbering files that changed since the scan earlier in this cycle.
func (s *Session) materialize(target map[string]journal.FileState, cache map[string]store.CachedFile) (int, error) {
changed := 0
for rel, want := range target {
c, ok := cache[rel]
if ok && c.Blob == want.Blob && c.Mode == want.Mode {
continue
}
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
if fi, err := os.Stat(abs); err == nil {
if ok && (fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS) {
continue // dirty: changed mid-cycle, next scan commits it
}
if !ok {
// Untracked file already at this path: adopt if identical,
// otherwise leave it for the next scan to journal.
sum, err := hashFile(abs)
if err != nil || sum != want.Blob {
continue
}
}
}
if !s.Store.HasBlob(want.Blob) {
continue // content not fetched yet; retry next cycle
}
if err := s.writeFile(abs, want); err != nil {
return changed, fmt.Errorf("write %s: %w", rel, err)
}
fi, err := os.Stat(abs)
if err != nil {
return changed, err
}
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
changed++
}
for rel, c := range cache {
if _, ok := target[rel]; ok {
continue
}
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
if fi, err := os.Stat(abs); err == nil {
if fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS {
continue // dirty; do not delete fresh local edits
}
if err := os.Remove(abs); err != nil {
return changed, err
}
pruneEmptyDirs(s.Folder, filepath.Dir(abs))
}
delete(cache, rel)
changed++
}
return changed, nil
}
func (s *Session) writeFile(abs string, want journal.FileState) error {
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
src, err := s.Store.OpenBlob(want.Blob)
if err != nil {
return err
}
defer src.Close()
tmp, err := os.CreateTemp(filepath.Dir(abs), ".sfs-tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
mode := os.FileMode(want.Mode)
if mode == 0 {
mode = 0o644
}
if err := os.Chmod(tmp.Name(), mode); err != nil {
return err
}
return os.Rename(tmp.Name(), abs)
}
// push uploads blobs referenced by unpushed ops, then the journal itself.
// Blob-before-journal ordering means peers never see an op whose content is
// missing.
func (s *Session) push(ctx context.Context, myOps []journal.Op, st *store.SyncState) error {
if st.PushedOps > int64(len(myOps)) {
st.PushedOps = int64(len(myOps))
}
uploaded := map[string]bool{}
for _, op := range myOps[st.PushedOps:] {
if op.Kind != journal.KindPut || op.Blob == "" || uploaded[op.Blob] {
continue
}
key := "blobs/" + op.Blob
ok, err := s.Backend.Exists(ctx, key)
if err != nil {
return err
}
if !ok {
f, err := s.Store.OpenBlob(op.Blob)
if err != nil {
return err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return err
}
err = s.Backend.Put(ctx, key, f, fi.Size())
f.Close()
if err != nil {
return err
}
}
uploaded[op.Blob] = true
}
jp := s.Store.JournalPath(s.Device.ID)
f, err := os.Open(jp)
if err != nil {
return err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
if err := s.Backend.Put(ctx, "journal/"+s.Device.ID+".jsonl", f, fi.Size()); err != nil {
return err
}
st.PushedOps = int64(len(myOps))
return nil
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func pruneEmptyDirs(root, dir string) {
root = filepath.Clean(root)
for {
dir = filepath.Clean(dir)
if dir == root || !strings.HasPrefix(dir, root+string(filepath.Separator)) {
return
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) > 0 {
return
}
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
// LogEntries returns the volume history, newest first.
func LogEntries(st *store.Store, pathFilter string, limit int) ([]journal.Op, error) {
all, err := st.AllOps()
if err != nil {
return nil, err
}
journal.Sort(all)
// reverse
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
all[i], all[j] = all[j], all[i]
}
if pathFilter != "" {
filtered := all[:0]
for _, op := range all {
if op.Path == pathFilter || strings.HasPrefix(op.Path, pathFilter+"/") || path.Dir(op.Path) == pathFilter {
filtered = append(filtered, op)
}
}
all = filtered
}
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, nil
}
+264
View File
@@ -0,0 +1,264 @@
package syncer
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/sfs/internal/config"
"github.com/runbear-io/sfs/internal/remote"
"github.com/runbear-io/sfs/internal/store"
)
// newDevice simulates one device: its own folder, volume store, and identity,
// all syncing through a shared file:// remote.
func newDevice(t *testing.T, name string, backend remote.Backend) *Session {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
if err != nil {
t.Fatal(err)
}
return &Session{
Folder: t.TempDir(),
Store: st,
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
Backend: backend,
}
}
func sharedRemote(t *testing.T) remote.Backend {
t.Helper()
be, err := remote.Open(context.Background(), "file://"+t.TempDir())
if err != nil {
t.Fatal(err)
}
return be
}
func write(t *testing.T, folder, rel, content string) {
t.Helper()
abs := filepath.Join(folder, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func read(t *testing.T, folder, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(folder, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("read %s: %v", rel, err)
}
return string(b)
}
func cycle(t *testing.T, s *Session) *Result {
t.Helper()
res, err := s.Cycle(context.Background())
if err != nil {
t.Fatal(err)
}
if res.Offline {
t.Fatalf("unexpected offline: %v", res.OfflineErr)
}
return res
}
func TestOfflineCycle(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, "notes/hello.md", "hi")
res := cycle(t, a)
if res.LocalOps != 1 {
t.Fatalf("LocalOps = %d, want 1", res.LocalOps)
}
// idempotent: second cycle sees no changes
res = cycle(t, a)
if res.Activity() {
t.Fatalf("second cycle should be quiet, got %+v", res)
}
}
func TestTwoDeviceSync(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// A creates files, B receives them
write(t, a.Folder, "doc.txt", "v1")
write(t, a.Folder, "sub/nested.txt", "deep")
cycle(t, a)
res := cycle(t, b)
if res.PulledOps != 2 || res.Materialized != 2 {
t.Fatalf("b pull: %+v", res)
}
if read(t, b.Folder, "doc.txt") != "v1" || read(t, b.Folder, "sub/nested.txt") != "deep" {
t.Fatal("content mismatch after sync")
}
// B edits, A receives the update
time.Sleep(10 * time.Millisecond) // ensure mtime moves
write(t, b.Folder, "doc.txt", "v2 from b")
cycle(t, b)
cycle(t, a)
if got := read(t, a.Folder, "doc.txt"); got != "v2 from b" {
t.Fatalf("a got %q", got)
}
// B deletes, A's copy disappears
os.Remove(filepath.Join(b.Folder, "sub", "nested.txt"))
cycle(t, b)
cycle(t, a)
if _, err := os.Stat(filepath.Join(a.Folder, "sub", "nested.txt")); !os.IsNotExist(err) {
t.Fatal("delete did not propagate")
}
// empty dir pruned
if _, err := os.Stat(filepath.Join(a.Folder, "sub")); !os.IsNotExist(err) {
t.Fatal("empty dir not pruned")
}
}
func TestHistoryTracksDeviceAndAuthor(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, "f.txt", "from a")
cycle(t, a)
cycle(t, b)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, "f.txt", "from b")
cycle(t, b)
cycle(t, a)
entries, err := LogEntries(a.Store, "f.txt", 0)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("want 2 history entries, got %d: %+v", len(entries), entries)
}
// newest first
if entries[0].Author != "devb@test" || entries[0].DeviceName != "devb" {
t.Fatalf("newest entry should be devb's: %+v", entries[0])
}
if entries[1].Author != "deva@test" {
t.Fatalf("oldest entry should be deva's: %+v", entries[1])
}
}
func TestConcurrentEditConflictPreserved(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// shared base
write(t, a.Folder, "shared.txt", "base")
cycle(t, a)
cycle(t, b)
// both edit before syncing
time.Sleep(10 * time.Millisecond)
write(t, a.Folder, "shared.txt", "edit from a")
write(t, b.Folder, "shared.txt", "edit from b")
cycle(t, a) // a pushes first
cycle(t, b) // b scans its edit, pulls a's, loses or wins deterministically
cycle(t, a) // a converges
cycle(t, b)
aContent := read(t, a.Folder, "shared.txt")
bContent := read(t, b.Folder, "shared.txt")
if aContent != bContent {
t.Fatalf("devices diverged: %q vs %q", aContent, bContent)
}
// both versions must survive somewhere (winner at path, loser as conflict copy)
all := map[string]bool{aContent: true}
for _, folder := range []string{a.Folder, b.Folder} {
entries, err := os.ReadDir(folder)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".sfs-conflict-") {
all[read(t, folder, e.Name())] = true
}
}
}
if !all["edit from a"] || !all["edit from b"] {
t.Fatalf("a version was lost; surviving: %v", all)
}
}
func TestMountExistingFolderImports(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, "pre-existing.txt", "I was here first")
res := cycle(t, a)
if res.LocalOps != 1 || !res.Pushed {
t.Fatalf("import failed: %+v", res)
}
b := newDevice(t, "devb", be)
cycle(t, b)
if read(t, b.Folder, "pre-existing.txt") != "I was here first" {
t.Fatal("existing file not imported/synced")
}
}
func TestIgnoredFiles(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, ".DS_Store", "junk")
write(t, a.Folder, ".git/config", "gitstuff")
write(t, a.Folder, "real.txt", "data")
res := cycle(t, a)
if res.LocalOps != 1 {
t.Fatalf("ignores leaked into journal: %+v", res)
}
}
func TestOfflineThenReconnect(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
// work offline
a.Backend = nil
write(t, a.Folder, "offline.txt", "written offline")
cycle(t, a)
// reconnect: pending ops push
a.Backend = be
res := cycle(t, a)
if !res.Pushed {
t.Fatalf("reconnect should push pending ops: %+v", res)
}
b := newDevice(t, "devb", be)
cycle(t, b)
if read(t, b.Folder, "offline.txt") != "written offline" {
t.Fatal("offline edit did not propagate after reconnect")
}
}
func TestExecutableBitPreserved(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
abs := filepath.Join(a.Folder, "run.sh")
os.WriteFile(abs, []byte("#!/bin/sh\necho hi\n"), 0o755)
cycle(t, a)
b := newDevice(t, "devb", be)
cycle(t, b)
fi, err := os.Stat(filepath.Join(b.Folder, "run.sh"))
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm()&0o100 == 0 {
t.Fatalf("exec bit lost: %v", fi.Mode())
}
}
+27
View File
@@ -0,0 +1,27 @@
# Homebrew formula for sfs.
#
# This is the source-build formula for the runbear-io/homebrew-tap repo.
# Releases via goreleaser (.goreleaser.yaml) generate a bottle-style formula
# with prebuilt binaries automatically; this file is the manual fallback and
# the template for the first tap publication. Update `url` and `sha256` per
# release (sha256: `curl -L <url> | shasum -a 256`).
class Sfs < Formula
desc "Synced file system for AI agents: mount, sync, and track folders"
homepage "https://github.com/runbear-io/sfs"
url "https://github.com/runbear-io/sfs/archive/refs/tags/v0.1.0.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000" # update per release
license "MIT"
head "https://github.com/runbear-io/sfs.git", branch: "main"
depends_on "go" => :build
def install
system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}"), "./cmd/sfs"
end
test do
assert_match "sfs", shell_output("#{bin}/sfs version")
ENV["SFS_HOME"] = testpath/".sfs"
system bin/"sfs", "whoami"
end
end