From fb6ce347c45ce91a1db741ad70a94dc636368e8a Mon Sep 17 00:00:00 2001 From: "Snow W. Lee (Sungwon)" Date: Thu, 30 Jul 2026 13:32:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20survive=20a=20reboot=20?= =?UTF-8?q?=E2=80=94=20login=20autostart=20on=20macOS/Linux/Windows,=20and?= =?UTF-8?q?=20a=20lock=20instead=20of=20a=20pidfile=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(daemon): bring sync back after a reboot, and stop trusting the pidfile A reboot killed every daemon and nothing restarted them. Agent hooks still synced per turn, which is what made it easy to miss: a folder looked fine while an agent worked in it and went stale the moment one didn't. `bdrive init` now registers a login item (macOS: a user LaunchAgent) that runs the new `bdrive resume` — one registration per machine, which starts a daemon for every enrolled, unpaused mount, so adding a project later needs no re-registration and `bdrive stop` still means stay stopped. `--no-autostart` opts out, `bdrive autostart install|uninstall` manages it. Writing the plist is the whole job: no `launchctl` shell-out. launchd loads agents at login anyway, the caller has just started the daemon for this session, and shelling out would let a test or a packaging script register a real login item as a side effect. The recovery path was also broken, which is why this is one change. Liveness came from `kill(pid, 0)` on daemon.pid — but that file lives in $BDRIVE_HOME and survives the reboot that killed its process, so any same-user process recycling the pid read as a live daemon. `bdrive status` said "running", and worse `daemon.Start` returned early, so the one documented recovery (`bdrive init`) reported success and started nothing. Liveness is now an flock held for the daemon's lifetime: the kernel drops it at death or reboot, and it makes two daemons on one mount impossible. The pid stays for display and for signalling. internal/autostart is darwin-only today; autostart_other.go returns ErrUnsupported and every caller already treats that as "nothing to do", so Linux (systemd user unit) and Windows are one file each. Tests: internal/daemon gets its first ones — a recycled pid must not read as running (the exact regression), the lock decides liveness, a second holder is refused. internal/autostart covers write/idempotency/stale-path-rewrite/ uninstall with HOME redirected, and lints the plist with plutil so launchd can actually parse it. The CLI e2e asserts init registers the agent, that it runs `resume`, that resume finds the live daemon instead of starting a second, and that --no-autostart is silent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * feat(autostart): Linux support — a systemd user unit alongside the launchd agent Same three functions, same discipline. Linux writes $XDG_CONFIG_HOME/systemd/user/beardrive.service (Type=oneshot, no Restart= — `bdrive resume` exits by design) plus the default.target.wants symlink that `systemctl --user enable` would create, because systemd ignores a unit nothing wants. No `systemctl` shell-out, for the same reasons as launchctl: the file is the registration, it only matters at the next login, and a container or ssh session has no session bus to talk to. Install declines with ErrUnsupported unless systemd is actually the init system (/run/systemd/system, i.e. sd_booted) — on Alpine, WSL1 or a slim container a unit file is inert decoration, and reporting "registered" would be a lie. Installed() likewise requires the enable symlink, not just the unit: a unit nothing wants never starts. os.UserConfigDir honors XDG_CONFIG_HOME, so relocated config dirs work. Windows is now the only gap; autostart_other.go is !darwin && !linux, and the shared writeIfDifferent/selfPath moved into the tag-free autostart.go (darwin now uses them too). Tests run on Linux, not just compiled for it: cross-compiled test binaries executed in a container, both with /run/systemd/system present (unit written, enabled, idempotent, stale ExecStart rewritten, broken symlink repaired, XDG honored, uninstall removes both) and without it (Install declines and writes nothing). The daemon flock tests were run there too, since flock semantics are per-OS. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * feat(autostart): Windows support — a per-user Run entry Third platform, same three functions. Windows has no user service manager in the launchd/systemd sense, so the registration is a HKCU\...\Run value via golang.org/x/sys/windows/registry (already in the module graph; go mod tidy just promotes it to direct). Chosen over the alternatives for the same reason the other two write files: no admin rights, no COM (a Startup-folder .lnk needs it), no schtasks shell-out. It is also honestly discoverable — the entry appears in Task Manager's Startup tab, where someone can disable it without knowing bdrive exists. The executable is quoted because Explorer parses the value as a command line and Program Files has a space in it. Two things a reader should not have to discover for themselves: - The tests here have NEVER RUN. They are written and compile-checked (GOOS=windows go test -c) from macOS; there is no Windows host or usable container on an arm64 mac. They execute the first time the suite runs on Windows. They also cannot use a temp HOME the way the macOS and Linux tests do — HKCU is real — so each one snapshots and restores the previous value. - `GOOS=windows go build ./...` still does not pass, and this package is not why: internal/store's Lock uses syscall.Flock and internal/daemon uses syscall.Kill and Setsid, all unix-only (true before this branch too). A Windows port means LockFileEx plus a stop story for a platform with no SIGTERM — a separate change, against the sync invariants, and untestable from here. So this code is correct and currently unreachable. autostart_other.go is now !darwin && !linux && !windows (the BSDs). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ --------- Co-authored-by: Claude Opus 5 --- CLAUDE.md | 8 +- INSTALL_FOR_AGENTS.md | 7 +- README.md | 5 +- architecture/README.md | 2 +- architecture/cli-sync.md | 19 +- cmd/bdrive/init.go | 31 ++- cmd/bdrive/main.go | 2 + cmd/bdrive/resume.go | 185 ++++++++++++++ go.mod | 2 +- internal/autostart/autostart.go | 87 +++++++ internal/autostart/autostart_darwin.go | 92 +++++++ internal/autostart/autostart_darwin_test.go | 135 +++++++++++ internal/autostart/autostart_linux.go | 149 ++++++++++++ internal/autostart/autostart_linux_test.go | 239 +++++++++++++++++++ internal/autostart/autostart_other.go | 13 + internal/autostart/autostart_windows.go | 98 ++++++++ internal/autostart/autostart_windows_test.go | 145 +++++++++++ internal/daemon/daemon.go | 82 ++++++- internal/daemon/daemon_test.go | 115 +++++++++ internal/webapp/cli_e2e_test.go | 31 ++- web/docs/src/content/docs/manual/hooks.md | 36 +++ web/docs/src/content/docs/reference/cli.md | 7 +- web/docs/src/content/docs/start/setup.md | 3 +- 23 files changed, 1470 insertions(+), 23 deletions(-) create mode 100644 cmd/bdrive/resume.go create mode 100644 internal/autostart/autostart.go create mode 100644 internal/autostart/autostart_darwin.go create mode 100644 internal/autostart/autostart_darwin_test.go create mode 100644 internal/autostart/autostart_linux.go create mode 100644 internal/autostart/autostart_linux_test.go create mode 100644 internal/autostart/autostart_other.go create mode 100644 internal/autostart/autostart_windows.go create mode 100644 internal/autostart/autostart_windows_test.go create mode 100644 internal/daemon/daemon_test.go 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.