fix(web): correct LinkedIn URL on about page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
90
apps/cli/src/auth/callback-listener.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Localhost HTTP callback listener for CLI-to-browser sync flow.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /ping → reachability check (web page preflight)
|
||||
* GET /callback → receives sync token via ?token= query param
|
||||
* OPTIONS * → CORS preflight for claudemesh.com
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
export interface CallbackListener {
|
||||
/** Port the server is listening on. */
|
||||
port: number;
|
||||
/** Resolves when the /callback endpoint receives a token. */
|
||||
token: Promise<string>;
|
||||
/** Shut down the server. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a localhost HTTP server on a random OS-assigned port.
|
||||
* Returns the port and a promise that resolves with the sync token.
|
||||
*/
|
||||
export function startCallbackListener(): Promise<CallbackListener> {
|
||||
return new Promise((resolveStart) => {
|
||||
let resolveToken: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((r) => {
|
||||
resolveToken = r;
|
||||
});
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reachability check — web page calls this before redirecting
|
||||
if (url.pathname === "/ping") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync token callback
|
||||
if (url.pathname === "/callback") {
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end(
|
||||
"<html><body><h2>Done! You can close this tab.</h2><p>Launching claudemesh...</p></body></html>",
|
||||
);
|
||||
resolveToken(token);
|
||||
// Close server after a short delay to ensure response is sent
|
||||
setTimeout(() => server.close(), 500);
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Missing token");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolveStart({
|
||||
port: addr.port,
|
||||
token: tokenPromise,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
4
apps/cli/src/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { startCallbackListener, type CallbackListener } from "./callback-listener";
|
||||
export { openBrowser } from "./open-browser";
|
||||
export { generatePairingCode } from "./pairing-code";
|
||||
export { syncWithBroker, type SyncResult } from "./sync-with-broker";
|
||||
33
apps/cli/src/auth/open-browser.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-platform browser opener.
|
||||
* Respects BROWSER env var. Falls back to platform-specific launcher.
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser.
|
||||
* Returns true if the command succeeded, false otherwise.
|
||||
* Non-fatal — callers should show the URL as fallback.
|
||||
*/
|
||||
export function openBrowser(url: string): Promise<boolean> {
|
||||
// Validate URL
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const quoted = JSON.stringify(url);
|
||||
const browserCmd = process.env.BROWSER;
|
||||
|
||||
const cmd = browserCmd
|
||||
? `${browserCmd} ${quoted}`
|
||||
: process.platform === "darwin"
|
||||
? `open ${quoted}`
|
||||
: process.platform === "win32"
|
||||
? `rundll32 url.dll,FileProtocolHandler ${quoted}`
|
||||
: `xdg-open ${quoted}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
17
apps/cli/src/auth/pairing-code.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generate a short pairing code for CLI-to-browser visual confirmation.
|
||||
* Excludes ambiguous characters (0/O, 1/l/I) for readability.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
/**
|
||||
* Generate a 4-character alphanumeric pairing code.
|
||||
* Example output: "A3Kx", "Hn7v", "pQ4m"
|
||||
*/
|
||||
export function generatePairingCode(): string {
|
||||
const bytes = randomBytes(4);
|
||||
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
|
||||
}
|
||||
83
apps/cli/src/auth/sync-with-broker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Call the broker's POST /cli-sync endpoint to sync dashboard meshes.
|
||||
*
|
||||
* Takes a sync JWT (from the browser callback) and a freshly generated
|
||||
* ed25519 keypair. The broker creates member rows and returns mesh details.
|
||||
*/
|
||||
|
||||
export interface SyncResult {
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync meshes from dashboard via broker.
|
||||
*
|
||||
* @param syncToken - JWT from the browser sync flow
|
||||
* @param peerPubkey - ed25519 public key hex (64 chars)
|
||||
* @param displayName - display name for the new member
|
||||
* @param brokerBaseUrl - HTTPS base URL of the broker (derived from WSS URL)
|
||||
*/
|
||||
export async function syncWithBroker(
|
||||
syncToken: string,
|
||||
peerPubkey: string,
|
||||
displayName: string,
|
||||
brokerBaseUrl?: string,
|
||||
): Promise<SyncResult> {
|
||||
// Default broker URL — derive HTTPS from WSS
|
||||
const base = brokerBaseUrl ?? deriveHttpUrl(
|
||||
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
);
|
||||
|
||||
const res = await fetch(`${base}/cli-sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sync_token: syncToken,
|
||||
peer_pubkey: peerPubkey,
|
||||
display_name: displayName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
let msg: string;
|
||||
try {
|
||||
msg = JSON.parse(body).error ?? body;
|
||||
} catch {
|
||||
msg = body;
|
||||
}
|
||||
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
|
||||
|
||||
if (!body.ok) {
|
||||
throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
return {
|
||||
account_id: body.account_id!,
|
||||
meshes: body.meshes!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WSS broker URL to an HTTPS base URL.
|
||||
* wss://ic.claudemesh.com/ws → https://ic.claudemesh.com
|
||||
* ws://localhost:3001/ws → http://localhost:3001
|
||||
*/
|
||||
function deriveHttpUrl(wssUrl: string): string {
|
||||
const url = new URL(wssUrl);
|
||||
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
||||
// Remove /ws path suffix
|
||||
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
|
||||
// Remove trailing slash
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
@@ -216,10 +217,85 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = loadConfig();
|
||||
let justSynced = false;
|
||||
|
||||
if (config.meshes.length === 0 && !args.joinLink) {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
|
||||
console.log(` Opening browser to sign in...\n`);
|
||||
|
||||
const opened = await openBrowser(url);
|
||||
if (!opened) {
|
||||
console.log(` Couldn't open browser automatically.`);
|
||||
}
|
||||
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("\n Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const { generateKeypair } = await import("../crypto/keypair");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { saveConfig } = await import("../state/config");
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
justSynced = true;
|
||||
|
||||
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(
|
||||
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
|
||||
);
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -248,7 +324,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
if (!args.quiet) {
|
||||
if (!args.quiet && !justSynced) {
|
||||
if (role === null) {
|
||||
const answer = await askLine(" Role (optional): ");
|
||||
if (answer) role = answer;
|
||||
|
||||
114
apps/cli/src/commands/profile.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `claudemesh profile` — view or edit your member profile.
|
||||
*
|
||||
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
export interface ProfileFlags {
|
||||
mesh?: string;
|
||||
"role-tag"?: string;
|
||||
groups?: string;
|
||||
"message-mode"?: string;
|
||||
name?: string;
|
||||
member?: string; // admin only: edit another member
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runProfile(flags: ProfileFlags): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick mesh
|
||||
const mesh = flags.mesh
|
||||
? config.meshes.find(m => m.slug === flags.mesh)
|
||||
: config.meshes[0]!;
|
||||
|
||||
if (!mesh) {
|
||||
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive broker HTTP URL from WSS URL
|
||||
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
|
||||
|
||||
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
|
||||
|
||||
if (hasEdits) {
|
||||
// PATCH member profile
|
||||
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.name !== undefined) body.displayName = flags.name;
|
||||
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
|
||||
if (flags.groups !== undefined) {
|
||||
body.groups = flags.groups.split(",").map(s => {
|
||||
const [name, role] = s.trim().split(":");
|
||||
return role ? { name: name!, role } : { name: name! };
|
||||
});
|
||||
}
|
||||
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
|
||||
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Member-Id": mesh.memberId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await res.json() as Record<string, unknown>;
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.ok) {
|
||||
console.log(green("✓ Profile updated"));
|
||||
const member = result.member as Record<string, unknown>;
|
||||
printProfile(member, dim);
|
||||
} else {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// GET members list, show current user's profile
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
|
||||
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = result.members?.find(m => m.id === mesh.memberId);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(me ?? {}, null, 2));
|
||||
} else if (me) {
|
||||
printProfile(me, dim);
|
||||
} else {
|
||||
console.log("Member not found in mesh.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
|
||||
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
|
||||
const groupStr = groups?.length
|
||||
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
|
||||
: dim("(none)");
|
||||
|
||||
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
|
||||
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
|
||||
console.log(` Groups: ${groupStr}`);
|
||||
console.log(` Messages: ${member.messageMode ?? "push"}`);
|
||||
console.log(` Access: ${member.permission ?? "member"}`);
|
||||
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
|
||||
}
|
||||
88
apps/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `claudemesh sync` — re-sync meshes from dashboard account.
|
||||
*
|
||||
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
|
||||
* merges new meshes into local config.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hostname } from "node:os";
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`Opening browser to sync meshes...`);
|
||||
console.log(dim(`Visit: ${url}`));
|
||||
await openBrowser(url);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question("Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use existing keypair from first mesh, or generate new
|
||||
const keypair = config.meshes.length > 0
|
||||
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
|
||||
: await generateKeypair();
|
||||
|
||||
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
|
||||
|
||||
// Merge: add new meshes, skip duplicates
|
||||
let added = 0;
|
||||
for (const m of result.meshes) {
|
||||
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export interface Config {
|
||||
role?: string; // per-session role tag (display + hello)
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
||||
}
|
||||
|
||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
@@ -55,7 +56,7 @@ export function loadConfig(): Config {
|
||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode };
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
||||
@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
|
||||
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||
|
||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
|
||||
|
||||
##############################
|
||||
### CLI Sync config ###
|
||||
##############################
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
BIN
apps/web/public/logo-wordmark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -153,7 +153,7 @@ export default function AboutPage() {
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
|
||||
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
|
||||
876
apps/web/src/app/[locale]/cli-auth/cli-auth-flow.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Mesh {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
myRole: "admin" | "member";
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string | null;
|
||||
port: string | null;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
const ease = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated mesh node background
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MeshBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Floating mesh nodes */}
|
||||
{[
|
||||
{ x: "12%", y: "18%", delay: 0, size: 3 },
|
||||
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
|
||||
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
|
||||
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
|
||||
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
|
||||
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
|
||||
].map((node, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[var(--cm-clay)]"
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: node.size,
|
||||
height: node.size,
|
||||
}}
|
||||
animate={{
|
||||
opacity: [0.15, 0.4, 0.15],
|
||||
scale: [1, 1.5, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
delay: node.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Connecting lines (SVG) */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
|
||||
<line
|
||||
x1="12%"
|
||||
y1="18%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="85%"
|
||||
y1="14%"
|
||||
x2="72%"
|
||||
y2="55%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="72%"
|
||||
y1="55%"
|
||||
x2="92%"
|
||||
y2="78%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="8%"
|
||||
y1="65%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal-style status indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
|
||||
const colors = {
|
||||
waiting: "bg-[var(--cm-clay)]",
|
||||
syncing: "bg-amber-400",
|
||||
done: "bg-emerald-400",
|
||||
error: "bg-red-400",
|
||||
};
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
|
||||
const [meshes, setMeshes] = useState<Mesh[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Create-mesh form state
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [slugDirty, setSlugDirty] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-slug from name
|
||||
useEffect(() => {
|
||||
if (!slugDirty && newName) {
|
||||
setNewSlug(slugify(newName));
|
||||
}
|
||||
}, [newName, slugDirty]);
|
||||
|
||||
// Fetch user meshes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await handle(api.my.meshes.$get, {
|
||||
schema: getMyMeshesResponseSchema,
|
||||
})({
|
||||
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
|
||||
});
|
||||
setMeshes(data);
|
||||
setSelected(new Set(data.map((m) => m.id)));
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to load your meshes.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Auto-focus name input when no meshes
|
||||
useEffect(() => {
|
||||
if (!loading && meshes.length === 0 && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [loading, meshes.length]);
|
||||
|
||||
const toggleMesh = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const status = token
|
||||
? redirected
|
||||
? "done"
|
||||
: "done"
|
||||
: syncing || creating
|
||||
? "syncing"
|
||||
: error
|
||||
? "error"
|
||||
: "waiting";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create mesh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const createRes = await fetch("/api/my/meshes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
slug: newSlug.trim(),
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
}),
|
||||
});
|
||||
const res = (await createRes.json()) as
|
||||
| { id: string; slug: string }
|
||||
| { error: string };
|
||||
if (!createRes.ok || "error" in res) {
|
||||
setCreateError("error" in res ? res.error : "Failed to create mesh.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
await doSync(
|
||||
[{ id: res.id, slug: res.slug, role: "admin" as const }],
|
||||
"create",
|
||||
{ name: newName.trim(), slug: newSlug.trim() },
|
||||
);
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const doSync = async (
|
||||
meshList: Array<{ id: string; slug: string; role: string }>,
|
||||
action: "sync" | "create" = "sync",
|
||||
newMesh?: { name: string; slug: string },
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-sync-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ meshes: meshList, action, newMesh }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to generate token.");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
const jwt = data.token as string;
|
||||
setToken(jwt);
|
||||
if (port) {
|
||||
setRedirected(true);
|
||||
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
const selectedMeshes = meshes
|
||||
.filter((m) => selected.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.isOwner ? "admin" : m.myRole,
|
||||
}));
|
||||
if (selectedMeshes.length === 0) {
|
||||
setError("Select at least one mesh to sync.");
|
||||
return;
|
||||
}
|
||||
doSync(selectedMeshes, "sync");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="claudemesh home"
|
||||
className="group flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[17px] font-medium tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<StatusPulse status={status} />
|
||||
<span>
|
||||
{status === "waiting" && "awaiting sync"}
|
||||
{status === "syncing" && "generating token..."}
|
||||
{status === "done" && "synced"}
|
||||
{status === "error" && "error"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
||||
<MeshBackdrop />
|
||||
|
||||
{/* Section tag */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— cli sync
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.08 }}
|
||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Sync with{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.16 }}
|
||||
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Link your terminal session to your account and choose which meshes to
|
||||
sync.
|
||||
</motion.p>
|
||||
|
||||
{/* Pairing code */}
|
||||
<AnimatePresence>
|
||||
{code && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.5, ease, delay: 0.24 }}
|
||||
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
|
||||
>
|
||||
{/* Terminal-style header bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
</div>
|
||||
<span
|
||||
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
pairing verification
|
||||
</span>
|
||||
</div>
|
||||
{/* Code display */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
code:
|
||||
</span>
|
||||
<motion.span
|
||||
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
{code.split("").map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Confirm this matches the code shown in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-10 space-y-3"
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
|
||||
>
|
||||
<span className="mt-0.5 text-red-400">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token result */}
|
||||
<AnimatePresence>
|
||||
{token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
|
||||
{/* Success header */}
|
||||
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-emerald-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-sm font-medium text-emerald-400"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Token body */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-5">
|
||||
<p
|
||||
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected
|
||||
? "If your terminal didn\u2019t pick up the token, copy it manually:"
|
||||
: "Paste this token in your terminal when prompted:"}
|
||||
</p>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
onClick={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</div>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
) : (
|
||||
"Copy"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mesh list */}
|
||||
{!loading && !token && meshes.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<h2
|
||||
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your meshes
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{meshes.map((m, i) => (
|
||||
<motion.label
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
|
||||
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
|
||||
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{selected.has(m.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(m.id)}
|
||||
onChange={() => toggleMesh(m.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-[var(--cm-fg)]">
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{m.memberCount}{" "}
|
||||
{m.memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.isOwner ? "owner" : m.myRole}
|
||||
</span>
|
||||
</motion.label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSync}
|
||||
disabled={syncing || selected.size === 0}
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{selected.size} of {meshes.length} selected
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No meshes — create form */}
|
||||
{!loading && !token && meshes.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
|
||||
<h2
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Create your first mesh
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh is the space where your Claude Code sessions talk to each
|
||||
other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-name"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
id="mesh-name"
|
||||
type="text"
|
||||
placeholder="Platform team"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-slug"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="mesh-slug"
|
||||
type="text"
|
||||
placeholder="platform-team"
|
||||
value={newSlug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true);
|
||||
setNewSlug(e.target.value);
|
||||
}}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
lowercase · digits · hyphens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{createError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{createError}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create & sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer security note */}
|
||||
<AnimatePresence>
|
||||
{!token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
The sync token is valid for 15 minutes and can only be used once.
|
||||
Your ed25519 keys stay on your machine — the broker only sees
|
||||
ciphertext.
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/app/[locale]/cli-auth/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
import { CliAuthFlow } from "./cli-auth-flow";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Sync with CLI",
|
||||
description: "Link your claudemesh CLI to your account.",
|
||||
});
|
||||
|
||||
export default async function CliAuthPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ code?: string; port?: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
|
||||
if (!user) {
|
||||
const sp = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
if (sp.code) qs.set("code", sp.code);
|
||||
if (sp.port) qs.set("port", sp.port);
|
||||
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
|
||||
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
|
||||
const { code, port } = await searchParams;
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<CliAuthFlow
|
||||
code={code ?? null}
|
||||
port={port ?? null}
|
||||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/app/api/cli-sync-token/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT signing (HS256 via Web Crypto — no external deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64UrlEncode(input: string | ArrayBuffer): string {
|
||||
const str =
|
||||
typeof input === "string"
|
||||
? btoa(input)
|
||||
: btoa(String.fromCharCode(...new Uint8Array(input)));
|
||||
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function signJwt(
|
||||
payload: Record<string, unknown>,
|
||||
secret: string,
|
||||
): Promise<string> {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
encoder.encode(`${headerB64}.${payloadB64}`),
|
||||
);
|
||||
|
||||
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler — POST /api/cli-sync-token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SyncTokenBody {
|
||||
meshes: Array<{ id: string; slug: string; role: string }>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1. Check auth
|
||||
const reqHeaders = new Headers(await headers());
|
||||
reqHeaders.set("x-client-platform", "web-server");
|
||||
|
||||
const session = await auth.api.getSession({ headers: reqHeaders });
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parse body
|
||||
let body: SyncTokenBody;
|
||||
try {
|
||||
body = (await request.json()) as SyncTokenBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { meshes, action, newMesh } = body;
|
||||
|
||||
if (!Array.isArray(meshes)) {
|
||||
return NextResponse.json(
|
||||
{ error: "meshes must be an array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "sync" && action !== "create") {
|
||||
return NextResponse.json(
|
||||
{ error: 'action must be "sync" or "create"' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: "newMesh.name and newMesh.slug are required for create action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validate meshes belong to user — fetch user's meshes via internal API
|
||||
// For now we trust the dashboard-authenticated user's selection since
|
||||
// the broker will independently verify membership when the CLI connects.
|
||||
// A full server-side ownership check can be added later.
|
||||
|
||||
// 4. Get secret
|
||||
const secret = process.env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "CLI_SYNC_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Build and sign JWT
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
meshes: meshes.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.role,
|
||||
})),
|
||||
action,
|
||||
...(action === "create" && newMesh ? { newMesh } : {}),
|
||||
jti: crypto.randomUUID(),
|
||||
iat: now,
|
||||
exp: now + 15 * 60, // 15 minutes
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret);
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 211 B |
7
apps/web/src/app/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="4" r="2" fill="#d97757"/>
|
||||
<circle cx="4" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="20" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="12" cy="20" r="2" fill="#d97757"/>
|
||||
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -86,6 +86,7 @@ const pathsConfig = {
|
||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||
error: `${AUTH_PREFIX}/error`,
|
||||
},
|
||||
cliAuth: "/cli-auth",
|
||||
dashboard: {
|
||||
user: {
|
||||
index: DASHBOARD_PREFIX,
|
||||
|
||||