diff --git a/apps/cli/package.json b/apps/cli/package.json index 7a2e325..4ac6ed8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.19.1", + "version": "1.19.2", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/rename.ts b/apps/cli/src/commands/rename.ts index 00a3724..6834052 100644 --- a/apps/cli/src/commands/rename.ts +++ b/apps/cli/src/commands/rename.ts @@ -1,13 +1,13 @@ import { rename as renameMesh } from "~/services/mesh/facade.js"; import { getStoredToken } from "~/services/auth/facade.js"; +import { ApiError } from "~/services/api/facade.js"; import { bold, dim, green, icons } from "~/ui/styles.js"; import { EXIT } from "~/constants/exit-codes.js"; export async function rename(slug: string, newName: string): Promise { - // Rename hits the account-scoped /api/my/meshes endpoint, which requires a - // web session token (~/.claudemesh/auth.json). Joining a mesh via invite - // does NOT create that token — it only writes a per-mesh apikey to - // config.json. Detect this case up front so the error is actionable. + // Rename requires a web session token (~/.claudemesh/auth.json). Joining + // a mesh via invite does NOT create that token — it only writes a per-mesh + // apikey to config.json. Detect this case up front so the error is actionable. const auth = getStoredToken(); if (!auth) { console.error(` ${icons.cross} Renaming a mesh requires a claudemesh.com account session.`); @@ -22,6 +22,18 @@ export async function rename(slug: string, newName: string): Promise { console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`); return EXIT.SUCCESS; } catch (err) { + if (err instanceof ApiError) { + // Server returns { error: "..." } for actionable messages + // (not the owner, mesh missing, expired token). Surface the + // body — the bare HTTP code alone hides why it failed. + const body = err.body as { error?: string } | undefined; + const detail = body?.error ?? err.statusText; + console.error(` ${icons.cross} ${detail}`); + if (err.status === 401) return EXIT.AUTH_FAILED; + if (err.status === 403) return EXIT.PERMISSION_DENIED; + if (err.status === 404) return EXIT.NOT_FOUND; + return EXIT.INTERNAL_ERROR; + } console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`); return EXIT.INTERNAL_ERROR; } 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 a247cc8..9632dcc 100644 --- a/apps/web/src/app/api/cli/meshes/[slug]/route.ts +++ b/apps/web/src/app/api/cli/meshes/[slug]/route.ts @@ -93,19 +93,36 @@ export async function PATCH( return NextResponse.json({ error: "name too long (max 80 chars)" }, { status: 400 }); } - // Only the owner can rename. The slug is the public identifier; we - // match (slug, ownerUserId) to scope the update. - const [updated] = await db - .update(mesh) - .set({ name: newName }) - .where(and(eq(mesh.slug, slug), eq(mesh.ownerUserId, payload.sub))) - .returning({ slug: mesh.slug, name: mesh.name }); + // 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 + // collapsing both into a bare "API error 404" — unhelpful when + // the user has multiple accounts and signs in to the wrong one. + const [existing] = await db + .select({ ownerUserId: mesh.ownerUserId }) + .from(mesh) + .where(eq(mesh.slug, slug)) + .limit(1); - if (!updated) { + if (!existing) { return NextResponse.json( - { error: "mesh not found or you are not the owner" }, + { error: `mesh "${slug}" not found` }, { status: 404 }, ); } + if (existing.ownerUserId !== payload.sub) { + return NextResponse.json( + { + error: `you are signed in as a different account than the owner of "${slug}". Run \`claudemesh logout && claudemesh login\` and pick the owning account.`, + }, + { status: 403 }, + ); + } + + const [updated] = await db + .update(mesh) + .set({ name: newName }) + .where(eq(mesh.slug, slug)) + .returning({ slug: mesh.slug, name: mesh.name }); + return NextResponse.json(updated); }