Files
beardrive/cmd/bdrive/helpers.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

105 lines
2.7 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
func absFolder(args []string) (string, error) {
arg := "."
if len(args) > 0 {
arg = args[0]
}
return filepath.Abs(arg)
}
// mustProject resolves a folder's project settings (from .bdrive/config.json,
// self-healing the registry when the folder moved).
func mustProject(folder string) (config.Project, error) {
proj, found, err := config.ResolveMount(folder)
if err != nil {
return proj, err
}
if !found {
return proj, fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder)
}
if proj.Volume == "" {
proj.Volume = filepath.Base(folder)
}
return proj, nil
}
// openSession builds a syncer session for a project 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.Project, error) {
proj, err := mustProject(folder)
if err != nil {
return nil, proj, err
}
dev, err := config.LoadDevice()
if err != nil {
return nil, proj, err
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
return nil, proj, err
}
st, err := store.Open(vdir)
if err != nil {
return nil, proj, err
}
settings, _ := config.LoadSettings()
sess := &syncer.Session{Folder: folder, MountID: proj.ID, Store: st, Device: dev, Account: settings}
if withRemote && proj.Remote != "" {
be, err := remote.Open(ctx, proj.Remote)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: remote unavailable, working offline: %v\n", err)
} else {
sess.Backend = be
}
}
return sess, proj, 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 *.bdrive-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")
}
}