Files
beardrive/internal/config/project.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

94 lines
2.9 KiB
Go

package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ProjectDir is the per-folder settings directory at the mount root. It
// carries the mount's stable identity, so a project keeps syncing after the
// folder is renamed or moved — nothing is keyed by the path. It travels with
// the folder (copy the folder to a new machine and `bdrive init` resumes the
// same project) but is never synced, and it holds no session credentials —
// those stay in the bdrive home.
const ProjectDir = ".bdrive"
// Project holds the settings stored in <folder>/.bdrive/config.json.
type Project struct {
// ID is the stable mount identity (m-xxxxxxxx). The volume store, the
// daemon, and the registry are keyed by it, never by the folder path.
ID string `json:"id"`
Volume string `json:"volume,omitempty"`
Remote string `json:"remote,omitempty"`
// Include optionally narrows what syncs: when non-empty, only paths
// matching one of these patterns (gitignore-style, same syntax as
// .bdriveignore) are scanned and materialized.
Include []string `json:"include,omitempty"`
}
// NewMountID mints a stable mount identity.
func NewMountID() string {
b := make([]byte, 4)
rand.Read(b)
return "m-" + hex.EncodeToString(b)
}
func projectConfigPath(folder string) string {
return filepath.Join(folder, ProjectDir, "config.json")
}
// LoadProject reads <folder>/.bdrive/config.json; ok is false if it does not
// exist.
func LoadProject(folder string) (Project, bool, error) {
var p Project
data, err := os.ReadFile(projectConfigPath(folder))
if err != nil {
if os.IsNotExist(err) {
return p, false, nil
}
return p, false, err
}
if err := json.Unmarshal(data, &p); err != nil {
return p, false, fmt.Errorf("parse %s: %w", projectConfigPath(folder), err)
}
return p, true, nil
}
// SaveProject writes <folder>/.bdrive/config.json, assigning a mount ID on
// first save.
func SaveProject(folder string, p Project) (Project, error) {
if p.ID == "" {
p.ID = NewMountID()
}
if err := os.MkdirAll(filepath.Join(folder, ProjectDir), 0o755); err != nil {
return p, err
}
return p, writeJSON(projectConfigPath(folder), p)
}
// ResolveMount loads a folder's project settings and self-heals the
// registry: if the folder was renamed or moved, the registry entry is
// updated to the new path so `bdrive status` and the daemon find it again.
func ResolveMount(folder string) (Project, bool, error) {
p, ok, err := LoadProject(folder)
if err != nil || !ok {
return p, ok, err
}
mounts, err := LoadMounts()
if err != nil {
return p, true, err
}
mi, registered := mounts[p.ID]
if !registered || mi.Path != folder || mi.Volume != p.Volume || mi.Remote != p.Remote {
mounts[p.ID] = MountInfo{Path: folder, Volume: p.Volume, Remote: p.Remote}
if err := SaveMounts(mounts); err != nil {
return p, true, err
}
}
return p, true, nil
}