feat(cli+broker): kick, ban, unban, bans commands
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

Broker WS handlers:
- kick: disconnect peer(s) by name, --stale duration, or --all.
  Authz: owner or admin only. Closes WS + marks presence disconnected.
- ban: kick + set revokedAt on mesh.member. Hello already rejects
  revoked members, so ban is instant and permanent until unban.
- unban: clear revokedAt. Peer can rejoin with their existing keypair.
- list_bans: return all revoked members for a mesh.

Session-id dedup (previous commit): handleHello disconnects ghost
presences with matching (meshId, sessionId) before inserting the new
one. Eliminates duplicate entries after broker restarts.

CLI (alpha.37):
- claudemesh kick <peer|--stale 30m|--all>
- claudemesh ban/unban <peer>
- claudemesh bans [--json]
- Uses new sendAndWait() on ws-client for request-response pattern
  over WS (generic _reqId resolver).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-17 08:37:38 +01:00
parent 5ddb11b2d5
commit 3ceac68e67
6 changed files with 328 additions and 2 deletions

View File

@@ -0,0 +1,80 @@
/**
* `claudemesh ban <peer>` — kick + permanently revoke member (can't reconnect)
* `claudemesh unban <peer>` — clear revocation, peer can rejoin
* `claudemesh bans` — list banned members
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function runBan(
target: string | undefined,
opts: { mesh?: string } = {},
): Promise<number> {
if (!target) { render.err("Usage: claudemesh ban <peer-name-or-pubkey>"); return EXIT.INVALID_ARGS; }
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
return await withMesh({ meshSlug }, async (client) => {
const result = await client.sendAndWait({ type: "ban", target }) as { banned?: string; error?: string };
if (result?.banned) {
render.ok(`Banned ${result.banned} from ${meshSlug}. They cannot reconnect until unbanned.`);
render.hint(`Undo: claudemesh unban ${result.banned} --mesh ${meshSlug}`);
} else {
render.err(result?.error ?? "ban failed");
}
return result?.banned ? EXIT.SUCCESS : EXIT.INTERNAL_ERROR;
});
}
export async function runUnban(
target: string | undefined,
opts: { mesh?: string } = {},
): Promise<number> {
if (!target) { render.err("Usage: claudemesh unban <peer-name-or-pubkey>"); return EXIT.INVALID_ARGS; }
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
return await withMesh({ meshSlug }, async (client) => {
const result = await client.sendAndWait({ type: "unban", target }) as { unbanned?: string; error?: string };
if (result?.unbanned) {
render.ok(`Unbanned ${result.unbanned} from ${meshSlug}. They can rejoin.`);
} else {
render.err(result?.error ?? "unban failed");
}
return result?.unbanned ? EXIT.SUCCESS : EXIT.INTERNAL_ERROR;
});
}
export async function runBans(
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
return await withMesh({ meshSlug }, async (client) => {
const result = await client.sendAndWait({ type: "list_bans" }) as { bans?: Array<{ name: string; pubkey: string; revokedAt: string }> };
const bans = result?.bans ?? [];
if (opts.json) {
process.stdout.write(JSON.stringify(bans, null, 2) + "\n");
return EXIT.SUCCESS;
}
if (bans.length === 0) {
render.info("No banned members.");
return EXIT.SUCCESS;
}
render.section(`banned members on ${meshSlug}`);
for (const b of bans) {
render.kv([[b.name, `${b.pubkey.slice(0, 16)}… · banned ${new Date(b.revokedAt).toLocaleDateString()}`]]);
}
return EXIT.SUCCESS;
});
}

View File

@@ -0,0 +1,59 @@
/**
* `claudemesh kick` — disconnect peers from the mesh.
*
* claudemesh kick <name> kick one peer (can reconnect)
* claudemesh kick --stale 30m kick idle peers (> 30 min no activity)
* claudemesh kick --all kick everyone except yourself
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
function parseStaleMs(input: string): number | null {
const m = input.match(/^(\d+)(s|m|h)$/);
if (!m) return null;
const val = parseInt(m[1]!, 10);
const unit = m[2]!;
if (unit === "s") return val * 1000;
if (unit === "m") return val * 60_000;
if (unit === "h") return val * 3600_000;
return null;
}
export async function runKick(
target: string | undefined,
opts: { mesh?: string; stale?: string; all?: boolean } = {},
): Promise<number> {
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
return await withMesh({ meshSlug }, async (client) => {
let payload: Record<string, unknown>;
if (opts.all) {
payload = { type: "kick", all: true };
} else if (opts.stale) {
const ms = parseStaleMs(opts.stale);
if (!ms) { render.err(`Invalid stale duration: "${opts.stale}". Use e.g. 30m, 1h, 300s.`); return EXIT.INVALID_ARGS; }
payload = { type: "kick", stale: ms };
} else if (target) {
payload = { type: "kick", target };
} else {
render.err("Usage: claudemesh kick <peer> | --stale 30m | --all");
return EXIT.INVALID_ARGS;
}
const result = await client.sendAndWait(payload) as { kicked?: string[] };
const kicked = result?.kicked ?? [];
if (kicked.length === 0) {
render.info("No peers matched.");
} else {
render.ok(`Kicked ${kicked.length} peer(s): ${kicked.join(", ")}`);
}
return EXIT.SUCCESS;
});
}

View File

@@ -30,6 +30,12 @@ Mesh
claudemesh delete [slug] delete a mesh (alias: rm)
claudemesh rename <slug> <name> rename a mesh
claudemesh share [email] share mesh (invite link / send email)
claudemesh kick <peer> disconnect a peer (can reconnect)
claudemesh kick --stale 30m disconnect idle peers (> duration)
claudemesh kick --all disconnect everyone except you
claudemesh ban <peer> kick + permanently revoke (can't rejoin)
claudemesh unban <peer> lift a ban
claudemesh bans list banned members
Messaging
claudemesh peers see who's online
@@ -133,6 +139,10 @@ async function main(): Promise<void> {
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 "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 "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 "ban": { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(positionals[0], { mesh: flags.mesh as string })); break; }
case "unban": { const { runUnban } = await import("~/commands/ban.js"); process.exit(await runUnban(positionals[0], { mesh: flags.mesh as string })); break; }
case "bans": { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); break; }
// Messaging
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }

View File

@@ -1408,6 +1408,28 @@ export class BrokerClient {
this.ws.send(JSON.stringify(payload));
}
/**
* Public request-response: sends a raw message with a generated _reqId
* and resolves when the broker responds with any message containing the
* same _reqId. Used by kick/ban/unban/bans CLI commands.
*/
async sendAndWait<T = Record<string, unknown>>(payload: Record<string, unknown>, timeoutMs = 10_000): Promise<T> {
const reqId = `rw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.genericResolvers.delete(reqId);
reject(new Error("sendAndWait timeout"));
}, timeoutMs);
this.genericResolvers.set(reqId, (msg) => {
clearTimeout(timer);
this.genericResolvers.delete(reqId);
resolve(msg as T);
});
this.sendRaw({ ...payload, _reqId: reqId });
});
}
private genericResolvers = new Map<string, (msg: Record<string, unknown>) => void>();
close(): void {
this.closed = true;
this.stopStatsReporting();
@@ -1627,6 +1649,13 @@ export class BrokerClient {
private handleServerMessage(msg: Record<string, unknown>): void {
const msgReqId = msg._reqId as string | undefined;
// Generic request-response resolver (kick_ack, ban_ack, unban_ack, etc.)
if (msgReqId && this.genericResolvers.has(msgReqId)) {
const resolve = this.genericResolvers.get(msgReqId)!;
resolve(msg);
return;
}
if (msg.type === "ack") {
const pending = this.pendingSends.get(String(msg.id ?? ""));
if (pending) {