2026-06-11 23:40:26 -07:00
package main
import (
"fmt"
2026-08-03 16:20:51 +09:00
"strings"
2026-06-11 23:40:26 -07:00
"time"
2026-08-03 16:20:51 +09:00
"unicode"
2026-06-11 23:40:26 -07:00
"github.com/spf13/cobra"
2026-07-07 15:02:40 -07:00
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/daemon"
"github.com/runbear-io/beardrive/internal/journal"
2026-07-27 10:41:57 +09:00
"github.com/runbear-io/beardrive/internal/store"
2026-07-07 15:02:40 -07:00
"github.com/runbear-io/beardrive/internal/syncer"
2026-06-11 23:40:26 -07:00
)
func syncCmd () * cobra . Command {
2026-07-11 10:56:53 -07:00
var note string
var noteTTL time . Duration
2026-07-16 10:25:07 -07:00
var hookLabel string
2026-07-28 07:22:11 +09:00
var prune bool
2026-07-11 10:56:53 -07:00
c := & cobra . Command {
2026-06-11 23:40:26 -07:00
Use : "sync [folder]" ,
Short : "Sync a mounted folder with its remote now" ,
2026-07-28 07:22:11 +09:00
Long : `Run one sync cycle now: journal local changes, pull teammates' changes,
and push.
--prune additionally reconciles the hub against .bdriveignore: anything the
hub still holds that the ignore rules now exclude is removed from the hub
while staying on disk, here and on every teammate's device. That is the
2026-07-29 10:08:45 +09:00
cleanup path for files that synced before the rule was added.
It refuses outright when .bdriveignore narrows the sync scope with "!"
rules (what bdrive scope and init --only write): there, pruning would mean
removing everything outside the scope from the hub, for the whole team.
Drop specific paths with bdrive forget instead. A legacy per-device include
list in .bdrive/config.json is never pruned against either.` ,
2026-07-28 07:22:11 +09:00
Example : ` bdrive sync
bdrive sync --prune # also drop hub files that .bdriveignore now excludes` ,
Args : cobra . MaximumNArgs ( 1 ),
2026-06-11 23:40:26 -07:00
RunE : func ( cmd * cobra . Command , args [] string ) error {
folder , err := absFolder ( args )
if err != nil {
return err
}
2026-07-29 10:08:45 +09:00
// A folder resolves to the mount it is, the mount above it, or the
// mounts below it — a repo root with wiki/ and docs/ mounted syncs
// both, and a session inside a mount syncs its root.
targets := syncTargets ( folder )
syncOne := func ( target string ) error {
// Gate before openSession: hooks fire in every folder on every
// turn, and must never enroll this device or resume a paused
// project — that is `bdrive init`'s job alone.
proj , ok , err := config . LoadProject ( target )
if err != nil {
2026-07-11 10:56:53 -07:00
return err
}
2026-07-29 10:08:45 +09:00
if ! ok {
return fmt . Errorf ( "%s is not a beardrive project (run `bdrive init` there first)" , target )
}
switch syncBlocked ( proj ) {
case "init" :
return fmt . Errorf ( "%s is not synced on this device yet (run `bdrive init` there to connect it)" , target )
case "paused" :
return fmt . Errorf ( "syncing is paused for %s (run `bdrive init` there to resume)" , target )
}
sess , proj , err := openSession ( cmd . Context (), target , true )
if err != nil {
return err
}
defer closeSession ( sess )
if cmd . Flags (). Changed ( "note" ) {
// Persist the note so the daemon's own scans stamp it too —
// history then links every change from this working session
// to its context, not just the ones this invocation catches.
// An explicit empty --note clears it. Expires after --note-ttl.
if err := sess . Store . SaveNote ( note , noteTTL ); err != nil {
return err
}
sess . Note = note
}
sess . Prune = prune
sess . OnProgress = progressReporter ()
res , err := sess . Cycle ( cmd . Context ())
if err != nil {
return err
}
fmt . Printf ( "synced %s (project %q)\n" , target , proj . Volume )
printCycle ( res )
return nil
2026-07-11 10:56:53 -07:00
}
2026-07-29 10:08:45 +09:00
if hookLabel != "" {
// Agent-hook mode: event JSON on stdin, silent best-effort
2026-07-31 15:15:02 +09:00
// sync, link-formula context on stdout. Never fails. Every
// mount contributes its own prefix→URL pair; the JSON
// contract is one object, so they are emitted together after
// the loop.
sessionID := hookSessionID ( cmd )
var links [] hookLink
2026-07-29 10:08:45 +09:00
for _ , target := range targets {
proj , ok , err := config . LoadProject ( target )
if err != nil || ! ok || syncBlocked ( proj ) != "" {
continue
}
2026-08-10 14:33:20 -07:00
if h , ok := runHookSync ( cmd , target , sessionID , hookLabel ); ok {
link := hookLinkFor ( folder , target , h . base )
link . paths = h . paths
links = append ( links , link )
2026-07-29 10:08:45 +09:00
}
}
2026-07-31 15:15:02 +09:00
emitHookContext ( cmd , links )
2026-07-29 10:08:45 +09:00
return nil
}
if len ( targets ) == 0 {
return fmt . Errorf ( "%s is not a beardrive project (run `bdrive init` there first)" , folder )
}
if prune {
for _ , target := range targets {
if err := pruneSafe ( target ); err != nil {
return err
}
}
}
for _ , target := range targets {
if err := syncOne ( target ); err != nil {
return err
}
2026-06-11 23:40:26 -07:00
}
return nil
},
}
2026-07-28 07:22:11 +09:00
c . Flags (). BoolVar ( & prune , "prune" , false , "also remove from the hub what .bdriveignore now excludes (files stay on disk everywhere)" )
2026-07-11 10:56:53 -07:00
c . Flags (). StringVar ( & note , "note" , "" , "session context stamped onto changes (e.g. an agent session id); shown in history; empty clears" )
c . Flags (). DurationVar ( & noteTTL , "note-ttl" , 30 * time . Minute , "how long the note keeps applying to daemon-committed changes" )
2026-07-16 10:25:07 -07:00
c . Flags (). StringVar ( & hookLabel , "hook" , "" , "agent-hook mode: read the platform's hook event JSON from stdin, sync with a session note labeled by this value, and emit the project's link-formula context (Claude Code hook JSON) on stdout" )
2026-07-11 10:56:53 -07:00
return c
2026-06-11 23:40:26 -07:00
}
2026-07-29 10:08:45 +09:00
// pruneSafe refuses --prune on a mount whose rules narrow the scope. Prune
// removes from the hub everything the shared rules exclude — with "only
// these folders" rules that is everything else the project holds, deleted
// for every teammate on their next sync. Excluding one path is what
// `bdrive forget` is for.
func pruneSafe ( folder string ) error {
filter , err := syncer . LoadFilter ( folder , nil )
if err != nil {
return err
}
if ! filter . Negated () {
return nil
}
return fmt . Errorf ( "%s/.bdriveignore narrows the scope with `!` rules, so --prune would remove\n" +
"everything outside that scope from the hub — for every teammate, not just this device.\n" +
"drop specific paths with `bdrive forget <path>`, or widen the rules first" , folder )
}
2026-06-11 23:40:26 -07:00
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
}
2026-07-08 13:12:49 -07:00
proj , err := mustProject ( folder ) // also self-heals the registry
if err != nil {
return err
2026-06-11 23:40:26 -07:00
}
2026-07-08 13:12:49 -07:00
mounts = map [ string ] config . MountInfo { proj . ID : { Path : folder , Volume : proj . Volume , Remote : proj . Remote }}
2026-06-11 23:40:26 -07:00
}
if len ( mounts ) == 0 {
2026-07-08 13:12:49 -07:00
fmt . Println ( "no beardrive projects on this device (run `bdrive init` in a folder)" )
2026-06-11 23:40:26 -07:00
return nil
}
dev , err := config . LoadDevice ()
if err != nil {
return err
}
2026-07-18 21:24:00 -07:00
if settings , _ := config . LoadSettings (); settings . Email != "" {
who := settings . Email
if settings . Name != "" {
who = settings . Name + " <" + settings . Email + ">"
}
2026-08-03 16:20:51 +09:00
// The account name/email come from the hub, like the rows below.
fmt . Printf ( "device: %s (%s) signed in as %s\n\n" , dev . Name , dev . ID , safeField ( who , 160 ))
2026-07-18 21:24:00 -07:00
} else {
fmt . Printf ( "device: %s (%s) as %s\n\n" , dev . Name , dev . ID , dev . Author )
}
2026-06-11 23:40:26 -07:00
first := true
2026-07-08 13:12:49 -07:00
for id , mi := range mounts {
2026-06-11 23:40:26 -07:00
if ! first {
fmt . Println ()
}
first = false
2026-07-08 13:12:49 -07:00
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
2026-07-07 14:05:36 -07:00
}
2026-06-11 23:40:26 -07:00
fmt . Printf ( "%s\n" , folder )
2026-08-03 16:20:51 +09:00
// Volume and Remote come out of .bdrive/config.json, and
// Volume comes originally from the hub's project name — any
// org member's string, reaching a terminal. Same treatment as
// `bdrive log`'s rows.
fmt . Printf ( " project: %s (%s)\n" , safeField ( mi . Volume , 120 ), id )
2026-06-11 23:40:26 -07:00
if mi . Remote != "" {
2026-08-03 16:20:51 +09:00
fmt . Printf ( " remote: %s\n" , safeField ( mi . Remote , 200 ))
2026-06-11 23:40:26 -07:00
} else {
fmt . Printf ( " remote: (none — local only)\n" )
}
2026-07-08 13:12:49 -07:00
vdir , err := config . VolumeDir ( id )
2026-06-11 23:40:26 -07:00
if err != nil {
return err
}
2026-07-08 13:12:49 -07:00
if pid , ok := daemon . Running ( vdir ); ok {
2026-06-11 23:40:26 -07:00
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
}
2026-07-08 13:12:49 -07:00
cache , err := sess . Store . LoadCache ( id )
2026-06-11 23:40:26 -07:00
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 )
2026-07-27 10:41:57 +09:00
switch st . Access {
case store . AccessReadOnly :
fmt . Printf ( " access: read-only (pull only) — %d local change(s) stay on this device\n" , pending )
case store . AccessNone :
fmt . Printf ( " access: no access to this project — sync paused\n" )
}
2026-08-10 20:33:26 -07:00
// `status` is the command someone runs when sync is stuck, and
// it never talks to the hub — so the refusal it reports is only
// as useful as the reason the last cycle recorded with it.
if st . AccessReason != "" {
fmt . Printf ( " reason: %s\n" , safeField ( st . AccessReason , 300 ))
}
2026-06-11 23:40:26 -07:00
}
}
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
}
2026-07-30 21:49:23 +09:00
// Limit after the display sort, not before: -n 25 means the 25
// newest by the time shown, not the 25 highest lamport.
entries , err := syncer . LogEntries ( sess . Store , pathFilter , 0 )
2026-06-11 23:40:26 -07:00
if err != nil {
return err
}
2026-07-30 21:49:23 +09:00
syncer . SortForDisplay ( entries )
if limit > 0 && len ( entries ) > limit {
entries = entries [: limit ]
}
out := cmd . OutOrStdout ()
2026-06-11 23:40:26 -07:00
if len ( entries ) == 0 {
2026-07-30 21:49:23 +09:00
fmt . Fprintln ( out , "no history yet" )
2026-06-11 23:40:26 -07:00
return nil
}
for _ , op := range entries {
2026-07-30 21:49:23 +09:00
when := syncer . DisplayTime ( op ). Local (). Format ( "2006-01-02 15:04:05" )
2026-06-11 23:40:26 -07:00
kind := op . Kind
if kind == journal . KindPut {
kind = "put "
} else {
kind = "delete"
}
2026-07-18 21:24:00 -07:00
// Prefer the signed-in account over the git/OS author fallback,
// so team history shows hub identities.
who := op . UserName
if who == "" {
who = op . User
}
if who == "" {
who = op . Author
}
2026-08-03 16:20:51 +09:00
line := fmt . Sprintf ( "%s %s %-40s %s on %s" , when , kind ,
safeField ( op . Path , 160 ), safeField ( who , 64 ), safeField ( op . DeviceName , 64 ))
2026-06-11 23:40:26 -07:00
if op . Kind == journal . KindPut {
line += fmt . Sprintf ( " (%s)" , humanBytes ( op . Size ))
}
2026-08-03 16:20:51 +09:00
if note := safeField ( op . Note , 200 ); note != "" {
line += " [" + note + "]"
2026-06-11 23:40:26 -07:00
}
2026-07-30 21:49:23 +09:00
fmt . Fprintln ( out , line )
2026-06-11 23:40:26 -07:00
}
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
}
2026-08-03 16:20:51 +09:00
// safeField prepares a string that came out of a peer's journal for a terminal
// row. Every string `bdrive log` and `bdrive restore --list` print — Path,
// Note, User, UserName, Author, DeviceName — is arbitrary JSON someone else
// wrote, and a terminal executes what it is handed: ESC sequences repaint
// rows, clear the scrollback, set the window title, write the system clipboard
// (OSC 52) and, through DECRQSS/CPR, make some emulators type a reply onto the
// shell; a lone CR redraws the row that was just printed as something else; a
// newline forges a whole entry. The audit tool an operator uses to catch a
// peer must not be renderable BY that peer.
//
// So: no C0 or DEL, one entry is one line, and each part is bounded — 50 rows
// of log is also owned by one 40 KB entry that scrolls the rest away.
func safeField ( s string , max int ) string {
s = strings . Map ( func ( r rune ) rune {
switch {
case r < 0x20 , r == 0x7f :
return - 1
// C1, U+0080..U+009F. In a UTF-8 terminal these arrive as two bytes
// and xterm and its descendants decode them straight back to 8-bit
// controls: U+009B IS CSI, U+009D IS OSC, U+0090 IS DCS, U+0085 IS
// NEL. The whole escape vocabulary, with no ESC byte anywhere.
case r >= 0x80 && r <= 0x9f :
return - 1
// Every format character (category Cf) plus the tag block, as a
// CLASS. The bidirectional controls this used to enumerate (Trojan
// Source, CVE-2021-42574) are Cf: not control characters by Unicode's
// own definition, so they survive every C0/C1 filter, and one U+202E
// draws the rest of the row right-to-left — the columns naming the
// actor and the device come after the path on the same line.
//
// The class, not the list, for the reason journal.SafeText and
// webapp.trimText arrived at the same rule in round 13: an enumeration
// grows by neighbours and misses the rest. U+E0020..U+E007F encodes all
// of printable ASCII with no glyph at all, and this output is read by
// agents as often as by people — `bdrive status` and `bdrive log` land
// in a session's context verbatim.
case unicode . Is ( unicode . Cf , r ), r >= 0xe0000 && r <= 0xe01ef :
return - 1
}
return r
}, s )
if len ( s ) > max {
s = strings . ToValidUTF8 ( s [: max ], "" ) + "…"
}
return s
}
2026-06-11 23:40:26 -07:00
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" )
2026-06-12 08:46:44 -07:00
run . Flags (). DurationVar ( & remoteInterval , "remote-interval" , 10 * time . Second , "remote sync interval" )
2026-06-11 23:40:26 -07:00
c . AddCommand ( run )
return c
}