diff --git a/CLAUDE.md b/CLAUDE.md index b8fc7a5..163a790 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,11 +42,12 @@ Package roles (`internal/`): - **`store`** — a volume's local on-disk state: content-addressed blob store (`blobs//`), per-device journal copies, the per-mount materialization cache (`state-.json`, size+mtime fingerprints for cheap change detection), sync state (lamport clock + push cursor), and the exclusive flock that serializes cycles. - **`remote`** — the `Backend` interface (Put/Get/List/Exists) with `file://`, `s3://`, `gs://`, and `https://` implementations (`https://` syncs through a `bdrive web` server's `/api/store` API — the client device holds no storage credentials). `PutSigner` is the optional presign capability (S3/GCS). Remote layout: `blobs/` + `journal/.jsonl` under the URL prefix. - **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely. -- **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). +- **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log`/`daemon.lock` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). **Liveness is the flock on `daemon.lock`, never the pidfile**: the pidfile outlives its process (it lives in `$BDRIVE_HOME`, which survives reboots), so a recycled pid used to read as a live daemon — making `status` lie and `Start` a silent no-op, which broke the one documented recovery. The kernel drops the flock when the holder dies, including at reboot and on a crash; the pid is display-only, and holding the lock also makes two daemons on one mount impossible (two writers of one journal). +- **`autostart`** — the login registration that undoes a reboot: one unit per machine, user-level, running `bdrive resume` — macOS `~/Library/LaunchAgents/ai.beardrive.daemon.plist` (`RunAtLoad`, deliberately no `KeepAlive` since the job exits); Linux `$XDG_CONFIG_HOME/systemd/user/beardrive.service` (`Type=oneshot`, no `Restart=`) plus the `default.target.wants` symlink that `systemctl --user enable` would create, since systemd ignores a unit nothing wants. Linux also requires systemd to be booted (`/run/systemd/system`, i.e. `sd_booted`) — otherwise `Install` returns `ErrUnsupported` rather than writing a file nothing would ever read (Alpine/runit, WSL1, slim containers), which starts a daemon for every enrolled, unpaused mount — so mounts added later need no re-registration and `bdrive stop` still means stay stopped. `init` installs it (`--no-autostart` skips). Writing the file is the whole job: no `launchctl` shell-out, so a test or a packaging script can't register a real login item as a side effect, and launchd loads it at the next login anyway. Windows uses a per-user `HKCU\...\CurrentVersion\Run` value (`golang.org/x/sys/windows/registry`) — no admin, no COM (a Startup `.lnk` would need it), no `schtasks`, and it shows up in Task Manager's Startup tab where a user can disable it. Its tests exist but have **never been executed** (written and compile-checked from macOS); they run the first time the suite runs on Windows. **`GOOS=windows go build ./...` does not pass yet** and this package is not the reason: `internal/store`'s `Lock` uses `syscall.Flock` and `internal/daemon` uses `syscall.Kill`/`Setsid`, all unix-only — a Windows port means `LockFileEx` and a kill story for a platform with no SIGTERM, which is a separate change against the sync invariants. `autostart_other.go` (`!darwin && !linux && !windows`) covers the BSDs with `ErrUnsupported`; shared bits (`writeIfDifferent`, `selfPath`) live in the tag-free `autostart.go`. - **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes//`. `.bdrive/` is never synced and holds no credentials. - **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied into `src/components/ui`, themed from the BearDrive tokens in `src/tw.css`; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). It learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal). **Every user-facing page owns a URL path**: new surfaces are view routes (`//{dashboard|history|install|settings}[/]`, `VIEW_ROUTES` in `router.ts`; renamed segments live in `LEGACY_VIEWS` and are normalized away on arrival) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings and a per-project Dashboard quadrant (reads × staleness, route `//dashboard`) — both visible to every project member, since `/heat` is membership-gated and identity-free. **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. -`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `read-log`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (`--name/--project/--only/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks in each platform's USER config (`~/.claude/settings.json` and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; `Install` also migrates away hooks older versions wrote into projects), and starts sync via `startSync`; re-running it resumes — including after a folder move. **A mount is always exactly the folder named** — there is no re-rooting flag. Syncing only part of a mount is `--only wiki,docs`, which writes a bdrive-managed block of `.bdriveignore` negation rules (`cmd/bdrive/scopefile.go`; `bdrive scope add/rm` edits the same block) rather than a second scope mechanism: the old `Include` list in `config.json` is legacy — still honored, never written. Because the rules live in the synced `.bdriveignore`, scope is team-wide, which is why `sync --prune` refuses when `!` rules are present (it would strip everything outside the scope from the hub for everyone; `bdrive forget ` is the per-path tool). `init` also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. `bdrive web -c config.json` configures the server from a file, explicit flags winning. +`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `resume`, `autostart`, `read-log`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (`--name/--project/--only/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks in each platform's USER config (`~/.claude/settings.json` and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; `Install` also migrates away hooks older versions wrote into projects), and starts sync via `startSync`; re-running it resumes — including after a folder move. **A mount is always exactly the folder named** — there is no re-rooting flag. Syncing only part of a mount is `--only wiki,docs`, which writes a bdrive-managed block of `.bdriveignore` negation rules (`cmd/bdrive/scopefile.go`; `bdrive scope add/rm` edits the same block) rather than a second scope mechanism: the old `Include` list in `config.json` is legacy — still honored, never written. Because the rules live in the synced `.bdriveignore`, scope is team-wide, which is why `sync --prune` refuses when `!` rules are present (it would strip everything outside the scope from the hub for everyone; `bdrive forget ` is the per-path tool). `init` also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. `bdrive web -c config.json` configures the server from a file, explicit flags winning. Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p//history?path=|prefix=` (newest first, device-registry join) and `GET /api/p//blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into). @@ -59,6 +60,7 @@ Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in h - **Materialize never clobbers dirty files**: a file whose size/mtime differs from the state cache changed mid-cycle and is left for the next scan. - **All state files are written atomically** (temp file + rename, see `store.WriteFileAtomic`). Temp files are prefixed `.bdrive-tmp-` and ignored by the scanner. - **The agent hook guard stays pure shell.** It runs on every session and every tool call on the machine, so it must never spawn `bdrive` (or anything else) outside a BearDrive project — a couple of `stat`s and at most one `grep` of `mounts.json`. +- **A daemon's liveness is its `daemon.lock` flock, never its pidfile.** Anything asking "is the daemon running" goes through `daemon.Running`; a pid is for display and for signalling only. - **`Cycle` runs under the volume flock** — the daemon and one-shot CLI commands (`bdrive sync`) coexist through it. - Errors during pull/push degrade to `Result.Offline` rather than failing the cycle; unreadable/vanished files during scan are skipped and retried next cycle. Follow this "never break sync, retry next cycle" posture. @@ -92,7 +94,7 @@ Every PR body starts with a `## TL;DR` section: at most 5 bullet points, one sho ## Architecture diagrams in PRs -`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}`), `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. +`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,autostart}`), `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 diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index 0d5bb99..6934527 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -9,7 +9,8 @@ or skip them. BearDrive syncs a folder across a team and their agents through a hub, with per-file history and share links. Setup is short by design: make sure the CLI exists, ask the user what to sync, and run **one** `bdrive init` — it signs -in, registers the sync hooks, starts syncing and prints the project link. Every extra command you invent is another permission prompt +in, registers the sync hooks (and, on macOS and Linux, a login item so sync +resumes after a reboot), starts syncing and prints the project link. Every extra command you invent is another permission prompt for the user. Full documentation: https://docs.beardrive.ai (agent-readable index at https://docs.beardrive.ai/llms.txt). @@ -116,8 +117,8 @@ the chosen folder is git-tracked, git and BearDrive would both write it (silent-revert hazard). Get consent, then `git rm -r --cached ` and add `/` to `.gitignore`; stage but let the user commit. -Then run **one** command — init signs in if needed, registers the hooks, -syncs, and prints the project link. Do not precede it +Then run **one** command — init signs in if needed, registers the hooks and +the login autostart, syncs, and prints the project link. Do not precede it with `command -v bdrive` or `bdrive --version`: every extra command is another permission prompt, and if the binary is missing this one says so. diff --git a/README.md b/README.md index b45033d..a484b96 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ initialized /Users/snow/workspace server: https://your-hub project: workspace (p-7f3a2c91) claude hooks registered → /Users/snow/.claude/settings.json + login: autostart registered → ~/Library/LaunchAgents/ai.beardrive.daemon.plist daemon: running (pid 55434, scan 3s, remote sync 10s) ``` @@ -143,7 +144,9 @@ 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 approval-link 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 ` | | `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 — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; registers agent sync hooks in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | +| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | +| `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent; this is what the login agent runs | +| `bdrive autostart [install\|uninstall]` | Show, add, or remove the login registration that runs `bdrive resume` after a reboot — a launchd user agent on macOS, a systemd user unit on Linux, an HKCU Run entry on Windows. `bdrive init` installs it; `--no-autostart` skips it | | `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) | | `bdrive scope [add\|rm ]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes, so no one hand-writes negation syntax. The daemon picks changes up in seconds; `rm` deletes nothing, locally or on the hub. `--explain` lists every path in the folder split into what syncs and what does not, so you can verify what leaves this machine (pure read — no daemon, no lock, no network) | | `bdrive forget ...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices | diff --git a/architecture/README.md b/architecture/README.md index 2fcadc3..3d7cf4e 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -23,7 +23,7 @@ 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}`) +- [cli-sync.md](cli-sync.md) — class diagram of the CLI and sync engine (`cmd/bdrive` + `internal/{syncer,store,journal,config,daemon,agenthooks,autostart}`) - [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`) diff --git a/architecture/cli-sync.md b/architecture/cli-sync.md index 830b31f..61e792a 100644 --- a/architecture/cli-sync.md +++ b/architecture/cli-sync.md @@ -1,7 +1,7 @@ # `bdrive` CLI & sync engine — class diagram Source of truth: `cmd/bdrive` (commands, gates) and `internal/{syncer,store, -journal,config,daemon,agenthooks}`; the `internal/remote` seam is drawn in +journal,config,daemon,agenthooks,autostart}`; 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. @@ -117,6 +117,7 @@ classDiagram sync stop scope forget status log restore url share export import web daemon hooks read-log + resume autostart } note for Commands "cmd/bdrive — thin cobra layer; init is the front door (one command: login + hooks + sync + link), stop pauses" @@ -172,6 +173,22 @@ classDiagram } note for PausedMarker "set by bdrive stop, cleared only by bdrive init (startSync)" + class Autostart { + Install / Uninstall / Installed / Path + launchd | systemd | HKCU Run + ErrUnsupported (BSD, no-systemd) + } + note for Autostart "internal/autostart — ONE login unit per machine that runs `bdrive resume`: darwin a LaunchAgents plist (RunAtLoad, no KeepAlive), linux a systemd user unit + its default.target.wants symlink (needs sd_booted), windows an HKCU Run value. Writes the registration only — never launchctl/systemctl/schtasks" + + class DaemonLock { + volumes/id/daemon.lock + volumes/id/daemon.pid + } + note for DaemonLock "internal/daemon — liveness is the flock, held for the daemon's lifetime; the kernel drops it at death/reboot, so a leftover pid can never read as running (pid is display + signal only)" + + Commands --> Autostart : autostart install/uninstall (init runs install automatically) + Autostart ..> Commands : login runs `bdrive resume` + Commands --> DaemonLock : Running / Start / Stop Commands --> AgentHooks : hooks install/uninstall (init runs install automatically) AgentHooks --> Commands : runs sync and read-log Commands --> syncBlocked : sync and read-log gate first diff --git a/cmd/bdrive/init.go b/cmd/bdrive/init.go index dda2bbe..f992a84 100644 --- a/cmd/bdrive/init.go +++ b/cmd/bdrive/init.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net" @@ -17,6 +18,7 @@ import ( "github.com/spf13/cobra" "github.com/runbear-io/beardrive/internal/agenthooks" + "github.com/runbear-io/beardrive/internal/autostart" "github.com/runbear-io/beardrive/internal/config" ) @@ -50,7 +52,7 @@ venv/ func initCmd() *cobra.Command { var projectID, projectName, serverURL string var only []string - var yes, foreground, noHooks bool + var yes, foreground, noHooks, noAutostart bool c := &cobra.Command{ Use: "init [folder]", Short: "Start syncing a project in this folder", @@ -121,6 +123,9 @@ the folder was renamed or moved.`, if !noHooks { installAgentHooks(folder) } + if !noAutostart { + installAutostart() + } return startSync(cmd.Context(), folder, proj, foreground, 3*time.Second, 10*time.Second) } @@ -195,6 +200,9 @@ the folder was renamed or moved.`, if !noHooks { installAgentHooks(folder) } + if !noAutostart { + installAutostart() + } if len(scope) > 0 { dirs := make([]string, len(scope)) for i, d := range scope { @@ -228,6 +236,7 @@ next steps: c.Flags().BoolVarP(&yes, "yes", "y", false, "accept defaults, never prompt") c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground") c.Flags().BoolVar(&noHooks, "no-hooks", false, "skip registering agent sync hooks") + c.Flags().BoolVar(&noAutostart, "no-autostart", false, "skip registering sync to restart at login") return c } @@ -262,6 +271,26 @@ func installAgentHooks(folder string) { } } +// installAutostart registers the login unit so a reboot doesn't quietly stop +// sync. Best effort and one line of output: a platform without one (a BSD, or +// Linux without systemd) or an unwritable config dir is not a reason to fail +// an init that otherwise worked — the folder syncs, it just won't come back by +// itself. +func installAutostart() { + res, err := autostart.Install() + if err != nil { + if !errors.Is(err, autostart.ErrUnsupported) { + fmt.Printf(" login: autostart not registered (%v) — run `bdrive resume` after a reboot\n", err) + } + return + } + state := "autostart registered" + if !res.Changed { + state = "autostart already registered" + } + fmt.Printf(" login: %s → %s\n", state, res.Path) +} + // checkNotAlreadyMounted refuses a second folder for a project this device // already syncs. Each device writes one journal per project on the remote, so // two local mounts of one project are two writers of the same journal: the diff --git a/cmd/bdrive/main.go b/cmd/bdrive/main.go index 80445b0..f1912cc 100644 --- a/cmd/bdrive/main.go +++ b/cmd/bdrive/main.go @@ -57,6 +57,8 @@ everything keeps working offline; changes sync when the remote is reachable.`, syncCmd(), readLogCmd(), hooksCmd(), + resumeCmd(), + autostartCmd(), statusCmd(), logCmd(), restoreCmd(), diff --git a/cmd/bdrive/resume.go b/cmd/bdrive/resume.go new file mode 100644 index 0000000..45f3678 --- /dev/null +++ b/cmd/bdrive/resume.go @@ -0,0 +1,185 @@ +package main + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/runbear-io/beardrive/internal/autostart" + "github.com/runbear-io/beardrive/internal/config" + "github.com/runbear-io/beardrive/internal/daemon" + "github.com/runbear-io/beardrive/internal/store" +) + +// resumeCmd restarts the sync daemon for every project this device syncs. It +// is what the login agent runs, and the one command to reach for when the +// daemons are gone but the projects are fine — after a reboot, a crash, or a +// `killall bdrive`. +// +// It deliberately does NOT touch the pause marker: `bdrive stop` means stay +// stopped, including across a reboot, and only `bdrive init` re-consents. +func resumeCmd() *cobra.Command { + var quiet bool + c := &cobra.Command{ + Use: "resume", + Short: "Restart the sync daemon for every project on this device", + Long: `Start the background sync daemon for each project this device syncs and +has not paused. Already-running daemons are left alone, so running it twice +is harmless. + +This is what the login agent registered by ` + "`bdrive autostart install`" + ` runs, so +sync comes back by itself after a reboot. Run it by hand if the daemons died +some other way; ` + "`bdrive status`" + ` shows which are running. + +Projects paused with ` + "`bdrive stop`" + ` stay paused — resume never overrides that.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + mounts, err := config.LoadMounts() + if err != nil { + return err + } + var started, running, skipped, failed int + for id, mi := range mounts { + // The folder config is the source of truth for whether this is + // still a project: a moved or deleted folder must not be + // resurrected here (that is `bdrive init`'s job at the new + // path), and a daemon started on a vanished folder would exit + // immediately anyway. + if _, ok, err := config.LoadProject(mi.Path); err != nil || !ok { + skipped++ + if !quiet { + fmt.Printf(" skipped %s (not a project any more — moved or deleted)\n", mi.Path) + } + continue + } + vdir, err := config.VolumeDir(id) + if err != nil { + failed++ + continue + } + if store.Paused(vdir) { + skipped++ + if !quiet { + fmt.Printf(" paused %s (bdrive init resumes it)\n", mi.Path) + } + continue + } + if pid, ok := daemon.Running(vdir); ok { + running++ + if !quiet { + fmt.Printf(" running %s (pid %d)\n", mi.Path, pid) + } + continue + } + pid, err := daemon.Start(mi.Path, vdir, 3*time.Second, 10*time.Second) + if err != nil { + failed++ + fmt.Fprintf(os.Stderr, " failed %s: %v\n", mi.Path, err) + continue + } + started++ + if !quiet { + fmt.Printf(" started %s (pid %d)\n", mi.Path, pid) + } + } + if len(mounts) == 0 && !quiet { + fmt.Println("no beardrive projects on this device (run `bdrive init` in a folder)") + return nil + } + if !quiet { + fmt.Printf("resumed %d, already running %d, skipped %d, failed %d\n", + started, running, skipped, failed) + } + // A partial failure must not fail the login agent: the projects + // that did start are syncing, and launchd retrying the whole run + // would not fix the one that didn't. + return nil + }, + } + c.Flags().BoolVar(&quiet, "quiet", false, "print nothing but errors (used by the login agent)") + return c +} + +// autostartCmd manages the login registration. Bare `bdrive autostart` shows +// the status, mirroring `bdrive hooks`. +func autostartCmd() *cobra.Command { + c := &cobra.Command{ + Use: "autostart", + Short: "Show whether sync restarts at login", + Long: `Show, add, or remove the login registration that restarts syncing after a +reboot. It runs ` + "`bdrive resume`" + `, so it covers every project this device syncs +— one registration per machine, not one per project. + +macOS uses a launchd user agent, Linux a systemd user unit (systemd must be the +init system), Windows a per-user Run entry. All are user-level: no sudo, +nothing machine-wide. + +` + "`bdrive init`" + ` installs it for you; these subcommands are for checking, +retrying, or opting out.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := autostart.Path() + if errors.Is(err, autostart.ErrUnsupported) { + fmt.Println("autostart: not available here (needs macOS, Windows, or Linux with systemd)") + fmt.Println(" after a reboot, run `bdrive resume` (or `bdrive init` in a project) to start syncing again") + return nil + } + if err != nil { + return err + } + if autostart.Installed() { + fmt.Printf("autostart: registered → %s\n", path) + } else { + fmt.Printf("autostart: not registered (run `bdrive autostart install`)\n would write: %s\n", path) + } + return nil + }, + } + c.AddCommand(&cobra.Command{ + Use: "install", + Short: "Restart syncing at login (idempotent)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := autostart.Install() + if errors.Is(err, autostart.ErrUnsupported) { + fmt.Println("autostart: not available here (needs macOS, Windows, or Linux with systemd) — run `bdrive resume` after a reboot") + return nil + } + if err != nil { + return err + } + state := "registered" + if !res.Changed { + state = "already registered" + } + fmt.Printf("autostart: %s → %s\n", state, res.Path) + return nil + }, + }) + c.AddCommand(&cobra.Command{ + Use: "uninstall", + Short: "Stop restarting syncing at login", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := autostart.Uninstall() + if errors.Is(err, autostart.ErrUnsupported) { + fmt.Println("autostart: nothing registered on this platform") + return nil + } + if err != nil { + return err + } + if !res.Changed { + fmt.Println("autostart: was not registered") + return nil + } + fmt.Printf("autostart: removed → %s\n", res.Path) + fmt.Println(" running daemons keep going; after the next reboot, sync starts on the next `bdrive resume`, `bdrive init`, or agent turn") + return nil + }, + }) + return c +} diff --git a/go.mod b/go.mod index 5ecf23f..5ddfefc 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/yuin/goldmark v1.8.2 golang.org/x/crypto v0.52.0 golang.org/x/sync v0.21.0 + golang.org/x/sys v0.45.0 google.golang.org/api v0.284.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 @@ -81,7 +82,6 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/internal/autostart/autostart.go b/internal/autostart/autostart.go new file mode 100644 index 0000000..6c127e0 --- /dev/null +++ b/internal/autostart/autostart.go @@ -0,0 +1,87 @@ +// Package autostart registers beardrive to start syncing again when the user +// logs in, so a reboot doesn't silently stop sync. +// +// The daemon is a detached child process (see internal/daemon): a reboot kills +// it and nothing brought it back — every project stayed unsynced until someone +// ran `bdrive init` in that folder again. Agent hooks still synced per turn, +// which is exactly what made the gap easy to miss: the folder looked fine +// while an agent was working in it and went stale the moment one wasn't. +// +// The unit registered here is ONE per machine, not one per project. It runs +// `bdrive resume`, which walks the mount registry and starts a daemon for +// every enrolled, unpaused mount — so mounts added or removed later need no +// change to the registration, and `bdrive stop` keeps meaning "stay stopped". +// +// Platform support: macOS (launchd user agent), Linux (systemd user unit), and +// Windows (a per-user Run entry). Anything else gets the stub, which returns +// ErrUnsupported — callers already treat that as "nothing to do". +// +// No implementation shells out to a service manager (`launchctl`, `systemctl`, +// `schtasks`). Writing the registration IS the registration: launchd reads +// ~/Library/LaunchAgents at login, systemd reads the enable symlink in +// default.target.wants, Explorer reads HKCU\...\Run at logon. All of them +// matter only at the NEXT login — the caller has just started the daemon for +// this session — and shelling out would let a test or a packaging script +// register something real as a side effect, or fail on a machine with no +// session bus at all (ssh, container, CI). +package autostart + +import ( + "errors" + "os" + "path/filepath" +) + +// ErrUnsupported is returned by Install/Uninstall on platforms that have no +// implementation yet. Callers treat it as "nothing to do", never as failure: +// autostart is a convenience, and a hard error would break `bdrive init` on a +// platform that syncs perfectly well without it. +var ErrUnsupported = errors.New("autostart is not supported on this platform yet") + +// Result reports what Install did, mirroring agenthooks.Result so callers can +// print the two the same way. +type Result struct { + Path string // the file written (or that would be) + Changed bool // false when it was already correct +} + +// writeIfDifferent writes content to path unless it is already exactly that, +// and reports whether it wrote. Atomic (temp + rename) with the repo's +// .bdrive-tmp- prefix, so a crash mid-write can't leave the service manager +// reading half a unit file. +// +// "Unless already exactly that" is what makes Install idempotent enough for +// `bdrive init` to call on every run, while still correcting a stale binary +// path (a Homebrew prefix change, a moved binary) instead of skipping it. +func writeIfDifferent(path, content string) (bool, error) { + if have, err := os.ReadFile(path); err == nil && string(have) == content { + return false, nil + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return false, err + } + tmp := filepath.Join(dir, ".bdrive-tmp-"+filepath.Base(path)) + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + return false, err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return false, err + } + return true, nil +} + +// selfPath is the binary to register. Symlinks are resolved because the +// service manager holds this path for the next login: Homebrew installs a +// symlink into its prefix, and an upgrade can repoint it. +func selfPath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + return resolved, nil + } + return exe, nil +} diff --git a/internal/autostart/autostart_darwin.go b/internal/autostart/autostart_darwin.go new file mode 100644 index 0000000..799bdf9 --- /dev/null +++ b/internal/autostart/autostart_darwin.go @@ -0,0 +1,92 @@ +//go:build darwin + +package autostart + +import ( + "os" + "path/filepath" +) + +// Label is the launchd job label, and names the plist file. +const Label = "ai.beardrive.daemon" + +// Path is where the user's LaunchAgent lives. User-level on purpose: no sudo, +// no machine-wide state, and it starts as that user with their $HOME — a +// LaunchDaemon would run as root and sync the wrong account's projects. +func Path() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", Label+".plist"), nil +} + +// plist renders the agent. RunAtLoad with no KeepAlive is deliberate: +// `bdrive resume` starts the daemons and exits, so KeepAlive would read that +// exit as a crash and respawn it forever. +func plist(exe string) string { + return ` + + + + Label + ` + Label + ` + ProgramArguments + + ` + exe + ` + resume + + RunAtLoad + + ProcessType + Background + + +` +} + +// Install writes (or refreshes) the LaunchAgent. Writing the file is the whole +// job: launchd loads agents from this directory at login. See the package doc +// for why there is no `launchctl` call. +func Install() (Result, error) { + path, err := Path() + if err != nil { + return Result{}, err + } + exe, err := selfPath() + if err != nil { + return Result{}, err + } + changed, err := writeIfDifferent(path, plist(exe)) + if err != nil { + return Result{}, err + } + return Result{Path: path, Changed: changed}, nil +} + +// Installed reports whether the agent file is in place. +func Installed() bool { + path, err := Path() + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +// Uninstall removes the agent, so it no longer loads at login. Missing is +// success. Nothing to unload: the job is RunAtLoad-and-exit, so by now it has +// already run and gone. +func Uninstall() (Result, error) { + path, err := Path() + if err != nil { + return Result{}, err + } + if _, err := os.Stat(path); err != nil { + return Result{Path: path, Changed: false}, nil + } + if err := os.Remove(path); err != nil { + return Result{}, err + } + return Result{Path: path, Changed: true}, nil +} diff --git a/internal/autostart/autostart_darwin_test.go b/internal/autostart/autostart_darwin_test.go new file mode 100644 index 0000000..23628ed --- /dev/null +++ b/internal/autostart/autostart_darwin_test.go @@ -0,0 +1,135 @@ +//go:build darwin + +package autostart + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Every test here redirects HOME (os.UserHomeDir reads it), so nothing touches +// the developer's real ~/Library/LaunchAgents. That isolation is only safe +// because Install writes a file and stops — if it ever shells out to +// launchctl, these tests would register a real login item. +func TestInstallWritesAndIsIdempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + want := filepath.Join(home, "Library", "LaunchAgents", "ai.beardrive.daemon.plist") + if Installed() { + t.Fatal("a fresh HOME cannot have the agent installed") + } + + res, err := Install() + if err != nil { + t.Fatalf("Install: %v", err) + } + if !res.Changed { + t.Fatal("first Install reported no change") + } + if res.Path != want { + t.Fatalf("path = %s, want %s", res.Path, want) + } + if !Installed() { + t.Fatal("Installed() false right after Install") + } + + body, err := os.ReadFile(want) + if err != nil { + t.Fatal(err) + } + exe, _ := os.Executable() + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + for _, frag := range []string{ + "" + exe + "", // the binary that installed it, not $PATH + "resume", // one job for every mount, not one per project + "RunAtLoad", + } { + if !strings.Contains(string(body), frag) { + t.Errorf("plist missing %q:\n%s", frag, body) + } + } + // KeepAlive would respawn `bdrive resume` forever: it starts the daemons + // and exits, which launchd would read as a crash. + if strings.Contains(string(body), "KeepAlive") { + t.Error("plist sets KeepAlive on a command that exits by design") + } + + // Idempotent: init calls this on every run. + res, err = Install() + if err != nil { + t.Fatalf("second Install: %v", err) + } + if res.Changed { + t.Error("second Install rewrote an identical plist") + } + + // A stale binary path (Homebrew prefix change, a moved binary) must be + // corrected rather than left alone. + if err := os.WriteFile(want, []byte(plist("/old/path/bdrive")), 0o644); err != nil { + t.Fatal(err) + } + res, err = Install() + if err != nil { + t.Fatal(err) + } + if !res.Changed { + t.Error("Install left a plist pointing at the wrong binary") + } + + // No temp file may survive the atomic write. + entries, _ := os.ReadDir(filepath.Dir(want)) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".bdrive-tmp-") { + t.Errorf("temp file left behind: %s", e.Name()) + } + } +} + +func TestUninstall(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Removing what was never there is success, not an error. + res, err := Uninstall() + if err != nil || res.Changed { + t.Fatalf("Uninstall on a clean HOME = (%+v, %v), want no change and no error", res, err) + } + + if _, err := Install(); err != nil { + t.Fatal(err) + } + res, err = Uninstall() + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if !res.Changed { + t.Error("Uninstall reported no change while a plist existed") + } + if Installed() { + t.Error("plist survived Uninstall") + } +} + +// launchd has to be able to parse it — a plist that only looks like XML would +// fail silently at login, which is the one moment nobody is watching. +func TestPlistIsValid(t *testing.T) { + if _, err := exec.LookPath("plutil"); err != nil { + t.Skip("no plutil") + } + home := t.TempDir() + t.Setenv("HOME", home) + res, err := Install() + if err != nil { + t.Fatal(err) + } + out, err := exec.Command("plutil", "-lint", res.Path).CombinedOutput() + if err != nil { + t.Fatalf("plutil -lint failed: %v\n%s", err, out) + } +} diff --git a/internal/autostart/autostart_linux.go b/internal/autostart/autostart_linux.go new file mode 100644 index 0000000..b289444 --- /dev/null +++ b/internal/autostart/autostart_linux.go @@ -0,0 +1,149 @@ +//go:build linux + +package autostart + +import ( + "os" + "path/filepath" +) + +// Unit is the systemd user unit's name, and the file it lives in. +const Unit = "beardrive.service" + +// wantsDir is the directory systemd reads to decide what starts with the +// user's default target. A symlink in here is exactly what +// `systemctl --user enable` creates for a unit with WantedBy=default.target — +// so we create it ourselves and never shell out (see the package doc). +const wantsDir = "default.target.wants" + +// unitDir is the user unit directory: $XDG_CONFIG_HOME/systemd/user, falling +// back to ~/.config/systemd/user. os.UserConfigDir already implements that +// rule, which matters on distros and desktops that relocate XDG_CONFIG_HOME. +func unitDir() (string, error) { + cfg, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(cfg, "systemd", "user"), nil +} + +// Path is the unit file this package owns. +func Path() (string, error) { + dir, err := unitDir() + if err != nil { + return "", err + } + return filepath.Join(dir, Unit), nil +} + +// booted reports whether systemd is the init system. This is sd_booted(3): +// /run/systemd/system exists only under systemd. Without it a unit file is +// inert decoration — Alpine/runit, WSL1, and slim containers would be told +// "registered" while nothing would ever start. +func booted() bool { + fi, err := os.Stat("/run/systemd/system") + return err == nil && fi.IsDir() +} + +// unit renders the service. Type=oneshot because `bdrive resume` starts the +// daemons and exits; systemd must not treat that exit as a failure or restart +// it. No After=network-online.target on purpose: a cycle with an unreachable +// hub degrades to offline and retries, so waiting for the network would delay +// local scanning for no benefit. +func unit(exe string) string { + return `[Unit] +Description=BearDrive — resume folder sync +Documentation=https://docs.beardrive.ai/manual/hooks/ + +[Service] +Type=oneshot +ExecStart=` + exe + ` resume --quiet + +[Install] +WantedBy=default.target +` +} + +// Install writes the unit and enables it by linking it into +// default.target.wants. Both steps are needed: systemd ignores a unit file +// that nothing wants. +func Install() (Result, error) { + if !booted() { + return Result{}, ErrUnsupported + } + path, err := Path() + if err != nil { + return Result{}, err + } + exe, err := selfPath() + if err != nil { + return Result{}, err + } + changed, err := writeIfDifferent(path, unit(exe)) + if err != nil { + return Result{}, err + } + linked, err := enable(path) + if err != nil { + return Result{}, err + } + return Result{Path: path, Changed: changed || linked}, nil +} + +// enable creates (or repairs) the default.target.wants symlink, reporting +// whether it had to. A relative target keeps the link valid if the config +// directory moves with the user. +func enable(unitPath string) (bool, error) { + link := filepath.Join(filepath.Dir(unitPath), wantsDir, Unit) + want := filepath.Join("..", Unit) + if have, err := os.Readlink(link); err == nil { + if have == want || have == unitPath { + return false, nil + } + } + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + return false, err + } + os.Remove(link) // a wrong link, or a regular file left by something else + if err := os.Symlink(want, link); err != nil { + return false, err + } + return true, nil +} + +// Installed reports whether the unit is both present and enabled — a unit +// file with no wants symlink never starts, so it does not count. +func Installed() bool { + path, err := Path() + if err != nil { + return false + } + if _, err := os.Stat(path); err != nil { + return false + } + _, err = os.Lstat(filepath.Join(filepath.Dir(path), wantsDir, Unit)) + return err == nil +} + +// Uninstall removes the enable symlink and the unit. Missing is success. +// +// systemd keeps its loaded copy until `daemon-reload` or the next login, but +// the daemons already running are untouched either way, and nothing will start +// at the next login — which is what "uninstalled" has to mean here. +func Uninstall() (Result, error) { + path, err := Path() + if err != nil { + return Result{}, err + } + link := filepath.Join(filepath.Dir(path), wantsDir, Unit) + var changed bool + if err := os.Remove(link); err == nil { + changed = true + } + if err := os.Remove(path); err == nil { + changed = true + } else if !os.IsNotExist(err) { + return Result{}, err + } + return Result{Path: path, Changed: changed}, nil +} diff --git a/internal/autostart/autostart_linux_test.go b/internal/autostart/autostart_linux_test.go new file mode 100644 index 0000000..d980a2e --- /dev/null +++ b/internal/autostart/autostart_linux_test.go @@ -0,0 +1,239 @@ +//go:build linux + +package autostart + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests redirect HOME (and sometimes XDG_CONFIG_HOME), so nothing +// touches the developer's real ~/.config/systemd/user. That isolation only +// holds because Install writes files and stops — if it ever shells out to +// `systemctl --user`, it would talk to the real session bus. +// +// systemd must look booted for Install to do anything (see booted()); a +// container without /run/systemd/system exercises the other branch, in +// TestInstallNeedsSystemd. +func requireSystemd(t *testing.T) { + t.Helper() + if !booted() { + t.Skip("no /run/systemd/system: this environment is covered by TestInstallNeedsSystemd") + } +} + +func TestInstallWritesUnitAndEnablesIt(t *testing.T) { + requireSystemd(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + if Installed() { + t.Fatal("a fresh HOME cannot have the unit installed") + } + res, err := Install() + if err != nil { + t.Fatalf("Install: %v", err) + } + if !res.Changed { + t.Fatal("first Install reported no change") + } + wantPath := filepath.Join(home, ".config", "systemd", "user", "beardrive.service") + if res.Path != wantPath { + t.Fatalf("path = %s, want %s", res.Path, wantPath) + } + if !Installed() { + t.Fatal("Installed() false right after Install") + } + + body, err := os.ReadFile(wantPath) + if err != nil { + t.Fatal(err) + } + exe, _ := selfPath() + for _, frag := range []string{ + "ExecStart=" + exe + " resume --quiet", // one job for every mount + "Type=oneshot", // resume exits; that is not a failure + "WantedBy=default.target", // what the enable symlink answers + } { + if !strings.Contains(string(body), frag) { + t.Errorf("unit missing %q:\n%s", frag, body) + } + } + // Restart=/network-online would either fight the by-design exit or delay + // local scanning for a hub that sync already retries on its own. + for _, unwanted := range []string{"Restart=", "network-online"} { + if strings.Contains(string(body), unwanted) { + t.Errorf("unit should not mention %q:\n%s", unwanted, body) + } + } + + // Enabled means a symlink systemd will read, not just a unit on disk. + link := filepath.Join(filepath.Dir(wantPath), "default.target.wants", "beardrive.service") + target, err := os.Readlink(link) + if err != nil { + t.Fatalf("unit was written but never enabled: %v", err) + } + if target != filepath.Join("..", "beardrive.service") { + t.Errorf("enable symlink points at %q", target) + } + resolved, err := filepath.EvalSymlinks(link) + if err != nil || resolved != wantPath { + t.Errorf("enable symlink resolves to %q (err %v), want %s", resolved, err, wantPath) + } + + // Idempotent: init calls this on every run. + if res, err = Install(); err != nil || res.Changed { + t.Errorf("second Install = (%+v, %v), want no change", res, err) + } + + // No temp file may survive the atomic write. + entries, _ := os.ReadDir(filepath.Dir(wantPath)) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".bdrive-tmp-") { + t.Errorf("temp file left behind: %s", e.Name()) + } + } +} + +// A unit nothing wants never starts, so a missing or wrong symlink has to be +// repaired — and must not count as installed while it is broken. +func TestEnableSymlinkIsRepaired(t *testing.T) { + requireSystemd(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + if _, err := Install(); err != nil { + t.Fatal(err) + } + path, _ := Path() + link := filepath.Join(filepath.Dir(path), "default.target.wants", "beardrive.service") + + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if Installed() { + t.Error("a unit with no enable symlink counts as installed") + } + res, err := Install() + if err != nil { + t.Fatal(err) + } + if !res.Changed { + t.Error("Install did not report re-enabling the unit") + } + if !Installed() { + t.Fatal("symlink not restored") + } + + // A link pointing somewhere else (an older layout, a hand edit) is replaced. + os.Remove(link) + if err := os.Symlink("/nonexistent/beardrive.service", link); err != nil { + t.Fatal(err) + } + if _, err := Install(); err != nil { + t.Fatal(err) + } + if target, _ := os.Readlink(link); target != filepath.Join("..", "beardrive.service") { + t.Errorf("wrong symlink survived: %q", target) + } +} + +// A stale ExecStart (binary moved or upgraded) must be rewritten, not skipped. +func TestInstallRewritesStaleBinaryPath(t *testing.T) { + requireSystemd(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + if _, err := Install(); err != nil { + t.Fatal(err) + } + path, _ := Path() + if err := os.WriteFile(path, []byte(unit("/old/path/bdrive")), 0o644); err != nil { + t.Fatal(err) + } + res, err := Install() + if err != nil { + t.Fatal(err) + } + if !res.Changed { + t.Error("Install left a unit pointing at the wrong binary") + } + body, _ := os.ReadFile(path) + if strings.Contains(string(body), "/old/path/bdrive") { + t.Error("stale ExecStart survived") + } +} + +func TestUninstallRemovesBoth(t *testing.T) { + requireSystemd(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + // Removing what was never there is success, not an error. + if res, err := Uninstall(); err != nil || res.Changed { + t.Fatalf("Uninstall on a clean HOME = (%+v, %v), want no change and no error", res, err) + } + if _, err := Install(); err != nil { + t.Fatal(err) + } + res, err := Uninstall() + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if !res.Changed { + t.Error("Uninstall reported no change while a unit existed") + } + path, _ := Path() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("unit file survived Uninstall") + } + link := filepath.Join(filepath.Dir(path), "default.target.wants", "beardrive.service") + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Error("enable symlink survived Uninstall") + } + if Installed() { + t.Error("Installed() true after Uninstall") + } +} + +// Desktops and distros that relocate XDG_CONFIG_HOME must still get a unit +// systemd will read. +func TestHonorsXDGConfigHome(t *testing.T) { + requireSystemd(t) + home := t.TempDir() + cfg := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", cfg) + + res, err := Install() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(cfg, "systemd", "user", "beardrive.service") + if res.Path != want { + t.Fatalf("path = %s, want %s", res.Path, want) + } + if _, err := os.Stat(filepath.Join(home, ".config")); err == nil { + t.Error("wrote under ~/.config despite XDG_CONFIG_HOME") + } +} + +// Without systemd a unit file is inert decoration: reporting "registered" +// would be a lie, so Install must decline instead. +func TestInstallNeedsSystemd(t *testing.T) { + if booted() { + t.Skip("systemd present: the supported path is covered by the other tests") + } + home := t.TempDir() + t.Setenv("HOME", home) + if _, err := Install(); err != ErrUnsupported { + t.Fatalf("Install on a non-systemd machine = %v, want ErrUnsupported", err) + } + if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { + t.Error("Install wrote a unit that nothing would ever start") + } +} diff --git a/internal/autostart/autostart_other.go b/internal/autostart/autostart_other.go new file mode 100644 index 0000000..8232f89 --- /dev/null +++ b/internal/autostart/autostart_other.go @@ -0,0 +1,13 @@ +//go:build !darwin && !linux && !windows + +package autostart + +// Everything that is not macOS, Linux, or Windows lands here — the BSDs +// mainly, where the answer would be an rc.d script or the desktop's own +// autostart directory. `bdrive resume` is already the command to run; only the +// registration differs. + +func Path() (string, error) { return "", ErrUnsupported } +func Install() (Result, error) { return Result{}, ErrUnsupported } +func Uninstall() (Result, error) { return Result{}, ErrUnsupported } +func Installed() bool { return false } diff --git a/internal/autostart/autostart_windows.go b/internal/autostart/autostart_windows.go new file mode 100644 index 0000000..2ddb3f1 --- /dev/null +++ b/internal/autostart/autostart_windows.go @@ -0,0 +1,98 @@ +//go:build windows + +package autostart + +import ( + "strings" + + "golang.org/x/sys/windows/registry" +) + +// Windows has no user service manager in the launchd/systemd sense, so the +// registration is a per-user Run entry: HKCU\...\CurrentVersion\Run, which +// Explorer executes once at logon. Chosen over the alternatives because it +// needs no admin rights, no COM (a Startup-folder .lnk does), and no +// shell-out to schtasks — the same "write the registration, don't talk to a +// service manager" rule the other platforms follow. +// +// It is also honestly discoverable: the entry shows up in Task Manager's +// Startup tab, where a user can disable it without knowing bdrive exists. +const ( + runKey = `Software\Microsoft\Windows\CurrentVersion\Run` + valueName = "BearDrive" +) + +// Path names the registration for display. There is no file, so this is the +// registry location — what a user would look at to verify or remove it. +func Path() (string, error) { + return `HKCU\` + runKey + `\` + valueName, nil +} + +// command is the Run value. The executable is quoted because Program Files +// has a space in it and Explorer parses this as a command line. +// +// A brief console window can flash at logon: bdrive is a console binary and +// Run gives it a console. `resume --quiet` exits in milliseconds, so this is +// a flicker rather than a window; hiding it entirely would mean shipping a +// second GUI-subsystem launcher, which is not worth a binary for. +func command(exe string) string { + return `"` + exe + `" resume --quiet` +} + +// Install writes the Run value, creating the key if needed. Idempotent: an +// identical value is reported as unchanged, so `bdrive init` can call it every +// run, while a stale path (an upgrade that moved bdrive.exe) is rewritten. +func Install() (Result, error) { + path, _ := Path() + exe, err := selfPath() + if err != nil { + return Result{}, err + } + want := command(exe) + + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return Result{}, err + } + defer key.Close() + + if have, _, err := key.GetStringValue(valueName); err == nil && strings.EqualFold(have, want) { + return Result{Path: path, Changed: false}, nil + } + if err := key.SetStringValue(valueName, want); err != nil { + return Result{}, err + } + return Result{Path: path, Changed: true}, nil +} + +// Installed reports whether the Run value is present. +func Installed() bool { + key, err := registry.OpenKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE) + if err != nil { + return false + } + defer key.Close() + _, _, err = key.GetStringValue(valueName) + return err == nil +} + +// Uninstall removes the Run value. Missing is success. Running daemons are +// untouched — this only decides what happens at the next logon. +func Uninstall() (Result, error) { + path, _ := Path() + key, err := registry.OpenKey(registry.CURRENT_USER, runKey, registry.SET_VALUE|registry.QUERY_VALUE) + if err != nil { + if err == registry.ErrNotExist { + return Result{Path: path, Changed: false}, nil + } + return Result{}, err + } + defer key.Close() + if _, _, err := key.GetStringValue(valueName); err != nil { + return Result{Path: path, Changed: false}, nil + } + if err := key.DeleteValue(valueName); err != nil { + return Result{}, err + } + return Result{Path: path, Changed: true}, nil +} diff --git a/internal/autostart/autostart_windows_test.go b/internal/autostart/autostart_windows_test.go new file mode 100644 index 0000000..b47e88d --- /dev/null +++ b/internal/autostart/autostart_windows_test.go @@ -0,0 +1,145 @@ +//go:build windows + +package autostart + +import ( + "strings" + "testing" + + "golang.org/x/sys/windows/registry" +) + +// NOTE: these have never been executed — they are written and compile-checked +// (GOOS=windows go test -c) from a non-Windows machine. They run for real the +// first time the suite runs on Windows, or the day CI grows a windows runner. +// +// Unlike the macOS and Linux tests, these cannot be isolated with a temp HOME: +// the registration lives in HKCU, so they touch the real Run key of whoever +// runs them. Each test therefore restores the previous value, and they refuse +// to clobber a value they did not write. + +// saveAndRestore snapshots the Run value so a test run leaves the user's +// logon behaviour exactly as it found it, pass or fail. +func saveAndRestore(t *testing.T) { + t.Helper() + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + t.Skipf("cannot open %s: %v", runKey, err) + } + prev, _, prevErr := key.GetStringValue(valueName) + key.Close() + t.Cleanup(func() { + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.SET_VALUE) + if err != nil { + return + } + defer key.Close() + if prevErr == nil { + key.SetStringValue(valueName, prev) + } else { + key.DeleteValue(valueName) + } + }) +} + +func TestInstallWritesRunValue(t *testing.T) { + saveAndRestore(t) + if _, err := Uninstall(); err != nil { + t.Fatal(err) + } + if Installed() { + t.Fatal("Installed() true after Uninstall") + } + + res, err := Install() + if err != nil { + t.Fatalf("Install: %v", err) + } + if !res.Changed { + t.Error("first Install reported no change") + } + if !strings.HasPrefix(res.Path, `HKCU\`) { + t.Errorf("Path = %q, want the HKCU location for display", res.Path) + } + if !Installed() { + t.Fatal("Installed() false right after Install") + } + + key, err := registry.OpenKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE) + if err != nil { + t.Fatal(err) + } + defer key.Close() + got, _, err := key.GetStringValue(valueName) + if err != nil { + t.Fatal(err) + } + exe, _ := selfPath() + if want := command(exe); got != want { + t.Errorf("Run value = %q, want %q", got, want) + } + // Explorer parses this as a command line, so an unquoted Program Files + // path would silently run the wrong thing. + if !strings.HasPrefix(got, `"`) { + t.Errorf("executable is not quoted: %q", got) + } + if !strings.Contains(got, "resume") { + t.Errorf("Run value does not run resume: %q", got) + } + + // Idempotent: init calls this on every run. + if res, err = Install(); err != nil || res.Changed { + t.Errorf("second Install = (%+v, %v), want no change", res, err) + } +} + +// A moved or upgraded bdrive.exe must be corrected, not left pointing at a +// path that no longer exists. +func TestInstallRewritesStalePath(t *testing.T) { + saveAndRestore(t) + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.SET_VALUE) + if err != nil { + t.Fatal(err) + } + if err := key.SetStringValue(valueName, `"C:\old\bdrive.exe" resume --quiet`); err != nil { + t.Fatal(err) + } + key.Close() + + res, err := Install() + if err != nil { + t.Fatal(err) + } + if !res.Changed { + t.Error("Install left a Run value pointing at the wrong binary") + } +} + +func TestUninstall(t *testing.T) { + saveAndRestore(t) + if _, err := Install(); err != nil { + t.Fatal(err) + } + res, err := Uninstall() + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if !res.Changed { + t.Error("Uninstall reported no change while a value existed") + } + if Installed() { + t.Error("Run value survived Uninstall") + } + // Removing what is not there is success, not an error. + if res, err = Uninstall(); err != nil || res.Changed { + t.Errorf("second Uninstall = (%+v, %v), want no change and no error", res, err) + } +} + +// Paths with spaces are the common case on Windows (Program Files). +func TestCommandQuotesSpacedPath(t *testing.T) { + got := command(`C:\Program Files\BearDrive\bdrive.exe`) + if got != `"C:\Program Files\BearDrive\bdrive.exe" resume --quiet` { + t.Errorf("command = %q", got) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9956e83..f17949c 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -35,22 +35,72 @@ func LogPath(volDir string) string { return filepath.Join(volDir, "daemon.log") } -// Running reports the daemon pid for a mount if one is alive. +// LockPath is the file a live daemon holds an exclusive flock on for its +// whole lifetime. Liveness is the LOCK, not the pidfile: the kernel drops a +// flock when the holder dies — including at reboot, and including a crash — +// so a leftover daemon.pid can never be mistaken for a running daemon. +// +// The pid alone cannot answer this. `kill(pid, 0)` only asks "does some +// process own this number", and daemon.pid outlives the process (it sits in +// ~/.bdrive, which survives reboots). Any same-user process that later +// recycles the pid used to read as a live daemon — which made `bdrive status` +// lie and, worse, made Start() a silent no-op, so the one documented recovery +// (`bdrive init`) left the folder unsynced. +func LockPath(volDir string) string { + return filepath.Join(volDir, "daemon.lock") +} + +// Running reports the daemon pid for a mount if one is alive. The pid is +// informational (for display and for Stop's signal); aliveness comes from +// LockPath — see the comment there. func Running(volDir string) (int, bool) { + if !locked(LockPath(volDir)) { + return 0, false + } data, err := os.ReadFile(PidPath(volDir)) if err != nil { - return 0, false + return 0, true // held by a daemon whose pidfile we can't read } pid, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil || pid <= 0 { - return 0, false - } - if err := syscall.Kill(pid, 0); err != nil { - return 0, false + return 0, true } return pid, true } +// locked reports whether another process holds the lock file. Taking it +// non-blocking and immediately releasing is the probe: success means nobody +// held it (so: no daemon), failure means someone does. +func locked(path string) bool { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return false // can't tell; treat as not running so Start can try + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return true + } + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + return false +} + +// hold takes the daemon's lifetime lock. The returned closer releases it; +// process death releases it too, which is the point. +func hold(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, fmt.Errorf("another daemon is already running for this mount: %w", err) + } + return func() { + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + f.Close() + }, nil +} + // Start launches a detached daemon for the folder (no-op if already running). func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (int, error) { if pid, ok := Running(volDir); ok { @@ -81,19 +131,26 @@ func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (i return pid, cmd.Process.Release() } -// Stop terminates the daemon for a mount and waits for it to exit. +// Stop terminates the daemon for a mount and waits for it to exit. Exit is +// observed by the lock being released, not by the pid disappearing: the pid +// could be recycled while we wait, and the lock cannot. func Stop(volDir string) (bool, error) { pid, ok := Running(volDir) if !ok { os.Remove(PidPath(volDir)) return false, nil } + if pid <= 0 { + // Alive (lock held) but no readable pid — nothing to signal. + return false, fmt.Errorf("a daemon holds %s but %s is unreadable; kill it by hand", + LockPath(volDir), PidPath(volDir)) + } if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { return false, err } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - if err := syscall.Kill(pid, 0); err != nil { + if !locked(LockPath(volDir)) { os.Remove(PidPath(volDir)) return true, nil } @@ -129,6 +186,15 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error { if err != nil { return err } + // Hold the lifetime lock before announcing the pid: it is what makes + // "is a daemon running" answerable, and it also makes a double start + // impossible (two daemons on one mount would write one journal twice). + release, err := hold(LockPath(volDir)) + if err != nil { + return err + } + defer release() + if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { return err } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..340879c --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -0,0 +1,115 @@ +package daemon + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// A pidfile outlives the process that wrote it — it sits in ~/.bdrive, which +// survives the reboot that killed the daemon. Liveness therefore cannot be +// "some process owns this number": any same-user process that later recycles +// the pid used to read as a live daemon, which made `bdrive status` lie and +// made Start() a silent no-op, so `bdrive init` left the folder unsynced. +// +// os.Getpid() stands in for the recycler: it is alive, same-user, and +// certainly not a bdrive daemon. +func TestRecycledPidIsNotALiveDaemon(t *testing.T) { + vdir := t.TempDir() + writePid(t, vdir, os.Getpid()) + + if pid, ok := Running(vdir); ok { + t.Fatalf("Running reports pid %d as a live daemon; only the lock may say that", pid) + } +} + +// Garbage and stale-but-plausible pidfiles are equally not daemons. +func TestPidFileWithoutLockIsNeverRunning(t *testing.T) { + for _, body := range []string{"", "\n", "not-a-number", "0", "-1", "999999999"} { + vdir := t.TempDir() + if err := os.WriteFile(PidPath(vdir), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, ok := Running(vdir); ok { + t.Errorf("pidfile %q read as running", body) + } + } +} + +// The lock is the answer: held → running, released → not. This is what makes +// the reboot case correct without asking the OS about processes at all. +func TestLockDecidesLiveness(t *testing.T) { + vdir := t.TempDir() + if _, ok := Running(vdir); ok { + t.Fatal("a fresh volume dir cannot have a running daemon") + } + + release, err := hold(LockPath(vdir)) + if err != nil { + t.Fatalf("hold: %v", err) + } + writePid(t, vdir, 4242) + pid, ok := Running(vdir) + if !ok { + t.Fatal("a held lock means a running daemon") + } + if pid != 4242 { + t.Fatalf("pid = %d, want the pidfile's 4242 (informational only)", pid) + } + + // A second holder must be refused: two daemons on one mount would both + // write the same device journal. + if _, err := hold(LockPath(vdir)); err == nil { + t.Fatal("hold succeeded twice — a double daemon is possible") + } + + release() + if _, ok := Running(vdir); ok { + t.Fatal("releasing the lock must end the daemon's liveness") + } +} + +// A held lock with an unreadable pid is still a running daemon — we just +// cannot name it. Stop must say so rather than pretending it stopped one. +func TestLockedWithoutPidFile(t *testing.T) { + vdir := t.TempDir() + release, err := hold(LockPath(vdir)) + if err != nil { + t.Fatal(err) + } + defer release() + + pid, ok := Running(vdir) + if !ok || pid != 0 { + t.Fatalf("Running = (%d, %v), want (0, true)", pid, ok) + } + if stopped, err := Stop(vdir); stopped || err == nil { + t.Fatalf("Stop = (%v, %v), want (false, error) — nothing to signal", stopped, err) + } +} + +// Stopping when nothing runs is success, and it cleans up the stale pidfile. +func TestStopWithNoDaemon(t *testing.T) { + vdir := t.TempDir() + writePid(t, vdir, os.Getpid()) + + stopped, err := Stop(vdir) + if err != nil { + t.Fatalf("Stop: %v", err) + } + if stopped { + t.Fatal("Stop reported killing a daemon that was never running") + } + if _, err := os.Stat(PidPath(vdir)); !os.IsNotExist(err) { + t.Fatal("Stop left the stale pidfile behind") + } +} + +func writePid(t *testing.T, vdir string, pid int) { + t.Helper() + if err := os.WriteFile(filepath.Join(vdir, "daemon.pid"), + []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/webapp/cli_e2e_test.go b/internal/webapp/cli_e2e_test.go index 5ddbfb3..8bb74f2 100644 --- a/internal/webapp/cli_e2e_test.go +++ b/internal/webapp/cli_e2e_test.go @@ -21,6 +21,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strings" "testing" "time" @@ -119,6 +120,31 @@ func TestCLIOnboardingE2E(t *testing.T) { // Nothing agent-shaped may be created inside the project: it would sync. assertNoProjectHookFiles(t, work) + // A reboot kills the daemon, so init registers the login agent that + // brings it back. It must land in the user's own LaunchAgents dir (this + // test's isolated HOME) and point at `bdrive resume`, which covers every + // mount rather than needing one registration per project. + if runtime.GOOS == "darwin" { + plist := filepath.Join(e.home, "Library", "LaunchAgents", "ai.beardrive.daemon.plist") + body, err := os.ReadFile(plist) + if err != nil { + t.Fatalf("init did not register the login agent: %v", err) + } + if !strings.Contains(string(body), "resume") { + t.Fatalf("login agent does not run `bdrive resume`:\n%s", body) + } + if out, err := run(work, "autostart"); err != nil || !strings.Contains(out, "registered") { + t.Fatalf("autostart status: %v\n%s", err, out) + } + } + + // resume is idempotent against a live daemon — the login agent runs it on + // a machine where nothing is stopped, and must not start a second one. + out, err = run(work, "resume") + if err != nil || !strings.Contains(out, "already running 1") { + t.Fatalf("resume should have found the running daemon: %v\n%s", err, out) + } + // The hooks are the whole agent integration: init must not install a // skill file anywhere, and no `skill` subcommand may come back. for _, agent := range []string{"claude", "codex", "gemini", "hermes"} { @@ -165,13 +191,16 @@ func TestCLIOnboardingE2E(t *testing.T) { t.Fatal(err) } defer run(work2, "stop", work2) - out, err = run(work2, "init", "--name", "cli-e2e-nohooks", "--yes", "--no-hooks") + out, err = run(work2, "init", "--name", "cli-e2e-nohooks", "--yes", "--no-hooks", "--no-autostart") if err != nil { t.Fatalf("init --no-hooks: %v\n%s", err, out) } if _, err := os.Stat(filepath.Join(work2, ".claude", "settings.json")); !os.IsNotExist(err) { t.Fatalf("--no-hooks still wrote .claude/settings.json (stat err: %v)", err) } + if strings.Contains(out, "login:") { + t.Fatalf("--no-autostart still touched the login agent:\n%s", out) + } if out, err = run(work2, "stop", work2); err != nil { t.Fatalf("stop: %v\n%s", err, out) } diff --git a/web/docs/src/content/docs/manual/hooks.md b/web/docs/src/content/docs/manual/hooks.md index af37f0f..4cc1e20 100644 --- a/web/docs/src/content/docs/manual/hooks.md +++ b/web/docs/src/content/docs/manual/hooks.md @@ -79,3 +79,39 @@ without a `.bdrive/` directory, which is what makes registering it globally safe `bdrive hooks uninstall` takes them back out — it removes only BearDrive's own entries and leaves every other hook in those files untouched. Syncing itself is unaffected; only turn-boundary sync stops. + +## Surviving a reboot + +The sync daemon is an ordinary background process, so a restart ends it. `bdrive +init` therefore also registers a login item that runs `bdrive resume`, which +starts a daemon for every project this device syncs and has not paused: + +```sh +bdrive autostart # is it registered? +bdrive autostart install # register it (init already did) +bdrive autostart uninstall # stop starting sync at login +bdrive resume # start the daemons right now +``` + +One registration covers every project — add or remove projects freely, nothing +to re-register. Projects paused with `bdrive stop` stay paused; only `bdrive +init` resumes those. + +Where it lives, per platform — user-level either way, no `sudo`, nothing +machine-wide: + +| Platform | What gets written | +|---|---| +| macOS | `~/Library/LaunchAgents/ai.beardrive.daemon.plist` (launchd loads it at login) | +| Linux | `~/.config/systemd/user/beardrive.service` plus the `default.target.wants` symlink that enables it (honors `XDG_CONFIG_HOME`) | +| Windows | a `BearDrive` value under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` — visible in Task Manager's Startup tab, where you can disable it | + +Linux needs systemd as the init system. Without it — Alpine or another +runit/OpenRC distro, WSL1, a slim container — `bdrive autostart` says so rather +than writing a unit nothing would read. + +On Windows you may see a console window flicker at logon: bdrive is a console +program and `resume` exits in milliseconds. Nothing is wrong. + +Either way this is not the only thing that recovers sync: an agent turn in a +project syncs it too, so a machine you actually work on catches up on its own. diff --git a/web/docs/src/content/docs/reference/cli.md b/web/docs/src/content/docs/reference/cli.md index d8ac07a..1eea746 100644 --- a/web/docs/src/content/docs/reference/cli.md +++ b/web/docs/src/content/docs/reference/cli.md @@ -11,7 +11,9 @@ 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 approval-link 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 `. `--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 — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also registers agent sync hooks for detected platforms (`--no-hooks` skips them), and prints the project's hub link. Re-run to resume | +| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also registers agent sync hooks for detected platforms (`--no-hooks` skips them) and a login item so sync resumes after a reboot (`--no-autostart` skips), and prints the project's hub link. Re-run to resume | +| `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent, so running it twice is harmless. This is what the login item runs | +| `bdrive autostart [install\|uninstall]` | Show, add, or remove the login registration that runs `bdrive resume` after a reboot: a user LaunchAgent on macOS, a systemd user unit on Linux (needs systemd), a per-user Run entry on Windows. `bdrive init` installs it; `--no-autostart` skips it | | `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes | | `bdrive scope [add\|rm ]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes. Run from the mount root; the daemon picks changes up in seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub | | `bdrive scope --explain` | List every path in the folder, split into what syncs and what does not, with counts — the verifiable answer to "what leaves this machine". Pure read: no daemon, no lock, no network | @@ -49,7 +51,8 @@ it never prompts without a TTY. It runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks for every detected platform (Claude Code, Codex, Gemini CLI, Hermes — `--no-hooks` -skips them), starts sync, and prints the +skips them) and a login item that restarts syncing after a reboot +(`--no-autostart` skips), starts sync, and prints the project's hub link. That is deliberate: one command means one permission prompt for an agent, instead of four. Re-running it resumes — including after a folder move. diff --git a/web/docs/src/content/docs/start/setup.md b/web/docs/src/content/docs/start/setup.md index 9b680c5..46e123b 100644 --- a/web/docs/src/content/docs/start/setup.md +++ b/web/docs/src/content/docs/start/setup.md @@ -47,7 +47,8 @@ The sync hooks, and nothing else you have to think about: read. They go in the agent's user config, once per machine, and are a no-op outside -BearDrive folders. `bdrive init` registers them for you, so there is nothing +BearDrive folders. Your agent also registers a login item, so syncing comes +back on its own after a reboot rather than waiting for the next session. `bdrive init` registers them for you, so there is nothing extra to run — [hooks in detail](/manual/hooks/) covers what gets written where.