mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
v0.14.0
246
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c8ab5504f7 |
feat(webapp): history can be narrowed by path, author and date (BEA-67) (#113)
* feat(webapp): history can be narrowed by path, author and date (BEA-67) The project History view was a flat scroll with no controls but per-row restore/open/download. Fine at eleven rows; unreadable after a month of agent writes, and agents write far more than people do. Four reader filters on GET /api/p/<id>/history — q= (case-insensitive substring of the path), user= (exact account), since=/until= (UTC bounds, inclusive at both ends, RFC3339 or a bare YYYY-MM-DD). They compose with each other and with the existing path=/prefix= scoping, and they are applied in the same walk as path/prefix — BEFORE the sort and the cursor skip — so next_cursor keeps meaning "the next matching entry" and paging under a filter needed no new machinery. kinds[] is still computed over every op, so a filtered view classifies edits the same as the full feed. A malformed since/until is a 400, not a silently unfiltered feed. The filter bar drives those params through the URL rather than component state: a narrowed feed is a link you can send, it survives reload, and Back undoes it. The author list accumulates across fetches — rebuilding it from the current feed would strand a reader who filtered by one author with only their own name to pick from. Zero matches gets its own empty state with a Clear. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): the frontend diagram parses again The escaped quotes inside the NewProjectDialog note made the whole classDiagram fail to parse — it has been rendering as an error box, not a diagram, since that note landed. Same breakage on origin/main; noticed while adding HistoryFilters to the same file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>v0.14.0 |
||
|
|
7861b755ac |
fix(webapp): the reads × freshness chart's busiest dot fits inside its own frame (BEA-60) (#111)
Three defects on one panel, all of them stopping it from delivering its one insight — "these docs are hot but stale, go fix them": - The hottest file plotted at exactly y = M.t with a radius up to 7px, so the most important point on the chart straddled the top border. The plotted box is now inset on both axes by the largest radius the size formula can produce, derived from that formula rather than hardcoded, so a future radius change can't quietly reintroduce the clipping. Thresholds, the danger rect and the dots all read X/Y and shift together; the axis lines use M and stay. - No dot said which file it was — identity lived only in the hover tooltip. The six busiest hot+stale files now carry their basename beside their dot, flipped to the dot's other side rather than leaving the frame and stacked when two would print on one baseline. Placement is a pure function in lib/heat.ts so it can be unit-tested; every dot keeps its title and click. - "dot size = agent share of reads" was drawn inside the <svg> directly under the right-anchored "hot + stale" label, 14px apart. It moved to the panel heading row, capped at the chart's own max-width. The e2e seed grew three hot-but-months-old files: every seeded file was hours old, so the danger quadrant — the whole reason this panel exists — was empty in every test that had ever run against it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d7772a22df |
fix(webapp): restore is not offered on the version that is already current (BEA-57) (#110)
The newest row for a path IS the file's current content, so its `restore` button could only ever journal a +0 −0 change — attributed to a real person, on a real device, replicated to every teammate, in the audit trail the whole history story depends on. It was also the single most tempting row to click. One rule, enforced at both ends. handleRestore now 409s when the requested sha is already the path's head (journal.Replay, the way the CLI already answers this question), placed after the "no such version of that path" 404 and before CheckWrite so an unknown sha still 404s and a refused restore records no quota. HistoryView computes each path's head from the loaded window — entries are strictly newest-first, so a path's first occurrence decides — and restoreSha returns undefined for bytes that already are the head, which removes the button, its title and its busy state together. The rule is content equality, not row index: an older row hand-reverted to the current bytes is just as much of a no-op, and matching what the server checks means the UI can never show a button that errors. A newest DELETE leaves the path out of the replay, so a deleted file still restores — that is a real change. Confirm-on-restore stays out, deliberately (HistoryRow.tsx:154): the defect was never "restore should ask", it was "restore should not be offered where it cannot do anything". Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5f8114e07 |
fix(webapp): the palette always offers a way back (BEA-52) (#102)
⌘K on a path that doesn't resolve degraded to History ×2 and Sign out — the tree-derived entries are gone on a dead route and the switcher lists only OTHER projects, so on a single-project hub the palette, which is the natural escape hatch there, was the one surface with no way out. Four static entries now lead the candidate list whenever hub && project: Go to project root, Dashboard, Installation, Settings — the same four destinations (and icons) the sidebar has. They're independent of the tree and of whether the path resolves, and each navigates to a real VIEW_ROUTES URL, so a reload renders the same view. Panels only close on a location change, so selecting the page you're already on closes them explicitly. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe872dd1fa |
fix(dashboard): keep reads for a deleted file on the map (BEA-49) (#100)
The Dashboard's file panels built every point by joining the heat map onto the current file tree, so a heat row whose path had left the project was silently dropped — while the agent-coverage panel below, which does no such join, rendered those same reads. One page, one ledger, two answers. The production consequence is the real bug: delete or rename a well-read doc and its whole read history vanishes from the map, which is exactly the signal the Dashboard exists to give. Hot path now ranks orphaned rows alongside tree files, labelled "no longer in the project" and opening that path's History (the file view would land on the not-found page). The two plots stay tree-only — both position by freshness and an orphan has no mtime, so any position would be invented — but each carries a count of what it can't show. "No reads in the window yet" can now only render when the scope genuinely has none. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ac87fbfde0 | fix(webapp): the e2e harness binds the port before it wipes anything (BEA-50) (#98) | ||
|
|
9f13c70b78 |
feat: ask for a GitHub star where it can't annoy anyone (#108)
* feat: ask for a GitHub star where it can't annoy anyone Three surfaces, all passive or once-per-setup: - `bdrive init` prints one line after a successful setup, TTY-only. init runs about once per project per machine, and the guard keeps it out of CI logs and any output a script parses — putting a star plea in repeating output is what got postinstall ads banned from npm. - The hub sidebar gets a dim "Star on GitHub" link above the account row. A link that always sits there reads as social proof; a dismissible banner would need dismissal state and would still have interrupted. - README grows star/pkg.go.dev/docs badges — until now the only user-facing link to the repo was the docs sidebar. Covered by two checks: the CLI e2e asserts init stays silent about the repo when stdout is piped, and a Playwright spec pins the sidebar link's href/target and that nothing modal appears with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv * fix(webapp): the star link wears the GitHub mark, at 11px A star glyph next to "Star on GitHub" said the same word twice; the mark is what people scan for. lucide dropped its brand icons in v1, so the path is inline rather than a second icon dependency for one glyph — and it is filled, so it sits outside the `.ico` stroke sizing. Text drops 12px → 11px to match the account email below it: this is the dimmest thing in the sidebar on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2a7dca252f |
docs: PR architecture sections carry one diff diagram, not a before/after pair (#107)
Two diagrams make the reviewer do the diffing. The mermaid-diff-diagram skill folds them into one flowchart with additions marked ✅ and removals ❌ struck through, so the change reads at a glance. The committed architecture/*.md files are untouched by this: they stay full-state classDiagram; only the PR excerpt changes. Claude-Session: https://claude.ai/code/session_01FNgeJVcsXQ5sTfWLgCR4cv Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26334ec328 |
fix(webapp): auth submit buttons answer to button[type=submit] (BEA-53) (#103)
Every server-rendered /auth form shipped a bare <button>. Browsers default one inside a form to submit, so humans never noticed — but the conventional automation selector matched nothing, and the e2e suite carried a "form button" workaround at two call sites to compensate. All five buttons (sign in, sign up, approve, send reset link, set password) now carry an explicit type="submit", and both e2e call sites use the standard selector. Every spec's login() routes through helpers.ts, so a regression fails the whole run at the first sign-in. Markup only: authlocal.go styles button by element, not by [type], so the rendered pages are byte-identical before and after. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9c18c83845 |
fix(dashboard): an empty project says it's empty instead of drawing empty charts (BEA-51) (#101)
A brand-new project's Dashboard rendered ~840px of empty bordered SVG
frames with the "hot + stale" / "hot + fresh" / "cold + stale" quadrant
labels floating over nothing: Treemap and Scatter had no empty guard, and
the quadrant labels come from the HOT_READS/STALE_DAYS constants rather
than from data. Only HotPath said anything, and what it said ("No reads in
the window yet") is the wrong claim — the project has no files at all.
The guard goes one level up in Insights, where the three panel headers and
the lens switcher also live, so those go away too instead of sitting over
nothing. Gated on "no files", never on "no reads": files-with-no-reads is
the other zero state and it already behaves correctly (Treemap pads every
file to reads + 1 so unread files keep a sliver).
Two new optional props. `installHref` puts a real <a> in the empty state,
routed through linkProps so it stays copyable and middle-clickable — the
dashboard route passes it, the project home doesn't, because ConnectGuide
directly above it IS the set-up-a-device guide. `loading` keeps the tree's
first frame from claiming a populated project has no files.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
820978cd76 |
fix(history): page the History API and view so old changes are reachable (BEA-46) (#99)
* fix(history): page the API so old changes stop being unreachable (BEA-46) GET /api/p/<id>/history capped at ?n= and said nothing about what it was hiding, so every change older than the cap was unreachable — a project's early history sat in the journals and blob store with no way to display it. The display order used to come from two mechanisms: a stable time sort over a slice built in reverse-journal order, so the tie-break was implicit in the construction and a cursor could not re-derive it. histLess makes it one function — newest wall-clock first, ties in reverse journal.Less — used for both the sort and the skip-past-cursor step, so paging cannot disagree with the feed. The cursor is server-minted and opaque because it has to be: HistoryEntry.time is formatted to whole seconds and carries no lamport/seq, so a client-computed cursor would be lossy across same-second ops. ?n= alone returns exactly the entries it always did (the tie-break IS reverse-Less); it just gains a next_cursor key when more exist. A cursor is a position in an ordering, not a snapshot: an offline device pushing mid-scroll lands ops mid-feed by timestamp and the reader sees them on refresh — pinning would mean server state for the life of a scroll. BenchmarkHistoryPage over 5000 ops: page 1 14.4ms, page 20 15.8ms — every page re-lists and re-parses the journals, so the ceiling is gone but the per-page work is not. No cache needed at this scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): follow the cursor in the History view, with a Load more (BEA-46) The view hard-coded n=200 and rendered whatever came back, so a project past 200 changes showed a list that simply stopped. useInfiniteQuery now follows next_cursor at 100 a page, and the foot of the list says "Load more" while older changes exist — a button, not an IntersectionObserver, so it is keyboard-reachable and states out loud that there is more. Pages accumulate into one array, which is what makes the rest free: groupRuns already groups across the whole window (a run straddling a page boundary becomes one card when its second page lands — verified live: 7 files on page 1, 12 after Load more) and prevBlob already returns undefined past the end, so the oldest loaded row shows no diff base rather than diffing against the wrong predecessor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9a52aed3bb | fix(palette): the ⌘K search box typed black on black (BEA-54) (#106) | ||
|
|
a84fe444f5 |
fix(hooks): every mount gets its own hub link, so agents stop mixing projects up (#105)
A session whose root holds two connected folders got exactly one project's
base URL in its turn context: `bdrive sync --hook` emitted for the first
mount and stopped. Agents then hung the other project's paths on that URL,
producing links to project B carrying project A's path — confidently wrong,
and 404 on arrival.
The context now carries every mount as a `prefix → URL` pair, where the
prefix is the mount's path as the agent sees it from the session's folder.
A session started INSIDE a mount has no prefix to strip, so its own subpath
is baked into the base URL instead — the other half of the same bug, which
made every link from a subdirectory session miss its leading segments.
The formula also now states outright that the URL path is folder-relative
and that a path matching no listed folder is not synced and must not be
linked at all. Encoding is per-segment, `/` left literal (encodePathSegments).
Two more fell out along the way:
- `runHookSync` returns nil on every path by design ("a hook must never
fail the turn"), so the caller's `err == nil` guard could never be false.
A first mount that emitted nothing — non-hub remote, session open error —
still consumed the one emission, and NO mount got a link. It now reports
whether it produced a URL.
- stdin was read once per mount, so with several mounts only the first got
its session note stamped. The event JSON is read once per run now.
Single-mount output is unchanged apart from the folder-relative sentence.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1c703c95f8 |
feat(templates): start a project from a structure, not an empty folder (#97)
* feat(templates): start a project from a structure, not an empty folder A new project was an empty folder with a .bdriveignore in it, so every agent session invented its own layout and the folder rotted into a pile. Both surfaces now offer the same three starting points — from a template, from scratch, from an existing folder (which is just a non-empty folder, and is never restructured). internal/templates holds the shipped set as literal go:embed'ed files: `docs` (docs/, decisions/) and `para` (projects/, areas/, resources/, archives/). cmd/bdrive is one binary for the CLI and the hub, so both read the identical set — no gallery, no drift. The AGENTS.md in each is the deliverable: where a new note goes, when something is archived, what a good filename looks like. Every directory holds a real file, because BearDrive syncs paths and an empty directory would never reach a teammate. The hub seeds at creation through the existing Upload+Commit path, journaled under its own device, and records the choice on the project record — so a user who picked PARA in a browser sees PARA in the browser, and a later init cannot seed a second copy. `bdrive init --template <name>` goes through the same endpoint, with a local-seed fallback for a hub too old to know the field, and seeds in place when re-run in an already-initialized folder (the agent's post-init path). Seeding never overwrites an existing path, which is what makes a double-seed a no-op rather than a divergence. Refusals cost nothing: an unknown name and --template with --only are both rejected before any network call or write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cli): joining a project that already has a template is refused by name The one acceptance case with no test behind it: connecting to an existing project with --template must say what the project was actually created from, and must not write the other skeleton on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(templates): name the docs template in plain English, not an acronym "Plain docs + ADRs" was the recommended, first, preselected-adjacent option in a picker that non-engineers see — and it's the label people accept without reading further, so half of it not parsing is the worst place for jargon. The title also disagreed with its own blurb: "ADRs" over "docs/, decisions/", two words for the same folder one line apart. Now "Docs + decision records", which says the same thing to everyone and matches the folder names. The term itself moves into decisions/0001-record-decisions.md, where the reader is already inside the structure and the file can teach it in passing. One line in the registry drives both the web dialog and the CLI menu; the rest is prose echoing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(templates): add the LLM wiki template The third starting point from the issue title, unblocked: the spec parked it because shipping an approximation under someone's name needed a source, and there is now one — Karpathy's LLM Wiki gist. Worth noting the issue's own one-line description of it ("few large, append-heavy topic pages") does not match the source, which is the opposite: many interlinked pages, where a single ingest touches 10-15 of them. The pattern is three layers and three operations, not a folder shape. sources/ is yours and immutable; wiki/ is the agent's and it owns every page; AGENTS.md is the schema layer — which is exactly the file this template system already treats as the deliverable, so the fit is direct. index.md and log.md ship as the two navigation files the pattern turns on. Three of the things the gist tells you to go set up, BearDrive already is: version history and collaboration (per-file history, bdrive log), an Obsidian- style reader for [[wikilinks]] (the hub viewer), and a surface for the lint pass (the dashboard is literally reads x staleness). Two rules in the AGENTS.md are load-bearing and deliberate. A page write that has not updated the index is an incomplete write — a stale index is worse than a missing page, because it is read first and believed. And with no sources yet, build nothing: the structure grows out of the material rather than ahead of it. Shipped second, not first: docs stays the recommendation because a default is the option chosen by people not reading closely, and this pattern degrades badly when half-followed. Promoting it later is one line in the registry. The shipped-template test now checks the "what happens when something stops being true" question through a set of alternatives — PARA archives, a wiki supersedes and revises — since the vocabulary honestly differs by structure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): "I already have a folder" as a starting point The third way to start from the spec — adopt what you already have — had no presence in the browser. Templates and "empty" were the only visible answers, so someone with a folder of notes either hesitated or picked a template and got four directories merged into their material. The constraint that shapes it: the browser cannot reach your disk, so this cannot change what is created. It creates the same empty project "Empty project" does; what it changes is the next screen. Create therefore stays enabled — disabling it would leave the dialog a dead end AND produce no project id, which is the one thing the paste prompt actually needs. Landing on the project home with the intent, three things differ: the guide says "in the folder you already have", a note states plainly that connecting never moves, renames or overwrites anything, and the paste prompt tells the agent a folder already exists. That last one is the part that isn't cosmetic — without it an agent reads an empty project and proposes creating shared/, the one recommendation that is wrong here. It still asks which folder: that is the runbook's hard gate and nothing here weakens it. The intent rides in the URL (?connect=existing) rather than onto the project record, the same way ?v= pins a file version. It belongs to whoever is connecting right now — a teammate who connects next week has their own answer and would be told the wrong thing by a persisted flag. Five rows made the dialog tall enough to push Create off a short viewport, so .modal scrolls internally. A hairline divider between the seeding and non-seeding rows was tried and removed: --border is 7% white, which at 1px in a gap renders as literally nothing. The gap is the cue that reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): with no projects, open the create dialog and give the page a way in A signed-in account with no projects landed on a page whose only path forward was pasting a prompt into a coding agent. Now the create dialog opens itself — with nothing to browse there is nothing else on that page to do — and the page behind it leads with "Start a project" and a button, so closing the dialog is not a dead end. The dialog moves up to HubApp because three things ask for it now: the sidebar's +, the empty state's button, and the auto-open. ProjectNav keeps only an onNew callback; one owner beats three copies of the create handler. Two guards on the auto-open. It fires once per mount, keyed off a ref rather than the empty state, or closing it would immediately reopen it. And it never fires on a read-only hub, which refuses creation server-side with a 403 — opening a dialog that cannot succeed is worse than the page it covers. The agent paste-prompt stays, demoted to "Or let your agent do it": it is still the right path for someone who wants the folder connected in the same breath, and it is the only path on a hub where this account cannot create. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a05a1f8a52 |
refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth) (#104)
* refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth)
The `bdrive login` surface — /auth/cli, /auth/device/<token>, the approval
page both show, /api/auth/exchange and /api/auth/device/{start,poll} — moves
out of BuiltinAuth into its own type. A provider supplies the two things
that actually differ: who the browser session is, and how a device token is
minted.
Nothing changes for a self-hosted hub; this is the same code behind the same
paths. It moves because the managed hub's provider carries its own copy, and
the copy drifted: months after the OSS flow moved to a single approval link
naming the device, that hub was still printing a four-byte code to retype
into a text box. Sharing the implementation is the only fix that stays fixed.
BuiltinAuth's own grant map now holds just what it should: verification and
password-reset links.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(architecture): make the webapp-server diagram parse again
Two mermaid syntax errors, so GitHub rendered the first block as an error
box instead of a diagram:
- the CLIAuth class listed its routes as bare lines, and the `{` in
/auth/device/{token} opens a struct inside a class body — the routes are a
note now, where prose belongs;
- `note for` strings escaped quotes as \" (mermaid has no backslash escapes,
so the string ended early). Pre-existing, in the DirectUploader and
Project notes; both use " now, like the </> already in there.
Checked by parsing every block in architecture/*.md with mermaid 11.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b408e004b3 |
fix(history): stop reporting the device IP on every change (BEA-43) (#81)
* fix(history): stop reporting the device IP on every change (BEA-43) The history API embedded the whole DeviceInfo, so every project member read a teammate's server-observed IP (plus user/last_seen) next to every change on a page whose job is "who changed this file". Project a three-field historyDevice instead — id/name/os — mirroring heatByDevice. The device registry is unchanged: Observe/requestIP and both MetaStore backends keep recording the IP. This is a response projection, not a change in what gets collected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: history shows device name and OS, not the IP (BEA-43) README, SKILL.md and the docs site all promised the History view would show the connecting IP. It no longer does — the registry still records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
af9ef4d181 |
fix(dashboard): make the knowledge treemap honest on a young project (#93)
* fix(dashboard): make the treemap honest on a young project Every cell was the same green (staleColor spreads 0-300d, so <=3d-old content lands in one stop), half the cells were anonymous with no hover fallback, and read counts existed only inside file <title>s. The map said nothing the file tree didn't. - group rects get a <title> (folder, reads/30d, file count), so a group whose files are all too small to label is still identifiable - read counts appended to file and group labels; the count is part of the string the fit is measured against, so it can never overflow, and when it doesn't fit the label degrades to the bare truncated name as before - a freshness legend under the map: the gradient plus the age span actually observed in this scope+lens, and when that span is under a week it says the colour channel carries no signal instead of implying one - group read totals come from a true sum, not the padded layout value No relative colour scale: normalising to the observed range would paint a 3-day-old file the red that means hot-and-stale everywhere else on the page. Sizing (reads + 1), scatter, hot path and coverage are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): heat.ts joins the frontend lib Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d0eee0254 |
fix(history): size an agent run by files touched, not by ops (BEA-39) (#87)
The run card header counted ops, so a path rewritten five times inflated the one number a reader uses to size a run: 14 ops across 10 paths read "14 files". It now counts distinct paths and keeps the word "files"; every op is still a row inside the card. The same count decided whether a run got a card at all, so a run that hit one path five times drew a card claiming "5 files". Counting by file demotes it to bare rows, each still showing its session note. groupRuns and the Run/Item types move to src/lib/runs.ts (pure, no React) so node's test runner can import them — a .tsx with JSX can't be. The grouping key, ordering, time span, who and device are unchanged, and run.idx still addresses the flat feed so diffs and restore shas are unaffected. The key's NUL separator moves across as an explicit "\0" — it was a raw NUL byte in the source, which is also why git saw the old HistoryView.tsx as binary. Deviation from the reviewed plan: flipping the threshold in place would have dropped rows. Only a run's first entry was ever pushed to the output list, so a demoted 5-op run would have rendered one row, not five. groupRuns now builds runs first and emits items in a second pass over the feed, which keeps demoted rows at their own newest-first positions. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
01f33c9fe7 |
fix(dashboard): the hot-path bar paints share reads as share, not as people (BEA-38) (#86)
* fix(dashboard): the hot-path bar paints share reads as share, not as people The bar computed one fraction (agent/total) and painted the whole remainder in the human colour, so a file read only through a share link rendered as if a person had browsed the hub — and the legend named only two readers, while the file header has been breaking out all three all along. Each reader now gets its own segment from its own count. hotPathSplit() does that arithmetic, and it lives with the rest of the heat helpers in the new lib/heat.ts (pure, no React) so one unit test over one fixture can pin the invariant the report doubted: the file header and the Dashboard read the same total from the same helper. useBrowse.ts re-exports them, so no import site moved. No server change — /heat already returns share and stays identity-free. * docs(architecture): lib gains heat.ts, the one read-count arithmetic --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
11dd7ac528 |
fix(webapp): the viewer credits the account you signed in with, like History does (BEA-37) (#84)
* fix(webapp): viewer credits the signed-in account, like History does RemoteSource.Files built each FileInfo from op.Author alone and dropped op.User/op.UserName, so the file viewer header showed a git/OS identity the user never signed in with while History rows for the same op showed the account. Carry both fields through FileInfo -> Node -> /render and render them with the whoChanged() helper History already uses, so there is one attribution rule in the frontend rather than two. The guard on the meta line stays on the raw fields: whoChanged() answers "unknown" rather than "", and plain-folder (DirSource) mode has no identity at all and must keep printing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(webapp): plain-folder render carries no empty identity fields Locks the other half of BEA-37: DirSource has no account behind a file, and sending empty user fields would make whoChanged() print "unknown" where plain-folder mode has always printed nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4dfe6f44f0 |
feat(history): undo a file an agent run created (BEA-35) (#82)
History could restore an edit or a deletion, but a file a run CREATED was the one thing it couldn't reverse — the ADDED row said so in copy and offered no button. The missing capability was a hub-written delete op: restore.go only ever journaled puts. POST /api/p/<id>/remove journals exactly one journal.KindDelete op under the hub's own device identity, behind restore's gates (gateUpload, PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume- snapshot existence check so the API 404s on what the tree doesn't show. Commit's journal-append tail moves into RemoteSource.appendOp, which both writes now share — one writer per journal, unchanged. The ADDED-in-a-run row gets an "undo — remove file" control that confirms first (it reaches every synced device), and the DELETED row it leaves behind restores the file with its original bytes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31472c56e7 |
fix(hub): one Revoke per share link — the modal hands off, the banner undoes (BEA-32) (#77)
Opening Share on a file that already had a live public link showed the URL twice, each copy with its own Revoke: the transient dialog on top of the persistent ShareBanner. Both hit the same link, so nothing was at risk, but a destructive control shown twice is a control nobody wants to click. The dialog loses Revoke and keeps its job — confirm what happened, hand over the URL (Copy link / Open / Done) and set an expiry. The banner keeps Revoke: it is what BEA-16 added so the undo outlives the dialog, and it is still there on every later visit to the file. Deviation from the reviewed plan: it also had this delete the `token` derivation and the `api` import, which existed only for the Revoke handler. BEA-29 (#74) landed first and its expiry PATCH now uses both, so they stay — the plan assumed this PR would go in ahead of it. The e2e that revoked through the modal now revokes through the banner, and asserts `.modal .ai-del` has count 0 right after Share — the assertion that stops the duplication coming back. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
946a552c64 | fix(dashboard): name the fourth quadrant on reads × freshness (BEA-42) (#79) | ||
|
|
872ba702cb |
fix(log): bdrive log reads as a timeline — newest first by edit time (#BEA-40) (#90)
`bdrive log` sorted by the lamport clock, so the wall-clock stamps it prints came out non-monotonic — two 06:09:24 rows above a 06:10:00 row. And every op of one scan is stamped with that scan's commit time, so a 22-file agent run collapsed onto a single stamp. Neither is readable as a timeline, which is the whole job of the command. Two display-only changes: - `journal.Op` gains `Mtime` (`omitzero`, so old journals and old binaries are unaffected), populated on put ops from the `os.FileInfo` the scan already holds. Deletes and conflict copies keep their commit time. - `syncer.DisplayTime` / `SortForDisplay` order by the timestamp that is actually printed, ties broken by reversed `journal.Less`. `bdrive log` sorts and *then* truncates, so `-n 25` is the 25 newest by that stamp. `journal.Less`, `Sort`, and `Replay` are untouched — replay order is the convergence contract, so the sort lives in `syncer`, not in `journal`. `LogEntries` also keeps returning causal order because `bdrive restore` walks it to find a file's previous version. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b1c0bba415 |
feat(auth): the browser sign-in says whose account the terminal gets (#96)
* feat(auth): ask before signing a terminal in as whoever the browser is `bdrive login` opened /auth/cli and the browser bounced straight back with a code. Whoever the browser happened to be signed in as is who the terminal became — silently. That is frequently not the account the user meant: a personal login left open in the default browser, a teammate's session on a shared machine. The mistake surfaces much later, as a synced folder full of commits authored by the wrong person, which is far more work to undo than one click would have been. The device flow already got this right in #83 — it names the account, offers to switch, and says what approving grants. The browser flow said nothing at all, for the same outcome: a token that acts as you. So /auth/cli now confirms first. GET renders the page (who you would be signing in as, a Switch account link that comes back to this same pending sign-in, what is asking, and where it is waiting); POST is what mints the code and redirects to the loopback listener. A GET therefore grants nothing, so a link someone else got you to open can no longer mint a code on your behalf. whoBlock loses its pendingGrant parameter and renders only the identity half. What is asking differs per flow — a device has a name and an OS, a CLI on this computer has a loopback port — so each page now renders its own rows through a small helper instead of whoBlock pretending to a shape neither quite fits. The CLI's own wording follows: "waiting for you to approve the sign-in in your browser", since being signed in already is no longer the whole story. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * docs: the browser sign-in confirms first, and says whose account it grants README, the CLI reference, and the self-hosting auth page all described the old behaviour — sign in and the page bounces a code straight to the terminal. They also read as though only `--device` had an approval step. Both flows now confirm; say so, and say why it matters (the browser session is often not the account the user meant). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * feat(auth): one web step for a first sign-in, not two The confirmation page fixed the wrong-account problem and created a smaller one: a user with no browser session now saw two pages on their first `bdrive init` — sign in, then approve — where the sign-in had already settled the only question the second page asks. So authenticating *for* a pending CLI sign-in now counts as approving it. The login and signup pages carry a line saying a terminal is waiting and that the account used here is the one it will act as, which is where that consent is made informed; reaching the callback then needs no second click. The marker is server-side, bound to the exact pending sign-in, single use, and two minutes long, so it can only ever skip the page it was granted for and only once. It cannot be forged: setting it requires authenticating as that account, and anyone who could do that could click Approve anyway. An existing session still gets the page — that is the case where the browser may be signed in as someone the user did not intend, which is the whole reason it exists. Net effect: exactly one web interaction either way. The device flow keeps its explicit approval. Its page names a machine that isn't this one, along with the OS and address it came from — information no login form can convey, about a grant to somewhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * fix(auth): keep `bdrive login` on one line in the approval hint It wrapped mid-phrase into two separate code boxes, which reads as two commands rather than one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * refactor(auth): one approval page for both sign-in flows The two flows ask the same question — shall this thing act as you? — and had two copies of the page asking it, differing in three strings. They had already started drifting: a wrapping fix went into the CLI copy only, leaving the device page able to break `bdrive login --device` across two code boxes. A page whose whole purpose is consistent disclosure is a bad place to keep two of everything. So pageAuth owns the shape (session check, redirect to login, whoBlock, rows, the Approve form, the note) and each flow supplies an authRequest describing what differs: how the request is identified, what is asking, and what approving does. Two asymmetries are now explicit rather than accidental. freshAuthSkips is true only for the local flow — signing in and approving are the same act when the terminal is on this machine, and are not when the token goes to another one. live() reports whether the request still exists, because the device flow's link expires while the CLI flow carries its whole request in the URL and has nothing to expire. detail is a function, not a slice: the device rows come off the pending grant, which only exists after live() has found it. No test changed. The pages render byte-identically — same sha256 for all three CLI screenshots before and after — and the device flow was driven end to end against a real hub, approving a real `bdrive login --device`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * feat(auth): both sign-in flows always ask you to approve Consistency between the two flows is worth more than the click it saves. Letting a sign-in count as its own approval made the local flow one step and the device flow two, so the same product asked for consent in two different shapes depending on which machine you were on — and the shape that skipped it was the one where the page had something to tell you. So the fresh-auth marker is gone: sign in, then approve, on both flows. That drops a map, two methods, a descriptor field, and a branch in pageAuth — the unified handler now has exactly one path through it. A first `bdrive init` on a fresh machine is two web pages again. That is the deliberate trade: the approval page is where a user sees which account a machine is about to act as, and nothing shortcuts it. The sign-in page keeps the line saying a terminal is waiting. It no longer carries the consent — the next page does — so it is there to explain why a password prompt appeared at all. TestBothFlowsAlwaysAskToApprove replaces the one-step test and runs the same assertions over both flows as subtests: no session sends you to sign in carrying the request, signing in returns to the request without granting, the approval page is there every time, and only the POST grants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
118abc67e2 | feat(cli): "bdrive serve" replaces "bdrive web" (web stays as an alias), and the README leads with the agent install (#94) | ||
|
|
951c6b9de2 |
feat(viewer): preview text by its bytes, not its extension — and render PDFs (#95)
The viewer decided what to preview from a filename regex, so every extensionless file an agent writes — Dockerfile, LICENSE, .bdriveignore — and every unlisted extension (main.tf, schema.graphql) hit a dead "No preview for this file type." card. That bites hardest in the core use case: an agent writes an artifact, a teammate opens the hub to read it, and the hub refuses. The unmatched path now fetches once and decides on the bytes. The logic already existed for the history diff — 1 MB cap, Content-Length cheap-out, 8 KB NUL scan, fatal UTF-8 decode — so the pure half moves to lib/sniff.ts (importable by node --test, no React Query) and both DiffView and FileView call it. Exactly one sniffer, per the spec. .pdf gets the browser's own viewer in an iframe, in the wide page column (768px is unreadable for a PDF page). No sandbox attribute, deliberately: the PDF viewer is not this page's JS realm, so it cannot reach the hub API or its cookies, and sandboxing without allow-same-origin breaks Firefox's pdf.js. Files that already previewed (md/html/img/.txt) are untouched and issue no extra fetch. BEA-44 |
||
|
|
7b863a4684 |
test(sandbox): a disposable Linux machine to run a scenario in (#92)
Some things cannot be tested from a Go test on your Mac. A real `claude`
session needs the real permission classifier and a $HOME it may write agent
hooks into. The systemd user unit only exists on Linux. A reboot needs
processes to die while the filesystem survives. Until now those were tested by
hand, against the real ~/.bdrive and ~/.claude — so testing onboarding from
scratch meant polluting the machine you were testing from, and `bdrive init`
registering hooks user-level made that worse.
This is an ENVIRONMENT, not a suite. It provides a hub on file:// storage, a
seeded account, browserless sign-in (bdrive-signin drives both halves of the
device flow), Claude Code, the binary under test, and a $HOME thrown away with
the container. Scenarios still live where they belong: deterministic ones in
internal/webapp/cli_e2e_test.go, the conversational one in the onboarding-e2e
skill. The rule, written into the Dockerfile so it survives me: if it doesn't
need a conversation or an OS, it's a Go test.
The two scripts it ships are the scenarios with nowhere else to go.
onboarding.sh runs a real `claude -p` following the LOCAL
INSTALL_FOR_AGENTS.md and checks the scope hard gate, hooks-via-init, and that
nothing reaches for a plugin or skill. daemon-linux.sh covers the systemd unit
and the daemon.pid/stop race.
Notes for whoever reads this next:
- The binary is bind-mounted, not built in, so a code change rebuilds the
binary and not the image. BDRIVE_SRC=<checkout> tests a branch without
touching your working tree; BDRIVE_BIN=<binary> skips the build.
- No `# syntax=` directive in the Dockerfile on purpose: it makes every
build resolve the frontend from the registry, which turns a slow network
into a build that hangs with no output. That also rules out RUN heredocs,
hence boot.sh being a file.
- Claude auth comes from CLAUDE_CODE_OAUTH_TOKEN (`claude setup-token`).
The Keychain is deliberately not read: the container would refresh that
token and rotate it out from under your Mac, logging you out there.
- The hub lives only as long as the container's command, so the project
link init prints is dead once a scripted run exits. Use the interactive
shell to browse it.
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.13.0
|
||
|
|
b927e56fab |
fix(daemon): let the daemon own its pidfile, so stop can stop it (#91)
* fix(daemon): let the daemon own its pidfile, so stop can stop it `bdrive stop` could fail with "no such process" and leave sync running. Liveness became the flock in #88, and Run already announces the child's own pid only after it holds that lock. But Start still wrote daemon.pid from the parent, right after fork, with the pid of a child that had not earned anything yet. Two starts inside that window — `bdrive init` followed by the login agent's `bdrive resume`, or two resumes close together — race: Running still reads false, a second child spawns, it loses hold(), and it exits without ever being the daemon. Its pid is already in the file. Everything downstream trusts that file. Stop signals the loser and gets ESRCH, so it reports failure while the winner keeps syncing — the one command whose job is "stop sending my files" silently does not. status prints the phantom pid, or "pid 0" when the loser's cleanup removed the file the winner wrote. So the parent no longer writes it: the pidfile belongs to whoever holds the lock. Start now waits for the lock to be taken instead of assuming the spawn worked, which also means a caller that gets a pid back can trust a daemon owns it — `bdrive resume` used to print "started (pid N)" for a child that had already died. The regression test needs the real binary (Start execs os.Executable), so it lives with the CLI e2e rather than in internal/daemon, whose tests synthesize locks. It is deterministic on Linux and roughly one run in five on macOS, where the window is tighter; `sandbox/run.sh daemon-linux` is the reliable reproducer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 * docs(architecture): the daemon owns its pidfile, Start only waits for the lock Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fd4f5c7964 |
feat(onboarding): folder name follows the project name, and project ids are UUIDs (#89)
The paste prompt now carries the project's name so an agent recommends a folder of that name; with no project at all the recommendation is `shared/` (and `bdrive init shared` names the new project after the folder), replacing the old `wiki/` default. New project ids are UUIDs instead of `p-` + 8 hex chars. The route validator still accepts the legacy shape — ids are permanent — and the client-side URL parsers (remote/http.go, bdrive share) now only check the shape of a URL segment, leaving the hub as the single authority on which ids are valid. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fb6ce347c4 |
feat(daemon): survive a reboot — login autostart on macOS/Linux/Windows, and a lock instead of a pidfile (#88)
* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31f705e287 |
chore: drop the Claude plugin and the bundled skill — hooks are the integration (#85)
Two front doors for the same setup, and one of them was a second copy of everything. The plugin shipped the skill the CLI already installs (to four platforms, not one), hooks that ran the identical commands `bdrive hooks install` writes machine-level, and an install flow duplicating INSTALL_FOR_AGENTS.md. Nothing deduped, so a machine with both pulled twice per turn — two blocking syncs — and showed two identical `beardrive` skills in the picker. What remains is `internal/agenthooks` plus the runbook: init registers a blocking pull (which also injects the gated-link convention as additionalContext), an async push on Write/Edit, and read-log for the heatmap, in each platform's user config, once per machine. That is the whole integration, and it is the part that was never optional. Removed: plugin/, .claude-plugin/marketplace.json, internal/agentskills, `bdrive skill`, `bdrive hook-approve` (its PreToolUse auto-approve only ever helped when a plugin pre-installed it; the substitute is a `Bash(bdrive:*)` permission entry, which is user-owned config and needs no code). The e2e now asserts the absence: no SKILL.md in any platform's skills dir after init, and no `skill` subcommand. login_test keeps the "no revoke surface" wording check on logoutNote alone. Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfb9da3260 |
feat(auth): one approval link for device sign-in, and a page that says what it grants (#83)
The headless flow printed a short code to retype into a bare "Approve" form. Now `bdrive login --device` prints a single link — the token lives in the path (/auth/device/<token>), so there is nothing to read off one screen and type into another. The page it opens is a consent page rather than a text field: it names the account the device would act as, offers Switch account (logout now honors ?next, so you land back here), and shows the device name, OS, and the address the server observed. That matters because this flow's weakness is a stranger sending you their pending link; an anonymous "Approve" gives you nothing to notice with. Approval is still a POST from the page, so a link alone cannot grant, and SameSite=Lax keeps a cross-site form out. Also aligns the /auth/* pages with the app's tokens, which had drifted: card #0c0e10 vs --color-card #15171b, 8px controls vs --radius-ctl 7px, hand-picked #ff9b91/#6fd699 vs --color-del/--color-add. The style block now declares the tw.css tokens by name and every rule uses them. Older CLIs still print /auth/device?code=…, so that shape 303s to the path form; a pre-0.13 hub returning no verify_url still gets the old type-the-code instruction from the CLI. Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f68bbe92e |
feat(hub): optional analytics seam so a managed deployment can measure the app (#80)
Adds Server.Analytics (webapp.AnalyticsConfig) and emits it as /api/config `analytics` when a key is set. The frontend loads posthog-js from the CDN at runtime rather than as a dependency, so an unconfigured hub ships no tracker and makes no third-party request — the OSS bundle grows 1.1KB (the loader), not 230KB. Product events come from one table in api/http.ts keyed on method+path. Every mutating call in the app already goes through api()/postJSON(), so a new write is measured or it isn't, instead of depending on someone remembering a capture() call. Share creation is the one raw fetch and fires its own. Session replay masks every text node: in this product nearly all of it is customer file names and document bodies. Replays are layout, clicks and navigation only. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
511b838da0 |
chore: gitignore .orca/
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ff92c56ab |
feat(history): group agent runs and restore any version (BEA-6) (#69)
* feat(history): group agent runs and restore any version (BEA-6) BearDrive recorded everything and could restore nothing. Now every version of a file has a Restore button — in the hub's History view and as `bdrive restore` — and the changes one agent run made read as one card instead of N loose rows. Restore is a NEW put op pointing at the old blob: journals are never rewritten, so one-writer-per-journal holds and peers converge on the restore like any other edit. The hub reuses RemoteSource.Commit (the upload commit minus the upload); the CLI writes the bytes into the working folder and lets the ordinary cycle journal them, so the sync engine gains no new write path. Grouping is a pure frontend group-by on (note, device) over the existing /history response — no journal or API change. Known gap, stated in the UI and the docs: nothing in the hub writes a delete op yet, so a file a run *created* cannot be un-created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): don't repeat a run's note on every row in its card UI pass on the real hub: inside a run card the note is the card's header, so printing it again on each row said the same thing N times. The header now carries the note (linkified, so an agent's session link still opens) and the collapse control is its own button rather than the whole header — the link could not live inside a button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>v0.12.0 |
||
|
|
dcd0517e92 |
feat(cli): bdrive scope --explain — prove what leaves this machine (BEA-24) (#70)
The local-first claim was asserted, never demonstrated: nothing anywhere told you what your laptop chose *not* to send. `bdrive scope --explain` walks the folder and prints two sorted lists — synced and not synced — with counts and a pointer at what it does not answer. The decisions come from the same walk the sync cycle uses. scan()'s WalkDir decision tree moves into walkFolder (internal/syncer/walk.go), the only copy of the rules; scan and Explain both go through it, so the output provably cannot drift from real sync behavior. Pure read: its own Filter, no Session, no volume flock, no network. Fully-excluded directories collapse to one counted line; nested mounts are annotated as syncing through their own project rather than called "not synced", which would be a lie in a trust surface. Known gap, deliberate: this answers "what leaves from now on", not "what is already on the hub" — the footer points at `bdrive forget`. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ecc8328512 |
fix(shares): say when "the latest version" was (BEA-31) (#76)
A /s/<token> page promises the reader it always shows the latest version, then never says when latest was — so a stranger can't tell whether a living wiki page is from today or last March. Print FileInfo.Time, already in hand at the point handleShared renders, as a muted line above the content: human date, precise RFC3339 in the title attribute. Zero time prints nothing rather than a 1970 date. Markdown shells only. The .html/.htm branch stays a raw io.Copy and binaries stay untouched — the new test asserts byte equality for both. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6da3c7957e |
fix(hub): deterministic order for public share links (BEA-30) (#75)
ShareDB.List ranged over a map, so the project Settings → Public links table and the org-wide share audit came back in a different order on every load — with a Revoke button on each row. Sort in List (Created desc, then Path, then Token) so both surfaces inherit one total order. Created.Equal rather than !=: a time.Time carries a monotonic reading and a location, so two logically-equal instants can compare unequal, which would make the comparator non-transitive. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
acb99e85e3 |
feat(hub): set an expiry on a share link from the UI (BEA-29) (#74)
The CLI has had --expires all along and the Public links table has always
said "no expiry" — naming an alternative the hub UI never offered. Every
link minted from the browser was permanent.
Expiry is offered AFTER minting, not before: the one-click share stays one
click, and PATCHing the token we just handed out keeps the URL already on
the clipboard valid. Minting with a TTL would instead create a second link,
since ShareDB.Create only reuses permanent ones.
- ShareDB.SetExpiry re-dates a live share in place; repo-write failure
restores the previous row rather than deleting it (unlike Create's
rollback, this row already existed — dropping it would revoke a live
link over a disk hiccup).
- PATCH /api/shares/{token} mirrors handleShareRevoke: resolve the token,
requirePerm(PermWrite), act. Duration parsing is copied from
handleShareCreate so the two routes can't drift.
- Share dialog gets an Expires select (Never / 24h / 7d / 30d). A failed
PATCH toasts and reverts the control. Copy link keeps the dialog's
initial focus — the new control would otherwise have taken it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6ae941683e |
fix(hub): a folder URL with a trailing slash is the same page as without (BEA-28) (#73)
* fix(hub): a folder URL with a trailing slash is the same page as without (BEA-28) * docs(architecture): Route.trailingSlash in the frontend diagram (BEA-28) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
feacbe3b3a |
fix(hub): put visible Open/Download controls on every history version (BEA-26) (#72)
A history row was already an address for the version it describes — BEA-7 made clicking one open that version, and the pinned file page has had "Download this version" since. But the row is a bare `div role="button"` with no visible affordance, so a reviewer who dumps every <button> and <a> on the page finds neither, and concludes recovery from a bad edit is impossible. Undiscoverable is close enough to absent. Every content-bearing row now carries "Open this version" and "Download", sitting on the same line as "show changes". No API work — /blob?sha= has always served the bytes. `apiBase` becomes HistoryRow's own prop rather than riding inside the optional `diff`, which is per-file-only: without that the two feeds that pass no diff (project/subtree, and the folder's Recent changes) could not build a download URL at all. Neither control claims aria-expanded — only the note and the diff really expand anything — and both stop click and keydown from reaching the row so acting on a version never doubles as navigating it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4e34d03e14 |
feat(hooks): user-scope agent sync hooks, one-command setup, --only scoping (#71)
* feat(hooks): register agent sync hooks per machine, not per project Agent platforms read hook config only from the directory a session starts in — never a parent, never a subfolder. Project-level hooks therefore fired only for sessions that happened to start at the mount, and, living inside a synced folder, they replicated one machine's agent config to the whole team (a second writer of a file bdrive already owns). Claude Code additionally ignores project hooks until the folder is trusted, so in practice they were often inert without any visible sign. Hooks now go to each platform's user config, once per machine, covering every session in every folder; the existing shell guard keeps them a no-op outside BearDrive projects. Install migrates away blocks older versions wrote into projects, and `bdrive hooks uninstall` removes ours while leaving foreign hooks untouched. Setup is also one command now. init absorbs the skill install, prints the hub link, and takes --server, so connecting to a named hub no longer needs a separate login; the runbook forbids preflight and command chaining, since each distinct command costs the user a permission prompt. For plugin users a PreToolUse hook auto-approves bdrive's own setup subcommands — narrowly: any shell operator in the command disqualifies it. Also drops --shared in favor of `init . --only wiki,docs`, which writes a managed block of .bdriveignore rules instead of a second scope mechanism. Because those rules sync, `sync --prune` now refuses on a scoped project rather than stripping everything outside the scope from the hub for everyone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ * docs: fix stale claims an audit found against the new CLI An audit of every doc surface against the code turned up claims that the user-scope hook move and the one-command init made false: project-level hooks "riding the repo", the Claude trust prompt, Codex's //hooks project layer, `--no-hooks` skipping the skill (it does not), prune reconciling against a per-device scope (it now refuses on a scoped project), and `--scan-interval`/`--remote-interval` documented as init flags when they only exist on `bdrive daemon run`. Also documents the surface added today — `--server`, `bdrive hooks uninstall`, and the plugin's PreToolUse auto-approval — refreshes the two sample `init` transcripts to the real output, and corrects hook matchers that had drifted from agenthooks.go. `bdrive scope` told users to narrow an existing mount with `bdrive init . --only <dirs>`, which resume then ignored — a dead end. Init now applies --only on resume, writing the scope block, so the advice works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22461a3b4f |
chore(release): v0.11.0 — changelog, plugin 0.4.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJv0.11.0 |
||
|
|
9088127176 |
feat(sync): bdrive forget + sync --prune to take ignored paths off the hub (BEA-20) (#68)
Adding a path to .bdriveignore only stopped future uploads: anything that synced before the rule existed stayed on the hub forever, with no command that removed it without deleting it from local disk on every device. Two engine changes make an explicit removal safe: - materialize's delete loop now consults the filter. A cached path absent from the replayed target that the rules exclude is dropped from tracking instead of unlinked — without this, any delete op for a now-filtered path wipes every peer's local copy, which is the data loss this issue is about. - the filter is reloaded mid-cycle from the pulled .bdriveignore, before materialize. A peer receiving the new rules and the deletes they justify in one batch would otherwise materialize with stale rules and the guard would never fire. materialize's write side is split into materializeFile so the ignore file can land on its own. On top of that, Session.Prune journals a delete for every path the replayed state still holds that the SHARED rules exclude — reconciling against the replay, not the local cache, because a path filtered out in an earlier cycle was dropped from the cache back then and is invisible locally today. The rules are deliberately ignore-only: the include scope lives in each device's own .bdrive/config.json and does not sync, so pruning against it would let a narrow-scope device delete a whole-folder teammate's files. Plain `bdrive sync` and the daemon are unchanged — pruning is never a side effect of editing .bdriveignore. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
411809a785 |
fix(hub): the history kind is a badge, not a fake disclosure toggle (BEA-17) (#67)
* fix(hub): the history kind is a badge, not a fake disclosure toggle (BEA-17) The +/x in a history row is the kind glyph (added/edited/deleted), but sitting leftmost inside a role="button" row it read as a tree disclosure control — clicked, it navigated away instead of expanding. Merge the glyph and its word into one text badge and vacate the toggle slot: the kind is now text (no icon shape, no colour-only meaning), and the row's only real expander stays the note, which keeps its own control and aria-expanded and now turns its chevron when open. * fix(hub): fit DELETED in the kind badge and align the row's meta under the path --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f35d889cfb |
fix(hub): manage a file's public links from the file, not the org panel (BEA-16) (#66)
Minting a public link was one click on the file page; revoking it meant
knowing to go avatar menu → Organization → scroll to "PUBLIC SHARE LINKS".
The action was instant and local, the undo remote and unhinted.
GET /api/p/{project}/shares already existed at PermRead with no frontend
consumer, so this is UI-only:
- A "Publicly shared" banner on the file page whenever the open file has
live links: the count, each URL, and copy / open / revoke — the same
words the Share dialog uses. Revoking updates it in place.
- A "Public links" card in project Settings listing that project's live
links (path, who, when, expiry) with Revoke.
- One SharesTable behind both, plus the org-wide audit, which stays as the
cross-project view and now links each row back to its file.
The banner shows for anyone with read (a member should know the folder
they rely on is exposed); Revoke only where the Share button already is.
The spec's double-mint bug does not exist: ShareDB.Create already reuses a
live share for (project, path) when neither side has a TTL, and the web UI
never sends expires_in. The real defect was the dialog claiming "Public
link created" on a second click — it now just says "Public link".
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
aeee881fa6 |
fix(hub): say what a read count is made of, and pin the debounce (BEA-15) (#65)
The reported symptom was a read counter inflating while you watched: reopen one file a few times as one account and it climbs. It does not — the ledger debounce is correct, and a handler-level repro proves it. What moves is the displayed *total*, which sums three kinds of reader (human, agent, share) with independent debounces: your own revisits fold into one visit, but the syncing agent's reads and share-link hits keep landing in the same number with nothing to say so. So the count was right and its framing was wrong. heatText now breaks the total out by reader kind whenever more than people are reading — the seeded "14 reads (9 agent)" becomes "15 reads (6 human, 9 agent)" — at the single chokepoint every heat surface already routes through (file meta line, folder row meta, folder subtitle, heat-dot tooltip). No ledger change: the investigation found nothing to fix there. The spec's prime suspect (a sandboxed-iframe fetch losing the session and recording as "anonymous") cannot fire in hub mode — authGate 401s an unauthenticated /api/ request before recordRead runs, and every authenticated identity carries an account email. TestReadCountsOnePageOpenOnce pins the invariant so this stays legible: one signed-in person, one file, one 10-minute window is exactly one read, whichever of /render, /file, /download served it; a second account adds exactly one; the sandboxed iframe's cookie-less fetch adds none; history spelunking adds none; and no actor identity reaches the /heat response. TestReadLedgerDebounce now also covers the other half — once the window passes, the same actor counts again. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f773b0c6e7 |
fix(hub): keep folder-row metadata on phones and name the heat dot (BEA-14) (#64)
Below 430px `.dl-meta` was `display:none`, so a folder listing on a phone showed a filename and an unexplained coloured dot — no read count, no size, no date. The comment justifying it assumed the dot carried the signal, but the dot's meaning lived entirely in a `title=` attribute: never shown on touch, never read by a screen reader on any viewport. - `.dl-row` wraps and `.dl-meta` takes a full-width second line at ≤430px, indented 27px to align under the filename. The name still wins line one and is never truncated; the full string fits at 360px, so no shortened variant is needed. - The heat dot gets `role="img"` + `aria-label` on every viewport, which also fixes desktop screen-reader users. - Playwright assertion in layout.spec.ts at 360/390/430: meta matches the desktop string, filename untruncated, rows ≥44px, no horizontal scroll, every dot has an accessible name. Desktop (≥431px) is unchanged — measured identical row heights and meta positions before and after. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
eba6fe1a64 |
fix(cli): stop pointing users at a device list that doesn't exist (BEA-13) (#63)
`bdrive logout` and the beardrive skill both told users the device token "stays valid until it expires — revoke it from the hub's device list". All three parts are false: there is no device list page or route, device tokens carry no expiry field, and logout makes no server call at all — it only rewrites the local settings file. Both strings now say what is true. The CLI note moves to a package-level `logoutNote` const so `login_test.go` can assert it, plus SKILL.md's logout row, mentions neither a device list nor expiry. Correcting the strings only; the real device list + revoke route touches the `AuthProvider` seam and is filed separately. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |