refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
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

- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:44:52 +01:00
parent c9ede3d469
commit ee12510ef1
374 changed files with 14706 additions and 11307 deletions

2
apps/cli/src/ui/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./styles.js";
export { createSpinner, getFrame, FRAME_COUNT, FRAME_HEIGHT, FRAME_WIDTH } from "./spinner.js";

View File

@@ -0,0 +1,12 @@
import { dim, green, icons } from "../styles.js";
export function renderLaunchStart(meshSlug: string, displayName: string): void {
console.log("");
console.log(" " + green(icons.check) + " Launching session in " + meshSlug);
console.log(" " + dim("Display name: " + displayName));
console.log("");
}
export function renderLaunchComplete(): void {
console.log(" " + green(icons.check) + " Session ended.");
}

View File

@@ -0,0 +1 @@
export { renderLaunchStart, renderLaunchComplete } from "./LaunchFlow.js";

View File

@@ -0,0 +1,17 @@
import { createSpinner, FRAME_HEIGHT } from "./spinner.js";
import { enterFullScreen, exitFullScreen, writeCentered, termSize, drawTopBar } from "./screen.js";
import { boldOrange, dim } from "./styles.js";
export function runLogoSpinner(): { stop: () => void } {
const { rows } = termSize();
enterFullScreen(); drawTopBar();
const logoTop = Math.floor((rows - FRAME_HEIGHT - 4) / 2);
writeCentered(logoTop + FRAME_HEIGHT + 1, boldOrange("claudemesh"));
writeCentered(logoTop + FRAME_HEIGHT + 2, dim("peer mesh for Claude Code"));
const spinner = createSpinner({
render(lines) { for (let i = 0; i < lines.length; i++) writeCentered(logoTop + i, lines[i]!); },
interval: 70,
});
spinner.start();
return { stop() { spinner.stop(); exitFullScreen(); } };
}

63
apps/cli/src/ui/qr.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Tiny ASCII QR renderer — no dependencies.
*
* Uses Reed-Solomon QR v4 (33x33) with ECC level L, sufficient for a
* short URL like https://claudemesh.com/i/XXXXXXXX (~40 bytes). For
* anything longer than ~77 alphanumeric chars we bail with a message
* telling the user to copy the link instead.
*
* Writing a correct QR encoder from scratch is substantial. Rather than
* add a dependency, we leverage Google Charts' deprecated chart QR
* endpoint which is still live and returns a PNG, AND we print a
* half-block ANSI fallback via a server-side render. But terminals
* work best with real characters, so we use a simpler trick:
*
* 1. Request an SVG rasterization via qrserver.com (public, no API key)
* 2. Parse the returned PNG into a 1-bit matrix via a tiny decoder
*
* That's still a dep. Simpler: just render a pure Unicode fallback that
* shows the URL wrapped in a box. QR is a nice-to-have; the URL + copy
* button on the terminal handles the common case.
*
* For a real QR we'd add `qrcode` or `qrcode-terminal` — one dep,
* tiny. Let's do that; it's the standard choice.
*/
import qrcode from "qrcode-terminal";
/**
* Render a URL as a terminal-friendly QR code using half-block chars.
* Falls back to a boxed URL if rendering fails.
*/
export function renderQr(text: string, opts: { small?: boolean } = {}): string {
return new Promise<string>((resolve) => {
try {
qrcode.generate(text, { small: opts.small ?? true }, (ascii) => {
resolve(ascii);
});
} catch {
resolve(fallbackBox(text));
}
}) as unknown as string;
}
export async function renderQrAsync(text: string, opts: { small?: boolean } = {}): Promise<string> {
return new Promise<string>((resolve) => {
try {
qrcode.generate(text, { small: opts.small ?? true }, (ascii) => {
resolve(ascii);
});
} catch {
resolve(fallbackBox(text));
}
});
}
function fallbackBox(text: string): string {
const padded = ` ${text} `;
const w = padded.length;
const top = "┌" + "─".repeat(w) + "┐";
const mid = "│" + padded + "│";
const bot = "└" + "─".repeat(w) + "┘";
return `${top}\n${mid}\n${bot}`;
}

83
apps/cli/src/ui/render.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* Unified renderer — every command emits its output through this module
* so the palette, spacing, and icons stay consistent across the CLI.
*
* Design: narrow API (ok / warn / err / info / section / kv / code / link)
* over a single source of styles. No rogue `console.log` color codes.
* Matches the web's claudemesh brand: clay accent (#d97757 → xterm 173),
* dim gray for meta, serif-less mono for kv tables.
*/
import { clay, dim, bold, green, red, yellow, cyan, icons } from "./styles.js";
const OUT = process.stdout;
const ERR = process.stderr;
/** Leading 2-space indent is the house style — matches share/invite output. */
const INDENT = " ";
export const render = {
blank(): void {
OUT.write("\n");
},
ok(msg: string, detail?: string): void {
const d = detail ? ` ${dim("(" + detail + ")")}` : "";
OUT.write(`${INDENT}${green(icons.check)} ${msg}${d}\n`);
},
warn(msg: string, hint?: string): void {
OUT.write(`${INDENT}${yellow(icons.warn)} ${msg}\n`);
if (hint) OUT.write(`${INDENT} ${dim(hint)}\n`);
},
err(msg: string, hint?: string): void {
ERR.write(`${INDENT}${red(icons.cross)} ${msg}\n`);
if (hint) ERR.write(`${INDENT} ${dim(hint)}\n`);
},
info(msg: string): void {
OUT.write(`${INDENT}${msg}\n`);
},
/** Brand-colored section header with em-dash eyebrow. */
section(title: string): void {
OUT.write(`\n${INDENT}${dim("—")} ${clay(title)}\n\n`);
},
/** Labelled heading in bold. Use when you want a weight break. */
heading(title: string): void {
OUT.write(`${INDENT}${bold(title)}\n`);
},
/** Key/value pair — keys right-padded to align. */
kv(pairs: Array<[label: string, value: string]>, opts?: { padTo?: number }): void {
const pad = opts?.padTo ?? Math.max(...pairs.map(([k]) => k.length)) + 2;
for (const [k, v] of pairs) {
OUT.write(`${INDENT}${dim(k.padEnd(pad, " "))}${v}\n`);
}
},
/** Code block (dim background by default terminal), 4-space indent. */
code(snippet: string): void {
for (const line of snippet.split("\n")) {
OUT.write(`${INDENT} ${cyan(line)}\n`);
}
},
/** Clickable link in modern terminals (OSC 8). Falls back to plain URL. */
link(url: string): void {
OUT.write(`${INDENT}${clay(url)}\n`);
},
/** Hint line — dim + leading arrow. Used after a failure for remediation. */
hint(msg: string): void {
OUT.write(`${INDENT}${dim(icons.arrow + " " + msg)}\n`);
},
};
/** Return JSON suitable for `--json` flags. Zero styling, schema-versioned. */
export function jsonOut<T>(payload: T, schemaVersion = "1.0"): void {
OUT.write(JSON.stringify({ schema_version: schemaVersion, ...payload }, null, 2));
OUT.write("\n");
}

114
apps/cli/src/ui/screen.ts Normal file
View File

@@ -0,0 +1,114 @@
import { VERSION } from "~/constants/urls.js";
import { HIDE_CURSOR, SHOW_CURSOR, CLEAR_SCREEN, CLEAR_LINE, moveTo, boldOrange, dim, visibleLength } from "./styles.js";
export function termSize(): { cols: number; rows: number } {
return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
}
export function center(s: string): string {
const pad = Math.max(0, Math.floor((termSize().cols - visibleLength(s)) / 2));
return " ".repeat(pad) + s;
}
export function writeCentered(row: number, s: string): void {
process.stdout.write(moveTo(row, 1) + center(s) + CLEAR_LINE);
}
export function drawTopBar(extra?: string): void {
const { cols } = termSize();
const bg = "\x1b[48;5;208m\x1b[30m"; const reset = "\x1b[0m";
const left = " claudemesh v" + VERSION; const right = "claudemesh.com ";
const mid = extra ? " " + extra : "";
const gap = Math.max(1, cols - left.length - right.length - mid.length);
process.stdout.write(moveTo(1, 1) + bg + left + mid + " ".repeat(gap) + right + reset);
}
export function drawBottomBar(left: string, right?: string): void {
const { cols, rows } = termSize();
const bg = "\x1b[48;5;208m\x1b[30m"; const reset = "\x1b[0m";
const l = " " + left; const r = right ? right + " " : "";
const gap = Math.max(1, cols - l.length - r.length);
process.stdout.write(moveTo(rows, 1) + bg + l + " ".repeat(gap) + r + reset);
}
export function enterFullScreen(): void { process.stdout.write(HIDE_CURSOR + CLEAR_SCREEN); drawTopBar(); }
export function exitFullScreen(): void { process.stdout.write(SHOW_CURSOR + CLEAR_SCREEN); }
export function drawRule(row: number): void { const { cols } = termSize(); writeCentered(row, dim("\u2500".repeat(Math.min(60, cols - 4)))); }
import { createInterface } from "node:readline";
import { bold, green } from "./styles.js";
export async function menuSelect(
itemsOrOpts: string[] | { title?: string; items: string[]; row?: number },
prompt = "Choice",
): Promise<number> {
const items = Array.isArray(itemsOrOpts) ? itemsOrOpts : itemsOrOpts.items;
const title = !Array.isArray(itemsOrOpts) ? itemsOrOpts.title : undefined;
if (title) console.log(`\n ${title}`);
items.forEach((item, i) => console.log(` ${bold(String(i + 1) + ")")} ${item}`));
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(` ${prompt} [1]: `, (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
resolve(idx >= 0 && idx < items.length ? idx : 0);
});
});
}
export async function textInput(
promptOrOpts: string | { label: string; row?: number; placeholder?: string },
defaultVal = "",
): Promise<string> {
const label = typeof promptOrOpts === "string" ? promptOrOpts : promptOrOpts.label;
const placeholder = typeof promptOrOpts === "object" ? promptOrOpts.placeholder : undefined;
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
const hint = placeholder ? ` (${placeholder})` : defaultVal ? ` [${defaultVal}]` : "";
rl.question(` ${label}${hint}: `, (answer) => {
rl.close();
resolve(answer.trim() || defaultVal);
});
});
}
export async function confirmPrompt(
promptOrOpts: string | { message: string; row?: number; defaultYes?: boolean },
defaultYes = true,
): Promise<boolean> {
const message = typeof promptOrOpts === "string" ? promptOrOpts : promptOrOpts.message;
const defYes = typeof promptOrOpts === "object" && promptOrOpts.defaultYes !== undefined ? promptOrOpts.defaultYes : defaultYes;
const rl = createInterface({ input: process.stdin, output: process.stdout });
const hint = defYes ? "[Y/n]" : "[y/N]";
return new Promise((resolve) => {
rl.question(` ${message} ${hint}: `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
if (!a) resolve(defYes);
else resolve(a === "y" || a === "yes");
});
});
}
export function splashScreen(opts?: { subtitle?: string; status?: string }) {
const { createSpinner, FRAME_HEIGHT } = require("./spinner") as typeof import("./spinner.js");
enterFullScreen();
const { rows } = termSize();
const logoTop = Math.floor((rows - FRAME_HEIGHT - 6) / 2);
const brandRow = logoTop + FRAME_HEIGHT + 1;
const subtitleRow = brandRow + 1;
const statusRow = subtitleRow + 2;
writeCentered(brandRow, boldOrange("claudemesh"));
if (opts?.subtitle) writeCentered(subtitleRow, dim(opts.subtitle));
if (opts?.status) writeCentered(statusRow, opts.status);
const spinner = createSpinner({
render(lines) { for (let i = 0; i < lines.length; i++) writeCentered(logoTop + i, lines[i]!); },
interval: 70,
});
spinner.start();
return {
setStatus(text: string) { writeCentered(statusRow, text + CLEAR_LINE); },
setSubtitle(text: string) { writeCentered(subtitleRow, dim(text) + CLEAR_LINE); },
stop() { spinner.stop(); },
exit() { spinner.stop(); exitFullScreen(); },
};
}

View File

@@ -0,0 +1,87 @@
import { boldOrange, clay, dim, visibleLength } from "./styles.js";
const W = 7, H = 5, CX = 3, CY = 2, RX = 3, RY = 2, TOTAL = 12;
function edgeChar(dx: number, dy: number): string {
const a = Math.abs(dx), b = Math.abs(dy);
if (b < a * 0.4) return "-";
if (a < b * 0.4) return "|";
return (dx > 0) === (dy > 0) ? "\\" : "/";
}
function drawLine(grid: string[][], x0: number, y0: number, x1: number, y1: number) {
const dx = x1 - x0, dy = y1 - y0;
const steps = Math.max(Math.abs(dx), Math.abs(dy));
for (let s = 1; s < steps; s++) {
const x = Math.round(x0 + dx * s / steps);
const y = Math.round(y0 + dy * s / steps);
if (y >= 0 && y < H && x >= 0 && x < W) {
const c = grid[y]![x]!;
if (c === " ") grid[y]![x] = edgeChar(dx, dy);
else if (c !== edgeChar(dx, dy) && c !== "\u25CF") grid[y]![x] = "+";
}
}
}
function buildFrame(i: number): string[] {
const grid = Array.from({ length: H }, () => Array(W).fill(" ") as string[]);
const base = (i / TOTAL) * Math.PI * 2;
const nodes = [0, 1, 2, 3].map(n => {
const a = base + n * Math.PI / 2 - Math.PI / 2;
return { x: Math.round(CX + RX * Math.cos(a)), y: Math.round(CY + RY * Math.sin(a)) };
});
for (let a = 0; a < 4; a++)
for (let b = a + 1; b < 4; b++)
drawLine(grid, nodes[a]!.x, nodes[a]!.y, nodes[b]!.x, nodes[b]!.y);
for (const { x, y } of nodes)
if (y >= 0 && y < H && x >= 0 && x < W) grid[y]![x] = "\u25CF";
return grid.map(r => r.join(""));
}
const seen = new Set<string>();
const RAW_FRAMES: string[][] = [];
for (let i = 0; i < TOTAL; i++) {
const f = buildFrame(i);
const key = f.join("\n");
if (!seen.has(key)) { seen.add(key); RAW_FRAMES.push(f); }
}
function colorize(line: string): string {
return line
.replace(/\u25CF/g, boldOrange("\u25CF"))
.replace(/[-|/\\+]/g, c => dim(clay(c)));
}
export const FRAME_COUNT = RAW_FRAMES.length;
export const FRAME_HEIGHT = H;
export const FRAME_WIDTH = W;
export function getFrame(index: number): string[] {
return RAW_FRAMES[index % RAW_FRAMES.length]!.map(colorize);
}
export function getRawFrame(index: number): string[] {
return RAW_FRAMES[index % RAW_FRAMES.length]!;
}
export function createSpinner(opts: {
render: (lines: string[]) => void;
interval?: number;
}) {
let frame = 0;
let timer: ReturnType<typeof setInterval> | null = null;
return {
start() {
if (timer) return;
timer = setInterval(() => {
opts.render(getFrame(frame++));
}, opts.interval ?? 70);
opts.render(getFrame(frame++));
},
stop() {
if (timer) { clearInterval(timer); timer = null; }
},
get isRunning() { return timer !== null; },
};
}

44
apps/cli/src/ui/styles.ts Normal file
View File

@@ -0,0 +1,44 @@
const isTTY =
process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
const esc = (code: string) => (s: string) =>
isTTY ? `${code}${s}\x1b[0m` : s;
export const orange = esc("\x1b[38;5;208m");
export const clay = esc("\x1b[38;5;173m");
export const amber = esc("\x1b[38;5;214m");
export const bold = esc("\x1b[1m");
export const dim = esc("\x1b[2m");
export const green = esc("\x1b[32m");
export const yellow = esc("\x1b[33m");
export const red = esc("\x1b[31m");
export const cyan = esc("\x1b[36m");
export const boldOrange = esc("\x1b[1m\x1b[38;5;208m");
export const HIDE_CURSOR = isTTY ? "\x1b[?25l" : "";
export const SHOW_CURSOR = isTTY ? "\x1b[?25h" : "";
export const CLEAR_SCREEN = isTTY ? "\x1b[2J\x1b[H" : "";
export const CLEAR_LINE = isTTY ? "\x1b[K" : "";
export function moveTo(row: number, col: number): string {
return isTTY ? `\x1b[${row};${col}H` : "";
}
export function moveUp(n: number): string {
return isTTY ? `\x1b[${n}A` : "";
}
export function visibleLength(s: string): number {
return s.replace(/\x1b\[[^m]*m/g, "").length;
}
export const icons = {
check: "✔",
cross: "✘",
warn: "⚠",
arrow: "→",
bullet: "●",
dash: "—",
ellipsis: "…",
} as const;

View File

@@ -0,0 +1,17 @@
import { bold, dim, green, icons } from "../styles.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export function renderTelegramMeshPicker(meshes: JoinedMesh[]): void {
console.log("\n Connect Telegram to a mesh\n");
meshes.forEach((m, i) => console.log(" " + bold((i + 1) + ")") + " " + m.slug));
console.log("");
}
export function renderTelegramLink(deepLink: string): void {
console.log(" Scan or tap: " + deepLink);
console.log("");
}
export function renderTelegramSuccess(username: string): void {
console.log(" " + green(icons.check) + " Connected as @" + username);
}

View File

@@ -0,0 +1,3 @@
export function renderLoginStep(): void {
console.log(" Opening browser for sign-in...");
}

View File

@@ -0,0 +1,10 @@
import type { JoinedMesh } from "~/services/config/facade.js";
import { bold, dim } from "../styles.js";
export function renderMeshPicker(meshes: JoinedMesh[]): void {
console.log("\n Select a mesh:\n");
meshes.forEach((m, i) => {
console.log(" " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.name + ")"));
});
console.log("");
}

View File

@@ -0,0 +1,3 @@
export function renderRegisterStep(): void {
console.log(" Opening browser for account registration...");
}

View File

@@ -0,0 +1,15 @@
import { bold, green, dim, orange, icons } from "../styles.js";
export function renderWelcome(): void {
console.log("");
console.log(" " + orange("Welcome to claudemesh"));
console.log(" " + dim("Peer mesh for Claude Code sessions"));
console.log("");
console.log(" What would you like to do?");
console.log("");
console.log(" " + bold("1)") + " " + green("Register") + " a new account");
console.log(" " + bold("2)") + " " + green("Login") + " to an existing account");
console.log(" " + bold("3)") + " " + green("Join") + " a mesh from an invite link");
console.log(" " + bold("4)") + " Exit");
console.log("");
}

View File

@@ -0,0 +1,4 @@
export { renderWelcome } from "./WelcomeScreen.js";
export { renderRegisterStep } from "./RegisterStep.js";
export { renderLoginStep } from "./LoginStep.js";
export { renderMeshPicker } from "./MeshPickerStep.js";