diff --git a/apps/cli/package.json b/apps/cli/package.json index 5ecf7c9..dfd926b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.19.3", + "version": "1.20.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/slug.ts b/apps/cli/src/commands/slug.ts new file mode 100644 index 0000000..cb225be --- /dev/null +++ b/apps/cli/src/commands/slug.ts @@ -0,0 +1,83 @@ +/** + * `claudemesh slug ` — change a mesh's slug. + * + * Slugs are NOT globally unique (mesh.id is canonical). Two users — or + * even the same user — can own meshes with identical slugs without + * colliding at the broker layer. The only collision risk is *local*: + * the CLI's config keys on slug, so two joined meshes with the same + * slug make the picker ambiguous. We refuse the rename in that case + * and point the user at the conflict. + * + * Other peers connected to this mesh keep using the old slug in their + * local configs until they run `claudemesh sync`. The broker doesn't + * care — it routes by mesh.id internally. + */ + +import { reslug as reslugMesh } from "~/services/mesh/facade.js"; +import { getStoredToken } from "~/services/auth/facade.js"; +import { ApiError } from "~/services/api/facade.js"; +import { readConfig, setMeshConfig, removeMeshConfig } from "~/services/config/facade.js"; +import { bold, dim, green, icons } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,31}$/; + +export async function slug(oldSlug: string, newSlug: string): Promise { + if (!oldSlug || !newSlug) { + console.error(` ${icons.cross} Usage: ${bold("claudemesh slug")} `); + return EXIT.INVALID_ARGS; + } + if (!SLUG_RE.test(newSlug)) { + console.error(` ${icons.cross} Invalid slug: must be 2-32 chars, lowercase alnum + hyphens, start with alnum`); + return EXIT.INVALID_ARGS; + } + if (oldSlug === newSlug) { + console.error(` ${icons.cross} Old and new slug are the same.`); + return EXIT.INVALID_ARGS; + } + + const auth = getStoredToken(); + if (!auth) { + console.error(` ${icons.cross} Renaming a mesh requires a claudemesh.com account session.`); + console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first.")}`); + return EXIT.AUTH_FAILED; + } + + // Local-collision check: refuse if another joined mesh already owns + // this slug locally. The broker would happily accept the change, but + // the CLI picker keys on slug — we'd render two indistinguishable + // entries. + const cfg = readConfig(); + const collision = cfg.meshes.find((m) => m.slug === newSlug && m.slug !== oldSlug); + if (collision) { + console.error(` ${icons.cross} Slug "${newSlug}" already used locally by another joined mesh.`); + console.error(` ${dim("Pick a different slug, or leave the other mesh first.")}`); + return EXIT.ALREADY_EXISTS; + } + + try { + const updated = await reslugMesh(oldSlug, newSlug); + // Reflect the new slug in local config so the picker / --mesh + // flag work without needing `claudemesh sync` afterwards. + const local = cfg.meshes.find((m) => m.slug === oldSlug); + if (local) { + removeMeshConfig(oldSlug); + setMeshConfig(updated.slug, { ...local, slug: updated.slug, name: updated.name }); + } + console.log(` ${green(icons.check)} Slug changed: "${oldSlug}" → "${updated.slug}"`); + console.log(` ${dim("Other peers will pick up the new slug after they run")} ${bold("claudemesh sync")}`); + return EXIT.SUCCESS; + } catch (err) { + if (err instanceof ApiError) { + const body = err.body as { error?: string } | undefined; + console.error(` ${icons.cross} ${body?.error ?? err.statusText}`); + if (err.status === 401) return EXIT.AUTH_FAILED; + if (err.status === 403) return EXIT.PERMISSION_DENIED; + if (err.status === 404) return EXIT.NOT_FOUND; + if (err.status === 400) return EXIT.INVALID_ARGS; + return EXIT.INTERNAL_ERROR; + } + console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`); + return EXIT.INTERNAL_ERROR; + } +} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 7ddfd2a..2d994e0 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -73,7 +73,8 @@ Mesh claudemesh launch [slug] launch Claude Code on a mesh (alias: connect) claudemesh list show your meshes (alias: ls) claudemesh delete [slug] delete a mesh (alias: rm) - claudemesh rename rename a mesh + claudemesh rename rename a mesh's display name (slug stays) + claudemesh slug change a mesh's slug (URL-safe identifier) claudemesh share [email] share mesh (invite link / send email) Peer (resource form, recommended) @@ -312,6 +313,7 @@ async function main(): Promise { case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; } case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; } case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; } + case "slug": { const { slug } = await import("~/commands/slug.js"); process.exit(await slug(positionals[0] ?? "", positionals[1] ?? "")); break; } case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } case "disconnect": { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } diff --git a/apps/cli/src/services/api/my.ts b/apps/cli/src/services/api/my.ts index c6afc1a..6b17139 100644 --- a/apps/cli/src/services/api/my.ts +++ b/apps/cli/src/services/api/my.ts @@ -31,6 +31,15 @@ export async function renameMesh(token: string, slug: string, newName: string) { }); } +export async function reslugMesh(token: string, oldSlug: string, newSlug: string) { + return request<{ slug: string; name: string }>({ + path: `/api/cli/meshes/${oldSlug}`, + method: "PATCH", + body: { slug: newSlug }, + token, + }); +} + export async function createInvite( token: string, meshSlug: string, diff --git a/apps/cli/src/services/mesh/facade.ts b/apps/cli/src/services/mesh/facade.ts index b8fd725..6e9034e 100644 --- a/apps/cli/src/services/mesh/facade.ts +++ b/apps/cli/src/services/mesh/facade.ts @@ -1,6 +1,7 @@ export { listMeshes as list } from "./list.js"; export { createMesh as create } from "./create.js"; export { renameMesh as rename } from "./rename.js"; +export { reslugMesh as reslug } from "./reslug.js"; export { leaveMesh as leave } from "./leave.js"; export { joinMesh as join, joinMesh } from "./join.js"; export { resolveTarget } from "./resolve-target.js"; diff --git a/apps/cli/src/services/mesh/reslug.ts b/apps/cli/src/services/mesh/reslug.ts new file mode 100644 index 0000000..6a41718 --- /dev/null +++ b/apps/cli/src/services/mesh/reslug.ts @@ -0,0 +1,8 @@ +import { my } from "~/services/api/facade.js"; +import { getStoredToken } from "~/services/auth/facade.js"; + +export async function reslugMesh(oldSlug: string, newSlug: string): Promise<{ slug: string; name: string }> { + const auth = getStoredToken(); + if (!auth) throw new Error("Not signed in"); + return await my.reslugMesh(auth.session_token, oldSlug, newSlug); +} diff --git a/apps/web/src/app/api/cli/meshes/[slug]/route.ts b/apps/web/src/app/api/cli/meshes/[slug]/route.ts index 9632dcc..fb526b8 100644 --- a/apps/web/src/app/api/cli/meshes/[slug]/route.ts +++ b/apps/web/src/app/api/cli/meshes/[slug]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "@turbostarter/db/server"; import { mesh } from "@turbostarter/db/schema/mesh"; @@ -79,19 +79,33 @@ export async function PATCH( } const { slug } = await params; - let body: { name?: string }; + let body: { name?: string; slug?: string }; try { - body = (await request.json()) as { name?: string }; + body = (await request.json()) as { name?: string; slug?: string }; } catch { return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); } const newName = body.name?.trim(); - if (!newName) { - return NextResponse.json({ error: "name is required" }, { status: 400 }); + const newSlug = body.slug?.trim(); + + if (!newName && !newSlug) { + return NextResponse.json({ error: "name or slug is required" }, { status: 400 }); } - if (newName.length > 80) { + if (newName !== undefined && newName.length > 80) { return NextResponse.json({ error: "name too long (max 80 chars)" }, { status: 400 }); } + // Slug regex matches the CLI's pre-flight check. Lowercase only, + // must start with alnum, may contain hyphens, 2-32 chars total. + // Slugs are NOT globally unique (mesh.id is canonical) — see schema + // comment on mesh.slug — so we don't enforce a uniqueness collision + // here. Local CLI configs key on slug, so the picker collides + // locally; that's the user's call. + if (newSlug !== undefined && !/^[a-z0-9][a-z0-9-]{1,31}$/.test(newSlug)) { + return NextResponse.json( + { error: "slug must be 2-32 chars, lowercase alnum + hyphens, start with alnum" }, + { status: 400 }, + ); + } // Look up the mesh first so we can distinguish "doesn't exist" // (404) from "exists but you don't own it" (403). The CLI was @@ -118,9 +132,13 @@ export async function PATCH( ); } + const patch: { name?: string; slug?: string } = {}; + if (newName !== undefined) patch.name = newName; + if (newSlug !== undefined) patch.slug = newSlug; + const [updated] = await db .update(mesh) - .set({ name: newName }) + .set(patch) .where(eq(mesh.slug, slug)) .returning({ slug: mesh.slug, name: mesh.name }); diff --git a/docs/roadmap.md b/docs/roadmap.md index 977ccaa..f1586d1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -298,6 +298,18 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. default returns last 30d). CLI: omitting `--mesh` on each verb routes through the matching aggregator. *Shipped 2026-05-03 in CLI v1.16.0.* +- **v0.6.2 — `claudemesh slug `** — change a mesh's + URL-safe slug (the identifier the CLI picker, `--mesh` flag, + and dashboard sidebar all key on). Slugs are NOT globally + unique — `mesh.id` is canonical — so the route only validates + the regex (`^[a-z0-9][a-z0-9-]{1,31}$`); it does not enforce + cross-user uniqueness. The CLI does refuse a local collision + (two joined meshes with the same slug would make the picker + ambiguous). On success, local config rewrites the slug in + place; other peers heal on next `claudemesh sync`. Server-side + reuses the existing `PATCH /api/cli/meshes/:slug` route — body + now accepts `{ name?, slug? }`. *Shipped 2026-05-03 in CLI + v1.20.0 + web.* - **v0.6.1 — `claudemesh rename` actually works** — adds the missing endpoint `PATCH /api/cli/meshes/:slug` on the web app. Lives under `/api/cli/*` (not `/api/my/*`) because the CLI's