feat: collapse mesh.name and mesh.slug into one identifier (v1.21.0)
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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.20.1",
|
"version": "1.21.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr
|
|||||||
}
|
}
|
||||||
render.section("select mesh to remove");
|
render.section("select mesh to remove");
|
||||||
config.meshes.forEach((m, i) => {
|
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();
|
render.blank();
|
||||||
const choice = await prompt(` ${dim("choice:")} `);
|
const choice = await prompt(` ${dim("choice:")} `);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function invite(
|
|||||||
// Show picker
|
// Show picker
|
||||||
console.log("\n Select mesh to share:\n");
|
console.log("\n Select mesh to share:\n");
|
||||||
config.meshes.forEach((m, i) => {
|
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("");
|
console.log("");
|
||||||
const choice = await prompt(" Choice [1]: ") || "1";
|
const choice = await prompt(" Choice [1]: ") || "1";
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
|||||||
|
|
||||||
console.log("\n Select mesh:");
|
console.log("\n Select mesh:");
|
||||||
meshes.forEach((m, i) => {
|
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}) ${m.slug}`);
|
||||||
console.log(` ${i + 1}) ${label}`);
|
|
||||||
});
|
});
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
@@ -219,9 +218,7 @@ async function runLaunchWizard(opts: {
|
|||||||
spinner.stop();
|
spinner.stop();
|
||||||
const choice = await menuSelect({
|
const choice = await menuSelect({
|
||||||
title: "Select mesh",
|
title: "Select mesh",
|
||||||
items: opts.meshes.map((m) =>
|
items: opts.meshes.map((m) => m.slug),
|
||||||
m.name && m.name !== m.slug ? `${m.name} \x1b[2m(${m.slug})\x1b[0m` : m.slug,
|
|
||||||
),
|
|
||||||
row,
|
row,
|
||||||
});
|
});
|
||||||
mesh = opts.meshes[choice]!;
|
mesh = opts.meshes[choice]!;
|
||||||
@@ -229,8 +226,7 @@ async function runLaunchWizard(opts: {
|
|||||||
for (let i = 0; i < opts.meshes.length + 1; i++) {
|
for (let i = 0; i < opts.meshes.length + 1; i++) {
|
||||||
writeCentered(row + i, " ");
|
writeCentered(row + i, " ");
|
||||||
}
|
}
|
||||||
const meshLabel = mesh.name && mesh.name !== mesh.slug ? `${mesh.name} (${mesh.slug})` : mesh.slug;
|
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||||
writeCentered(row, `Mesh ${tGreen("✓")} ${meshLabel}`);
|
|
||||||
spinner.start();
|
spinner.start();
|
||||||
row++;
|
row++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { getStoredToken } from "~/services/auth/facade.js";
|
||||||
import { ApiError } from "~/services/api/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 { bold, dim, green, icons } from "~/ui/styles.js";
|
||||||
import { EXIT } from "~/constants/exit-codes.js";
|
import { EXIT } from "~/constants/exit-codes.js";
|
||||||
|
|
||||||
export async function rename(slug: string, newName: string): Promise<number> {
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
||||||
// 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
|
export async function rename(oldSlug: string, newSlug: string): Promise<number> {
|
||||||
// apikey to config.json. Detect this case up front so the error is actionable.
|
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();
|
const auth = getStoredToken();
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
console.error(` ${icons.cross} Renaming a mesh requires a claudemesh.com account session.`);
|
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.")}`);
|
||||||
console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first, then retry, or rename from the dashboard:")}`);
|
|
||||||
console.error(` https://claudemesh.com/dashboard`);
|
|
||||||
return EXIT.AUTH_FAILED;
|
return EXIT.AUTH_FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 cfg = readConfig();
|
||||||
const local = cfg.meshes.find((m) => m.slug === slug);
|
const collision = cfg.meshes.find((m) => m.slug === newSlug && m.slug !== oldSlug);
|
||||||
if (local) setMeshConfig(slug, { ...local, name: newName });
|
if (collision) {
|
||||||
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
|
console.error(` ${icons.cross} Slug "${newSlug}" already used locally by another joined mesh.`);
|
||||||
console.log(` ${dim("(slug stays \"" + slug + "\" — only the display name changed)")}`);
|
console.error(` ${dim("Pick a different slug, or leave the other mesh first.")}`);
|
||||||
|
return EXIT.ALREADY_EXISTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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;
|
return EXIT.SUCCESS;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
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 body = err.body as { error?: string } | undefined;
|
||||||
const detail = body?.error ?? err.statusText;
|
console.error(` ${icons.cross} ${body?.error ?? err.statusText}`);
|
||||||
console.error(` ${icons.cross} ${detail}`);
|
|
||||||
if (err.status === 401) return EXIT.AUTH_FAILED;
|
if (err.status === 401) return EXIT.AUTH_FAILED;
|
||||||
if (err.status === 403) return EXIT.PERMISSION_DENIED;
|
if (err.status === 403) return EXIT.PERMISSION_DENIED;
|
||||||
if (err.status === 404) return EXIT.NOT_FOUND;
|
if (err.status === 404) return EXIT.NOT_FOUND;
|
||||||
|
if (err.status === 400) return EXIT.INVALID_ARGS;
|
||||||
return EXIT.INTERNAL_ERROR;
|
return EXIT.INTERNAL_ERROR;
|
||||||
}
|
}
|
||||||
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
|
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,8 +73,7 @@ Mesh
|
|||||||
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
|
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
|
||||||
claudemesh list show your meshes (alias: ls)
|
claudemesh list show your meshes (alias: ls)
|
||||||
claudemesh delete [slug] delete a mesh (alias: rm)
|
claudemesh delete [slug] delete a mesh (alias: rm)
|
||||||
claudemesh rename <slug> <name> rename a mesh's display name (slug stays)
|
claudemesh rename <old> <new> change a mesh's slug (the identifier you see and type)
|
||||||
claudemesh slug <old> <new> change a mesh's slug (URL-safe identifier)
|
|
||||||
claudemesh share [email] share mesh (invite link / send email)
|
claudemesh share [email] share mesh (invite link / send email)
|
||||||
|
|
||||||
Peer (resource form, recommended)
|
Peer (resource form, recommended)
|
||||||
@@ -313,7 +312,6 @@ async function main(): Promise<void> {
|
|||||||
case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; }
|
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 "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 "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 "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 "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; }
|
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; }
|
||||||
|
|||||||
@@ -19,20 +19,13 @@ export async function createMesh(
|
|||||||
return post<{ id: string; slug: string; name: string }>("/api/my/meshes", body, token);
|
return post<{ id: string; slug: string; name: string }>("/api/my/meshes", body, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameMesh(token: string, slug: string, newName: string) {
|
export async function renameMesh(token: string, oldSlug: string, newSlug: string) {
|
||||||
// Routed through /api/cli/* (not /api/my/*) because the CLI JWT
|
// Routed through /api/cli/* (not /api/my/*) because the CLI JWT
|
||||||
// can't authenticate against the better-auth-protected myRouter.
|
// can't authenticate against the better-auth-protected myRouter.
|
||||||
// The /api/cli/meshes/:slug route validates the JWT inline.
|
// The /api/cli/meshes/:slug route validates the JWT inline.
|
||||||
return request<{ slug: string; name: string }>({
|
// v0.7.0 collapse: rename = change slug. mesh.name kept in sync
|
||||||
path: `/api/cli/meshes/${slug}`,
|
// server-side (column stays for now, value mirrors slug).
|
||||||
method: "PATCH",
|
return request<{ slug: string }>({
|
||||||
body: { name: newName },
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reslugMesh(token: string, oldSlug: string, newSlug: string) {
|
|
||||||
return request<{ slug: string; name: string }>({
|
|
||||||
path: `/api/cli/meshes/${oldSlug}`,
|
path: `/api/cli/meshes/${oldSlug}`,
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { slug: newSlug },
|
body: { slug: newSlug },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export { listMeshes as list } from "./list.js";
|
export { listMeshes as list } from "./list.js";
|
||||||
export { createMesh as create } from "./create.js";
|
export { createMesh as create } from "./create.js";
|
||||||
export { renameMesh as rename } from "./rename.js";
|
export { renameMesh as rename, renameMesh as reslug } from "./rename.js";
|
||||||
export { reslugMesh as reslug } from "./reslug.js";
|
|
||||||
export { leaveMesh as leave } from "./leave.js";
|
export { leaveMesh as leave } from "./leave.js";
|
||||||
export { joinMesh as join, joinMesh } from "./join.js";
|
export { joinMesh as join, joinMesh } from "./join.js";
|
||||||
export { resolveTarget } from "./resolve-target.js";
|
export { resolveTarget } from "./resolve-target.js";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { my } from "~/services/api/facade.js";
|
import { my } from "~/services/api/facade.js";
|
||||||
import { getStoredToken } from "~/services/auth/facade.js";
|
import { getStoredToken } from "~/services/auth/facade.js";
|
||||||
|
|
||||||
export async function renameMesh(slug: string, newName: string): Promise<void> {
|
export async function renameMesh(oldSlug: string, newSlug: string): Promise<{ slug: string }> {
|
||||||
const auth = getStoredToken();
|
const auth = getStoredToken();
|
||||||
if (!auth) throw new Error("Not signed in");
|
if (!auth) throw new Error("Not signed in");
|
||||||
await my.renameMesh(auth.session_token, slug, newName);
|
return await my.renameMesh(auth.session_token, oldSlug, newSlug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { JoinedMesh } from "~/services/config/facade.js";
|
import type { JoinedMesh } from "~/services/config/facade.js";
|
||||||
import { bold, dim } from "../styles.js";
|
import { bold } from "../styles.js";
|
||||||
|
|
||||||
export function renderMeshPicker(meshes: JoinedMesh[]): void {
|
export function renderMeshPicker(meshes: JoinedMesh[]): void {
|
||||||
console.log("\n Select a mesh:\n");
|
console.log("\n Select a mesh:\n");
|
||||||
meshes.forEach((m, i) => {
|
meshes.forEach((m, i) => {
|
||||||
console.log(" " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.name + ")"));
|
console.log(" " + bold((i + 1) + ")") + " " + m.slug);
|
||||||
});
|
});
|
||||||
console.log("");
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,20 +79,15 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
let body: { name?: string; slug?: string };
|
let body: { slug?: string };
|
||||||
try {
|
try {
|
||||||
body = (await request.json()) as { name?: string; slug?: string };
|
body = (await request.json()) as { slug?: string };
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const newName = body.name?.trim();
|
|
||||||
const newSlug = body.slug?.trim();
|
const newSlug = body.slug?.trim();
|
||||||
|
if (!newSlug) {
|
||||||
if (!newName && !newSlug) {
|
return NextResponse.json({ error: "slug is required" }, { status: 400 });
|
||||||
return NextResponse.json({ error: "name or slug is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
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,
|
// Slug regex matches the CLI's pre-flight check. Lowercase only,
|
||||||
// must start with alnum, may contain hyphens, 2-32 chars total.
|
// must start with alnum, may contain hyphens, 2-32 chars total.
|
||||||
@@ -100,7 +95,7 @@ export async function PATCH(
|
|||||||
// comment on mesh.slug — so we don't enforce a uniqueness collision
|
// comment on mesh.slug — so we don't enforce a uniqueness collision
|
||||||
// here. Local CLI configs key on slug, so the picker collides
|
// here. Local CLI configs key on slug, so the picker collides
|
||||||
// locally; that's the user's call.
|
// locally; that's the user's call.
|
||||||
if (newSlug !== undefined && !/^[a-z0-9][a-z0-9-]{1,31}$/.test(newSlug)) {
|
if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(newSlug)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "slug must be 2-32 chars, lowercase alnum + hyphens, start with alnum" },
|
{ error: "slug must be 2-32 chars, lowercase alnum + hyphens, start with alnum" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -132,15 +127,14 @@ export async function PATCH(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch: { name?: string; slug?: string } = {};
|
// Soft-collapse: name and slug are a single concept user-facing,
|
||||||
if (newName !== undefined) patch.name = newName;
|
// so we always sync name = slug. mesh.name column stays for now
|
||||||
if (newSlug !== undefined) patch.slug = newSlug;
|
// (avoids touching ~25 reader sites); a future migration drops it.
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(mesh)
|
.update(mesh)
|
||||||
.set(patch)
|
.set({ slug: newSlug, name: newSlug })
|
||||||
.where(eq(mesh.slug, slug))
|
.where(eq(mesh.slug, slug))
|
||||||
.returning({ slug: mesh.slug, name: mesh.name });
|
.returning({ slug: mesh.slug });
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,23 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
|
|||||||
default returns last 30d). CLI: omitting `--mesh` on each
|
default returns last 30d). CLI: omitting `--mesh` on each
|
||||||
verb routes through the matching aggregator. *Shipped
|
verb routes through the matching aggregator. *Shipped
|
||||||
2026-05-03 in CLI v1.16.0.*
|
2026-05-03 in CLI v1.16.0.*
|
||||||
|
- **v0.7.0 — collapse `mesh.name` and `mesh.slug` into one identifier** —
|
||||||
|
pre-launch correction of a piece of generic SaaS scaffolding that
|
||||||
|
was earning no keep here. Every visible surface (CLI picker,
|
||||||
|
`--mesh` flag, dashboard sidebar, broker presence rows) already
|
||||||
|
keyed on slug; `name` was a parallel string that confused users
|
||||||
|
on rename ("I renamed it but nothing visible changed"). Now: slug
|
||||||
|
IS the identifier. `claudemesh rename <old-slug> <new-slug>` is
|
||||||
|
the entire rename surface — there is no separate display name.
|
||||||
|
CLI picker drops the `(parens)`. Server `PATCH /api/cli/meshes/:slug`
|
||||||
|
body becomes `{ slug }`; the route writes both columns to keep
|
||||||
|
them in sync. New mesh creation derives slug from input.name and
|
||||||
|
stores `name = slug`. The `mesh.name` DB column is kept for now
|
||||||
|
(avoids touching ~25 reader sites in queries.ts / v1-router.ts /
|
||||||
|
dashboard pages) and always equals slug; a follow-up migration
|
||||||
|
drops it. The just-shipped `claudemesh slug` verb (v0.6.2) is
|
||||||
|
removed — its semantics merge into `rename`. *Shipped 2026-05-03
|
||||||
|
in CLI v1.21.0 + web.*
|
||||||
- **v0.6.2 — `claudemesh slug <old> <new>`** — change a mesh's
|
- **v0.6.2 — `claudemesh slug <old> <new>`** — change a mesh's
|
||||||
URL-safe slug (the identifier the CLI picker, `--mesh` flag,
|
URL-safe slug (the identifier the CLI picker, `--mesh` flag,
|
||||||
and dashboard sidebar all key on). Slugs are NOT globally
|
and dashboard sidebar all key on). Slugs are NOT globally
|
||||||
|
|||||||
@@ -136,10 +136,13 @@ export const createMyMesh = async ({
|
|||||||
s.base64_variants.URLSAFE_NO_PADDING,
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v0.7.0 collapse: mesh.name always == mesh.slug. Input.name is
|
||||||
|
// accepted from create UIs (dashboard, CLI) and used to derive the
|
||||||
|
// slug; we drop the original spelling so name and slug never drift.
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(mesh)
|
.insert(mesh)
|
||||||
.values({
|
.values({
|
||||||
name: input.name,
|
name: slug,
|
||||||
slug,
|
slug,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
transport: input.transport,
|
transport: input.transport,
|
||||||
|
|||||||
Reference in New Issue
Block a user