refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- 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:
2
apps/cli/src/ui/index.ts
Normal file
2
apps/cli/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./styles.js";
|
||||
export { createSpinner, getFrame, FRAME_COUNT, FRAME_HEIGHT, FRAME_WIDTH } from "./spinner.js";
|
||||
12
apps/cli/src/ui/launch/LaunchFlow.ts
Normal file
12
apps/cli/src/ui/launch/LaunchFlow.ts
Normal 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.");
|
||||
}
|
||||
1
apps/cli/src/ui/launch/index.ts
Normal file
1
apps/cli/src/ui/launch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { renderLaunchStart, renderLaunchComplete } from "./LaunchFlow.js";
|
||||
17
apps/cli/src/ui/logo-spinner.ts
Normal file
17
apps/cli/src/ui/logo-spinner.ts
Normal 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
63
apps/cli/src/ui/qr.ts
Normal 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
83
apps/cli/src/ui/render.ts
Normal 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
114
apps/cli/src/ui/screen.ts
Normal 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(); },
|
||||
};
|
||||
}
|
||||
87
apps/cli/src/ui/spinner.ts
Normal file
87
apps/cli/src/ui/spinner.ts
Normal 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
44
apps/cli/src/ui/styles.ts
Normal 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;
|
||||
17
apps/cli/src/ui/telegram/ConnectWizard.ts
Normal file
17
apps/cli/src/ui/telegram/ConnectWizard.ts
Normal 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);
|
||||
}
|
||||
3
apps/cli/src/ui/welcome/LoginStep.ts
Normal file
3
apps/cli/src/ui/welcome/LoginStep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function renderLoginStep(): void {
|
||||
console.log(" Opening browser for sign-in...");
|
||||
}
|
||||
10
apps/cli/src/ui/welcome/MeshPickerStep.ts
Normal file
10
apps/cli/src/ui/welcome/MeshPickerStep.ts
Normal 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("");
|
||||
}
|
||||
3
apps/cli/src/ui/welcome/RegisterStep.ts
Normal file
3
apps/cli/src/ui/welcome/RegisterStep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function renderRegisterStep(): void {
|
||||
console.log(" Opening browser for account registration...");
|
||||
}
|
||||
15
apps/cli/src/ui/welcome/WelcomeScreen.ts
Normal file
15
apps/cli/src/ui/welcome/WelcomeScreen.ts
Normal 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("");
|
||||
}
|
||||
4
apps/cli/src/ui/welcome/index.ts
Normal file
4
apps/cli/src/ui/welcome/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user