feat: collapse mesh.name and mesh.slug into one identifier (v1.21.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Pre-launch fix: every visible surface already keyed on slug, so
"name" was a parallel string that only existed to confuse users
on rename ("I renamed but nothing visible changed").

Now slug IS the identifier. claudemesh rename <old> <new> is the
whole rename surface. PATCH /api/cli/meshes/:slug body becomes
{ slug } and the route writes both columns to keep them in sync.
Mesh create derives slug from input.name and stores name = slug.
Pickers drop the (parens). The claudemesh slug verb shipped 30
min ago is removed — merged into rename.

The mesh.name DB column stays for now to avoid touching ~25
reader sites; a follow-up migration drops it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 15:23:04 +01:00
parent 03cff156e2
commit 5785454ac9
15 changed files with 94 additions and 160 deletions

View File

@@ -45,7 +45,7 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr
}
render.section("select mesh to remove");
config.meshes.forEach((m, i) => {
process.stdout.write(` ${bold(String(i + 1) + ")")} ${clay(m.slug)} ${dim("(" + m.name + ")")}\n`);
process.stdout.write(` ${bold(String(i + 1) + ")")} ${clay(m.slug)}\n`);
});
render.blank();
const choice = await prompt(` ${dim("choice:")} `);

View File

@@ -39,7 +39,7 @@ export async function invite(
// Show picker
console.log("\n Select mesh to share:\n");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
console.log(` ${bold(String(i + 1) + ")")} ${m.slug}`);
});
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";

View File

@@ -49,8 +49,7 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
const label = m.name && m.name !== m.slug ? `${m.name} \x1b[2m(${m.slug})\x1b[0m` : m.slug;
console.log(` ${i + 1}) ${label}`);
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
@@ -219,9 +218,7 @@ async function runLaunchWizard(opts: {
spinner.stop();
const choice = await menuSelect({
title: "Select mesh",
items: opts.meshes.map((m) =>
m.name && m.name !== m.slug ? `${m.name} \x1b[2m(${m.slug})\x1b[0m` : m.slug,
),
items: opts.meshes.map((m) => m.slug),
row,
});
mesh = opts.meshes[choice]!;
@@ -229,8 +226,7 @@ async function runLaunchWizard(opts: {
for (let i = 0; i < opts.meshes.length + 1; i++) {
writeCentered(row + i, " ");
}
const meshLabel = mesh.name && mesh.name !== mesh.slug ? `${mesh.name} (${mesh.slug})` : mesh.slug;
writeCentered(row, `Mesh ${tGreen("✓")} ${meshLabel}`);
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
spinner.start();
row++;
}

View File

@@ -1,44 +1,69 @@
import { rename as renameMesh } from "~/services/mesh/facade.js";
/**
* `claudemesh rename <old-slug> <new-slug>` — change a mesh's identifier.
*
* v0.7.0 collapse: slug IS the identifier — there is no separate
* "display name". Pre-launch we collapsed the model so users only ever
* deal with one identifier per mesh. The mesh.name column on the DB is
* kept for now (avoids touching ~25 reader sites) but is always synced
* to slug; a follow-up migration drops it.
*/
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 } from "~/services/config/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";
export async function rename(slug: string, newName: string): Promise<number> {
// 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 SLUG_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
export async function rename(oldSlug: string, newSlug: string): Promise<number> {
if (!oldSlug || !newSlug) {
console.error(` ${icons.cross} Usage: ${bold("claudemesh rename")} <old-slug> <new-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("Joining via invite signs you in to the mesh, not to a web account.")}`);
console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first, then retry, or rename from the dashboard:")}`);
console.error(` https://claudemesh.com/dashboard`);
console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first.")}`);
return EXIT.AUTH_FAILED;
}
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 {
await renameMesh(slug, newName);
// Sync the new name into local config so launch / picker
// reflect the change without waiting for the next sync.
const cfg = readConfig();
const local = cfg.meshes.find((m) => m.slug === slug);
if (local) setMeshConfig(slug, { ...local, name: newName });
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
console.log(` ${dim("(slug stays \"" + slug + "\" — only the display name changed)")}`);
const updated = await reslugMesh(oldSlug, newSlug);
const local = cfg.meshes.find((m) => m.slug === oldSlug);
if (local) {
removeMeshConfig(oldSlug);
setMeshConfig(updated.slug, { ...local, slug: updated.slug, name: updated.slug });
}
console.log(` ${green(icons.check)} Renamed: "${oldSlug}" "${updated.slug}"`);
console.log(` ${dim("Other peers will pick up the new identifier after they run")} ${bold("claudemesh sync")}`);
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}`);
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}`);

View File

@@ -1,83 +0,0 @@
/**
* `claudemesh slug <old-slug> <new-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<number> {
if (!oldSlug || !newSlug) {
console.error(` ${icons.cross} Usage: ${bold("claudemesh slug")} <old-slug> <new-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;
}
}