From 1c703c95f82a99028d63f6d71603411f9fce93ca Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Fri, 31 Jul 2026 14:56:58 +0900 Subject: [PATCH] feat(templates): start a project from a structure, not an empty folder (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ` 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 5 (1M context) --- INSTALL_FOR_AGENTS.md | 25 +++ README.md | 25 ++- architecture/cli-sync.md | 9 + architecture/overview.md | 4 + architecture/webapp-frontend.md | 3 +- architecture/webapp-server.md | 14 +- cmd/bdrive/init.go | 157 +++++++++++++-- cmd/bdrive/migrate.go | 2 +- internal/syncer/flows_test.go | 62 ++++++ internal/templates/files/docs/AGENTS.md | 58 ++++++ .../docs/decisions/0001-record-decisions.md | 41 ++++ internal/templates/files/docs/docs/README.md | 10 + internal/templates/files/para/AGENTS.md | 61 ++++++ .../templates/files/para/archives/README.md | 10 + internal/templates/files/para/areas/README.md | 12 ++ .../templates/files/para/projects/README.md | 12 ++ .../templates/files/para/resources/README.md | 12 ++ internal/templates/files/wiki/AGENTS.md | 107 +++++++++++ internal/templates/files/wiki/index.md | 24 +++ internal/templates/files/wiki/log.md | 27 +++ .../templates/files/wiki/sources/README.md | 16 ++ internal/templates/files/wiki/wiki/README.md | 15 ++ internal/templates/templates.go | 156 +++++++++++++++ internal/templates/templates_test.go | 128 +++++++++++++ internal/webapp/cli_e2e_test.go | 123 ++++++++++++ internal/webapp/db_conformance_test.go | 6 + internal/webapp/db_sql.go | 13 +- internal/webapp/frontend/e2e/hub.spec.ts | 75 +++++++- internal/webapp/frontend/shot3.mjs | 45 +++++ internal/webapp/frontend/src/api/types.ts | 13 ++ internal/webapp/frontend/src/apps/Browser.tsx | 2 +- internal/webapp/frontend/src/apps/HubApp.tsx | 66 ++++++- .../frontend/src/components/ConnectGuide.tsx | 23 ++- .../frontend/src/components/EmptyState.tsx | 29 ++- .../src/components/NewProjectDialog.tsx | 137 ++++++++++++++ .../frontend/src/components/ProjectNav.tsx | 32 ++-- internal/webapp/frontend/src/router.ts | 11 +- internal/webapp/frontend/src/style.css | 29 +++ internal/webapp/projects.go | 18 ++ internal/webapp/server.go | 34 ++++ .../webapp/static/assets/index-344uOWlP.js | 120 ------------ ...{index-CkrUL9C1.css => index-BZ355MrB.css} | 2 +- .../webapp/static/assets/index-BlfX6Osf.js | 120 ++++++++++++ internal/webapp/static/index.html | 4 +- internal/webapp/templates.go | 55 ++++++ internal/webapp/templates_test.go | 178 ++++++++++++++++++ internal/webapp/wiki_template_test.go | 44 +++++ web/docs/src/content/docs/reference/cli.md | 23 ++- .../content/docs/reference/project-files.md | 5 +- web/docs/src/content/docs/start/setup.md | 20 ++ 50 files changed, 2015 insertions(+), 202 deletions(-) create mode 100644 internal/templates/files/docs/AGENTS.md create mode 100644 internal/templates/files/docs/decisions/0001-record-decisions.md create mode 100644 internal/templates/files/docs/docs/README.md create mode 100644 internal/templates/files/para/AGENTS.md create mode 100644 internal/templates/files/para/archives/README.md create mode 100644 internal/templates/files/para/areas/README.md create mode 100644 internal/templates/files/para/projects/README.md create mode 100644 internal/templates/files/para/resources/README.md create mode 100644 internal/templates/files/wiki/AGENTS.md create mode 100644 internal/templates/files/wiki/index.md create mode 100644 internal/templates/files/wiki/log.md create mode 100644 internal/templates/files/wiki/sources/README.md create mode 100644 internal/templates/files/wiki/wiki/README.md create mode 100644 internal/templates/templates.go create mode 100644 internal/templates/templates_test.go create mode 100644 internal/webapp/frontend/shot3.mjs create mode 100644 internal/webapp/frontend/src/components/NewProjectDialog.tsx delete mode 100644 internal/webapp/static/assets/index-344uOWlP.js rename internal/webapp/static/assets/{index-CkrUL9C1.css => index-BZ355MrB.css} (59%) create mode 100644 internal/webapp/static/assets/index-BlfX6Osf.js create mode 100644 internal/webapp/templates.go create mode 100644 internal/webapp/templates_test.go create mode 100644 internal/webapp/wiki_template_test.go diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index c787ec8..1698248 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -160,6 +160,31 @@ committed). Re-running `bdrive init --yes` later is always safe. To change the scope later use `bdrive scope add/rm `, never hand-edit `.bdrive/config.json`. +**If the folder is empty after init — ask once.** Init has already pulled the +project, so an empty folder *now* means there is genuinely nothing to build on. +(If the project was created from a template in the browser, its files are +already here — say nothing.) Ask: start from a structure, or from scratch? + +> On Claude Code use `AskUserQuestion`, header "Starting point", in this +> order: **Docs + decision records** — `docs/`, `decisions/` — labelled +> "(Recommended)"; **LLM wiki** — you curate sources, the agent writes and +> maintains every page; **PARA** — `projects/`, `areas/`, `resources/`, +> `archives/`; then **Start from scratch**. Every other agent: prose. + +Each template is a directory skeleton plus an `AGENTS.md` saying where a new +note goes, when something is archived, and what a good filename looks like — +follow that file from then on, the same way you would one the user wrote. On a +pick, run one command in the same folder: + +```sh +bdrive init --template docs --yes +``` + +**A folder with files in it skips this entirely.** Never offer to restructure +someone's existing notes, and never ask before init — before init you cannot +know whether the project already has a structure, which is how you end up with +two copies. + ## 4. Confirm the sync hooks `bdrive init` already did this — do not run a separate hooks command. It diff --git a/README.md b/README.md index c3f0747..59f7886 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ hub's own storage, never something a syncing client points at directly: |---|---| | `bdrive login [server-url]` | Sign this device in (browser flow — the page names the account this terminal would act as and lets you switch before approving; `--device` forces the approval-link flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login ` | | `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) | -| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | +| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--template/--yes`) for scripts; `--template docs\|wiki\|para` starts the project from a structure (directories plus the `AGENTS.md` that explains them) instead of an empty folder; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | | `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent; this is what the login agent runs | | `bdrive autostart [install\|uninstall]` | Show, add, or remove the login registration that runs `bdrive resume` after a reboot — a launchd user agent on macOS, a systemd user unit on Linux, an HKCU Run entry on Windows. `bdrive init` installs it; `--no-autostart` skips it | | `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) | @@ -400,11 +400,24 @@ hub**, run `bdrive login ` and then re-run `bdrive init` in each folder to connect it to a project there; `bdrive logout` signs out entirely. `bdrive init` then, per project, walks you through it on a terminal: **create a new project or -connect an existing one** (picked from the server's list), and **sync the -whole folder or only some of its subfolders** (e.g. `./wiki`). Every question -has a flag (`--name`, `--project`, `--only`, `--yes`), and without a TTY -init never prompts — it creates-or-joins a project named after the folder -and syncs everything. It writes `.bdrive/config.json`, seeds a starter +connect an existing one** (picked from the server's list), **start from a +structure or from scratch**, and **sync the whole folder or only some of its +subfolders** (e.g. `./wiki`). Every question has a flag (`--name`, +`--project`, `--template`, `--only`, `--yes`), and without a TTY init never +prompts — it creates-or-joins a project named after the folder, empty, and +syncs everything. + +`--template docs`, `wiki` or `para` starts a **new** project from a +structure rather than an empty folder: a small directory skeleton plus the +`AGENTS.md` that tells an agent where a new note goes, when something is +archived, and what a good filename looks like — which is the part that keeps +a shared folder from rotting into a pile. The hub seeds it at creation, so it +is already there for the browser and for every device that connects later; +joining a project that already exists never restructures it, and +`--template` is refused together with `--only` (scope rules live in the +synced `.bdriveignore`, so a scope that left out the template's folders would +hide them for the whole team). Creating a project in the web UI offers the +same three starting points. It writes `.bdrive/config.json`, seeds a starter `.bdriveignore` (node_modules, build dirs, caches, `.env*`), and starts the daemon — local changes are detected within seconds, and the agent sync hooks sync at every turn boundary. Not signed in yet? init runs the login diff --git a/architecture/cli-sync.md b/architecture/cli-sync.md index 4a89660..c8de29b 100644 --- a/architecture/cli-sync.md +++ b/architecture/cli-sync.md @@ -123,6 +123,14 @@ classDiagram } note for Commands "cmd/bdrive — thin cobra layer; init is the front door (one command: login + hooks + sync + link), stop pauses" + class Templates { + <> + go:embed files/docs, files/wiki, files/para + List / Get / Names + WriteTo(dir) skips existing paths + } + note for Templates "init --template : refused with --only and on an unknown name BEFORE any write. The hub seeds at creation (the CLI creates through POST /api/projects), so WriteTo runs only as the fallback for a hub too old to know the field, and in an already-initialized folder — the agent's post-init path. Skipping existing paths is what makes a double-seed a no-op" + class syncBlocked { <> enrolled in mounts.json? @@ -198,6 +206,7 @@ classDiagram syncBlocked --> PausedMarker : Paused check Commands --> openSession : after the gate openSession --> MountRegistry : path self-heal (enrolled only) + Commands --> Templates : init --template (seed) / init resume (agent's post-init path) Commands --> startSync : init startSync --> MountRegistry : enrolls startSync --> PausedMarker : clears diff --git a/architecture/overview.md b/architecture/overview.md index f9f3ea0..82ccfeb 100644 --- a/architecture/overview.md +++ b/architecture/overview.md @@ -30,6 +30,8 @@ flowchart LR store["object store (hub-owned)
internal/remote: file:// s3:// gs://
blobs + per-device journals"] + tpl["internal/templates
go:embed'ed starting structures
(docs, wiki, para: skeleton + AGENTS.md)"] + docs["web/docs — docs.beardrive.ai
Astro/Starlight, deploys separately"] cloud["cloud/ (PRIVATE nested repo, gitignored)
managed beardrive.ai: swaps AuthProvider,
QuotaProvider, MetaStore seams"] @@ -42,6 +44,8 @@ flowchart LR hooks -->|"bdrive sync --hook / --note, read-log
gated: enrolled + not paused"| cli srv --> store srv --> meta + cli -->|"init --template: seed locally"| tpl + srv -->|"POST /api/projects template:
seed as ops under the hub's device"| tpl fe -->|/api/config, /api/projects, viewer APIs| srv cloud -.->|imports OSS packages,
replaces providers| srv docs -.->|documents| cli diff --git a/architecture/webapp-frontend.md b/architecture/webapp-frontend.md index 9debef5..10552a0 100644 --- a/architecture/webapp-frontend.md +++ b/architecture/webapp-frontend.md @@ -69,11 +69,12 @@ classDiagram class components { FileView FolderListing FileTree HistoryView HistoryRow DiffView VersionBanner - Insights ShareDialog + Insights ShareDialog NewProjectDialog ShareBanner SharesTable AdminTable OrgAdmin HubSettings ProjectSettings Palette shell AccountBar ... } + note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; \"Empty project\" (value \"\") stays preselected so an unpicked create behaves exactly as it did before templates. modal.tsx keeps its one-field API — teaching it about choices would tax every other caller" note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree" class lib { diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index 66cddc5..2b99b82 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -135,16 +135,25 @@ classDiagram -repo ProjectRepo -byID +Get +Create +Update +Rename +List - +SetCreator +SetDefault + +SetCreator +SetDefault +SetTemplate +SetPerm +ClearPerm } class Project { +ID +Name +Org +Created +Description +Icon +Creator string + +Template string +Default string +Perms map email→level } + class seedTemplate { + <> + POST /api/projects `template` + templates.Get before GetOrCreate → 400 + Upload() per file, hub's own device + skips paths that already exist + CheckWrite / RecordUsage + } note for Project "Default == "" means write — the historical behavior, so an upgraded hub needs no migration. SetPerm/ClearPerm refuse to drop the last explicit admin." class projectPerm { @@ -236,6 +245,9 @@ classDiagram BuiltinAuth ..> OrgDB : InviteValid wiring ProjectDB ..> Project + Server *-- seedTemplate : on create, when `template` is set + seedTemplate ..> Uploader : RemoteSource.Upload (blob, then journal) + seedTemplate ..> ProjectDB : SetTemplate records it once Server *-- projectPerm : gates every per-project route projectPerm ..> Project : Perms + Default projectPerm ..> Directory : org role diff --git a/cmd/bdrive/init.go b/cmd/bdrive/init.go index eca1292..2530b39 100644 --- a/cmd/bdrive/init.go +++ b/cmd/bdrive/init.go @@ -20,6 +20,7 @@ import ( "github.com/runbear-io/beardrive/internal/agenthooks" "github.com/runbear-io/beardrive/internal/autostart" "github.com/runbear-io/beardrive/internal/config" + "github.com/runbear-io/beardrive/internal/templates" ) // starterIgnore is seeded into new projects so build artifacts and @@ -50,7 +51,7 @@ venv/ // folder just resumes syncing (which is also how a moved/renamed folder // picks up where it left off). func initCmd() *cobra.Command { - var projectID, projectName, serverURL string + var projectID, projectName, serverURL, template string var only []string var yes, foreground, noHooks, noAutostart bool c := &cobra.Command{ @@ -92,6 +93,21 @@ the folder was renamed or moved.`, if projectID != "" && projectName != "" { return fmt.Errorf("--project and --name are mutually exclusive") } + // Both refusals land before any network call or file write: a + // scope that excludes a template's top level would hide it from + // the whole team (scope rules live in the synced .bdriveignore), + // and an unknown name should cost nothing. + var tpl templates.Template + if template != "" { + if len(only) > 0 { + return fmt.Errorf("--template and --only are mutually exclusive: " + + "scope rules live in the synced .bdriveignore, so a scope that leaves out " + + "the template's folders would hide them for everyone") + } + if tpl, err = templates.Get(template); err != nil { + return err + } + } // Already initialized → resume (also self-heals after a move). if proj, ok, err := config.ResolveMount(folder); err != nil { @@ -120,6 +136,16 @@ the folder was renamed or moved.`, fmt.Printf(" syncing: ./%s only (rules written to .bdriveignore)\n", strings.Join(scope, ", ./")) } } + // --template in an already-initialized folder is the agent's + // path: init pulled the project, the folder turned out to be + // empty, so the structure is written here and the usual cycle + // pushes it. Existing paths are never overwritten, which is + // what makes re-running this safe. + if tpl.Name != "" { + if err := seedLocally(folder, tpl); err != nil { + return err + } + } if !noHooks { installAgentHooks(folder) } @@ -138,21 +164,32 @@ the folder was renamed or moved.`, interactive := stdinIsTTY() && !yes - // Which project? + // Which project — and, when creating one, what does it start from? var p serverProject + var created bool switch { case projectID != "": p, err = getProject(server, settings.Token, projectID) case projectName != "": - p, _, err = createProject(server, settings.Token, projectName) + p, created, err = createProject(server, settings.Token, projectName, template) case interactive: - p, err = chooseProject(server, settings.Token, filepath.Base(folder)) + p, created, err = chooseProject(server, settings.Token, filepath.Base(folder), &template, &tpl) default: - p, _, err = createProject(server, settings.Token, filepath.Base(folder)) + p, created, err = createProject(server, settings.Token, filepath.Base(folder), template) } if err != nil { return fmt.Errorf("cannot set up project on %s: %w", server, err) } + // A template is applied when a project is created, and only then: + // joining one that already exists must never restructure it. + if tpl.Name != "" && !created && p.Template != tpl.Name { + from := "an empty project" + if p.Template != "" { + from = "the " + p.Template + " template" + } + return fmt.Errorf("project %q already exists and was created from %s\n"+ + "a template only applies to a new project; connect to this one without --template", p.Name, from) + } if err := checkNotAlreadyMounted(server+"/p/"+p.ID, folder, p.Name); err != nil { return err } @@ -197,6 +234,22 @@ the folder was renamed or moved.`, } } fmt.Printf("initialized %s\n server: %s\n project: %s (%s)\n", folder, server, p.Name, p.ID) + if tpl.Name != "" { + switch { + case p.Template == tpl.Name: + // The hub seeded it at creation; the initial cycle below + // is blocking and pulls, so the files land on disk before + // this command returns. + fmt.Printf(" start: %s template (seeded on the hub)\n", tpl.Name) + default: + // An older hub silently ignored the template field, which + // would make --template a quiet no-op. Seed from here and + // let the first cycle push it. + if err := seedLocally(folder, tpl); err != nil { + return err + } + } + } if !noHooks { installAgentHooks(folder) } @@ -232,6 +285,7 @@ next steps: c.Flags().StringVar(&serverURL, "server", "", "hub to connect to (default: the remembered one); signs in there if this device has no session") c.Flags().StringVar(&projectID, "project", "", "connect an existing project by id (p-xxxxxxxx)") c.Flags().StringVar(&projectName, "name", "", "project name to create or join (default: folder name)") + c.Flags().StringVar(&template, "template", "", "start from a structure ("+strings.Join(templates.Names(), ", ")+"); default: an empty project") c.Flags().StringSliceVar(&only, "only", nil, "sync only these subfolders of the mount (comma-separated, e.g. wiki,docs) — written as .bdriveignore rules") c.Flags().BoolVarP(&yes, "yes", "y", false, "accept defaults, never prompt") c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground") @@ -240,6 +294,23 @@ next steps: return c } +// seedLocally writes a template into the folder and says what it wrote. +// Paths that already exist are never touched, so seeding twice — an agent +// re-running init, a hub that already seeded — is a no-op rather than a +// conflict. +func seedLocally(folder string, tpl templates.Template) error { + wrote, err := tpl.WriteTo(folder) + if err != nil { + return fmt.Errorf("seed the %s template: %w", tpl.Name, err) + } + if len(wrote) == 0 { + fmt.Printf(" start: %s template (already present)\n", tpl.Name) + return nil + } + fmt.Printf(" start: %s template (%d files — read AGENTS.md)\n", tpl.Name, len(wrote)) + return nil +} + // installAgentHooks registers turn-boundary sync hooks as part of init, so // the one command a user (or their agent) already ran covers hooks too — a // separate `bdrive hooks install` is one more permission prompt, and it is @@ -398,31 +469,48 @@ func normalizeServer(raw string) string { return "https://" + raw } -func chooseProject(server, token, defaultName string) (serverProject, error) { +// chooseProject asks what to do and does it. The starting point is asked +// only on the create-a-new-project branch — connecting to an existing +// project never restructures it — and the picked template is handed back +// through name/tpl so the caller's refusals and summary see it too. +func chooseProject(server, token, defaultName string, name *string, tpl *templates.Template) (serverProject, bool, error) { var mode string if err := survey.AskOne(&survey.Select{ Message: "What would you like to do?", Options: []string{"Create a new project", "Connect an existing project"}, }, &mode); err != nil { - return serverProject{}, err + return serverProject{}, false, err } if mode == "Create a new project" { - name := defaultName - if err := survey.AskOne(&survey.Input{Message: "Project name:", Default: defaultName}, &name); err != nil { - return serverProject{}, err + projName := defaultName + if err := survey.AskOne(&survey.Input{Message: "Project name:", Default: defaultName}, &projName); err != nil { + return serverProject{}, false, err } - p, created, err := createProject(server, token, name) + if *name == "" { + picked, err := chooseTemplate() + if err != nil { + return serverProject{}, false, err + } + if picked != "" { + t, err := templates.Get(picked) + if err != nil { + return serverProject{}, false, err + } + *name, *tpl = picked, t + } + } + p, created, err := createProject(server, token, projName, *name) if err == nil && !created { fmt.Printf("project %q already exists — connecting to it\n", p.Name) } - return p, err + return p, created, err } projects, err := listProjects(server, token) if err != nil { - return serverProject{}, err + return serverProject{}, false, err } if len(projects) == 0 { - return serverProject{}, fmt.Errorf("the server has no projects yet; create one instead") + return serverProject{}, false, fmt.Errorf("the server has no projects yet; create one instead") } labels := make([]string, len(projects)) for i, p := range projects { @@ -430,9 +518,38 @@ func chooseProject(server, token, defaultName string) (serverProject, error) { } var idx int if err := survey.AskOne(&survey.Select{Message: "Connect to which project?", Options: labels}, &idx); err != nil { - return serverProject{}, err + return serverProject{}, false, err } - return projects[idx], nil + return projects[idx], false, nil +} + +// chooseTemplate offers the three starting points in the same words the web +// dialog uses, recommended first and "empty" as a real option rather than a +// footnote. Returns "" for an empty project. +func chooseTemplate() (string, error) { + list := templates.List() + options := make([]string, 0, len(list)+1) + for i, t := range list { + label := fmt.Sprintf("%s — %s", t.Title, t.Blurb) + if i == 0 { + label += " (recommended)" + } + options = append(options, label) + } + options = append(options, "Empty project — just the folder") + + var idx int + if err := survey.AskOne(&survey.Select{ + Message: "Start from a structure?", + Options: options, + Default: options[len(options)-1], + }, &idx); err != nil { + return "", err + } + if idx == len(list) { + return "", nil + } + return list[idx].Name, nil } // chooseScope returns nil for whole-folder sync, or the subfolders to narrow @@ -458,6 +575,10 @@ func chooseScope() ([]string, error) { type serverProject struct { ID string `json:"id"` Name string `json:"name"` + // Template is the structure the hub seeded the project from, "" for an + // empty project — and also for a hub too old to know the field, which is + // why init falls back to seeding locally rather than trusting it blindly. + Template string `json:"template"` } var initClient = &http.Client{Timeout: 10 * time.Second} @@ -527,8 +648,8 @@ func listProjects(server, token string) ([]serverProject, error) { return out.Projects, nil } -func createProject(server, token, name string) (serverProject, bool, error) { - body, err := json.Marshal(map[string]string{"name": name}) +func createProject(server, token, name, template string) (serverProject, bool, error) { + body, err := json.Marshal(map[string]string{"name": name, "template": template}) if err != nil { return serverProject{}, false, err } diff --git a/cmd/bdrive/migrate.go b/cmd/bdrive/migrate.go index 795d743..37a0de7 100644 --- a/cmd/bdrive/migrate.go +++ b/cmd/bdrive/migrate.go @@ -128,7 +128,7 @@ bdrive init --project .`, if name == "" { return fmt.Errorf("archive has no manifest; pass --name") } - p, created, err := createProject(settings.Server, settings.Token, name) + p, created, err := createProject(settings.Server, settings.Token, name, "") if err != nil { return fmt.Errorf("cannot create project on %s: %w", settings.Server, err) } diff --git a/internal/syncer/flows_test.go b/internal/syncer/flows_test.go index 2517023..6e0a1e1 100644 --- a/internal/syncer/flows_test.go +++ b/internal/syncer/flows_test.go @@ -11,6 +11,7 @@ import ( "github.com/runbear-io/beardrive/internal/journal" "github.com/runbear-io/beardrive/internal/remote" "github.com/runbear-io/beardrive/internal/store" + "github.com/runbear-io/beardrive/internal/templates" ) // End-to-end scenarios for the init knowledge flows documented in @@ -275,3 +276,64 @@ func TestSessionNoteStampsOps(t *testing.T) { t.Fatalf("cleared note = %q", got) } } + +// A template is inert files, so it has to converge like any other content — +// and the double-seed case has to be a no-op rather than a divergence. One +// device seeds a structure, a teammate connects and gets it byte-for-byte; +// then the teammate seeds the same template again (an agent that asked when +// it shouldn't have) and nothing forks: no second copy, no conflict copies. +func TestTemplateSeedConverges(t *testing.T) { + be := sharedRemote(t) + a := newDevice(t, "deva", be) + b := newDevice(t, "devb", be) + + tpl, err := templates.Get("docs") + if err != nil { + t.Fatal(err) + } + if _, err := tpl.WriteTo(a.Folder); err != nil { + t.Fatal(err) + } + cycle(t, a) + cycle(t, b) + + for _, f := range tpl.Files { + if got := read(t, b.Folder, f.Path); got != f.Content { + t.Fatalf("%s did not reach devb intact:\n%q", f.Path, got) + } + } + + // devb seeds the same template on top of what it just pulled. WriteTo + // skips every existing path, so this writes nothing at all. + wrote, err := tpl.WriteTo(b.Folder) + if err != nil { + t.Fatal(err) + } + if len(wrote) != 0 { + t.Fatalf("re-seeding an already-seeded folder wrote %v", wrote) + } + cycle(t, b) + cycle(t, a) + + for _, d := range []*Session{a, b} { + if got := conflictFiles(t, d.Folder); len(got) != 0 { + t.Fatalf("%s: double-seed made conflict copies: %v", d.Device.ID, got) + } + for _, f := range tpl.Files { + if read(t, d.Folder, f.Path) != f.Content { + t.Fatalf("%s: %s diverged after the second seed", d.Device.ID, f.Path) + } + } + } + // One op per file per device that wrote it: no path was journaled twice. + ops, err := a.Store.DeviceOps("deva") + if err != nil { + t.Fatal(err) + } + if len(ops) != len(tpl.Files) { + t.Fatalf("deva journaled %d ops for a %d-file template: %+v", len(ops), len(tpl.Files), ops) + } + if ops, err := b.Store.DeviceOps("devb"); err != nil || len(ops) != 0 { + t.Fatalf("devb journaled %d ops for files it only pulled (err %v)", len(ops), err) + } +} diff --git a/internal/templates/files/docs/AGENTS.md b/internal/templates/files/docs/AGENTS.md new file mode 100644 index 0000000..90af85c --- /dev/null +++ b/internal/templates/files/docs/AGENTS.md @@ -0,0 +1,58 @@ +# How this folder is organized + +This project uses the **docs + decision records** structure. Everything here +syncs to every teammate and every one of their agents, so the filing rules below +are not a preference — they are what keeps the folder readable for people who +did not write the file. + +## Where a new file goes + +| What you are writing | Where it goes | +| -- | -- | +| Anything explaining how something works, or how to do something | `docs/` | +| A decision, with its context and its consequences | `decisions/` | +| A quick note that has no home yet | `docs/` — a wrong-but-findable file beats a right-but-invisible one | + +Read the directory before you add to it. If a file already covers the topic, +append to that file instead of creating a near-duplicate beside it — two files +saying almost the same thing is the failure mode this structure exists to +prevent. + +Never create a new top-level directory. If nothing fits, put it in `docs/` and +say so in the file. + +## Decisions + +A decision record is written **once, when the decision is made**, and is not +edited afterwards — that is the whole point of the format. Number it with the +next free number: `decisions/0002-....md`. See `decisions/0001-record-decisions.md` +for the shape. + +## When something is no longer true + +Do not delete it and do not silently rewrite it. + +- **A doc that is out of date**: fix it in place, and say what changed at the + bottom under `## Changes`. +- **A decision that has been reversed**: leave the old record alone, write a new + one, and add one line to the old one — `Superseded by 0007-....md`. The reason + a decision was reversed is usually more useful than the decision itself. +- **A doc for something that no longer exists**: delete the file. There is no + archive directory here on purpose — BearDrive keeps every version of every + file forever, so `bdrive log ` is the archive, and a folder of dead docs + is just noise for the next person searching. + +## Filenames + +- lowercase, words joined by hyphens, `.md`: `docs/deploy-to-staging.md` +- name the subject, not the format: `docs/auth.md`, never `docs/notes.md`, + `docs/new.md`, `docs/misc.md`, or a date-stamped file +- decisions are `NNNN-imperative-phrase.md`: `decisions/0002-use-postgres.md` +- one topic per file; if a file grows past a screen or two of scrolling, split it + and link the parts + +## Every file starts with an H1 + +The first line is `# Title`, and the paragraph under it says who the file is for +and why they would open it. Both the search box and an agent reading in a hurry +use that line. diff --git a/internal/templates/files/docs/decisions/0001-record-decisions.md b/internal/templates/files/docs/decisions/0001-record-decisions.md new file mode 100644 index 0000000..423c34e --- /dev/null +++ b/internal/templates/files/docs/decisions/0001-record-decisions.md @@ -0,0 +1,41 @@ +# 0001 — Record decisions + +**Status:** accepted + +## Context + +Decisions get made in a call, a thread, or an agent session, and the reasoning +evaporates within a week. Six months later someone re-opens the same question, +or — worse — quietly reverses it without knowing what it cost the first time. + +## Decision + +Anything with a consequence someone could reasonably want to undo gets a file in +this directory. Copy the shape of this one: + +``` +# NNNN — Imperative phrase + +**Status:** accepted | superseded by NNNN + +## Context what was true, and what forced a choice +## Decision what we are doing, in the present tense +## Consequences what this costs, including what it makes harder +``` + +Number sequentially, never reuse a number, and write the record when the +decision is made rather than when someone remembers. + +(The format has a name — an **architecture decision record**, or ADR — if you +want to read more about it. Nothing here depends on knowing that.) + +A record is written once and not edited afterwards. Reversing a decision means a +new record, plus one line here — `Superseded by NNNN-....md` — so the trail stays +readable in both directions. + +## Consequences + +A small tax on every real decision, and a directory that answers "why is it like +this?" without anyone having to remember. The failure mode to watch for is +recording everything: a decision with no alternative and no cost is just a doc, +and belongs in `docs/`. diff --git a/internal/templates/files/docs/docs/README.md b/internal/templates/files/docs/docs/README.md new file mode 100644 index 0000000..1b02b90 --- /dev/null +++ b/internal/templates/files/docs/docs/README.md @@ -0,0 +1,10 @@ +# Docs + +How things work, and how to do them. One topic per file, named after the +subject: `deploy-to-staging.md`, `auth.md`, `onboarding.md`. + +Nothing here is precious. If a doc is wrong, fix it — the version you replaced +is still in the project's history, and `bdrive log ` will show you who +changed it and when. + +Delete this file once there are real docs beside it. diff --git a/internal/templates/files/para/AGENTS.md b/internal/templates/files/para/AGENTS.md new file mode 100644 index 0000000..cf8ac0f --- /dev/null +++ b/internal/templates/files/para/AGENTS.md @@ -0,0 +1,61 @@ +# How this folder is organized + +This project uses **PARA**: four top-level directories, sorted by how +*actionable* something is rather than by what topic it belongs to. Everything +here syncs to every teammate and every one of their agents, so the filing rules +below are what keep the folder usable by someone who did not write the file. + +## Where a new file goes + +Ask one question — *what is this for?* — and take the first row that fits: + +| The file is about… | It goes in | +| -- | -- | +| Something with a finish line and a deadline | `projects/` | +| A responsibility with no finish line — a system, a team, a customer you keep tending | `areas/` | +| A topic you are collecting material on, useful but not owned | `resources/` | +| Something already finished, dropped, or superseded | `archives/` | + +The ordering matters: a thing with a deadline is a project even if it is also a +topic. When two rows both fit, take the higher one. + +One directory per project or area, named after the thing, with a `README.md` +inside stating its goal and its current state. Notes live inside that directory, +not loose at the top level. + +Never create a fifth top-level directory. The four are the whole structure; if +something does not fit, it belongs in `resources/`. + +## When something is archived + +Archiving is an explicit move, and it is the habit that keeps PARA from becoming +a pile. Move the whole directory into `archives/`, unchanged, when: + +- a project is shipped, cancelled, or has not been touched in about a month and + nobody can say what the next step is; +- an area is no longer anyone's responsibility; +- a resource has been superseded by a better one — move it, do not delete it. + +Add one line at the top of its `README.md` saying what happened and when +(`Archived 2026-07-30 — shipped in v2.1.`). Nothing is deleted: `archives/` is +searchable, and it is what makes "we tried this before" answerable. Past work +that is still being cited is not archived — pull it back out into `resources/` +instead of copying it. + +Un-archiving is the same move backwards, and is completely fine. + +## Filenames + +- lowercase, words joined by hyphens, `.md`: `projects/q3-launch/press-plan.md` +- name the subject, not the format: never `notes.md`, `new.md`, `misc.md`, or a + bare date +- every project and area directory has a `README.md`; that is the file someone + opens first +- one topic per file — if a file grows past a screen or two of scrolling, split + it and link the parts + +## Every file starts with an H1 + +The first line is `# Title`, and the paragraph under it says who the file is for +and why they would open it. Both the search box and an agent reading in a hurry +use that line. diff --git a/internal/templates/files/para/archives/README.md b/internal/templates/files/para/archives/README.md new file mode 100644 index 0000000..46a8eee --- /dev/null +++ b/internal/templates/files/para/archives/README.md @@ -0,0 +1,10 @@ +# Archives + +Everything finished, cancelled, or superseded — moved here whole, unchanged, +with one line at the top of its README saying what happened and when. + +This is not a wastebasket. It is what makes "have we tried this before?" +answerable, and it stays searchable like the rest of the project. Nothing here +is deleted, and anything still being cited belongs back in `resources/`. + +Delete this file once there is something real beside it. diff --git a/internal/templates/files/para/areas/README.md b/internal/templates/files/para/areas/README.md new file mode 100644 index 0000000..0885021 --- /dev/null +++ b/internal/templates/files/para/areas/README.md @@ -0,0 +1,12 @@ +# Areas + +Standing responsibilities with no finish line: a service you run, a team you +lead, a customer you look after, hiring, security. + +One directory per area, named after the responsibility (`infrastructure/`), with +its own `README.md` saying what "healthy" looks like and who tends it. + +An area is not a project — if it has a deadline, it belongs in `projects/`. An +area nobody owns any more is archived. + +Delete this file once there are real areas beside it. diff --git a/internal/templates/files/para/projects/README.md b/internal/templates/files/para/projects/README.md new file mode 100644 index 0000000..b97d84d --- /dev/null +++ b/internal/templates/files/para/projects/README.md @@ -0,0 +1,12 @@ +# Projects + +Work with a finish line and a deadline: a launch, a migration, a hire, a +customer deliverable. + +One directory per project, named after the thing (`q3-launch/`), with its own +`README.md` stating the goal, the deadline, and where it currently stands. + +When a project ships, is cancelled, or goes quiet with no next step, move the +whole directory to `archives/` and note what happened at the top of its README. + +Delete this file once there are real projects beside it. diff --git a/internal/templates/files/para/resources/README.md b/internal/templates/files/para/resources/README.md new file mode 100644 index 0000000..0ced1a3 --- /dev/null +++ b/internal/templates/files/para/resources/README.md @@ -0,0 +1,12 @@ +# Resources + +Topics worth collecting material on, without owning them: reference notes, +research, competitor teardowns, saved patterns. + +Group by topic (`pricing/`, `postgres/`). If a resource turns out to have a +deadline it is a project; if you find yourself responsible for it, it is an +area. + +Superseded material moves to `archives/` — it is never deleted. + +Delete this file once there are real resources beside it. diff --git a/internal/templates/files/wiki/AGENTS.md b/internal/templates/files/wiki/AGENTS.md new file mode 100644 index 0000000..2df21ef --- /dev/null +++ b/internal/templates/files/wiki/AGENTS.md @@ -0,0 +1,107 @@ +# How this folder is organized + +This project is an **LLM wiki**: you curate sources and ask questions, and the +agent writes and maintains the pages. You rarely write the wiki yourself. + +It instantiates the LLM Wiki pattern described by Andrej Karpathy +() — *one* +instantiation of it, not the canonical one. That document is deliberately +abstract and says the specifics are yours to work out. So change anything here +that does not fit your material; this file is meant to be edited as you learn +what your domain needs. + +## The three layers + +| Layer | Who writes it | +| -- | -- | +| `sources/` | **you** — clippings, papers, transcripts, notes, exports | +| `wiki/` | **the agent** — every page here is generated and maintained by it | +| `AGENTS.md` (this file) | both of you, over time | + +**Nothing in `sources/` is ever edited.** It is the record of what was actually +said, and every claim in the wiki is only worth as much as that record. Add to +it freely; correct it never. Nothing enforces this — BearDrive syncs `sources/` +like any other folder — so it holds only because everyone honors it. + +## If there is nothing here yet + +Do not build scaffolding before there is material. With no sources there is no +wiki to maintain, and empty category pages are ceremony that will go stale +before anyone reads them. + +The first move is a source: put something in `sources/`, then ingest it. The +structure grows out of the material, never ahead of it. + +## Where a new page goes + +Everything the agent writes goes in `wiki/`. There are no fixed subdirectories — +add one when a category has earned it (a dozen people, a dozen papers), not +before. Until then flat is fine, because `index.md` is the navigation. + +Before creating a page, read `index.md` for one that already covers the subject. +Extending a page beats adding a near-duplicate beside it: two pages on one +subject is how a wiki starts contradicting itself. + +## The three operations + +### Ingest — a new source arrived + +1. Read it. +2. Say what you took from it, and stop. The human steers what matters here. +3. Write or update the pages it touches in `wiki/`. One source usually touches + several: a summary, the entities it names, the concepts it bears on. +4. **Update `index.md` in the same turn.** +5. Append one entry to `log.md`. + +**A page write that has not updated the index is an incomplete write.** The +index is what every future session reads *first* to find anything. A page +missing from it is invisible; a wrong line in it is worse than a missing one, +because it will be believed and acted on. If you can only do one thing +properly, do the index. + +### Query — a question was asked + +Read `index.md`, pick the pages that look relevant, read those, and answer with +links to the ones you used. + +Then ask whether the answer is worth keeping. A comparison, a synthesis, a +connection nobody had written down — file it as a page and index it. Answers +left in the conversation are answers you will pay to derive again. + +### Lint — periodically, or on request + +Walk the wiki and look for: + +- pages that contradict each other; +- claims a newer source has superseded; +- orphans — pages nothing links to; +- concepts referred to everywhere with no page of their own; +- gaps worth going and finding a source for. + +Report what you find. Fix the mechanical parts yourself — missing links, index +drift, broken names. Ask before rewriting anything that is a judgment call. + +## When something stops being true + +Revise the page. Do not append a correction underneath the old text and do not +leave both versions standing: a page should read as current truth from top to +bottom, because that is how it will be quoted. + +Nothing is lost by rewriting. BearDrive keeps every version of every file, so +`bdrive log ` still shows what the page said before and who changed it. +When a source is what changed your mind, cite it in the revision and note it in +`log.md`. + +## Filenames + +- lowercase, words joined by hyphens, `.md`: `wiki/margin-compression.md` +- name the subject: `wiki/anthropic.md`, never `wiki/notes-3.md` or a bare date +- one subject per page +- link pages to each other with `[[wikilinks]]` — the hub renders them, and they + are what lets a lint pass find orphans at all + +## Every page starts with an H1 and one line + +The first line is `# Title`. The line under it is a one-sentence summary of the +whole page — and it is the same sentence that goes in `index.md`. Writing it +once and using it in both places is what keeps the two agreeing. diff --git a/internal/templates/files/wiki/index.md b/internal/templates/files/wiki/index.md new file mode 100644 index 0000000..ba3f9c9 --- /dev/null +++ b/internal/templates/files/wiki/index.md @@ -0,0 +1,24 @@ +# Index + +The catalog of this wiki: every page in `wiki/`, listed once, with a one-line +summary. The agent updates it in the same turn it writes a page — see +[[AGENTS]]. + +This is the file a session reads *first* to find anything, so an entry that is +missing or out of date costs more than a page that is. + +Nothing here yet. It fills in as sources are ingested. + + diff --git a/internal/templates/files/wiki/log.md b/internal/templates/files/wiki/log.md new file mode 100644 index 0000000..c032124 --- /dev/null +++ b/internal/templates/files/wiki/log.md @@ -0,0 +1,27 @@ +# Log + +Append-only, oldest first. One entry per ingest, per query worth remembering, +and per lint pass. An entry that is already here is never edited — this file is +the timeline, not the current state. + +Keep the heading format exactly as below. The consistent prefix is what makes +the log readable with ordinary tools: + +```sh +grep "^## \[" log.md | tail -5 # what happened recently +``` + + diff --git a/internal/templates/files/wiki/sources/README.md b/internal/templates/files/wiki/sources/README.md new file mode 100644 index 0000000..76e64bf --- /dev/null +++ b/internal/templates/files/wiki/sources/README.md @@ -0,0 +1,16 @@ +# Sources + +Raw material, exactly as it arrived: article clippings, papers, transcripts, +meeting notes, exports, images. This is the record the whole wiki rests on. + +**Nothing in here is ever edited.** Not to fix a typo, not to trim it down, not +to summarize it in place. If something is wrong or misleading, that belongs in +the wiki page that cites it — the source keeps saying what it actually said. + +Add freely. One file per source, named so you can tell what it is a year from +now: `2026-07-30-earnings-call.md`, `hbr-switching-costs.md`. + +Nothing enforces the read-only rule — this folder syncs like any other — so it +holds only because everyone honors it, agents included. + +Delete this file once there are real sources beside it. diff --git a/internal/templates/files/wiki/wiki/README.md b/internal/templates/files/wiki/wiki/README.md new file mode 100644 index 0000000..5cbdee9 --- /dev/null +++ b/internal/templates/files/wiki/wiki/README.md @@ -0,0 +1,15 @@ +# Wiki + +The synthesized layer: summaries, entity pages, concept pages, comparisons — +everything the agent writes and keeps current. Humans read this; the agent +maintains it. + +Flat until it isn't. Add subdirectories when a category has genuinely earned +one, not in advance — `../index.md` is the navigation, so depth buys you very +little and costs you a filing decision on every write. + +Each page: an H1, a one-sentence summary under it (the same sentence that goes +in the index), and `[[wikilinks]]` to the pages it relates to. Pages are +revised in place when they stop being true, never appended to with corrections. + +Delete this file once there are real pages beside it. diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..0d78413 --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,156 @@ +// Package templates holds the starting structures a new project can be +// created from: a directory skeleton plus the AGENTS.md that explains it. +// +// The files are go:embed'ed rather than fetched, because `cmd/bdrive` is one +// binary serving the CLI, the daemon and the hub — so the hub that seeds a +// project at creation and the client that seeds one with `bdrive init +// --template` read the identical set, with no gallery and no drift. +// +// The AGENTS.md is the deliverable. Directories are the easy half and the +// half that decays; what makes a structure stick is the instruction file +// telling an agent where a new note goes, when something is archived, and +// what a good filename looks like. +// +// One hard rule for the content: BearDrive syncs paths, not directories +// (internal/syncer/walk.go only journals regular files), so an empty +// directory never reaches a teammate. Every directory in a template holds at +// least one real file — asserted in the tests. +package templates + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +//go:embed files +var content embed.FS + +// File is one file of a template: a slash-separated path relative to the +// project root, and its literal content. +type File struct { + Path string `json:"path"` + Content string `json:"-"` +} + +// Template is one starting structure. +type Template struct { + Name string `json:"name"` // the flag/API value, e.g. "docs" + Title string `json:"title"` // what a menu shows, e.g. "Docs + decision records" + Blurb string `json:"blurb"` // the one-line shape, e.g. "docs/, decisions/" + Files []File `json:"-"` +} + +// shipped is the registry, in the order both surfaces render: the +// recommended one first. Adding a template is a directory under files/ plus +// a row here — no other code. +var shipped = []struct{ Name, Title, Blurb string }{ + {"docs", "Docs + decision records", "docs/, decisions/"}, + {"wiki", "LLM wiki", "sources/, wiki/, index.md, log.md"}, + {"para", "PARA", "projects/, areas/, resources/, archives/"}, +} + +// List returns every shipped template, recommended first. +func List() []Template { + out := make([]Template, 0, len(shipped)) + for _, s := range shipped { + t, err := Get(s.Name) + if err != nil { + // Unreachable: the files are embedded in this binary. Panicking + // here would take a hub down for a packaging mistake. + continue + } + out = append(out, t) + } + return out +} + +// Names lists the valid template names, for error messages. +func Names() []string { + out := make([]string, len(shipped)) + for i, s := range shipped { + out[i] = s.Name + } + return out +} + +// Get returns the named template. An unknown name errors, naming the set. +func Get(name string) (Template, error) { + for _, s := range shipped { + if s.Name != name { + continue + } + files, err := load(name) + if err != nil { + return Template{}, err + } + return Template{Name: s.Name, Title: s.Title, Blurb: s.Blurb, Files: files}, nil + } + return Template{}, fmt.Errorf("unknown template %q (valid: %s)", name, strings.Join(Names(), ", ")) +} + +// load reads one template's files out of the embedded set, sorted by path so +// seeding order is stable (and so a journal's op order is reproducible). +func load(name string) ([]File, error) { + root := "files/" + name + var out []File + err := fs.WalkDir(content, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := content.ReadFile(p) + if err != nil { + return err + } + out = append(out, File{Path: strings.TrimPrefix(p, root+"/"), Content: string(b)}) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out, nil +} + +// WriteTo writes the template into dir and returns the paths it wrote. A path +// that already exists is never overwritten and is left out of the result — +// which is what makes seeding twice a no-op rather than a divergence. +func (t Template) WriteTo(dir string) ([]string, error) { + var wrote []string + for _, f := range t.Files { + abs := filepath.Join(dir, filepath.FromSlash(f.Path)) + if _, err := os.Stat(abs); err == nil { + continue + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return wrote, err + } + if err := os.WriteFile(abs, []byte(f.Content), 0o644); err != nil { + return wrote, err + } + wrote = append(wrote, f.Path) + } + return wrote, nil +} + +// Dirs lists every directory a template's paths imply, for the +// every-directory-holds-a-file check. +func (t Template) Dirs() []string { + seen := map[string]bool{} + for _, f := range t.Files { + for d := path.Dir(f.Path); d != "." && d != "/"; d = path.Dir(d) { + seen[d] = true + } + } + out := make([]string, 0, len(seen)) + for d := range seen { + out = append(out, d) + } + sort.Strings(out) + return out +} diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go new file mode 100644 index 0000000..4157856 --- /dev/null +++ b/internal/templates/templates_test.go @@ -0,0 +1,128 @@ +package templates + +import ( + "os" + "path" + "path/filepath" + "strings" + "testing" +) + +// Every shipped template has to satisfy the two things that make it work at +// all: the hub must be able to journal every path (so the paths obey the same +// rules cleanUploadPath enforces on an upload), and every directory must hold +// a file — BearDrive syncs paths, not directories, so an empty directory +// never reaches a teammate. +func TestShippedTemplates(t *testing.T) { + list := List() + if len(list) != len(shipped) { + t.Fatalf("List() returned %d templates, want %d", len(list), len(shipped)) + } + for _, tpl := range list { + if tpl.Title == "" || tpl.Blurb == "" { + t.Errorf("%s: needs a title and a blurb for the menus", tpl.Name) + } + var agents string + haveFileIn := map[string]bool{} + for _, f := range tpl.Files { + if f.Path == "AGENTS.md" { + agents = f.Content + } + haveFileIn[path.Dir(f.Path)] = true + + // The same rules cleanUploadPath applies, so the hub can never + // reject its own content. + if f.Path == "" || strings.HasPrefix(f.Path, "/") || strings.HasSuffix(f.Path, "/") || + path.Clean(f.Path) != f.Path || strings.HasPrefix(f.Path, "../") || + strings.Contains(f.Path, "/../") { + t.Errorf("%s: path %q is not a clean relative path", tpl.Name, f.Path) + } + if strings.HasPrefix(path.Base(f.Path), ".bdrive") { + t.Errorf("%s: path %q uses a reserved name", tpl.Name, f.Path) + } + if strings.TrimSpace(f.Content) == "" { + t.Errorf("%s: %s is empty", tpl.Name, f.Path) + } + } + if strings.TrimSpace(agents) == "" { + t.Errorf("%s: no AGENTS.md at the root — the instructions are the deliverable", tpl.Name) + } + // The three questions an AGENTS.md exists to answer. Each is checked + // through a set of alternatives, because the honest vocabulary + // differs by structure: PARA archives, a wiki supersedes and revises + // stale claims. What must not vary is that the question is answered. + for question, words := range map[string][]string{ + "where a new file goes": {"goes"}, + "what happens when something stops being true": {"rchiv", "supersede", "stale"}, + "what a good filename looks like": {"ilename"}, + } { + answered := false + for _, w := range words { + answered = answered || strings.Contains(agents, w) + } + if !answered { + t.Errorf("%s: AGENTS.md does not answer %s", tpl.Name, question) + } + } + for _, dir := range tpl.Dirs() { + if !haveFileIn[dir] { + t.Errorf("%s: directory %q holds no file, so it never syncs", tpl.Name, dir) + } + } + } +} + +func TestGetUnknownNamesTheSet(t *testing.T) { + _, err := Get("karpathy-wiki") + if err == nil { + t.Fatal("unknown template should error") + } + for _, name := range Names() { + if !strings.Contains(err.Error(), name) { + t.Fatalf("error %q does not name the valid template %q", err, name) + } + } +} + +// Seeding twice must be a no-op, not a divergence: WriteTo never overwrites, +// which is what makes `bdrive init --template` re-runnable and a hub-seeded +// project safe for an agent to seed again by mistake. +func TestWriteToSkipsExisting(t *testing.T) { + tpl, err := Get("docs") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + const sentinel = "# mine, not yours\n" + if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(sentinel), 0o644); err != nil { + t.Fatal(err) + } + + wrote, err := tpl.WriteTo(dir) + if err != nil { + t.Fatal(err) + } + for _, p := range wrote { + if p == "AGENTS.md" { + t.Fatal("WriteTo reported writing a path that already existed") + } + } + if len(wrote) != len(tpl.Files)-1 { + t.Fatalf("wrote %d paths, want %d", len(wrote), len(tpl.Files)-1) + } + if got, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")); string(got) != sentinel { + t.Fatalf("WriteTo clobbered an existing file: %q", got) + } + if _, err := os.Stat(filepath.Join(dir, "decisions", "0001-record-decisions.md")); err != nil { + t.Fatalf("WriteTo did not create the rest of the template: %v", err) + } + + // Second pass: nothing left to write. + again, err := tpl.WriteTo(dir) + if err != nil { + t.Fatal(err) + } + if len(again) != 0 { + t.Fatalf("re-seeding wrote %v, want nothing", again) + } +} diff --git a/internal/webapp/cli_e2e_test.go b/internal/webapp/cli_e2e_test.go index 8bb74f2..1277c0e 100644 --- a/internal/webapp/cli_e2e_test.go +++ b/internal/webapp/cli_e2e_test.go @@ -581,3 +581,126 @@ func mustParse(t *testing.T, raw string) *url.URL { } 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") + } +} diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go index d131652..5659fcc 100644 --- a/internal/webapp/db_conformance_test.go +++ b/internal/webapp/db_conformance_test.go @@ -147,6 +147,9 @@ func TestMetaStoreConformance(t *testing.T) { if err := projects.SetDefault(p1.ID, PermNone); err != nil { t.Fatal(err) } + if err := projects.SetTemplate(p1.ID, "para"); err != nil { + t.Fatal(err) + } for email, level := range map[string]string{ "boss@x.io": PermAdmin, "reader@x.io": PermRead, "cutoff@x.io": PermNone, } { @@ -256,6 +259,9 @@ func TestMetaStoreConformance(t *testing.T) { if hb.Creator != "boss@x.io" || hb.Default != PermNone { t.Fatalf("creator/default lost across reload: %+v", hb) } + if hb.Template != "para" { + t.Fatalf("template lost across reload: %+v", hb) + } if hb.Perms["boss@x.io"] != PermAdmin || hb.Perms["reader@x.io"] != PermRead || hb.Perms["cutoff@x.io"] != PermNone || len(hb.Perms) != 3 { t.Fatalf("grants lost across reload: %+v", hb.Perms) diff --git a/internal/webapp/db_sql.go b/internal/webapp/db_sql.go index 011f792..aa3378f 100644 --- a/internal/webapp/db_sql.go +++ b/internal/webapp/db_sql.go @@ -177,6 +177,7 @@ func (s *sqlMetaStore) migrate() error { "icon": `TEXT NOT NULL DEFAULT ''`, "creator": `TEXT NOT NULL DEFAULT ''`, "default_level": `TEXT NOT NULL DEFAULT ''`, + "template": `TEXT NOT NULL DEFAULT ''`, }) } @@ -299,7 +300,7 @@ type sqlProjectRepo struct{ s *sqlMetaStore } func (r *sqlProjectRepo) Load() ([]Project, error) { rows, err := r.s.db.Query( - `SELECT id, name, org, created, description, icon, creator, default_level FROM projects`) + `SELECT id, name, org, created, description, icon, creator, default_level, template FROM projects`) if err != nil { return nil, err } @@ -309,7 +310,7 @@ func (r *sqlProjectRepo) Load() ([]Project, error) { var p Project var created string if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created, - &p.Description, &p.Icon, &p.Creator, &p.Default); err != nil { + &p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template); err != nil { rows.Close() return nil, err } @@ -360,12 +361,12 @@ func (r *sqlProjectRepo) Put(p Project) error { } defer tx.Rollback() if _, err := tx.Exec(r.s.q( - `INSERT INTO projects (id,name,org,created,description,icon,creator,default_level) - VALUES (?,?,?,?,?,?,?,?) + `INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template) + VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created, description=excluded.description, icon=excluded.icon, - creator=excluded.creator, default_level=excluded.default_level`), - p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default); err != nil { + creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`), + p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template); err != nil { return err } if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil { diff --git a/internal/webapp/frontend/e2e/hub.spec.ts b/internal/webapp/frontend/e2e/hub.spec.ts index fbe68d4..68d2bee 100644 --- a/internal/webapp/frontend/e2e/hub.spec.ts +++ b/internal/webapp/frontend/e2e/hub.spec.ts @@ -69,14 +69,27 @@ test("join link accepts an invite after sign-in", async ({ page, browser }) => { await ctx.close(); }); -test("no-org account gets the onboarding empty state with the agent prompt", async ({ +test("no projects: the create dialog opens itself, and the page behind is no dead end", async ({ page, }) => { await login(page, "solo@example.com"); + // With nothing to browse, the one useful action opens on arrival. + await expect(page.locator(".modal .start-points")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator(".modal-input")).toHaveCount(0); + + // Closing it leaves a page that says what to do — and a way back in. await expect(page.locator(".onboard h1")).toHaveText("Welcome to BearDrive"); - await expect(page.locator(".ob-card h3")).toHaveText("Connect a new drive to your project"); - // The agent paste-prompt is the one path, with this hub's real origin - // filled in; the by-hand route is a docs link. + await expect(page.locator(".ob-start h3")).toHaveText("Start a project"); + await page.click("#ob-new"); + await expect(page.locator(".modal-input")).toBeVisible(); + await page.keyboard.press("Escape"); + // Dismissed once, it stays dismissed until asked for again. + await expect(page.locator(".modal-input")).toHaveCount(0); + + // The agent paste-prompt is still the other path, with this hub's real + // origin filled in; the by-hand route is a docs link. + await expect(page.locator(".ob-agent h3")).toHaveText("Or let your agent do it"); await expect(page.locator(".onboard .gd-code code")).toContainText( "to set up a new BearDrive project on http://localhost:8993. Ask me which folder to sync.", ); @@ -86,10 +99,21 @@ test("no-org account gets the onboarding empty state with the agent prompt", asy ); }); +// An account that already has projects must not get the dialog thrown at it. +test("the create dialog does not open itself when projects exist", async ({ page }) => { + await login(page); + await expect(page.locator("#project-select")).toBeVisible(); + await expect(page.locator(".modal-input")).toHaveCount(0); +}); + test("new project via the sidebar + modal", async ({ page }) => { await login(page); await page.click("#projects .nav-add"); await page.fill(".modal-input", "scratch"); + // The starting point defaults to an empty project, so this path still + // describes exactly what it did before templates existed. + await expect(page.locator(".start-points")).toBeVisible(); + await expect(page.locator(".start-point.on")).toContainText("Empty project"); await page.click(".modal .pbtn"); await page.waitForURL(/\/[0-9a-f-]{36}$/); await expect(page.locator("#project-select")).toContainText("scratch"); @@ -102,6 +126,49 @@ test("new project via the sidebar + modal", async ({ page }) => { await expectToast(page, "Created"); }); +// Picking a template seeds the project on the hub, so the folder listing +// shows the structure before any device has ever connected. +test("new project from a template", async ({ page }) => { + await login(page); + await page.click("#projects .nav-add"); + await page.fill(".modal-input", "from-template"); + await page.click('.start-point:has-text("Docs + decision records")'); + await expect(page.locator(".start-point.on")).toContainText("Docs + decision records"); + await page.click(".modal .pbtn"); + await page.waitForURL(/\/[0-9a-f-]{36}$/); + await expect(page.locator("#project-select")).toContainText("from-template"); + // Asserted on the file tree, not #content: a brand-new project's dashboard + // deliberately paints no treemap cells (#93), so #content is not where a + // seeded file reliably shows up. + for (const name of ["docs", "decisions", "AGENTS.md"]) { + await expect(page.locator("#sidebar").getByText(name, { exact: true }).first()).toBeVisible(); + } +}); + +// "I already have a folder" creates the same empty project as "Empty +// project" — the browser cannot touch your disk — so what it must change is +// the next screen: the paste prompt stops telling the agent to make a new +// folder, and the reassurance appears. The intent rides in the URL, so it +// survives a reload. +test("new project from an existing folder", async ({ page }) => { + await login(page); + await page.click("#projects .nav-add"); + await page.fill(".modal-input", "brought-my-own"); + await page.click('.start-point:has-text("I already have a folder")'); + await page.click(".modal .pbtn"); + await page.waitForURL(/connect=existing/); + await expect(page.locator(".gd-note")).toContainText("never moves, renames or overwrites"); + await expect(page.locator(".gd-code code").first()).toContainText( + "I already have a folder of notes — ask me which one to sync", + ); + // Nothing was seeded: same artifact as an empty project. Checked on the + // file tree, not #content — the paste prompt names INSTALL_FOR_AGENTS.md, + // which a substring match on "AGENTS.md" happily finds. + await expect(page.locator("#sidebar").getByText("AGENTS.md", { exact: true })).toHaveCount(0); + await page.reload(); + await expect(page.locator(".gd-note")).toBeVisible(); +}); + test("account menu closes on Escape and outside click", async ({ page }) => { await login(page); await page.click("#account-btn"); diff --git a/internal/webapp/frontend/shot3.mjs b/internal/webapp/frontend/shot3.mjs new file mode 100644 index 0000000..c9c4ecc --- /dev/null +++ b/internal/webapp/frontend/shot3.mjs @@ -0,0 +1,45 @@ +import { chromium } from "@playwright/test"; +const dir = process.argv[2]; +const browser = await chromium.launch(); + +// desktop: the three picker states, cropped to the dialog +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); +await page.goto("http://localhost:8993/"); +await page.waitForURL(/auth\/login/); +await page.fill('input[name="email"]', "e2e@example.com"); +await page.fill('input[name="password"]', "e2e-pass-1"); +await page.click("form button"); +await page.waitForSelector("#sidebar"); +await page.click("#projects .nav-add"); +await page.waitForSelector(".modal-input"); +await page.fill(".modal-input", "team-wiki"); +const modal = page.locator(".modal"); +for (const [file, label] of [ + ["template-select-empty", "Empty project"], + ["template-select-docs", "Docs + decision records"], + ["template-select-para", "PARA"], +]) { + await page.click(`.start-point:has-text("${label}")`); + await page.waitForTimeout(250); + await modal.screenshot({ path: `${dir}/${file}.png` }); +} +// full-window "after" shot of the dialog, default state +await page.click('.start-point:has-text("Empty project")'); +await page.waitForTimeout(200); +await page.fill(".modal-input", ""); +await page.screenshot({ path: `${dir}/new-project-dialog-after.png` }); +await page.close(); + +// mobile +const m = await browser.newPage({ viewport: { width: 375, height: 812 } }); +await m.goto("http://localhost:8993/"); +await m.waitForSelector("#sidebar"); +const burger = m.locator("#sb-toggle, .sb-toggle, [aria-label='Menu']").first(); +if (await burger.count()) await burger.click(); +await m.waitForTimeout(300); +await m.click("#projects .nav-add"); +await m.waitForSelector(".modal-input"); +await m.waitForTimeout(400); +await m.screenshot({ path: `${dir}/new-project-dialog-mobile-after.png` }); +console.log("overflow:", await m.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)); +await browser.close(); diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 58929f5..ec4c628 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -16,6 +16,10 @@ export interface ServerConfig { admin?: boolean; }; reads: { enabled: boolean }; + // Starting structures a new project can be created from (internal/templates, + // go:embed'ed into the server). Served rather than hardcoded here so a hub + // shipping another one needs no frontend change. + templates?: StartTemplate[]; me?: { email: string; name: string }; // Managed deployments only: where billing lives + the user's current plan. billing?: { plan: string; url: string }; @@ -40,6 +44,13 @@ export interface BillingInfo { portal_url: string; } +// One entry of /api/config's `templates` (templates.Template). +export interface StartTemplate { + name: string; // the API/flag value, e.g. "docs" + title: string; // menu label, e.g. "Docs + decision records" + blurb: string; // the one-line shape, e.g. "docs/, decisions/" +} + // Per-project permission levels (perms.go). Ordered: each includes the ones // before it. export type PermLevel = "none" | "read" | "write" | "admin"; @@ -60,6 +71,8 @@ export interface Project { /** lucide icon name (kebab-case); unknown or absent → the folder placeholder */ icon?: string; creator?: string; + /** the starting structure it was created from ("" / absent for an empty project) */ + template?: string; // The signed-in account's effective level on this project, resolved // server-side. A project you cannot read never appears in the list at all, // so this is always read or better here. diff --git a/internal/webapp/frontend/src/apps/Browser.tsx b/internal/webapp/frontend/src/apps/Browser.tsx index 01ce7e9..fb59b06 100644 --- a/internal/webapp/frontend/src/apps/Browser.tsx +++ b/internal/webapp/frontend/src/apps/Browser.tsx @@ -418,7 +418,7 @@ export default function Browser(props: { // dashboard below it. view = ( <> - +
parseRoute(loc, "hub"), [loc]); + // Creating a project is asked for from three places — the sidebar's +, the + // empty state's button, and the auto-open below — so the dialog and its one + // handler live here rather than in any of them. + const [creating, setCreating] = useState(false); + // A read-only hub refuses creation server-side (403), so never offer it. + const canCreate = config.upload.enabled; + + const createProject = async (name: string, template: string) => { + // "I already have a folder" is an empty project with a different next + // screen: the server never hears the sentinel, and the intent rides in + // the URL instead of onto the project record — it belongs to whoever is + // connecting right now, not to the project forever. + const existing = template === EXISTING; + try { + const out = await postJSON("/api/projects", { + name, + template: existing ? "" : template, + }); + setCreating(false); + await refresh(); + navigate("/" + out.project.id + (existing ? "?connect=existing" : "")); + toast(`Created “${out.project.name}”.`); + } catch (e) { + toast("Could not create the project: " + (e as Error).message, true); + } + }; + + // With no projects at all there is nothing else on the page to do, so the + // dialog opens itself. Once per mount, keyed off a ref rather than the + // empty state — otherwise closing it would immediately reopen it. + const autoOpened = useRef(false); + useEffect(() => { + if (autoOpened.current || joinToken) return; + if (!projects || projects.length > 0 || !canCreate) return; + autoOpened.current = true; + setCreating(true); + }, [projects, canCreate, joinToken]); + + const newProjectDialog = creating ? ( + setCreating(false)} + /> + ) : null; + const current: Project | null = useMemo(() => { if (!projects) return null; return ( @@ -110,13 +157,14 @@ export default function HubApp({ config }: { config: ServerConfig }) { return ( } + projectsNav={ setCreating(true)} />} orgBar={accountBar} topbar={} > - + setCreating(true)} canCreate={canCreate} /> + {newProjectDialog} ); } @@ -196,7 +244,7 @@ export default function HubApp({ config }: { config: ServerConfig }) { // it used to sit in the .onboard card, 320px narrower and 90px // lower than home, two sidebar items apart. crumb: "Installation", - body: , + body: , } : null; @@ -223,7 +271,8 @@ export default function HubApp({ config }: { config: ServerConfig }) { } return ( - + setCreating(true)} menu={{ // Scoped views (/dashboard/, /history/) belong to // the file/folder — the tree carries the selection, no menu @@ -281,7 +331,9 @@ export default function HubApp({ config }: { config: ServerConfig }) { }} panel={activePanel || orgPage || billingPage || routePage} onClosePanel={() => setPanel(null)} - /> + /> + {newProjectDialog} + ); } diff --git a/internal/webapp/frontend/src/components/ConnectGuide.tsx b/internal/webapp/frontend/src/components/ConnectGuide.tsx index abc0800..23019b8 100644 --- a/internal/webapp/frontend/src/components/ConnectGuide.tsx +++ b/internal/webapp/frontend/src/components/ConnectGuide.tsx @@ -14,8 +14,16 @@ import { projColor } from "./ProjectNav"; export const INSTALL_DOC = "https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md"; -export function ConnectGuide({ project }: { project: Project }) { +export function ConnectGuide({ project, existing }: { project: Project; existing?: boolean }) { const origin = window.location.origin; + // When the creator said they already have a folder, say so in the prompt. + // Without it an agent reads an empty project and proposes creating a new + // subfolder — the one recommendation that is wrong for this person. It + // still asks which folder: that question is the runbook's hard gate and + // nothing here may weaken it. + const ask = existing + ? '. I already have a folder of notes — ask me which one to sync (the project is named "' + : '. Ask me which folder to sync (the project is named "'; const prompt = "Follow " + INSTALL_DOC + @@ -23,7 +31,7 @@ export function ConnectGuide({ project }: { project: Project }) { project.id + " on " + origin + - '. Ask me which folder to sync (the project is named "' + + ask + project.name + '").'; const manual = @@ -48,9 +56,16 @@ export function ConnectGuide({ project }: { project: Project }) { {project.description &&

{project.description}

}

- Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the - folder where you want the files: + {existing + ? "Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the folder you already have:" + : "Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the folder where you want the files:"}

+ {existing && ( +

+ Your files stay exactly where they are. Connecting a folder never moves, renames or + overwrites anything in it — it uploads what is there and keeps it in sync. +

+ )}

The agent installs the CLI, signs this machine in, and registers the sync hooks — asking diff --git a/internal/webapp/frontend/src/components/EmptyState.tsx b/internal/webapp/frontend/src/components/EmptyState.tsx index 49fe0d6..c66e954 100644 --- a/internal/webapp/frontend/src/components/EmptyState.tsx +++ b/internal/webapp/frontend/src/components/EmptyState.tsx @@ -1,16 +1,35 @@ +import { Button } from "@/components/ui/button"; import { GuideCode, INSTALL_DOC } from "./ConnectGuide"; // Onboarding: a signed-in account with no projects shouldn't hit a blank -// sidebar. One path in — paste the canonical install prompt into a coding -// agent — with the by-hand route a docs link away. +// sidebar. Two paths in, in the order most people want them — create the +// project here and pick what it starts from, or paste the install prompt into +// a coding agent and let it do the whole thing. The by-hand route stays a +// docs link away. +// +// The create dialog also opens itself on arrival (HubApp): with no projects +// there is nothing else on this page to do. This page is what is left when +// someone closes it, so it must not be a dead end — hence the button. -export function EmptyState() { +export function EmptyState({ onNew, canCreate }: { onNew: () => void; canCreate: boolean }) { return (

Welcome to BearDrive

You're signed in, but you're not part of any project yet.

-
-

Connect a new drive to your project

+ {canCreate && ( +
+

Start a project

+

+ Name it and pick what it starts from — a structure, or nothing at all. Then connect a + folder on any machine and it stays in sync. +

+ +
+ )} +
+

{canCreate ? "Or let your agent do it" : "Connect a new drive to your project"}

Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the folder where you want the files. It creates the project and starts syncing: diff --git a/internal/webapp/frontend/src/components/NewProjectDialog.tsx b/internal/webapp/frontend/src/components/NewProjectDialog.tsx new file mode 100644 index 0000000..c830611 --- /dev/null +++ b/internal/webapp/frontend/src/components/NewProjectDialog.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import type { StartTemplate } from "../api/types"; + +// The "I already have a folder" pick. It creates the same empty project the +// "Empty project" pick does — the browser cannot reach your disk, so this can +// only change what you are told next, never what is created. 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. +export const EXISTING = "__existing__"; + +// The create-project dialog: a name, plus where the project starts from. +// +// Not a modalPrompt() variant on purpose — that API exists for one-field +// prompts, and teaching it about choices makes every other caller pay for the +// shape. This is a local useState over the Dialog we already have. +// +// The options come from /api/config, so a hub that ships another template +// needs no change here. "Empty project" is the synthetic first-class option +// (value "") and stays preselected: creating a project without picking +// anything must behave exactly as it did before templates existed. +export function NewProjectDialog({ + templates, + onCreate, + onClose, +}: { + templates: StartTemplate[]; + onCreate: (name: string, template: string) => Promise; + onClose: () => void; +}) { + const [name, setName] = useState(""); + // "" is an empty project; EXISTING is also an empty project — same artifact, + // different intent, and the intent is what the next screen needs to know. + const [template, setTemplate] = useState(""); + const [err, setErr] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (busy) return; + if (!name.trim()) { + setErr("Give it a name."); + return; + } + setBusy(true); + try { + await onCreate(name.trim(), template); + } finally { + setBusy(false); + } + }; + + // Recommended first, then the rest, then the two that seed nothing. The + // divider before them is doing real work: everything above answers "what + // should we put in it", everything below answers "nothing". + const options = [ + ...templates.map((t) => ({ value: t.name, title: t.title, blurb: t.blurb, rule: false })), + { + value: EXISTING, + title: "I already have a folder", + blurb: "nothing is seeded — connect it and your files stay as they are", + rule: true, + }, + { value: "", title: "Empty project", blurb: "just the folder", rule: false }, + ]; + + return ( +

!open && onClose()}> + + +

New project

+
+ + { + setName(e.currentTarget.value); + if (err) setErr(""); + }} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + {err && ( + + {err} + + )} + + {options.length > 1 && ( +
+ Starting point + {options.map((o, i) => ( + + ))} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/internal/webapp/frontend/src/components/ProjectNav.tsx b/internal/webapp/frontend/src/components/ProjectNav.tsx index fc71c45..41fde86 100644 --- a/internal/webapp/frontend/src/components/ProjectNav.tsx +++ b/internal/webapp/frontend/src/components/ProjectNav.tsx @@ -7,11 +7,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { postJSON } from "../api/http"; -import type { Project, ProjectCreated } from "../api/types"; -import { modalPrompt } from "../modal"; -import { toast } from "../toast"; -import { useHubRefresh } from "../hooks/useHub"; +import type { Project } from "../api/types"; import { closeSidebarOnMobile } from "./shell"; // Deterministic accent for a project's letter-mark, so each project keeps a @@ -35,32 +31,28 @@ export function ProjectNav({ projects, currentId, menu, + onNew, }: { projects: Project[]; currentId?: string; menu?: ProjectMenu; + // Opening the create dialog belongs to HubApp: three things ask for it now + // (this button, the empty state's button, and the auto-open when a signed-in + // account has no projects at all), and one owner beats three copies. + onNew: () => void; }) { - const refresh = useHubRefresh(); const current = projects.find((p) => p.id === currentId); - const create = async () => { - const name = await modalPrompt("New project", "Project name", "", "Create"); - if (name === null) return; - try { - const out = await postJSON("/api/projects", { name }); - await refresh(); - navigate("/" + out.project.id); - toast(`Created “${out.project.name}”.`); - } catch (e) { - toast("Could not create the project: " + (e as Error).message, true); - } - }; - return (