mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* 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>
707 lines
24 KiB
Go
707 lines
24 KiB
Go
package webapp
|
|
|
|
// CLI onboarding e2e: builds the real bdrive binary and drives the exact
|
|
// commands an agent (or the INSTALL_FOR_AGENTS.md runbook) runs against an
|
|
// in-process hub — device-code login, init, re-init, hooks, stop. This is
|
|
// the deterministic half of onboarding testing; the conversational half
|
|
// (does the agent ask before mounting?) lives in the onboarding-e2e skill.
|
|
//
|
|
// The key regression this guards: `bdrive init` must register agent sync
|
|
// hooks itself — a separate `bdrive hooks install` gets blocked by agent
|
|
// permission classifiers, which is how teams end up without hooks.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// cliEnv is a signed-in CLI against a throwaway hub: the real binary, an
|
|
// isolated HOME/BDRIVE_HOME, and a browser session for hub-side assertions.
|
|
type cliEnv struct {
|
|
run func(dir string, args ...string) (string, error)
|
|
hub *httptest.Server
|
|
browser *http.Client
|
|
home string // the isolated HOME; hooks live under here now
|
|
}
|
|
|
|
func newCLIEnv(t *testing.T) cliEnv {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("builds and execs the bdrive binary; skipped with -short")
|
|
}
|
|
bin := filepath.Join(t.TempDir(), "bdrive")
|
|
build := exec.Command("go", "build", "-o", bin, "github.com/runbear-io/beardrive/cmd/bdrive")
|
|
if out, err := build.CombinedOutput(); err != nil {
|
|
t.Fatalf("go build: %v\n%s", err, out)
|
|
}
|
|
|
|
hub := startTestHub(t)
|
|
|
|
// Isolate the CLI completely: fresh BDRIVE_HOME and a fresh HOME, so
|
|
// agent-platform detection can't see or touch the real ~/.codex etc.
|
|
home := t.TempDir()
|
|
env := append(envWithout("HOME", "BDRIVE_HOME"),
|
|
"HOME="+home, "BDRIVE_HOME="+filepath.Join(home, ".bdrive"))
|
|
run := func(dir string, args ...string) (string, error) {
|
|
cmd := exec.Command(bin, args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = env
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// Sign in via the real device-code flow, approved over HTTP as the
|
|
// runbook's "any signed-in browser" (a cookie session from /auth/login).
|
|
login := exec.Command(bin, "login", "--device", hub.URL)
|
|
login.Env = env
|
|
logFile := filepath.Join(t.TempDir(), "login.log")
|
|
f, err := os.Create(logFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
login.Stdout, login.Stderr = f, f
|
|
if err := login.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { login.Process.Kill() })
|
|
approve := waitForApprovalLink(t, logFile)
|
|
browser := signedInBrowser(t, hub.URL)
|
|
if _, err := browser.PostForm(approve, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := login.Wait(); err != nil {
|
|
out, _ := os.ReadFile(logFile)
|
|
t.Fatalf("login --device: %v\n%s", err, out)
|
|
}
|
|
return cliEnv{run: run, hub: hub, browser: browser, home: home}
|
|
}
|
|
|
|
func TestCLIOnboardingE2E(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run, hub, browser := e.run, e.hub, e.browser
|
|
|
|
// --- Init in a folder where Claude Code is in use. Hooks must be
|
|
// registered by init itself, before the first sync output.
|
|
work := filepath.Join(t.TempDir(), "proj")
|
|
if err := os.MkdirAll(filepath.Join(work, ".claude"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer run(work, "stop", work) // don't leak the daemon on failure
|
|
out, err := run(work, "init", "--name", "cli-e2e", "--yes")
|
|
if err != nil {
|
|
t.Fatalf("init: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "claude") || !strings.Contains(out, "hooks registered") {
|
|
t.Fatalf("init did not report registering claude hooks:\n%s", out)
|
|
}
|
|
settings, err := os.ReadFile(filepath.Join(e.home, ".claude", "settings.json"))
|
|
if err != nil {
|
|
t.Fatalf("init did not write the user's .claude/settings.json: %v", err)
|
|
}
|
|
for _, want := range []string{"bdrive sync", "bdrive read-log"} {
|
|
if !strings.Contains(string(settings), want) {
|
|
t.Fatalf("user settings.json missing %q hook:\n%s", want, settings)
|
|
}
|
|
}
|
|
// 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), "<string>resume</string>") {
|
|
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"} {
|
|
p := filepath.Join(e.home, "."+agent, "skills", "beardrive", "SKILL.md")
|
|
if _, err := os.Stat(p); err == nil {
|
|
t.Fatalf("init installed a skill at %s — the hooks are the integration now", p)
|
|
}
|
|
}
|
|
if out, err := run(work, "skill"); err == nil {
|
|
t.Fatalf("`bdrive skill` still exists:\n%s", out)
|
|
}
|
|
|
|
// The project must actually exist on the hub, created under the account.
|
|
if projects := hubProjects(t, browser, hub.URL); !strings.Contains(projects, "cli-e2e") {
|
|
t.Fatalf("hub project list missing cli-e2e: %s", projects)
|
|
}
|
|
|
|
// --- Re-running init resumes and converges hooks idempotently.
|
|
out, err = run(work, "init", "--yes")
|
|
if err != nil {
|
|
t.Fatalf("re-init: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "resuming") || !strings.Contains(out, "hooks already registered") {
|
|
t.Fatalf("re-init should resume with hooks already registered:\n%s", out)
|
|
}
|
|
|
|
if out, err = run(work, "hooks"); err != nil || !strings.Contains(out, "hooks registered") {
|
|
t.Fatalf("hooks status: %v\n%s", err, out)
|
|
}
|
|
|
|
if out, err = run(work, "stop", work); err != nil {
|
|
t.Fatalf("stop: %v\n%s", err, out)
|
|
}
|
|
cfg1, err := os.ReadFile(filepath.Join(work, ".bdrive", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// --- A second mount on the same machine (the "add another folder" flow)
|
|
// must create a separate project and leave the first mount untouched.
|
|
// Also the opt-out: --no-hooks must leave the platform config alone.
|
|
work2 := filepath.Join(t.TempDir(), "proj2")
|
|
if err := os.MkdirAll(filepath.Join(work2, ".claude"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer run(work2, "stop", work2)
|
|
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)
|
|
}
|
|
cfg1b, err := os.ReadFile(filepath.Join(work, ".bdrive", "config.json"))
|
|
if err != nil || string(cfg1) != string(cfg1b) {
|
|
t.Fatalf("second init disturbed the first mount's config (err %v):\nbefore: %s\nafter: %s", err, cfg1, cfg1b)
|
|
}
|
|
projects := hubProjects(t, browser, hub.URL)
|
|
for _, want := range []string{"cli-e2e", "cli-e2e-nohooks"} {
|
|
if !strings.Contains(projects, want) {
|
|
t.Fatalf("hub missing project %q after second init: %s", want, projects)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Two sibling folders under one parent, each synced to a DIFFERENT project —
|
|
// the shape a second project lands in on a machine that already syncs one.
|
|
// The mounts must stay fully independent: separate ids, separate content, and
|
|
// the first one's config untouched by the second's init.
|
|
func TestCLISiblingProjectMounts(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run, hub, browser := e.run, e.hub, e.browser
|
|
|
|
parent := t.TempDir()
|
|
a, b := filepath.Join(parent, "a"), filepath.Join(parent, "b")
|
|
for dir, file := range map[string]string{a: "brand.md", b: "adr.md"} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, file), []byte("# "+file+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if out, err := run(a, "init", "--name", "project-a", "--yes"); err != nil {
|
|
t.Fatalf("init a: %v\n%s", err, out)
|
|
}
|
|
defer run(a, "stop", a)
|
|
cfgA, err := os.ReadFile(filepath.Join(a, ".bdrive", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if out, err := run(b, "init", "--name", "project-b", "--yes"); err != nil {
|
|
t.Fatalf("init b: %v\n%s", err, out)
|
|
}
|
|
defer run(b, "stop", b)
|
|
|
|
// The second init must not have touched the first mount.
|
|
cfgAafter, err := os.ReadFile(filepath.Join(a, ".bdrive", "config.json"))
|
|
if err != nil || string(cfgA) != string(cfgAafter) {
|
|
t.Fatalf("init in b/ changed a/'s config (err %v):\nbefore: %s\nafter: %s", err, cfgA, cfgAafter)
|
|
}
|
|
cfgB, err := os.ReadFile(filepath.Join(b, ".bdrive", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(cfgA) == string(cfgB) {
|
|
t.Fatalf("sibling mounts share a config: %s", cfgA)
|
|
}
|
|
|
|
// Each project holds its own file and not the other's.
|
|
idA, idB := projectIDByName(t, browser, hub.URL, "project-a"), projectIDByName(t, browser, hub.URL, "project-b")
|
|
pathsA, pathsB := hubPaths(t, browser, hub.URL, idA), hubPaths(t, browser, hub.URL, idB)
|
|
if !pathsA["brand.md"] || pathsA["adr.md"] {
|
|
t.Fatalf("project-a content wrong: %v", pathsA)
|
|
}
|
|
if !pathsB["adr.md"] || pathsB["brand.md"] {
|
|
t.Fatalf("project-b content wrong: %v", pathsB)
|
|
}
|
|
}
|
|
|
|
// One project mounted from two folders on the SAME device. The remote journal
|
|
// key is per-device, so a second mount would restart the sequence and
|
|
// overwrite the first mount's ops — its files would vanish from the hub. Init
|
|
// must refuse; if it ever accepts again, the first mount's file must survive.
|
|
func TestCLISameProjectTwoMounts(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run, hub, browser := e.run, e.hub, e.browser
|
|
|
|
first, second := filepath.Join(t.TempDir(), "first"), filepath.Join(t.TempDir(), "second")
|
|
if err := os.MkdirAll(first, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(first, "adr.md"), []byte("# ADR\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := run(first, "init", "--name", "dup", "--yes"); err != nil {
|
|
t.Fatalf("init first: %v\n%s", err, out)
|
|
}
|
|
defer run(first, "stop", first)
|
|
id := projectIDByName(t, browser, hub.URL, "dup")
|
|
|
|
if err := os.MkdirAll(second, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := run(second, "init", "--project", id, "--yes")
|
|
defer run(second, "stop", second)
|
|
if err != nil {
|
|
// The refusal must name the folder already holding the project.
|
|
if !strings.Contains(out, first) {
|
|
t.Fatalf("refusal should point at the existing mount %s:\n%s", first, out)
|
|
}
|
|
if config := filepath.Join(second, ".bdrive", "config.json"); fileExists(config) {
|
|
t.Fatalf("refused init still wrote %s", config)
|
|
}
|
|
return
|
|
}
|
|
if paths := hubPaths(t, browser, hub.URL, id); !paths["adr.md"] {
|
|
t.Fatalf("second mount erased the first mount's file from the hub: %v\n%s", paths, out)
|
|
}
|
|
}
|
|
|
|
// A mount below the session's directory (a repo root with wiki/ mounted) and
|
|
// one above it (a session inside the synced folder) must both sync: agent
|
|
// hooks run wherever the editor was opened, which is rarely the mount root.
|
|
func TestCLISyncResolvesMountFromAnyDirectory(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run := e.run
|
|
|
|
repo := t.TempDir()
|
|
wiki := filepath.Join(repo, "wiki")
|
|
deep := filepath.Join(wiki, "notes")
|
|
for _, dir := range []string{deep, filepath.Join(repo, ".claude"), filepath.Join(wiki, ".claude")} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(deep, "a.md"), []byte("# A\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Run init the way an agent does: from the repo root, naming the subfolder.
|
|
if out, err := run(repo, "init", "wiki", "--name", "scoped", "--yes"); err != nil {
|
|
t.Fatalf("init wiki: %v\n%s", err, out)
|
|
}
|
|
defer run(wiki, "stop", wiki)
|
|
|
|
// From the repo root: the mount is below, found through the registry.
|
|
out, err := run(repo, "sync")
|
|
if err != nil || !strings.Contains(out, wiki) {
|
|
t.Fatalf("sync at the repo root missed the wiki mount: %v\n%s", err, out)
|
|
}
|
|
// From inside the mount: walk up to its root.
|
|
out, err = run(deep, "sync")
|
|
if err != nil || !strings.Contains(out, wiki) {
|
|
t.Fatalf("sync inside the mount did not resolve its root: %v\n%s", err, out)
|
|
}
|
|
// Hooks are user-level; the repo and the mount stay clean.
|
|
for _, dir := range []string{repo, wiki} {
|
|
assertNoProjectHookFiles(t, dir)
|
|
}
|
|
|
|
// Reaching the repo through a symlink must find the same mount: macOS
|
|
// hands sessions /tmp paths for mounts registered under /private/tmp.
|
|
link := filepath.Join(t.TempDir(), "link")
|
|
if err := os.Symlink(repo, link); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
if out, err := run(link, "sync"); err != nil || !strings.Contains(out, "project") {
|
|
t.Fatalf("sync through a symlinked repo root missed the mount: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
// Wherever init runs — inside the mount, at a repo root, anywhere — the hooks
|
|
// go to the user's config and the project tree is left clean. Platforms read
|
|
// hook config only from the directory a session starts in, so one user-level
|
|
// registration is what actually covers every session.
|
|
func TestCLIInitWritesNoProjectHookFiles(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run := e.run
|
|
|
|
repo := t.TempDir()
|
|
wiki := filepath.Join(repo, "wiki")
|
|
for _, dir := range []string{filepath.Join(repo, ".git"), filepath.Join(repo, ".claude"), filepath.Join(wiki, ".claude")} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// Pre-existing project hooks from an older version, plus a foreign hook.
|
|
stale := filepath.Join(repo, ".claude", "settings.json")
|
|
old := `{"hooks":{"UserPromptSubmit":[` +
|
|
`{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]},` +
|
|
`{"hooks":[{"type":"command","command":"echo mine"}]}]}}`
|
|
if err := os.WriteFile(stale, []byte(old), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
out, err := run(wiki, "init", "--name", "in-repo", "--yes")
|
|
if err != nil {
|
|
t.Fatalf("init: %v\n%s", err, out)
|
|
}
|
|
defer run(wiki, "stop", wiki)
|
|
|
|
if !fileExists(filepath.Join(e.home, ".claude", "settings.json")) {
|
|
t.Fatalf("hooks not registered in the user config:\n%s", out)
|
|
}
|
|
assertNoProjectHookFiles(t, wiki)
|
|
// The stale project config keeps the foreign hook but loses ours.
|
|
data, err := os.ReadFile(stale)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(data), "bdrive sync") {
|
|
t.Fatalf("stale project hooks survived: %s", data)
|
|
}
|
|
if !strings.Contains(string(data), "echo mine") {
|
|
t.Fatalf("migration removed a hook that was not ours: %s", data)
|
|
}
|
|
if !strings.Contains(out, "moved out of") {
|
|
t.Fatalf("init did not report the migration:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// assertNoProjectHookFiles fails if BearDrive left agent config in a project.
|
|
func assertNoProjectHookFiles(t *testing.T, dir string) {
|
|
t.Helper()
|
|
for _, rel := range []string{
|
|
filepath.Join(".claude", "settings.json"),
|
|
filepath.Join(".codex", "hooks.json"),
|
|
filepath.Join(".gemini", "settings.json"),
|
|
} {
|
|
if fileExists(filepath.Join(dir, rel)) {
|
|
t.Fatalf("%s exists in the project — hooks must be user-level only", filepath.Join(dir, rel))
|
|
}
|
|
}
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// hubPaths is the set of paths a project's history knows about.
|
|
func hubPaths(t *testing.T, browser *http.Client, hubURL, projectID string) map[string]bool {
|
|
t.Helper()
|
|
resp, err := browser.Get(hubURL + "/api/p/" + projectID + "/history")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var body struct {
|
|
Entries []struct{ Path string } `json:"entries"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
paths := map[string]bool{}
|
|
for _, e := range body.Entries {
|
|
paths[e.Path] = true
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// projectID looks a project up by name through the hub's own API.
|
|
func projectIDByName(t *testing.T, browser *http.Client, hubURL, name string) string {
|
|
t.Helper()
|
|
var body struct {
|
|
Projects []struct{ ID, Name string } `json:"projects"`
|
|
}
|
|
if err := json.Unmarshal([]byte(hubProjects(t, browser, hubURL)), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range body.Projects {
|
|
if p.Name == name {
|
|
return p.ID
|
|
}
|
|
}
|
|
t.Fatalf("no project named %q on the hub", name)
|
|
return ""
|
|
}
|
|
|
|
// startTestHub serves a minimal hub (auth + orgs + projects + store proxy)
|
|
// on an ephemeral port, mirroring TestE2EServe's wiring without the seeds.
|
|
func startTestHub(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
state := t.TempDir()
|
|
be, err := remote.Open(t.Context(), "file://"+filepath.Join(state, "storage"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := OpenProjectDB(filepath.Join(state, "projects.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := &Server{Root: be, Projects: db, Device: webDevice, Upload: UploadConfig{Enabled: true}}
|
|
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
|
|
auth, err := OpenBuiltinAuth(filepath.Join(state, "auth.json"), false, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := auth.signup(e2eAdmin, "E2E Admin", e2ePassword); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Auth = auth
|
|
orgs, err := OpenOrgDB(filepath.Join(state, "orgs.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := orgs.Create("default", e2eAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Dir = LocalDirectory{OrgDB: orgs}
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
// signedInBrowser returns an http client holding a hub session cookie for
|
|
// the admin account — the "any signed-in browser" of the device flow.
|
|
func signedInBrowser(t *testing.T, hubURL string) *http.Client {
|
|
t.Helper()
|
|
jar, _ := cookiejar.New(nil)
|
|
c := &http.Client{Jar: jar, Timeout: 10 * time.Second}
|
|
resp, err := c.PostForm(hubURL+"/auth/login", url.Values{
|
|
"email": {e2eAdmin}, "password": {e2ePassword},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if len(jar.Cookies(mustParse(t, hubURL))) == 0 {
|
|
t.Fatal("browser sign-in left no session cookie")
|
|
}
|
|
return c
|
|
}
|
|
|
|
func hubProjects(t *testing.T, browser *http.Client, hubURL string) string {
|
|
t.Helper()
|
|
resp, err := browser.Get(hubURL + "/api/projects")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
var approveRe = regexp.MustCompile(`(https?://\S+/auth/device/[a-f0-9]+)`)
|
|
|
|
// waitForCode polls the login command's output for the device code it prints.
|
|
func waitForApprovalLink(t *testing.T, logFile string) string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
data, _ := os.ReadFile(logFile)
|
|
if m := approveRe.FindSubmatch(data); m != nil {
|
|
return string(m[1])
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
data, _ := os.ReadFile(logFile)
|
|
t.Fatalf("login --device never printed an approval link:\n%s", data)
|
|
return ""
|
|
}
|
|
|
|
func envWithout(names ...string) []string {
|
|
var out []string
|
|
Env:
|
|
for _, kv := range os.Environ() {
|
|
for _, n := range names {
|
|
if strings.HasPrefix(kv, n+"=") {
|
|
continue Env
|
|
}
|
|
}
|
|
out = append(out, kv)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mustParse(t *testing.T, raw string) *url.URL {
|
|
t.Helper()
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// `bdrive init --template` is the CLI-first path to a structured project: the
|
|
// hub seeds it at creation (the CLI creates through the same endpoint the
|
|
// browser does), init's blocking first cycle pulls it, and re-running the
|
|
// same command is a no-op rather than a second copy.
|
|
func TestCLITemplateSeeding(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run, hub, browser := e.run, e.hub, e.browser
|
|
|
|
work := filepath.Join(t.TempDir(), "brain")
|
|
if err := os.MkdirAll(work, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer run(work, "stop", work)
|
|
out, err := run(work, "init", "--name", "seeded", "--template", "docs", "--yes")
|
|
if err != nil {
|
|
t.Fatalf("init --template: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "docs template") {
|
|
t.Fatalf("init said nothing about the template:\n%s", out)
|
|
}
|
|
|
|
want := []string{"AGENTS.md", filepath.Join("decisions", "0001-record-decisions.md"), filepath.Join("docs", "README.md")}
|
|
for _, rel := range want {
|
|
if !fileExists(filepath.Join(work, rel)) {
|
|
t.Fatalf("%s is not on disk after init --template docs:\n%s", rel, out)
|
|
}
|
|
}
|
|
id := projectIDByName(t, browser, hub.URL, "seeded")
|
|
paths := hubPaths(t, browser, hub.URL, id)
|
|
for _, rel := range []string{"AGENTS.md", "decisions/0001-record-decisions.md", "docs/README.md"} {
|
|
if !paths[rel] {
|
|
t.Fatalf("%s never reached the hub: %v", rel, paths)
|
|
}
|
|
}
|
|
// Every directory of the template has a file in it — an empty directory
|
|
// would never sync, so the structure would silently not exist for a
|
|
// teammate.
|
|
if !paths["docs/README.md"] || !paths["decisions/0001-record-decisions.md"] {
|
|
t.Fatalf("a template directory reached the hub empty: %v", paths)
|
|
}
|
|
|
|
// Re-running is safe: the runbook promises it, and agents pass --yes.
|
|
before, err := os.ReadFile(filepath.Join(work, "AGENTS.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := run(work, "init", "--template", "docs", "--yes"); err != nil {
|
|
t.Fatalf("re-init --template: %v\n%s", err, out)
|
|
}
|
|
after, err := os.ReadFile(filepath.Join(work, "AGENTS.md"))
|
|
if err != nil || string(after) != string(before) {
|
|
t.Fatalf("re-running init --template rewrote AGENTS.md (err %v)", err)
|
|
}
|
|
}
|
|
|
|
// The two refusals that must cost nothing: a scope that would hide the
|
|
// template from the whole team, and a name that does not exist.
|
|
func TestCLITemplateRefusals(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
run := e.run
|
|
|
|
scoped := filepath.Join(t.TempDir(), "scoped")
|
|
if err := os.MkdirAll(scoped, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := run(scoped, "init", "--name", "scoped", "--template", "para", "--only", "docs", "--yes")
|
|
if err == nil {
|
|
t.Fatalf("--template with --only should be refused:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, ".bdriveignore") {
|
|
t.Fatalf("the refusal should say why (scope lives in .bdriveignore):\n%s", out)
|
|
}
|
|
if fileExists(filepath.Join(scoped, ".bdrive", "config.json")) {
|
|
t.Fatal("a refused init still initialized the folder")
|
|
}
|
|
if fileExists(filepath.Join(scoped, "AGENTS.md")) {
|
|
t.Fatal("a refused init still seeded files")
|
|
}
|
|
|
|
// Joining a project that already exists never restructures it: the
|
|
// refusal has to name what it was actually created from.
|
|
first := filepath.Join(t.TempDir(), "first")
|
|
if err := os.MkdirAll(first, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := run(first, "init", "--name", "taken", "--template", "docs", "--yes"); err != nil {
|
|
t.Fatalf("init first: %v\n%s", err, out)
|
|
}
|
|
defer run(first, "stop", first)
|
|
second := filepath.Join(t.TempDir(), "second")
|
|
if err := os.MkdirAll(second, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err = run(second, "init", "--name", "taken", "--template", "para", "--yes")
|
|
defer run(second, "stop", second)
|
|
if err == nil {
|
|
t.Fatalf("a template on an existing project should be refused:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "docs") {
|
|
t.Fatalf("the refusal should name the project's existing template:\n%s", out)
|
|
}
|
|
if fileExists(filepath.Join(second, "projects", "README.md")) {
|
|
t.Fatal("a refused init still wrote the para skeleton")
|
|
}
|
|
|
|
bad := filepath.Join(t.TempDir(), "bad")
|
|
if err := os.MkdirAll(bad, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err = run(bad, "init", "--name", "bad", "--template", "karpathy-wiki", "--yes")
|
|
if err == nil {
|
|
t.Fatalf("an unknown template should be refused:\n%s", out)
|
|
}
|
|
for _, name := range []string{"docs", "para"} {
|
|
if !strings.Contains(out, name) {
|
|
t.Fatalf("the refusal should name the valid set (%s missing):\n%s", name, out)
|
|
}
|
|
}
|
|
if fileExists(filepath.Join(bad, ".bdrive", "config.json")) {
|
|
t.Fatal("a refused init still initialized the folder")
|
|
}
|
|
}
|