feat(cli+broker): kick, ban, unban, bans commands
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:
80
apps/cli/src/commands/ban.ts
Normal file
80
apps/cli/src/commands/ban.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
59
apps/cli/src/commands/kick.ts
Normal file
59
apps/cli/src/commands/kick.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user