Files
beardrive/cmd/bdrive/helpers.go
Snow LeeandClaude Opus 5 62c241d3d6 feat(hooks): register agent sync hooks per machine, not per project
Agent platforms read hook config only from the directory a session starts
in — never a parent, never a subfolder. Project-level hooks therefore fired
only for sessions that happened to start at the mount, and, living inside a
synced folder, they replicated one machine's agent config to the whole team
(a second writer of a file bdrive already owns). Claude Code additionally
ignores project hooks until the folder is trusted, so in practice they were
often inert without any visible sign.

Hooks now go to each platform's user config, once per machine, covering
every session in every folder; the existing shell guard keeps them a no-op
outside BearDrive projects. Install migrates away blocks older versions
wrote into projects, and `bdrive hooks uninstall` removes ours while leaving
foreign hooks untouched.

Setup is also one command now. init absorbs the skill install, prints the
hub link, and takes --server, so connecting to a named hub no longer needs a
separate login; the runbook forbids preflight and command chaining, since
each distinct command costs the user a permission prompt. For plugin users a
PreToolUse hook auto-approves bdrive's own setup subcommands — narrowly: any
shell operator in the command disqualifies it.

Also drops --shared in favor of `init . --only wiki,docs`, which writes a
managed block of .bdriveignore rules instead of a second scope mechanism.
Because those rules sync, `sync --prune` now refuses on a scoped project
rather than stripping everything outside the scope from the hub for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ
2026-07-28 17:48:54 -07:00

199 lines
5.9 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mattn/go-isatty"
"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"
)
// stdinIsTTY is the one answer to "is this an interactive shell?" — used both
// to decide whether init may prompt and whether login can drive a browser.
func stdinIsTTY() bool {
return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())
}
func absFolder(args []string) (string, error) {
arg := "."
if len(args) > 0 {
arg = args[0]
}
return filepath.Abs(arg)
}
// findMountRoot walks up from folder to the nearest mount root. An agent
// session usually starts wherever the user opened their editor, which may be
// inside a synced subfolder rather than at its root.
func findMountRoot(folder string) (string, bool) {
for cur := folder; ; cur = filepath.Dir(cur) {
if config.IsMount(cur) {
return cur, true
}
if filepath.Dir(cur) == cur {
return "", false
}
}
}
// mountsUnder lists the registered mount roots at or below folder, sorted.
// The registry is the only thing that knows about mounts a walk up can't see.
func mountsUnder(folder string) []string {
mounts, err := config.LoadMounts()
if err != nil {
return nil
}
root := resolvePath(folder) + string(filepath.Separator)
var out []string
for _, mi := range mounts {
if mi.Path != "" && strings.HasPrefix(resolvePath(mi.Path)+string(filepath.Separator), root) {
out = append(out, mi.Path)
}
}
sort.Strings(out)
return out
}
// resolvePath expands symlinks so paths that name the same directory compare
// equal: a mount registered as /private/tmp/x must still be found from a
// session in /tmp/x (macOS symlinks /tmp, /var, and /etc).
func resolvePath(p string) string {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r
}
return p
}
// syncTargets resolves which mount roots a sync at folder covers: the folder
// itself, else the mount enclosing it (session started inside a synced
// subfolder), else every mount below it (session started at a repo root whose
// wiki/ and docs/ are the mounts). Without this, turn hooks fire only when the
// session's directory happens to be exactly the mount root.
func syncTargets(folder string) []string {
if config.IsMount(folder) {
return []string{folder}
}
if root, ok := findMountRoot(folder); ok {
return []string{root}
}
return mountsUnder(folder)
}
// 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
}
// syncBlocked reports why syncing must not run for a project on this device:
// "init" when the mount was never enrolled here (.bdrive/config.json travels
// with the folder — e.g. arrives in a git clone — so its presence alone is
// not consent to sync; only `bdrive init` enrolls a device), "paused" after
// `bdrive stop`, "" to proceed. Deliberately reads the registry without
// ResolveMount's self-heal, which would enroll as a side effect.
func syncBlocked(proj config.Project) string {
mounts, err := config.LoadMounts()
if err != nil {
return "init"
}
if _, enrolled := mounts[proj.ID]; !enrolled {
return "init"
}
if vdir, err := config.VolumeDir(proj.ID); err == nil && store.Paused(vdir) {
return "paused"
}
return ""
}
// 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)
}
if res.Pruned > 0 {
fmt.Printf(" pruned: %d path(s) removed from the hub (kept on disk)\n", res.Pruned)
}
fmt.Printf(" files updated: %d\n", res.Materialized)
switch {
case res.NoAccess:
fmt.Printf(" remote: no access — sync paused (ask a project admin for access)\n")
case res.ReadOnly:
fmt.Printf(" remote: read-only (pull only) — local changes stay on this device\n")
case res.Offline:
fmt.Printf(" remote: offline (%v)\n", res.OfflineErr)
case res.Pushed:
fmt.Printf(" remote: pushed\n")
}
}