feat(onboarding): folder name follows the project name, and project ids are UUIDs (#89)

The paste prompt now carries the project's name so an agent recommends a
folder of that name; with no project at all the recommendation is `shared/`
(and `bdrive init shared` names the new project after the folder), replacing
the old `wiki/` default.

New project ids are UUIDs instead of `p-` + 8 hex chars. The route validator
still accepts the legacy shape — ids are permanent — and the client-side URL
parsers (remote/http.go, bdrive share) now only check the shape of a URL
segment, leaving the hub as the single authority on which ids are valid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-30 13:42:07 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent fb6ce347c4
commit fd4f5c7964
20 changed files with 91 additions and 50 deletions
@@ -28,6 +28,8 @@ test("guide: one paste for every agent, one line of prose, details collapsed", a
"Follow https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md",
);
expect(prompt).toContain(`project ${pid} on http://localhost:8993`);
// The name rides along so the agent can recommend it as the folder name.
expect(prompt).toContain('the project is named "wiki"');
expect(prompt).not.toContain("brew install");
// Detail lives behind the two collapsed sections.
await expect(page.locator(".gd-manual > summary")).toHaveText([
+4 -4
View File
@@ -25,7 +25,7 @@ test("deep link to a project resolves after reload", async ({ page }) => {
test("unknown project id falls back to a real project", async ({ page }) => {
await login(page);
await page.goto("/p-00000000");
await page.waitForURL(/\/p-[0-9a-f]{8}$/);
await page.waitForURL(/\/[0-9a-f-]{36}$/);
await expect(page.locator("#project-select")).toContainText(/.+/);
});
@@ -65,7 +65,7 @@ test("join link accepts an invite after sign-in", async ({ page, browser }) => {
await p2.fill('input[name="password"]', PASSWORD);
await p2.click("form button");
await expectToast(p2, "you joined");
await p2.waitForURL(/\/p-[0-9a-f]{8}$/); // lands on the org's project
await p2.waitForURL(/\/[0-9a-f-]{36}$/); // lands on the org's project
await ctx.close();
});
@@ -91,13 +91,13 @@ test("new project via the sidebar + modal", async ({ page }) => {
await page.click("#projects .nav-add");
await page.fill(".modal-input", "scratch");
await page.click(".modal .pbtn");
await page.waitForURL(/\/p-[0-9a-f]{8}$/);
await page.waitForURL(/\/[0-9a-f-]{36}$/);
await expect(page.locator("#project-select")).toContainText("scratch");
// Open the switcher: both projects listed; picking one navigates.
await page.click("#project-select");
await expect(page.getByRole("option", { name: "wiki" })).toBeVisible();
await page.getByRole("option", { name: "wiki" }).click();
await page.waitForURL(/\/p-[0-9a-f]{8}$/);
await page.waitForURL(/\/[0-9a-f-]{36}$/);
await expect(page.locator("#project-select")).toContainText("wiki");
await expectToast(page, "Created");
});
@@ -6,8 +6,9 @@ import { projColor } from "./ProjectNav";
/* ---- project home guide ----
One paste sets up any coding agent: the prompt points at the canonical
INSTALL_FOR_AGENTS.md with this hub's URL and this project's id filled
in. The agent fetches the doc and handles every deviation — already
INSTALL_FOR_AGENTS.md with this hub's URL and this project's id and
name filled in (the name is what the agent recommends as the folder
name). The agent fetches the doc and handles every deviation — already
installed, no Homebrew, sign-in, wrong folder — so the page itself
stays to one line of prose; detail lives in the collapsed sections. */
@@ -22,7 +23,9 @@ export function ConnectGuide({ project }: { project: Project }) {
project.id +
" on " +
origin +
". Ask me which folder to sync.";
'. Ask me which folder to sync (the project is named "' +
project.name +
'").';
const manual =
"brew install runbear-io/tap/beardrive" +
"\nbdrive login " +
+1 -1
View File
@@ -39,7 +39,7 @@ export interface Route {
org?: string;
// Billing (managed hubs) is hub-level like the org route; the URL comes
// from /api/config's billing block. Reserved only in hub mode — project
// ids are p-… so the segment can't collide with a project.
// ids are UUIDs (or legacy p-…), so the segment can't collide with one.
billing?: boolean;
project?: string;
path: string;
+9 -8
View File
@@ -1,8 +1,6 @@
package webapp
import (
"crypto/rand"
"encoding/hex"
"fmt"
"regexp"
"sort"
@@ -10,6 +8,8 @@ import (
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
// Project is one synced project hosted by this server. Its storage lives
@@ -41,7 +41,12 @@ func (p Project) level() string {
return p.Default
}
var projectIDRe = regexp.MustCompile(`^p-[0-9a-f]{8}$`)
// projectIDRe is the authority on what a project id may look like: a UUID
// (what new projects get) or the legacy `p-xxxxxxxx` form hubs minted before
// — ids are permanent, so the old shape stays valid forever. Client-side
// parsers (remote/http.go, the share command) only check the loose shape and
// let the hub decide.
var projectIDRe = regexp.MustCompile(`^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|p-[0-9a-f]{8})$`)
// iconRe validates the *shape* of an icon name only. The list of icons a
// project may pick from lives in the frontend (shell.tsx's PROJECT_ICONS) —
@@ -122,11 +127,7 @@ func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) {
return p, false, nil
}
}
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return Project{}, false, err
}
p := Project{ID: "p-" + hex.EncodeToString(buf[:]), Name: name, Org: org, Created: time.Now().UTC()}
p := Project{ID: uuid.NewString(), Name: name, Org: org, Created: time.Now().UTC()}
db.byID[p.ID] = p
if err := db.repo.Put(p); err != nil {
delete(db.byID, p.ID)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-BCH1-K2y.js"></script>
<script type="module" crossorigin src="/assets/index-D89jvKKO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ozNOEdCg.css">
</head>
<body>
+5 -1
View File
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/runbear-io/beardrive/internal/remote"
)
@@ -56,7 +57,10 @@ func TestProjectAPI(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if !out.Created || out.Project.Name != "my-app" || !strings.HasPrefix(out.Project.ID, "p-") {
// New ids are UUIDs (and must satisfy the route validator, which also
// still accepts the legacy p-xxxxxxxx ids old hubs minted).
if !out.Created || out.Project.Name != "my-app" ||
uuid.Validate(out.Project.ID) != nil || !projectIDRe.MatchString(out.Project.ID) {
t.Fatalf("create = %+v", out)
}
id := out.Project.ID