Files
beardrive/cmd/bdrive/cmds.go
T
Snow LeeandClaude Fable 5 2d7f2f8bfa feat: auth, move-proof projects, interactive init/login, web history
Authentication (previous phase, now landed together with its follow-ups):
- Email+password+name accounts behind an AuthProvider interface; the OSS
  server ships BuiltinAuth only (file-backed auth.json: bcrypt password
  hashes + SHA-256 token digests, plaintext never stored; server-owned
  /auth/* pages; managed deployments can swap in another provider).
- bdrive login: loopback-callback browser flow (sign-up on the page, the
  terminal finishes itself) with a device-code fallback for headless
  machines; long-lived revocable device tokens in settings.json.
- Password reset via plain SMTP (stdlib) with a log-link fallback when no
  SMTP is configured.

Move-proof projects:
- .bdrive is now a directory; config.json carries a stable mount id.
  The volume store (~/.bdrive/volumes/<mount-id>/) and registry are keyed
  by that id — never the folder path — so renames/moves are free.
- The daemon re-reads the project config each tick and exits cleanly
  (propagating nothing) when its folder vanishes; the registry self-heals
  and the next bdrive command at the new location resumes with zero
  spurious changes.

bdrive init is the front door (mnt/umnt removed; bdrive stop pauses):
- Interactive on a TTY (create new / connect existing project from the
  server's list; whole folder / shared subfolder via the include list),
  full flag bypass (--name/--project/--shared/--yes), never prompts
  without a TTY. Runs the login flow first when there is no session.
  Default server: beardrive.ai (config.DefaultServer).

Web history (revert-ready):
- Hubs now always require auth; journal ops carry the signed-in account
  (user/user_name) alongside the git/OS fallback author.
- File-backed device registry: per-device name, OS, account, and the
  public IP the server observed, joined into history at read time.
- GET /api/p/<id>/history?path=|prefix= (newest first) and
  GET /api/p/<id>/blob?sha= stream any exact version — blobs are retained
  forever, so the next phase's revert is re-putting an old blob.
- UI: History button (file versions or project feed), per-folder history
  shortcut, view/download of any past version.

Tests: auth flows (callback, device-code, reset single-use, persistence,
gating), history API + device registry, folder-move survival, registry
self-heal, ops-carry-account; docs (README/SKILL/CLAUDE) updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 13:12:49 -07:00

257 lines
6.9 KiB
Go

package main
import (
"fmt"
"net/url"
"time"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/daemon"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/syncer"
)
func syncCmd() *cobra.Command {
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, proj, 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 (project %q)\n", folder, proj.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
}
proj, err := mustProject(folder) // also self-heals the registry
if err != nil {
return err
}
mounts = map[string]config.MountInfo{proj.ID: {Path: folder, Volume: proj.Volume, Remote: proj.Remote}}
}
if len(mounts) == 0 {
fmt.Println("no beardrive projects on this device (run `bdrive init` in a 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 id, mi := range mounts {
if !first {
fmt.Println()
}
first = false
folder := mi.Path
if proj, ok, err := config.LoadProject(folder); err == nil && ok {
mi.Volume, mi.Remote = proj.Volume, proj.Remote // folder config wins
} else {
fmt.Printf("%s\n (folder missing — moved or deleted; run `bdrive init` at its new location)\n", folder)
continue
}
fmt.Printf("%s\n", folder)
fmt.Printf(" project: %s (%s)\n", mi.Volume, id)
if mi.Remote != "" {
fmt.Printf(" remote: %s\n", mi.Remote)
} else {
fmt.Printf(" remote: (none — local only)\n")
}
vdir, err := config.VolumeDir(id)
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(id)
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
}
proj, err := mustProject(folder)
if err != nil {
return err
}
if proj.Remote == "" {
fmt.Println("(none)")
} else {
fmt.Println(proj.Remote)
}
return nil
},
}
set := &cobra.Command{
Use: "set <folder> <url>",
Short: "Set the remote (s3://bucket/prefix, gs://bucket/prefix, file:///path, https://bdrive-server)",
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" && u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("invalid remote %q (want s3://bucket/prefix, gs://bucket/prefix, file:///path, or https://bdrive-server)", raw)
}
proj, err := mustProject(folder)
if err != nil {
return err
}
proj.Remote = raw
if proj, err = config.SaveProject(folder, proj); err != nil {
return err
}
if _, _, err := config.ResolveMount(folder); err != nil { // sync the registry
return err
}
fmt.Printf("remote of %s set to %s\n", folder, raw)
fmt.Println("run `bdrive 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", 10*time.Second, "remote sync interval")
c.AddCommand(run)
return c
}