Files
beardrive/internal/webapp/cli_e2e_test.go
T
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>
2026-08-02 09:09:39 +09:00

716 lines
25 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)
// init's star ask is for humans at a terminal only. `run` pipes stdout,
// which is exactly the shape of a CI job or a script parsing the output —
// asking there is the mistake that got postinstall ads banned from npm.
// (Matched on the repo URL, not the word "star" — `autostart registered`
// is a legitimate line that contains it.)
if strings.Contains(out, "github.com/runbear-io/beardrive") {
t.Fatalf("init asked for a GitHub star without a TTY:\n%s", out)
}
// 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")
}
}