mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(cli): agent hooks never sync folders this device didn't opt into (#44)
* fix(cli): agent hooks never sync folders this device didn't opt into The turn hooks decided "this folder is managed" from the mere presence of .bdrive/config.json — a file designed to travel with the folder. Two holes: - A config.json arriving via git clone / copied dir made one hook firing silently mint a device identity, register the mount, create a volume store, journal the whole folder, and inject the hub-link formula — on a device that never ran init or login. - `bdrive stop` only killed the daemon: the next agent turn's `bdrive sync --hook` resumed a full sync cycle and kept injecting links, and `stop --forget` was undone within one turn by registry self-heal. Fix: one gate (`syncBlocked`) in the paths all hooks route through — sync/sync --hook/read-log now require the mount to already be enrolled in this device's mounts.json (read without ResolveMount's enrolling self-heal) and not paused. Hook mode exits silently; plain `bdrive sync` errors with a `bdrive init` pointer. New per-device paused marker in the volume dir: set by `bdrive stop`, cleared by `bdrive init` (startSync). Only init enrolls or resumes; folder moves still self-heal since enrollment is keyed by mount id, not path. Docs updated (README, SKILL.md, docs cli reference, CHANGELOG). Tests: hook/read-log no-op + no-enrollment on unenrolled and paused mounts, plain-sync refusals, stop→pause→forget regression, paused marker contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: architecture-diagram PRs must show before/after excerpts of changed classes The "Architecture changes" PR section now names exactly what changed and shows Before and After mermaid excerpts scoped to the affected classes and their immediate relationships — never the full diagram (Before = merge base). Convention updated in CLAUDE.md, architecture/README.md, and the pre-PR hook's reminder text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: add cli-sync architecture diagram; widen diagram convention to the CLI architecture/cli-sync.md draws the CLI and sync engine (cmd/bdrive + internal/{syncer,store,journal,config,daemon,agenthooks}): the Session cycle over Store/journal/remote, and the command layer with the new syncBlocked opt-in gate, paused marker, and enrollment ownership. The pre-PR hook and CLAUDE.md now watch these packages too, so CLI-side structural changes trigger the before/after-excerpt convention the same way server changes do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: full-coverage architecture diagrams — overview, frontend, agentskills Every application package is now drawn somewhere: overview.md (system diagram — package map, device↔hub↔storage flow, agent surfaces, and the private cloud/ repo as an external seam consumer), webapp-frontend.md (the hub SPA's modules: App/HubApp/VolumeApp/Browser, the in-repo nav/router, api layer, hooks, components), and agentskills added to cli-sync.md. The pre-PR hook now watches all of cmd/, internal Go code, and frontend/src (generated static/ excluded); CLAUDE.md and architecture/README.md state the coverage rule: every code change lands in exactly one detail diagram's scope, web/docs and cloud/ deliberately excluded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: PR bodies start with a TL;DR — max 5 informal one-liners Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4f5f517a6f
commit
6628718f1d
@@ -12,17 +12,23 @@ case "$cmd" in
|
||||
*skip-diagram-check*) exit 0 ;;
|
||||
esac
|
||||
base=$(git merge-base origin/main HEAD 2>/dev/null || git merge-base main HEAD 2>/dev/null) || exit 0
|
||||
code=$(git diff --name-only "$base" HEAD -- 'internal/webapp/*.go' 'internal/remote/*.go')
|
||||
# Every application package is drawn in architecture/ (see its README);
|
||||
# frontend static/ is generated output, excluded via the src-only pathspec.
|
||||
code=$(git diff --name-only "$base" HEAD -- 'cmd/' 'internal/*.go' \
|
||||
'internal/webapp/frontend/src/')
|
||||
diag=$(git diff --name-only "$base" HEAD -- architecture/)
|
||||
if [ -n "$code" ] && [ -z "$diag" ]; then
|
||||
cat >&2 <<EOF
|
||||
This branch changes server code but architecture/ is untouched:
|
||||
This branch changes code covered by architecture/ diagrams but architecture/ is untouched:
|
||||
$code
|
||||
|
||||
Before creating the PR: if any of these change types or relationships drawn
|
||||
in architecture/, update the affected diagram, commit it, and embed the
|
||||
changed diagram(s) in the PR body. If nothing structural changed, re-run the
|
||||
same command with '# skip-diagram-check' appended.
|
||||
in architecture/, update the affected diagram, commit it, and add an
|
||||
"Architecture changes" section to the PR body: say what changed, then show
|
||||
Before and After mermaid excerpts of ONLY the affected classes and their
|
||||
immediate relationships (Before = diagram at the merge base), never the full
|
||||
diagram. If nothing structural changed, re-run the same command with
|
||||
'# skip-diagram-check' appended.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@@ -6,6 +6,16 @@ minor versions may ship breaking changes (see [SemVer §4](https://semver.org/#s
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Fix: agent hooks no longer sync (or inject hub links into) folders this
|
||||
device never opted into.** `bdrive sync`/`sync --hook`/`read-log` now
|
||||
require the mount to be enrolled here — a `.bdrive/config.json` that
|
||||
merely arrived with a folder (git clone, copied dir) is inert until
|
||||
`bdrive init`; previously one hook firing silently minted a device
|
||||
identity, registered the mount, and journaled the whole folder. And
|
||||
`bdrive stop` now truly pauses: it sets a per-device paused marker that
|
||||
gates the hooks and `bdrive sync` (which previously resumed a stopped
|
||||
project every agent turn and re-registered even after `stop --forget`);
|
||||
only `bdrive init` resumes.
|
||||
- **`bdrive skill install`** — the binary now carries the `beardrive`
|
||||
skill and installs it into any agent that reads `SKILL.md`
|
||||
(`~/.claude|.codex|.gemini|.hermes/skills/beardrive/`), idempotently;
|
||||
|
||||
@@ -73,9 +73,13 @@ The real coverage is the integration tests in `internal/syncer/syncer_test.go`:
|
||||
|
||||
`web/docs/` is the public documentation at docs.beardrive.ai — Astro 7 + [Starlight](https://starlight.astro.build) (Starlight requires Astro ^7; the cloud landing is still on Astro 5 — separate projects, so they upgrade independently), static output, Pagefind search, `llms.txt` via `starlight-llms-txt`. Unlike the hub frontend (`internal/webapp/static`) and the cloud landing page (`cloud/internal/landing/dist`) it is **not** `go:embed`ed: docs change far more often than the binary, and a search index has no business shipping in every self-hoster's install. It deploys on its own from `dist/`, so `go build` never touches it. Sidebar order is explicit in `astro.config.mjs` — a new page under `src/content/docs/` is invisible until listed there, and every page needs a `description` (meta description, search snippet, and `llms.txt` line). **The sidebar order is the recommended path, and that path is agent-first**: `Start here` (what it is → set up with your agent → your first hour) never mentions installing a binary; the CLI route lives in `Manual setup (optional)` (install the CLI, set up by hand, skills and hooks in detail) — same destination, one click away, never on the critical path. New onboarding content belongs in `Start here` and should say what to ask an agent, not what to type. `Use cases` (after `Manual setup`) holds job-shaped pages — "Share work across your team's agents", "Turn a personal brain into a company brain" — with the persona named in the first line and the `description`, not in the title; they route into the guides and never re-teach a feature. Moved URLs keep `redirects` entries in `astro.config.mjs` (static builds emit meta-refresh; real 301s live in the host config, see `web/docs/README.md`). **Guides are agent-workflow docs, not CLI tutorials** (`Working with agents`: shared agent memory, artifacts/links, read heat, scoping); command-by-command detail belongs in `reference/cli.md`. Design tokens are **generated, never copied**: `scripts/tokens.mjs` reads the `@theme` block in `internal/webapp/frontend/src/tw.css` and emits the gitignored `src/styles/tokens.gen.css`, which `src/styles/custom.css` maps onto Starlight's `--sl-color-*` — so the palette cannot drift and there is no checker to maintain (contrast the cloud landing, which keeps a copy policed by its own `check-tokens.mjs`). Because the build reads that file *outside* `web/docs`, a deploy host must check out the whole repo, not the subdirectory. Note `llms.txt` convention wants the root domain, so `beardrive.ai/llms.txt` should point at the docs subdomain — that redirect lives in the cloud landing and is the one cross-repo coordination point.
|
||||
|
||||
## PR descriptions
|
||||
|
||||
Every PR body starts with a `## TL;DR` section: at most 5 bullet points, one short informal sentence each — fewer is better. Lead with the user-visible symptom/outcome, not the mechanism; include a known-gap bullet if the PR knowingly leaves one. Detail sections follow after.
|
||||
|
||||
## Architecture diagrams in PRs
|
||||
|
||||
`architecture/` holds mermaid class diagrams of the server (`webapp-server.md`). Before `gh pr create`: if the branch changes types or relationships drawn there (new/removed types, new seams, changed fields/implements/ownership in `internal/webapp` or `internal/remote`), update the affected diagram and commit it on the branch, then include ONLY the changed diagrams' mermaid blocks in the PR description under an "Architecture changes" section (GitHub renders them). No structural change → no section, and append `# skip-diagram-check` to the `gh pr create` command to satisfy the pre-PR hook.
|
||||
`architecture/` holds mermaid diagrams covering every application package in the repo: `overview.md` (system diagram — the package map and how the pieces connect), `cli-sync.md` (the CLI and sync engine — `cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,agentskills}`), `webapp-server.md` (the `bdrive web` server — `internal/webapp` + `internal/remote`), and `webapp-frontend.md` (the hub's React SPA — `internal/webapp/frontend/src`). Every code change lands in exactly one detail diagram's scope; `overview.md` changes only when packages appear/disappear or the cross-piece wiring changes (`web/docs` and the private `cloud/` repo are deliberately out of scope). Before `gh pr create`: if the branch changes types or relationships drawn in any of them (new/removed types, new seams, changed fields/implements/ownership in those packages), update the affected diagram and commit it on the branch, then add an "Architecture changes" section to the PR description with, per changed diagram: (1) a sentence naming exactly which types/relationships changed and how, (2) a **Before** mermaid block and an **After** mermaid block (GitHub renders them), each an *excerpt* containing only the affected classes and their immediate relationships — never paste the full diagram. The committed diagram file stays the full current state; the before/after excerpts live only in the PR description (take Before from the diagram at the merge base). No structural change → no section, and append `# skip-diagram-check` to the `gh pr create` command to satisfy the pre-PR hook.
|
||||
|
||||
## Docs to keep in sync
|
||||
|
||||
|
||||
@@ -108,7 +108,8 @@ bdrive log
|
||||
# Check sync state and the daemon
|
||||
bdrive status
|
||||
|
||||
# Stop syncing (files stay on disk; bdrive init resumes any time)
|
||||
# Stop syncing — pauses everything, including agent turn hooks
|
||||
# (files stay on disk; bdrive init resumes any time)
|
||||
bdrive stop
|
||||
```
|
||||
|
||||
@@ -138,7 +139,7 @@ hub's own storage, never something a syncing client points at directly:
|
||||
| `bdrive login [server-url]` | Sign this device in (browser flow; `--device` forces the code flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login <new-url>` |
|
||||
| `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) |
|
||||
| `bdrive init [folder]` | Create/connect a project and start syncing — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
|
||||
| `bdrive stop [folder]` | Stop syncing (files stay; `bdrive init` resumes) |
|
||||
| `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) |
|
||||
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
|
||||
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
|
||||
|
||||
+22
-4
@@ -5,9 +5,27 @@ can update them alongside the change.
|
||||
|
||||
**Convention:** when a PR changes the structure drawn here (new/removed types,
|
||||
new seams, changed relationships), update the affected diagram in the same PR
|
||||
and embed the changed diagrams' mermaid blocks in the PR description under an
|
||||
"Architecture changes" section, so reviewers see the structural delta. A
|
||||
pre-PR hook (`.claude/hooks/check-arch-diagrams.sh`) reminds Claude Code
|
||||
sessions when server code changed but no diagram did.
|
||||
and add an "Architecture changes" section to the PR description that, per
|
||||
changed diagram:
|
||||
|
||||
1. names exactly which types/relationships changed and how (one sentence);
|
||||
2. shows a **Before** and an **After** mermaid block — each an *excerpt* of
|
||||
only the affected classes and their immediate relationships, never the
|
||||
full diagram (Before comes from the diagram at the merge base).
|
||||
|
||||
The committed diagram file stays the full current state; the before/after
|
||||
excerpts exist only in the PR description so reviewers see the structural
|
||||
delta at a glance. A pre-PR hook (`.claude/hooks/check-arch-diagrams.sh`)
|
||||
reminds Claude Code sessions when server code changed but no diagram did.
|
||||
|
||||
Together these cover every application package in the repo — every code
|
||||
change lands inside exactly one detail diagram's scope (plus the overview
|
||||
when the package map or cross-piece wiring changes):
|
||||
|
||||
- [overview.md](overview.md) — system diagram: every package and surface on one page, and how they connect
|
||||
- [cli-sync.md](cli-sync.md) — class diagram of the CLI and sync engine (`cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,agentskills}`)
|
||||
- [webapp-server.md](webapp-server.md) — class diagram of the `bdrive web` server (`internal/webapp` + its `internal/remote` seam)
|
||||
- [webapp-frontend.md](webapp-frontend.md) — module diagram of the hub's React SPA (`internal/webapp/frontend/src`)
|
||||
|
||||
Not covered on purpose: `web/docs` (content site, no application code) and
|
||||
`cloud/` (private nested repo — its architecture lives there).
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# `bdrive` CLI & sync engine — class diagram
|
||||
|
||||
Source of truth: `cmd/bdrive` (commands, gates) and `internal/{syncer,store,
|
||||
journal,config,daemon,agenthooks,agentskills}`; the `internal/remote` seam is drawn in
|
||||
[webapp-server.md](webapp-server.md). Reflects the code as of this commit;
|
||||
update this file in any PR that changes these types or their relationships.
|
||||
|
||||
## Sync engine — one cycle
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction LR
|
||||
|
||||
class Session {
|
||||
+Folder string
|
||||
+MountID string
|
||||
+Store *store.Store
|
||||
+Device config.Device
|
||||
+Account config.Settings
|
||||
+Backend remote.Backend
|
||||
+Note string
|
||||
+OnProgress func
|
||||
+Cycle(ctx) Result
|
||||
}
|
||||
note for Session "internal/syncer — scan → commit local ops → pull peer journals → preserve conflicts → materialize → push blobs then own journal"
|
||||
|
||||
class Result {
|
||||
+LocalOps +PulledOps
|
||||
+Conflicts +Materialized
|
||||
+Pushed +Offline +OfflineErr
|
||||
}
|
||||
|
||||
class Filter {
|
||||
+Skip(rel) bool
|
||||
+PruneDir(rel) bool
|
||||
}
|
||||
note for Filter "ignore.go — .bdriveignore rules + .bdrive include list, applied symmetrically in scan and materialize"
|
||||
|
||||
class Store {
|
||||
-dir volume dir
|
||||
+PutBlob / OpenBlob / HasBlob
|
||||
+AppendOps / DeviceOps / AllOps
|
||||
+LoadCache / SaveCache mountID
|
||||
+LoadSync / SaveSync
|
||||
+SaveNote / LoadNote
|
||||
+PendingReads read spool
|
||||
+Lock() flock
|
||||
}
|
||||
note for Store "internal/store — ~/.bdrive/volumes/mount-id: content-addressed blobs, per-device journal copies, state cache, paused marker (free funcs Paused/SetPaused, no flock)"
|
||||
|
||||
class Op {
|
||||
+Seq +Lamport +Time +Device
|
||||
+Author +User +UserName
|
||||
+Kind put or delete
|
||||
+Path +Blob +Size +Mode +Note
|
||||
}
|
||||
note for Op "internal/journal — Less orders by (lamport, time, device, seq); Replay folds to LWW-per-path state; each device writes only its own journal"
|
||||
|
||||
class Backend {
|
||||
<<interface>>
|
||||
+Put +Get +List +Exists +Close
|
||||
}
|
||||
note for Backend "internal/remote — client devices use the https:// hub backend (token from BDRIVE_TOKEN / settings.json)"
|
||||
|
||||
class daemon {
|
||||
+Run(folder, scan, remote)
|
||||
+Start / Stop / Running
|
||||
}
|
||||
note for daemon "per-mount detached loop; re-reads .bdrive/config.json each tick, exits without deletes if it vanishes"
|
||||
|
||||
Session --> Store : volume state
|
||||
Session --> Backend : pull and push
|
||||
Session --> Filter : scan and materialize
|
||||
Session ..> Op : commits, replays
|
||||
Session --> Result
|
||||
Store o-- Op : journal files
|
||||
daemon --> Session : one Cycle per tick
|
||||
```
|
||||
|
||||
## CLI commands, device state, and the opt-in gate
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction LR
|
||||
|
||||
class Commands {
|
||||
init login logout
|
||||
sync stop status log
|
||||
url share export import
|
||||
web daemon hooks read-log skill
|
||||
}
|
||||
note for Commands "cmd/bdrive — thin cobra layer; init is the front door, stop pauses"
|
||||
|
||||
class syncBlocked {
|
||||
<<gate>>
|
||||
enrolled in mounts.json?
|
||||
volume not paused?
|
||||
}
|
||||
note for syncBlocked "cmd/bdrive/helpers.go — sync, sync --hook, and read-log must pass it; reads the registry WITHOUT ResolveMount's enrolling self-heal. Hook mode fails silent; plain sync errors with a bdrive init pointer"
|
||||
|
||||
class openSession {
|
||||
mustProject → ResolveMount
|
||||
store.Open + remote.Open
|
||||
}
|
||||
class startSync {
|
||||
enroll + clear paused
|
||||
initial Cycle
|
||||
daemon.Start
|
||||
}
|
||||
note for startSync "cmd/bdrive/sync_run.go — init's engine; the ONLY enroller and the only thing that resumes a pause"
|
||||
|
||||
class Project {
|
||||
+ID stable mount id
|
||||
+Volume +Remote +Include
|
||||
}
|
||||
note for Project ".bdrive/config.json — travels with the folder (git clone, copy); presence alone is NOT consent to sync"
|
||||
|
||||
class MountRegistry {
|
||||
mounts.json
|
||||
id → Path Volume Remote
|
||||
}
|
||||
class Device {
|
||||
device.json
|
||||
}
|
||||
class Settings {
|
||||
settings.json
|
||||
server + token + account
|
||||
}
|
||||
note for MountRegistry "internal/config — per-device state under BDRIVE_HOME; ResolveMount self-heals the path for enrolled mounts (renames/moves stay free)"
|
||||
|
||||
class AgentHooks {
|
||||
Detect / Install / Registered
|
||||
turn-start: sync --hook
|
||||
post-edit: sync --note
|
||||
post-read: read-log
|
||||
}
|
||||
note for AgentHooks "internal/agenthooks — registers per-platform hook commands (claude, codex, gemini, hermes); they fire in every folder, every turn"
|
||||
|
||||
class PausedMarker {
|
||||
volumes/id/paused
|
||||
}
|
||||
note for PausedMarker "set by bdrive stop, cleared only by bdrive init (startSync)"
|
||||
|
||||
class AgentSkills {
|
||||
Detect / Install
|
||||
embedded SKILL.md
|
||||
}
|
||||
note for AgentSkills "internal/agentskills — installs the beardrive skill user-level (per-platform skills dir) from the binary's embedded copy; idempotent, refreshed on upgrade"
|
||||
|
||||
Commands --> AgentSkills : skill install
|
||||
AgentHooks --> Commands : runs sync and read-log
|
||||
Commands --> syncBlocked : sync and read-log gate first
|
||||
syncBlocked --> MountRegistry : reads only, never enrolls
|
||||
syncBlocked --> PausedMarker : Paused check
|
||||
Commands --> openSession : after the gate
|
||||
openSession --> MountRegistry : path self-heal (enrolled only)
|
||||
Commands --> startSync : init
|
||||
startSync --> MountRegistry : enrolls
|
||||
startSync --> PausedMarker : clears
|
||||
Commands --> PausedMarker : stop sets
|
||||
openSession ..> Project : loads
|
||||
openSession ..> Device : identity
|
||||
openSession ..> Settings : account and token
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# BearDrive — system overview
|
||||
|
||||
The whole repo on one page: every package and surface, and which detail
|
||||
diagram drills into it. Reflects the code as of this commit; update this
|
||||
file in any PR that adds/removes a package or changes how the pieces
|
||||
connect. Detail diagrams: [cli-sync.md](cli-sync.md),
|
||||
[webapp-server.md](webapp-server.md),
|
||||
[webapp-frontend.md](webapp-frontend.md).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph device["User device"]
|
||||
wf["working folder<br/>(real files + .bdrive/config.json)"]
|
||||
cli["cmd/bdrive<br/>CLI commands"]
|
||||
dmn["internal/daemon<br/>background loop"]
|
||||
eng["internal/syncer Session.Cycle<br/>internal/journal ops + replay"]
|
||||
vs["volume store ~/.bdrive/volumes/id<br/>internal/store: blobs, journals,<br/>state, paused marker"]
|
||||
cfg["internal/config<br/>device.json, settings.json, mounts.json"]
|
||||
end
|
||||
|
||||
subgraph agents["Agent platforms (claude / codex / gemini / hermes)"]
|
||||
hooks["internal/agenthooks<br/>turn-boundary sync hooks"]
|
||||
skills["internal/agentskills + plugin/<br/>SKILL.md, commands, hook scripts"]
|
||||
end
|
||||
|
||||
subgraph hub["bdrive web hub"]
|
||||
srv["internal/webapp Server<br/>auth, orgs, projects, shares,<br/>history, read heat, store proxy"]
|
||||
fe["webapp/frontend React SPA<br/>committed dist go:embed'ed at webapp/static"]
|
||||
meta["MetaStore: file JSON (default)<br/>or sqlite / postgres (db_sql)"]
|
||||
end
|
||||
|
||||
store["object store (hub-owned)<br/>internal/remote: file:// s3:// gs://<br/>blobs + per-device journals"]
|
||||
|
||||
docs["web/docs — docs.beardrive.ai<br/>Astro/Starlight, deploys separately"]
|
||||
cloud["cloud/ (PRIVATE nested repo, gitignored)<br/>managed beardrive.ai: swaps AuthProvider,<br/>QuotaProvider, MetaStore seams"]
|
||||
|
||||
wf <-->|scan / materialize| eng
|
||||
cli --> eng
|
||||
dmn --> eng
|
||||
cli --> cfg
|
||||
eng --> vs
|
||||
eng <-->|"https:// backend (internal/remote/http.go)<br/>device token, /api/p/id/store/*"| srv
|
||||
hooks -->|"bdrive sync --hook / --note, read-log<br/>gated: enrolled + not paused"| cli
|
||||
skills -.->|teach agents the CLI| agents
|
||||
srv --> store
|
||||
srv --> meta
|
||||
fe -->|/api/config, /api/projects, viewer APIs| srv
|
||||
cloud -.->|imports OSS packages,<br/>replaces providers| srv
|
||||
docs -.->|documents| cli
|
||||
```
|
||||
|
||||
Not drawn in any detail diagram (deliberately): `web/docs` (content site, no
|
||||
Go/TS application code) and `cloud/` (private repo — its architecture lives
|
||||
there; here it only consumes the provider seams drawn in webapp-server.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Hub frontend (React SPA) — module diagram
|
||||
|
||||
Source of truth: `internal/webapp/frontend/src`. The built output is
|
||||
committed at `internal/webapp/static` (the `go:embed` target), so `go build`
|
||||
never needs Node. Reflects the code as of this commit; update this file in
|
||||
any PR that changes these modules or their relationships.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction LR
|
||||
|
||||
class App {
|
||||
mode from /api/config
|
||||
}
|
||||
note for App "App.tsx — picks HubApp (multi-project) or VolumeApp (single volume) from server config; frontend learns everything from the API, never sees storage or credentials"
|
||||
|
||||
class HubApp {
|
||||
project list, org walls
|
||||
admin panels, invites
|
||||
}
|
||||
class VolumeApp {
|
||||
thin wrapper: one volume
|
||||
}
|
||||
class Browser {
|
||||
folder listing, file view
|
||||
per-view routes
|
||||
}
|
||||
|
||||
class router {
|
||||
+VIEW_ROUTES insights history install settings
|
||||
+parseRoute(pathname, mode) Route
|
||||
+urlForPath / urlForView
|
||||
+encodePath / decodePath
|
||||
}
|
||||
class nav {
|
||||
+navigate(url)
|
||||
+useLocationPath()
|
||||
+linkProps(href)
|
||||
+Redirect
|
||||
}
|
||||
note for nav "nav.ts + router.ts — deliberately NOT a router library (react-router v7 startTransition left stale views); History-API path routing, slashes literal, every user-facing page owns a URL path"
|
||||
|
||||
class api {
|
||||
+getJSON / postJSON / api
|
||||
types.ts server contracts
|
||||
}
|
||||
note for api "api/http.ts — all URLs root-absolute so deep paths never break relative resolution"
|
||||
|
||||
class hooks {
|
||||
+useConfig
|
||||
+useHub
|
||||
+useBrowse
|
||||
}
|
||||
note for hooks "TanStack Query wrappers over the viewer APIs"
|
||||
|
||||
class components {
|
||||
FileView FolderListing FileTree
|
||||
HistoryView Insights ShareDialog
|
||||
OrgAdmin HubSettings ProjectSettings
|
||||
Palette shell AccountBar ...
|
||||
}
|
||||
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
|
||||
|
||||
App --> HubApp
|
||||
App --> VolumeApp
|
||||
HubApp --> Browser
|
||||
VolumeApp --> Browser
|
||||
HubApp --> router
|
||||
Browser --> router
|
||||
Browser --> components
|
||||
HubApp --> components
|
||||
components --> nav : linkProps navigate
|
||||
hooks --> api
|
||||
Browser --> hooks
|
||||
HubApp --> hooks
|
||||
```
|
||||
@@ -25,11 +25,30 @@ func syncCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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(folder)
|
||||
if hookLabel != "" {
|
||||
// Agent-hook mode: event JSON on stdin, silent best-effort
|
||||
// sync, link-formula context on stdout. Never fails.
|
||||
if err != nil || !ok || syncBlocked(proj) != "" {
|
||||
return nil
|
||||
}
|
||||
return runHookSync(cmd, folder, hookLabel)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder)
|
||||
}
|
||||
switch syncBlocked(proj) {
|
||||
case "init":
|
||||
return fmt.Errorf("%s is not synced on this device yet (run `bdrive init` there to connect it)", folder)
|
||||
case "paused":
|
||||
return fmt.Errorf("syncing is paused for %s (run `bdrive init` there to resume)", folder)
|
||||
}
|
||||
sess, proj, err := openSession(cmd.Context(), folder, true)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -44,6 +44,26 @@ func mustProject(folder string) (config.Project, error) {
|
||||
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.
|
||||
|
||||
@@ -24,6 +24,9 @@ func TestSyncHookMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := config.ResolveMount(folder); err != nil { // enroll, as `bdrive init` would
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := syncCmd()
|
||||
var out bytes.Buffer
|
||||
@@ -38,7 +41,7 @@ func TestSyncHookMode(t *testing.T) {
|
||||
`"hookSpecificOutput"`,
|
||||
`"hookEventName":"UserPromptSubmit"`,
|
||||
"https://hub.example.com/p-12345678", // base URL: remote minus /p
|
||||
"[🔗](", // the emoji-link convention
|
||||
"[🔗](", // the emoji-link convention
|
||||
"code blocks", // paths in code blocks stay plain
|
||||
"PUBLIC", // bdrive share stays opt-in
|
||||
} {
|
||||
@@ -77,23 +80,109 @@ func TestSyncHookModeNoOps(t *testing.T) {
|
||||
t.Fatalf("non-mount emitted output: %s", out.String())
|
||||
}
|
||||
|
||||
// Garbage stdin: still sync, still emit, never fail.
|
||||
// A config.json that arrived with the folder (git clone, copied dir)
|
||||
// but was never enrolled on this device via `bdrive init`: silent no-op,
|
||||
// and — crucially — no device enrollment as a side effect.
|
||||
folder := t.TempDir()
|
||||
folder, _ = filepath.EvalSymlinks(folder)
|
||||
if _, err := config.SaveProject(folder, config.Project{
|
||||
proj, err := config.SaveProject(folder, config.Project{
|
||||
Volume: "wiki", Remote: "https://hub.example.com/p/p-12345678",
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2 := syncCmd()
|
||||
out.Reset()
|
||||
c2.SetOut(&out)
|
||||
c2.SetIn(strings.NewReader("not json at all"))
|
||||
c2.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
||||
c2.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c2.Execute(); err != nil {
|
||||
t.Fatalf("unenrolled mount must be a silent no-op: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("unenrolled mount emitted output: %s", out.String())
|
||||
}
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, enrolled := mounts[proj.ID]; enrolled {
|
||||
t.Fatal("hook auto-enrolled the mount; only `bdrive init` may do that")
|
||||
}
|
||||
|
||||
// Enrolled but paused by `bdrive stop`: silent no-op too.
|
||||
if _, _, err := config.ResolveMount(folder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetPaused(vdir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3 := syncCmd()
|
||||
out.Reset()
|
||||
c3.SetOut(&out)
|
||||
c3.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
||||
c3.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c3.Execute(); err != nil {
|
||||
t.Fatalf("paused mount must be a silent no-op: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("paused mount emitted output: %s", out.String())
|
||||
}
|
||||
|
||||
// Garbage stdin on a live mount: still sync, still emit, never fail.
|
||||
if err := store.SetPaused(vdir, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c4 := syncCmd()
|
||||
out.Reset()
|
||||
c4.SetOut(&out)
|
||||
c4.SetIn(strings.NewReader("not json at all"))
|
||||
c4.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c4.Execute(); err != nil {
|
||||
t.Fatalf("garbage stdin must not fail: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"hookSpecificOutput"`) {
|
||||
t.Fatalf("formula not emitted on garbage stdin: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Plain `bdrive sync` (the push hook's form, and what users type) refuses
|
||||
// unenrolled and paused mounts with instructions instead of silently
|
||||
// enrolling or resuming.
|
||||
func TestSyncRefusesUnenrolledAndPaused(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
folder, _ = filepath.EvalSymlinks(folder)
|
||||
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := syncCmd()
|
||||
c.SetArgs([]string{folder})
|
||||
err = c.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "bdrive init") {
|
||||
t.Fatalf("unenrolled sync error = %v, want a `bdrive init` pointer", err)
|
||||
}
|
||||
|
||||
if _, _, err := config.ResolveMount(folder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetPaused(vdir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2 := syncCmd()
|
||||
c2.SetArgs([]string{folder})
|
||||
err = c2.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "paused") {
|
||||
t.Fatalf("paused sync error = %v, want a paused message", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,12 @@ to run it by hand.`,
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
proj, found, err := config.ResolveMount(folder)
|
||||
if err != nil || !found {
|
||||
return nil // not a beardrive project: fast no-op
|
||||
// LoadProject, not ResolveMount: a hook must never enroll this
|
||||
// device (registry self-heal) — and syncBlocked keeps a paused
|
||||
// or never-inited project's spool from even being created.
|
||||
proj, found, err := config.LoadProject(folder)
|
||||
if err != nil || !found || syncBlocked(proj) != "" {
|
||||
return nil // not an actively synced project: fast no-op
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
|
||||
paths := extractEventPaths(data, folder)
|
||||
|
||||
@@ -107,6 +107,9 @@ func TestReadLogCommand(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := config.ResolveMount(folder); err != nil { // enroll, as `bdrive init` would
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := readLogCmd()
|
||||
c.SetIn(bytes.NewReader([]byte(`{"session_id":"abc","tool_name":"Read",
|
||||
@@ -133,3 +136,63 @@ func TestReadLogCommand(t *testing.T) {
|
||||
t.Fatalf("spool = %+v, want just the in-project read, mount-relative", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// read-log fires on every agent tool call in every folder, so it must be
|
||||
// inert where this device never opted in: an unenrolled config.json (one
|
||||
// that arrived with a git clone / copied folder) spools nothing, enrolls
|
||||
// nothing, and creates no volume store; a paused project spools nothing.
|
||||
func TestReadLogGated(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
folder, _ = filepath.EvalSymlinks(folder)
|
||||
proj, err := config.SaveProject(folder, config.Project{Volume: "wiki"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := `{"tool_name":"Read","tool_input":{"file_path":"` + folder + `/wiki/a.md"}}`
|
||||
run := func() {
|
||||
c := readLogCmd()
|
||||
c.SetIn(bytes.NewReader([]byte(event)))
|
||||
c.SetArgs([]string{folder})
|
||||
if err := c.Execute(); err != nil {
|
||||
t.Fatalf("read-log must never fail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unenrolled: no spool, no store, no registry entry.
|
||||
run()
|
||||
if _, err := os.Stat(vdir); !os.IsNotExist(err) {
|
||||
t.Fatal("unenrolled read-log created the volume store")
|
||||
}
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, enrolled := mounts[proj.ID]; enrolled {
|
||||
t.Fatal("read-log enrolled the mount; only `bdrive init` may do that")
|
||||
}
|
||||
|
||||
// Enrolled but paused by `bdrive stop`: still nothing spooled.
|
||||
if _, _, err := config.ResolveMount(folder); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetPaused(vdir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run()
|
||||
st, err := store.Open(vdir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evs, err := st.PendingReads()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(evs) != 0 {
|
||||
t.Fatalf("paused read-log spooled %+v, want nothing", evs)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -26,6 +26,11 @@ func startSync(ctx context.Context, folder string, proj config.Project, foregrou
|
||||
if _, err := store.Open(vdir); err != nil {
|
||||
return err
|
||||
}
|
||||
// init is the one gesture that (re)consents to syncing: clear any pause
|
||||
// left by `bdrive stop` so the daemon and the agent hooks run again.
|
||||
if err := store.SetPaused(vdir, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initial cycle: import existing files, pull remote state.
|
||||
sess, _, err := openSession(ctx, folder, true)
|
||||
@@ -82,10 +87,16 @@ folder's .bdrive settings and the local volume data are kept).`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The pause must outlive the daemon: agent turn hooks run
|
||||
// `bdrive sync` in this folder on every turn and would silently
|
||||
// resume without it. Cleared by `bdrive init`.
|
||||
if err := store.SetPaused(vdir, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if stopped {
|
||||
fmt.Printf("stopped syncing %s\n", folder)
|
||||
fmt.Printf("stopped syncing %s (run `bdrive init` to resume)\n", folder)
|
||||
} else {
|
||||
fmt.Printf("no sync daemon running for %s\n", folder)
|
||||
fmt.Printf("no sync daemon running for %s; syncing paused (run `bdrive init` to resume)\n", folder)
|
||||
}
|
||||
if forget {
|
||||
mounts, err := config.LoadMounts()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/config"
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
)
|
||||
|
||||
// `bdrive stop` must actually stop: it pauses the volume so the agent turn
|
||||
// hooks and `bdrive sync` no-op (they used to resume a stopped project on
|
||||
// the very next turn), and after --forget nothing re-enrolls the mount
|
||||
// behind the user's back.
|
||||
func TestStopPausesHooksAndForgetSticks(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
folder, _ = filepath.EvalSymlinks(folder)
|
||||
proj, err := config.SaveProject(folder, config.Project{
|
||||
Volume: "wiki",
|
||||
Remote: "https://hub.example.com/p/p-12345678",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := config.ResolveMount(folder); err != nil { // enroll, as `bdrive init` would
|
||||
t.Fatal(err)
|
||||
}
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runHook := func() string {
|
||||
c := syncCmd()
|
||||
var out bytes.Buffer
|
||||
c.SetOut(&out)
|
||||
c.SetIn(strings.NewReader(`{"session_id":"s"}`))
|
||||
c.SetArgs([]string{folder, "--hook", "claude-code"})
|
||||
if err := c.Execute(); err != nil {
|
||||
t.Fatalf("hook mode must never fail: %v", err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// Live project: hook syncs and emits the link formula.
|
||||
if !strings.Contains(runHook(), `"hookSpecificOutput"`) {
|
||||
t.Fatal("enrolled live project: hook did not emit the formula")
|
||||
}
|
||||
|
||||
// stop (no daemon running is fine): pause marker set, hook goes quiet.
|
||||
c := stopCmd()
|
||||
c.SetArgs([]string{folder})
|
||||
if err := c.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !store.Paused(vdir) {
|
||||
t.Fatal("stop did not set the paused marker")
|
||||
}
|
||||
if out := runHook(); out != "" {
|
||||
t.Fatalf("hook after stop emitted %q, want silence", out)
|
||||
}
|
||||
|
||||
// stop --forget: unregistered, and the hook must not self-heal it back.
|
||||
c2 := stopCmd()
|
||||
c2.SetArgs([]string{folder, "--forget"})
|
||||
if err := c2.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := runHook(); out != "" {
|
||||
t.Fatalf("hook after --forget emitted %q, want silence", out)
|
||||
}
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, enrolled := mounts[proj.ID]; enrolled {
|
||||
t.Fatal("hook re-enrolled a forgotten mount")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The paused marker is per-device, per-volume state: `bdrive stop` sets it,
|
||||
// `bdrive init` clears it. While set, nothing may sync the volume — not the
|
||||
// daemon, not `bdrive sync`, and especially not the agent turn hooks, which
|
||||
// would otherwise silently resume a project the user explicitly paused.
|
||||
// Free functions on the volume dir so the gate is checked without opening
|
||||
// (and flocking) the store.
|
||||
|
||||
func pausedPath(dir string) string { return filepath.Join(dir, "paused") }
|
||||
|
||||
// Paused reports whether syncing is paused for the volume dir.
|
||||
func Paused(dir string) bool {
|
||||
_, err := os.Stat(pausedPath(dir))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SetPaused sets or clears the paused marker.
|
||||
func SetPaused(dir string, on bool) error {
|
||||
if !on {
|
||||
err := os.Remove(pausedPath(dir))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFileAtomic(pausedPath(dir), []byte("paused by `bdrive stop`; `bdrive init` resumes\n"), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The paused marker gates every sync path (daemon, `bdrive sync`, agent
|
||||
// hooks): set by `bdrive stop`, cleared by `bdrive init`, absent by default.
|
||||
func TestPaused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if Paused(dir) {
|
||||
t.Fatal("fresh volume dir must not be paused")
|
||||
}
|
||||
if err := SetPaused(dir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Paused(dir) {
|
||||
t.Fatal("marker not set")
|
||||
}
|
||||
if err := SetPaused(dir, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Paused(dir) {
|
||||
t.Fatal("marker not cleared")
|
||||
}
|
||||
// Clearing an already-clear volume is a no-op, not an error — init runs
|
||||
// it unconditionally.
|
||||
if err := SetPaused(dir, false); err != nil {
|
||||
t.Fatalf("double clear: %v", err)
|
||||
}
|
||||
// Stop can run before the volume dir exists (e.g. after wiping
|
||||
// ~/.bdrive/volumes by hand); the marker still sticks.
|
||||
missing := filepath.Join(dir, "never-synced")
|
||||
if err := SetPaused(missing, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Paused(missing) {
|
||||
t.Fatal("marker not set in fresh dir")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|
||||
|---|---|
|
||||
| Start syncing a project (create/connect; the front door) | `bdrive init [<folder>]` — interactive on a TTY; flags `--name <x>` / `--project <id>` / `--shared <dir>` / `--yes` for scripts and agents (NEVER prompts without a TTY). Re-run to resume, including after the folder was renamed/moved. Runs the login flow first (against your hub URL) if the device has no session. |
|
||||
| Run the daemon in the foreground | `bdrive init -f` |
|
||||
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
|
||||
| Stop syncing | `bdrive stop [<folder>]` — pauses daemon *and* agent hooks; `bdrive init` resumes (`--forget` also unregisters) |
|
||||
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
|
||||
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
|
||||
| Install this skill on another agent (Codex, Gemini CLI, Hermes, Claude Code) | `bdrive skill install [<folder>]` — writes the binary's own copy of this skill to each detected platform's user-level skills dir (`~/.codex/skills/beardrive/SKILL.md` and friends), idempotently; bare `bdrive skill` shows the status table. Then the user asks that agent to set the folder up and it runs `init` + `hooks install` itself |
|
||||
@@ -110,9 +110,11 @@ Renaming/moving a project folder is safe: the daemon notices its folder vanished
|
||||
|
||||
### Stop
|
||||
|
||||
- `bdrive stop <folder>` — stop the daemon. Files stay on disk; the local volume store under `~/.bdrive/volumes/<mount-id>/` is kept. `bdrive init` resumes any time.
|
||||
- `bdrive stop <folder>` — stop the daemon and pause all syncing for the folder, **including the agent turn hooks** (`bdrive sync` refuses while paused, and the turn-start hook stops injecting the link formula). Files stay on disk; the local volume store under `~/.bdrive/volumes/<mount-id>/` is kept. Only `bdrive init` resumes.
|
||||
- `bdrive stop <folder> --forget` — also drop from the mount registry. Local volume data is still preserved; `rm -rf ~/.bdrive/volumes/<mount-id>/` reclaims disk.
|
||||
|
||||
Syncing only ever runs where this device opted in: a `.bdrive/config.json` that merely arrived with the folder (git clone, copied dir) is inert — hooks and `bdrive sync` no-op until someone runs `bdrive init` there.
|
||||
|
||||
### On-demand sync
|
||||
|
||||
`bdrive sync [<folder>]` runs a single cycle (scan → upload blobs+journal → pull remote journals → materialize). Useful to:
|
||||
|
||||
@@ -12,10 +12,10 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
|
||||
| `bdrive login [server-url]` | Sign this device in. Browser flow; `--device` forces the code flow, and shells without a TTY (agents, CI, SSH) fall back to it automatically. Default server is beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host. Switch hubs with `bdrive login <new-url>`. `--status` shows the current server and account |
|
||||
| `bdrive logout` | Sign this device out — clear the saved token and account. `--forget` also drops the remembered server |
|
||||
| `bdrive init [folder]` | Create or connect a project and start syncing. Interactive on a TTY; flags (`--name`, `--project`, `--shared`, `--yes`) for scripts. Re-run to resume |
|
||||
| `bdrive stop [folder]` | Stop syncing. Files stay on disk; `bdrive init` resumes |
|
||||
| `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes |
|
||||
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
|
||||
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--hook <label>` is agent-hook plumbing |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--hook <label>` is agent-hook plumbing |
|
||||
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms. Idempotent; `--agent` overrides detection |
|
||||
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms so the agent can do setup itself. Idempotent; `--agent` overrides detection |
|
||||
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |
|
||||
|
||||
Reference in New Issue
Block a user