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

View File

@@ -0,0 +1,70 @@
import { URLS } from "~/constants/urls.js";
import { TIMINGS } from "~/constants/timings.js";
import { debug } from "~/services/logger/facade.js";
import { ApiError, NetworkError } from "./errors.js";
export interface RequestOpts {
path: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
token?: string;
baseUrl?: string;
timeoutMs?: number;
}
export async function request<T = unknown>(opts: RequestOpts): Promise<T> {
const base = opts.baseUrl ?? URLS.API_BASE;
const url = `${base}${opts.path}`;
const method = opts.method ?? "GET";
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
opts.timeoutMs ?? TIMINGS.API_TIMEOUT_MS,
);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "claudemesh-cli/1.0",
};
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
debug(`${method} ${url}`);
try {
const res = await fetch(url, {
method,
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
});
if (!res.ok) {
let body: unknown;
try { body = await res.json(); } catch { body = await res.text(); }
throw new ApiError(res.status, res.statusText, body);
}
const text = await res.text();
if (!text) return undefined as unknown as T;
return JSON.parse(text) as T;
} catch (err) {
if (err instanceof ApiError) throw err;
throw new NetworkError(url, err);
} finally {
clearTimeout(timeout);
}
}
export async function get<T = unknown>(path: string, token?: string): Promise<T> {
return request<T>({ path, token });
}
export async function post<T = unknown>(path: string, body?: unknown, token?: string): Promise<T> {
return request<T>({ path, method: "POST", body, token });
}
export async function del<T = unknown>(path: string, token?: string): Promise<T> {
return request<T>({ path, method: "DELETE", token });
}

View File

@@ -0,0 +1,37 @@
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body?: unknown,
) {
super(`API error ${status}: ${statusText}`);
this.name = "ApiError";
}
get isUnauthorized(): boolean {
return this.status === 401;
}
get isNotFound(): boolean {
return this.status === 404;
}
get isConflict(): boolean {
return this.status === 409;
}
get isRateLimited(): boolean {
return this.status === 429;
}
}
export class NetworkError extends Error {
constructor(
public readonly url: string,
cause?: unknown,
) {
super(`Network error reaching ${url}`);
this.name = "NetworkError";
this.cause = cause;
}
}

View File

@@ -0,0 +1,5 @@
export { get, post, del, request } from "./client.js";
export type { RequestOpts } from "./client.js";
export { ApiError, NetworkError } from "./errors.js";
export * as my from "./my.js";
export * as pub from "./public.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,60 @@
import { get, post, del, request } from "./client.js";
import type { RequestOpts } from "./client.js";
export async function getProfile(token: string) {
return get<{ id: string; display_name: string; email: string }>("/api/my/profile", token);
}
export async function getMeshes(token: string) {
return get<Array<{ id: string; slug: string; name: string; role: string; member_count: number }>>(
"/api/my/meshes",
token,
);
}
export async function createMesh(
token: string,
body: { name: string; slug?: string; template?: string; description?: string },
) {
return post<{ id: string; slug: string; name: string }>("/api/my/meshes", body, token);
}
export async function renameMesh(token: string, slug: string, newName: string) {
return request<{ slug: string; name: string }>({
path: `/api/my/meshes/${slug}`,
method: "PATCH",
body: { name: newName },
token,
});
}
export async function createInvite(
token: string,
meshSlug: string,
body: { email?: string; expires_in?: string; max_uses?: number; role?: string },
) {
return post<{ url: string; code: string; expires_at: string }>(
`/api/my/meshes/${meshSlug}/invites`,
body,
token,
);
}
export async function revokeSession(token: string) {
const BROKER_HTTP = (await import("~/constants/urls.js")).URLS.BROKER
.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
return request<{ ok: boolean }>({
path: "/cli/session/revoke",
method: "POST",
body: { token },
baseUrl: BROKER_HTTP,
});
}
export async function cliSync(token: string) {
return post<{ meshes: Array<{ meshId: string; slug: string; name: string; brokerUrl: string }> }>(
"/cli-sync",
undefined,
token,
);
}

View File

@@ -0,0 +1,46 @@
import { post, get, request } from "./client.js";
import { URLS } from "~/constants/urls.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
export async function claimInvite(code: string, body: { pubkey: string; display_name: string }) {
return post<{
meshId: string;
memberId: string;
slug: string;
name: string;
brokerUrl: string;
rootKey?: string;
}>(`/api/public/invites/${code}/claim`, body);
}
export async function requestDeviceCode(deviceInfo: {
hostname: string;
platform: string;
arch: string;
}) {
return request<{
device_code: string;
user_code: string;
session_id: string;
expires_at: string;
verification_url: string;
token_url: string;
}>({
path: "/cli/device-code",
method: "POST",
body: deviceInfo,
baseUrl: BROKER_HTTP,
});
}
export async function pollDeviceCode(deviceCode: string) {
return request<{
status: "pending" | "approved" | "expired";
session_token?: string;
user?: { id: string; display_name: string; email: string };
}>({
path: `/cli/device-code/${deviceCode}`,
baseUrl: BROKER_HTTP,
});
}

View File

@@ -0,0 +1,70 @@
import { createServer, type Server } from "node:http";
export interface CallbackListener {
port: number;
token: Promise<string>;
close: () => void;
}
export function startCallbackListener(): Promise<CallbackListener> {
return new Promise((resolveStart) => {
let resolveToken: (token: string) => void;
let resolved = false;
const tokenPromise = new Promise<string>((r) => {
resolveToken = r;
});
const server: Server = createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
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;
}
if (url.pathname === "/ping") {
res.writeHead(200, {
"Content-Type": "text/plain",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end("ok");
return;
}
if (url.pathname === "/callback") {
const token = url.searchParams.get("token");
if (token && !resolved) {
resolved = true;
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></body></html>");
resolveToken(token);
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(),
});
});
});
}

View File

@@ -0,0 +1,51 @@
import { my } from "~/services/api/facade.js";
import { ApiError } from "~/services/api/facade.js";
import { getStoredToken, clearToken } from "./token-store.js";
import { NotSignedIn } from "./errors.js";
import type { WhoAmIResult } from "./schemas.js";
function requireToken(): string {
const auth = getStoredToken();
if (!auth) throw new NotSignedIn();
return auth.session_token;
}
export async function whoAmI(): Promise<WhoAmIResult> {
const auth = getStoredToken();
if (!auth) return { signed_in: false };
try {
const profile = await my.getProfile(auth.session_token);
const meshes = await my.getMeshes(auth.session_token);
const owned = meshes.filter((m) => m.role === "owner").length;
return {
signed_in: true,
user: profile,
token_source: auth.token_source,
meshes: { owned, guest: meshes.length - owned },
};
} catch (err) {
if (err instanceof ApiError && err.isUnauthorized) {
clearToken();
return { signed_in: false };
}
throw err;
}
}
export async function logout(): Promise<{ revoked: boolean }> {
const token = requireToken();
let revoked = false;
try {
await my.revokeSession(token);
revoked = true;
} catch {}
clearToken();
return { revoked };
}
export async function register(callbackPort: number): Promise<void> {
const { openBrowser } = await import("~/services/spawn/facade.js");
const url = `https://claudemesh.com/register?source=cli&callback=http://localhost:${callbackPort}`;
await openBrowser(url);
}

View File

@@ -0,0 +1,50 @@
import { URLS } from "~/constants/urls.js";
export interface SyncResult {
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
export async function syncWithBroker(
syncToken: string,
peerPubkey: string,
displayName: string,
brokerBaseUrl?: string,
): Promise<SyncResult> {
const base = brokerBaseUrl ?? deriveHttpUrl(URLS.BROKER);
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) as { error?: string }).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! };
}
function deriveHttpUrl(wssUrl: string): string {
const url = new URL(wssUrl);
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
return url.toString().replace(/\/$/, "");
}

View File

@@ -0,0 +1,132 @@
import { createInterface } from "node:readline";
import { TIMINGS } from "~/constants/timings.js";
import { pub } from "~/services/api/facade.js";
import { getDeviceInfo } from "~/services/device/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { log, warn } from "~/services/logger/facade.js";
import { storeToken } from "./token-store.js";
import { DeviceCodeExpired } from "./errors.js";
export interface DeviceCodeResult {
user: { id: string; display_name: string; email: string };
session_token: string;
}
function parseJwtUser(token: string): { id: string; display_name: string; email: string } {
try {
const parts = token.split(".");
if (parts[1]) {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
sub?: string; email?: string; name?: string; exp?: number;
};
if (payload.exp && payload.exp < Date.now() / 1000) throw new Error("expired");
return {
id: payload.sub ?? "",
display_name: payload.name ?? payload.email ?? "",
email: payload.email ?? "",
};
}
} catch {}
throw new Error("Invalid token");
}
export async function loginWithDeviceCode(): Promise<DeviceCodeResult> {
const device = getDeviceInfo();
const { device_code, user_code, session_id, verification_url, token_url } = await pub.requestDeviceCode({
hostname: device.hostname,
platform: device.platform,
arch: device.arch,
});
const browserUrl = `${verification_url}?session=${session_id}`;
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
const orange = (s: string) => isTTY ? `\x1b[38;5;208m${s}\x1b[0m` : s;
const bold = (s: string) => isTTY ? `\x1b[1m${s}\x1b[0m` : s;
const dim = (s: string) => isTTY ? `\x1b[2m${s}\x1b[0m` : s;
log("");
log(" " + orange("claudemesh") + " — sign in to connect your terminal");
log("");
log(" ┌──────────────────────────────────┐");
log(" │ │");
log(" │ Your code: " + bold(user_code) + " │");
log(" │ │");
log(" └──────────────────────────────────┘");
log("");
log(" " + dim("Confirm this code matches your browser."));
log("");
log(" " + dim("If the browser didn't open, visit:"));
log(" " + browserUrl);
log("");
log(" " + dim("Can't use a browser? Generate a token at:"));
log(" " + (token_url || verification_url.replace("/cli-auth", "/token")));
log(" " + dim("Then paste it below."));
log("");
log(" Waiting… " + dim("(paste token or Ctrl-C to cancel)"));
try {
await openBrowser(browserUrl);
} catch {
warn(" Could not open browser automatically.");
}
// Race: device-code polling vs stdin token paste
return new Promise<DeviceCodeResult>((resolve, reject) => {
let done = false;
// Stdin paste listener
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.on("line", (line) => {
if (done) return;
const trimmed = line.trim();
// JWT format: xxx.yyy.zzz
if (trimmed.split(".").length === 3 && trimmed.length > 50) {
done = true;
rl.close();
try {
const user = parseJwtUser(trimmed);
storeToken({ session_token: trimmed, user, token_source: "manual" });
resolve({ user, session_token: trimmed });
} catch (e) {
reject(new Error("Invalid or expired token. Generate a new one."));
}
}
});
// Device-code polling
const startTime = Date.now();
const poll = async () => {
while (!done && Date.now() - startTime < TIMINGS.DEVICE_CODE_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, TIMINGS.DEVICE_CODE_POLL_MS));
if (done) return;
try {
const result = await pub.pollDeviceCode(device_code);
if (result.status === "approved" && result.session_token && result.user) {
if (done) return;
done = true;
rl.close();
storeToken({ session_token: result.session_token, user: result.user, token_source: "device-code" });
resolve({ user: result.user, session_token: result.session_token });
return;
}
if (result.status === "expired") {
if (done) return;
done = true;
rl.close();
reject(new DeviceCodeExpired());
return;
}
} catch { /* network error, retry */ }
}
if (!done) {
done = true;
rl.close();
reject(new DeviceCodeExpired());
}
};
poll();
});
}

View File

@@ -0,0 +1,18 @@
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthError";
}
}
export class DeviceCodeExpired extends AuthError {
constructor() {
super("Device code expired. Run `claudemesh login` again.");
}
}
export class NotSignedIn extends AuthError {
constructor() {
super("Not signed in. Run `claudemesh login` first.");
}
}

View File

@@ -0,0 +1,16 @@
export { loginWithDeviceCode } from "./device-code.js";
export type { DeviceCodeResult } from "./device-code.js";
export { whoAmI, logout, register } from "./client.js";
export { syncWithBroker } from "./dashboard-sync.js";
export type { SyncResult } from "./dashboard-sync.js";
export { getStoredToken, storeToken, clearToken } from "./token-store.js";
export { startCallbackListener } from "./callback-listener.js";
export type { CallbackListener } from "./callback-listener.js";
export { AuthError, DeviceCodeExpired, NotSignedIn } from "./errors.js";
export type { StoredAuth, WhoAmIResult } from "./schemas.js";
import { randomBytes } from "node:crypto";
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
export function generatePairingCode(): string {
const bytes = randomBytes(4);
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
}

View File

@@ -0,0 +1,5 @@
export { loginWithDeviceCode } from "./device-code.js";
export { whoAmI, logout, register } from "./client.js";
export { syncWithBroker } from "./dashboard-sync.js";
export { getStoredToken, storeToken, clearToken } from "./token-store.js";
export { startCallbackListener } from "./callback-listener.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,21 @@
export interface StoredAuth {
session_token: string;
user: {
id: string;
display_name: string;
email: string;
};
token_source: "device-code" | "callback" | "manual";
stored_at: string;
}
export interface WhoAmIResult {
signed_in: boolean;
user?: {
id: string;
display_name: string;
email: string;
};
token_source?: string;
meshes?: { owned: number; guest: number };
}

View File

@@ -0,0 +1,30 @@
import { readFileSync, writeFileSync, unlinkSync, existsSync, chmodSync, openSync, closeSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { ensureConfigDir } from "~/services/config/facade.js";
import type { StoredAuth } from "./schemas.js";
export function getStoredToken(): StoredAuth | null {
if (!existsSync(PATHS.AUTH_FILE)) return null;
try {
const raw = readFileSync(PATHS.AUTH_FILE, "utf-8");
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
export function storeToken(auth: Omit<StoredAuth, "stored_at">): void {
ensureConfigDir();
const data: StoredAuth = { ...auth, stored_at: new Date().toISOString() };
const content = JSON.stringify(data, null, 2) + "\n";
const fd = openSync(PATHS.AUTH_FILE, "w", 0o600);
try {
writeFileSync(fd, content, "utf-8");
} finally {
closeSync(fd);
}
}
export function clearToken(): void {
try { unlinkSync(PATHS.AUTH_FILE); } catch {}
}

View File

@@ -0,0 +1,6 @@
export {
encryptDirect,
decryptDirect,
isDirectTarget,
} from "~/services/crypto/facade.js";
export type { Envelope } from "~/services/crypto/facade.js";

View File

@@ -0,0 +1,13 @@
export class BrokerConnectionError extends Error {
constructor(message: string, public readonly url: string) {
super(message);
this.name = "BrokerConnectionError";
}
}
export class HelloAckTimeout extends Error {
constructor() {
super("hello_ack timeout — broker did not respond");
this.name = "HelloAckTimeout";
}
}

View File

@@ -0,0 +1,8 @@
export { BrokerClient } from "./ws-client.js";
export type { Priority, ConnStatus, PeerInfo, InboundPush } from "./ws-client.js";
export { ensureClient, startClients, findClient, allClients, stopAll } from "./manager.js";
export { signHello } from "./hello-sig.js";
export { encryptDirect, decryptDirect, isDirectTarget } from "./envelope.js";
export type { Envelope } from "./envelope.js";
export { BrokerConnectionError, HelloAckTimeout } from "./errors.js";
export type { WsMessageType } from "./schemas.js";

View File

@@ -0,0 +1,17 @@
import { ensureSodium } from "~/services/crypto/facade.js";
export async function signHello(
meshId: string,
memberId: string,
pubkey: string,
secretKeyHex: string,
): Promise<{ timestamp: number; signature: string }> {
const s = await ensureSodium();
const timestamp = Date.now();
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
const sig = s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(secretKeyHex),
);
return { timestamp, signature: s.to_hex(sig) };
}

View File

@@ -0,0 +1,2 @@
export { BrokerClient } from "./ws-client.js";
export type { Priority, ConnStatus, PeerInfo, InboundPush } from "./ws-client.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,47 @@
import { BrokerClient } from "./ws-client.js";
import type { Config, JoinedMesh } from "~/services/config/facade.js";
const clients = new Map<string, BrokerClient>();
let configDisplayName: string | undefined;
let configGroups: Config["groups"] = [];
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
const existing = clients.get(mesh.meshId);
if (existing) return existing;
const isDebug = process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true";
const client = new BrokerClient(mesh, { debug: isDebug, displayName: configDisplayName });
clients.set(mesh.meshId, client);
try {
await client.connect();
for (const g of configGroups ?? []) {
try { await client.joinGroup(g.name, g.role); } catch {}
}
} catch (err) {
process.stderr.write(`[claudemesh] broker connect failed for ${mesh.slug}: ${err instanceof Error ? err.message : err} (will retry)\n`);
}
return client;
}
export async function startClients(config: Config): Promise<void> {
configDisplayName = config.displayName;
configGroups = config.groups ?? [];
await Promise.allSettled(config.meshes.map(ensureClient));
}
export function findClient(needle: string): BrokerClient | null {
const byId = clients.get(needle);
if (byId) return byId;
for (const c of clients.values()) {
if (c.meshSlug === needle) return c;
}
return null;
}
export function allClients(): BrokerClient[] {
return [...clients.values()];
}
export function stopAll(): void {
for (const c of clients.values()) c.close();
clients.clear();
}

View File

@@ -0,0 +1,28 @@
export type WsMessageType =
| "hello" | "hello_ack"
| "send" | "ack" | "push"
| "list_peers" | "peers_list"
| "set_status" | "set_summary" | "set_visible" | "set_profile" | "set_stats"
| "join_group" | "leave_group"
| "set_state" | "get_state" | "list_state" | "state_result" | "state_list_result" | "state_changed"
| "remember" | "memory_stored" | "recall" | "recall_result" | "forget"
| "schedule" | "schedule_ack" | "list_scheduled" | "scheduled_list" | "cancel_scheduled" | "cancel_scheduled_ack"
| "message_status" | "message_status_result"
| "upload_file" | "file_url" | "list_files" | "files_list" | "file_status" | "file_status_result" | "delete_file" | "grant_file_access"
| "vector_store" | "vector_stored" | "vector_search" | "vector_results" | "vector_delete" | "list_collections" | "collections_list"
| "graph_query" | "graph_result" | "graph_execute"
| "share_context" | "list_contexts" | "contexts_list" | "get_context" | "context_results"
| "create_task" | "task_created" | "list_tasks" | "tasks_list" | "claim_task" | "complete_task"
| "mesh_query" | "mesh_query_result" | "mesh_execute" | "mesh_schema" | "mesh_schema_result"
| "create_stream" | "stream_created" | "publish" | "subscribe" | "stream_data" | "list_streams" | "streams_list"
| "mcp_register" | "mcp_registered" | "mcp_list" | "mcp_list_result" | "mcp_call" | "mcp_call_result" | "mcp_call_forward" | "mcp_call_response" | "mcp_remove"
| "mcp_deploy" | "mcp_deploy_result" | "mcp_undeploy" | "mcp_update" | "mcp_logs" | "mcp_logs_result" | "mcp_schema" | "mcp_schema_result" | "mcp_catalog" | "mcp_catalog_result" | "mcp_scope" | "mcp_scope_result"
| "vault_set" | "vault_ack" | "vault_list" | "vault_list_result" | "vault_delete"
| "mesh_watch" | "watch_ack" | "mesh_unwatch" | "mesh_watches" | "watches_list" | "watch_triggered"
| "create_webhook" | "webhook_created" | "list_webhooks" | "webhooks_list" | "delete_webhook"
| "share_skill" | "skill_shared" | "get_skill" | "skill_result" | "list_skills" | "skills_list" | "remove_skill" | "skill_deploy" | "skill_deploy_result"
| "mesh_info" | "mesh_info_result" | "mesh_stats" | "mesh_stats_result" | "mesh_clock" | "mesh_clock_result"
| "mesh_set_clock" | "mesh_pause_clock" | "mesh_resume_clock"
| "ping" | "pong"
| "peer_file_request" | "peer_file_response" | "peer_dir_request" | "peer_dir_response"
| "error";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { readClipboard, writeClipboard } from "./read.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,45 @@
import { execSync } from "node:child_process";
import { platform } from "node:os";
export function readClipboard(): string | null {
try {
const os = platform();
if (os === "darwin") return execSync("pbpaste", { encoding: "utf-8" }).trim();
if (os === "linux") {
try {
return execSync("xclip -selection clipboard -o", { encoding: "utf-8" }).trim();
} catch {
return execSync("wl-paste --no-newline", { encoding: "utf-8" }).trim();
}
}
if (os === "win32") return execSync("powershell -command Get-Clipboard", { encoding: "utf-8" }).trim();
return null;
} catch {
return null;
}
}
export function writeClipboard(text: string): boolean {
try {
const os = platform();
if (os === "darwin") {
execSync("pbcopy", { input: text });
return true;
}
if (os === "linux") {
try {
execSync("xclip -selection clipboard", { input: text });
} catch {
execSync("wl-copy", { input: text });
}
return true;
}
if (os === "win32") {
execSync("clip", { input: text });
return true;
}
return false;
} catch {
return false;
}
}

View File

@@ -0,0 +1,9 @@
export { readConfig, getMeshConfig } from "./read.js";
export { writeConfig, ensureConfigDir, setMeshConfig, removeMeshConfig } from "./write.js";
export { emptyConfig } from "./schemas.js";
export type { Config, JoinedMesh, GroupEntry } from "./schemas.js";
import { PATHS } from "~/constants/paths.js";
export function getConfigPath(): string {
return PATHS.CONFIG_FILE;
}

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,31 @@
import { readFileSync, existsSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { emptyConfig } from "./schemas.js";
import type { Config, JoinedMesh } from "./schemas.js";
export function readConfig(): Config {
if (!existsSync(PATHS.CONFIG_FILE)) return emptyConfig();
try {
const raw = readFileSync(PATHS.CONFIG_FILE, "utf-8");
const parsed = JSON.parse(raw) as Partial<Config>;
if (!parsed || !Array.isArray(parsed.meshes)) return emptyConfig();
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 ${PATHS.CONFIG_FILE}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
export function getMeshConfig(slug: string): JoinedMesh | undefined {
const config = readConfig();
return config.meshes.find((m) => m.slug === slug);
}

View File

@@ -0,0 +1,31 @@
export interface JoinedMesh {
meshId: string;
memberId: string;
slug: string;
name: string;
pubkey: string;
secretKey: string;
brokerUrl: string;
joinedAt: string;
rootKey?: string;
inviteVersion?: 1 | 2;
}
export interface GroupEntry {
name: string;
role?: string;
}
export interface Config {
version: 1;
meshes: JoinedMesh[];
displayName?: string;
role?: string;
groups?: GroupEntry[];
messageMode?: "push" | "inbox" | "off";
accountId?: string;
}
export function emptyConfig(): Config {
return { version: 1, meshes: [] };
}

View File

@@ -0,0 +1,51 @@
import { writeFileSync, mkdirSync, chmodSync, openSync, closeSync, renameSync } from "node:fs";
import { platform } from "node:os";
import { PATHS } from "~/constants/paths.js";
import type { Config, JoinedMesh } from "./schemas.js";
import { readConfig } from "./read.js";
const isWindows = platform() === "win32";
export function ensureConfigDir(): void {
mkdirSync(PATHS.CONFIG_DIR, { recursive: true });
if (!isWindows) {
try { chmodSync(PATHS.CONFIG_DIR, 0o700); } catch (e) {
process.stderr.write(`warning: could not set permissions on ${PATHS.CONFIG_DIR}: ${e}\n`);
}
}
}
export function writeConfig(config: Config): void {
ensureConfigDir();
const content = JSON.stringify(config, null, 2) + "\n";
const tmpPath = PATHS.CONFIG_FILE + ".tmp";
if (isWindows) {
writeFileSync(tmpPath, content, "utf-8");
} else {
const fd = openSync(tmpPath, "w", 0o600);
try { writeFileSync(fd, content, "utf-8"); } finally { closeSync(fd); }
}
renameSync(tmpPath, PATHS.CONFIG_FILE);
}
export function setMeshConfig(slug: string, mesh: JoinedMesh): void {
const config = readConfig();
const idx = config.meshes.findIndex((m) => m.slug === slug);
if (idx >= 0) {
config.meshes[idx] = mesh;
} else {
config.meshes.push(mesh);
}
writeConfig(config);
}
export function removeMeshConfig(slug: string): boolean {
const config = readConfig();
const before = config.meshes.length;
config.meshes = config.meshes.filter((m) => m.slug !== slug);
if (config.meshes.length < before) {
writeConfig(config);
return true;
}
return false;
}

View File

@@ -0,0 +1,59 @@
import { ensureSodium } from "./keypair.js";
export interface Envelope {
nonce: string;
ciphertext: string;
}
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
export function isDirectTarget(targetSpec: string): boolean {
return HEX_PUBKEY.test(targetSpec);
}
export async function encryptDirect(
message: string,
recipientPubkeyHex: string,
senderSecretKeyHex: string,
): Promise<Envelope> {
const s = await ensureSodium();
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(
s.from_hex(recipientPubkeyHex),
);
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(
s.from_hex(senderSecretKeyHex),
);
const nonce = s.randombytes_buf(s.crypto_box_NONCEBYTES);
const ct = s.crypto_box_easy(
s.from_string(message),
nonce,
recipientPub,
senderSec,
);
return {
nonce: s.to_base64(nonce, s.base64_variants.ORIGINAL),
ciphertext: s.to_base64(ct, s.base64_variants.ORIGINAL),
};
}
export async function decryptDirect(
envelope: Envelope,
senderPubkeyHex: string,
recipientSecretKeyHex: string,
): Promise<string | null> {
const s = await ensureSodium();
try {
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(
s.from_hex(senderPubkeyHex),
);
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(
s.from_hex(recipientSecretKeyHex),
);
const nonce = s.from_base64(envelope.nonce, s.base64_variants.ORIGINAL);
const ct = s.from_base64(envelope.ciphertext, s.base64_variants.ORIGINAL);
const plain = s.crypto_box_open_easy(ct, nonce, senderPub, recipientSec);
return s.to_string(plain);
} catch {
return null;
}
}

View File

@@ -0,0 +1,55 @@
import { generateKeypair as _generateKeypair, ensureSodium } from "./keypair.js";
import type { Ed25519Keypair } from "./keypair.js";
import { encryptFile, decryptFile, sealKeyForPeer, openSealedKey } from "./file-crypto.js";
import type { EncryptedFile } from "./file-crypto.js";
import { encryptDirect, decryptDirect, isDirectTarget } from "./box.js";
import type { Envelope } from "./box.js";
import { randomBytes, randomHex } from "./random.js";
export type { Ed25519Keypair, EncryptedFile, Envelope };
export async function generateKeypair(): Promise<Ed25519Keypair> {
return _generateKeypair();
}
export async function sign(
message: string,
secretKeyHex: string,
): Promise<string> {
const s = await ensureSodium();
const sig = s.crypto_sign_detached(
s.from_string(message),
s.from_hex(secretKeyHex),
);
return s.to_hex(sig);
}
export async function verify(
message: string,
signatureHex: string,
publicKeyHex: string,
): Promise<boolean> {
const s = await ensureSodium();
try {
return s.crypto_sign_verify_detached(
s.from_hex(signatureHex),
s.from_string(message),
s.from_hex(publicKeyHex),
);
} catch {
return false;
}
}
export {
encryptFile as encrypt,
decryptFile as decrypt,
sealKeyForPeer as boxSeal,
openSealedKey as boxOpen,
encryptDirect,
decryptDirect,
isDirectTarget,
randomBytes,
randomHex,
ensureSodium,
};

View File

@@ -0,0 +1,65 @@
import { ensureSodium } from "./keypair.js";
export interface EncryptedFile {
ciphertext: Uint8Array;
nonce: string;
key: Uint8Array;
}
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
const s = await ensureSodium();
const key = s.randombytes_buf(s.crypto_secretbox_KEYBYTES);
const nonce = s.randombytes_buf(s.crypto_secretbox_NONCEBYTES);
const ciphertext = s.crypto_secretbox_easy(plaintext, nonce, key);
return {
ciphertext,
nonce: s.to_base64(nonce, s.base64_variants.ORIGINAL),
key,
};
}
export async function decryptFile(
ciphertext: Uint8Array,
nonceB64: string,
key: Uint8Array,
): Promise<Uint8Array | null> {
const s = await ensureSodium();
try {
const nonce = s.from_base64(nonceB64, s.base64_variants.ORIGINAL);
return s.crypto_secretbox_open_easy(ciphertext, nonce, key);
} catch {
return null;
}
}
export async function sealKeyForPeer(
kf: Uint8Array,
recipientPubkeyHex: string,
): Promise<string> {
const s = await ensureSodium();
const recipientCurve = s.crypto_sign_ed25519_pk_to_curve25519(
s.from_hex(recipientPubkeyHex),
);
const sealed = s.crypto_box_seal(kf, recipientCurve);
return s.to_base64(sealed, s.base64_variants.ORIGINAL);
}
export async function openSealedKey(
sealedB64: string,
myPubkeyHex: string,
mySecretKeyHex: string,
): Promise<Uint8Array | null> {
const s = await ensureSodium();
try {
const myCurvePub = s.crypto_sign_ed25519_pk_to_curve25519(
s.from_hex(myPubkeyHex),
);
const myCurveSec = s.crypto_sign_ed25519_sk_to_curve25519(
s.from_hex(mySecretKeyHex),
);
const sealed = s.from_base64(sealedB64, s.base64_variants.ORIGINAL);
return s.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
} catch {
return null;
}
}

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,25 @@
import sodium from "libsodium-wrappers";
let ready = false;
export async function ensureSodium(): Promise<typeof sodium> {
if (!ready) {
await sodium.ready;
ready = true;
}
return sodium;
}
export interface Ed25519Keypair {
publicKey: string;
secretKey: string;
}
export async function generateKeypair(): Promise<Ed25519Keypair> {
const s = await ensureSodium();
const kp = s.crypto_sign_keypair();
return {
publicKey: s.to_hex(kp.publicKey),
secretKey: s.to_hex(kp.privateKey),
};
}

View File

@@ -0,0 +1,11 @@
import { ensureSodium } from "./keypair.js";
export async function randomBytes(n: number): Promise<Uint8Array> {
const s = await ensureSodium();
return s.randombytes_buf(n);
}
export async function randomHex(n: number): Promise<string> {
const s = await ensureSodium();
return s.to_hex(s.randombytes_buf(n));
}

View File

@@ -0,0 +1,2 @@
export { getDeviceInfo } from "./info.js";
export type { DeviceInfo } from "./info.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,19 @@
import { hostname, platform, arch, release } from "node:os";
export interface DeviceInfo {
hostname: string;
platform: string;
arch: string;
osRelease: string;
nodeVersion: string;
}
export function getDeviceInfo(): DeviceInfo {
return {
hostname: hostname(),
platform: platform(),
arch: arch(),
osRelease: release(),
nodeVersion: process.version,
};
}

View File

@@ -0,0 +1,8 @@
import { findClaudeBinary } from "~/services/spawn/facade.js";
import type { CheckResult } from "./types.js";
export function checkClaudeBinary(): CheckResult {
const bin = findClaudeBinary();
if (bin) return { name: "claude-binary", ok: true, message: `Found at ${bin}` };
return { name: "claude-binary", ok: false, message: "Claude binary not found" };
}

View File

@@ -0,0 +1,19 @@
import { existsSync, statSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import type { CheckResult } from "./types.js";
export function checkConfigPerms(): CheckResult {
const configFile = PATHS.CONFIG_FILE;
if (!existsSync(configFile)) {
return { name: "config-perms", ok: true, message: "No config file yet (first run)" };
}
try {
const mode = statSync(configFile).mode & 0o777;
if (mode <= 0o600) {
return { name: "config-perms", ok: true, message: `config.json mode ${mode.toString(8)}` };
}
return { name: "config-perms", ok: false, message: `config.json mode ${mode.toString(8)} — should be 600` };
} catch {
return { name: "config-perms", ok: false, message: "Could not stat config.json" };
}
}

View File

@@ -0,0 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import type { CheckResult } from "./types.js";
export function checkHooksRegistered(): CheckResult {
try {
if (!existsSync(PATHS.CLAUDE_SETTINGS)) {
return { name: "hooks-registered", ok: false, message: "~/.claude/settings.json not found" };
}
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
const config = JSON.parse(raw) as { hooks?: Record<string, unknown> };
if (config.hooks) {
return { name: "hooks-registered", ok: true, message: "Hooks configured" };
}
return { name: "hooks-registered", ok: false, message: "No hooks in settings.json" };
} catch {
return { name: "hooks-registered", ok: false, message: "Could not read settings.json" };
}
}

View File

@@ -0,0 +1,31 @@
import { existsSync, readFileSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import type { CheckResult } from "./types.js";
const HEX_64 = /^[0-9a-f]{64}$/;
const HEX_128 = /^[0-9a-f]{128}$/;
export function checkKeypairsValid(): CheckResult {
if (!existsSync(PATHS.CONFIG_FILE)) {
return { name: "keypairs-valid", ok: true, message: "No config (first run)" };
}
try {
const raw = readFileSync(PATHS.CONFIG_FILE, "utf-8");
const config = JSON.parse(raw) as { meshes?: Array<{ pubkey?: string; secretKey?: string; slug?: string }> };
const meshes = config.meshes ?? [];
if (meshes.length === 0) {
return { name: "keypairs-valid", ok: true, message: "No joined meshes" };
}
for (const m of meshes) {
if (!m.pubkey || !HEX_64.test(m.pubkey)) {
return { name: "keypairs-valid", ok: false, message: `Invalid pubkey for mesh ${m.slug ?? "unknown"}` };
}
if (!m.secretKey || !HEX_128.test(m.secretKey)) {
return { name: "keypairs-valid", ok: false, message: `Invalid secretKey for mesh ${m.slug ?? "unknown"}` };
}
}
return { name: "keypairs-valid", ok: true, message: `${meshes.length} keypair(s) valid` };
} catch {
return { name: "keypairs-valid", ok: false, message: "Could not parse config.json" };
}
}

View File

@@ -0,0 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import type { CheckResult } from "./types.js";
export function checkMcpRegistered(): CheckResult {
try {
if (!existsSync(PATHS.CLAUDE_JSON)) {
return { name: "mcp-registered", ok: false, message: "~/.claude.json not found" };
}
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
const config = JSON.parse(raw) as { mcpServers?: Record<string, unknown> };
if (config.mcpServers && "claudemesh" in config.mcpServers) {
return { name: "mcp-registered", ok: true, message: "MCP server registered" };
}
return { name: "mcp-registered", ok: false, message: "claudemesh not in mcpServers" };
} catch {
return { name: "mcp-registered", ok: false, message: "Could not read ~/.claude.json" };
}
}

View File

@@ -0,0 +1,7 @@
import type { CheckResult } from "./types.js";
export function checkNodeVersion(): CheckResult {
const major = parseInt(process.version.slice(1), 10);
if (major >= 20) return { name: "node-version", ok: true, message: `Node ${process.version}` };
return { name: "node-version", ok: false, message: `Node ${process.version} — requires >= 20` };
}

View File

@@ -0,0 +1,27 @@
import { checkNodeVersion } from "./check-node-version.js";
import { checkClaudeBinary } from "./check-claude-binary.js";
import { checkMcpRegistered } from "./check-mcp-registered.js";
import { checkHooksRegistered } from "./check-hooks-registered.js";
import { checkConfigPerms } from "./check-config-perms.js";
import { checkKeypairsValid } from "./check-keypairs-valid.js";
import type { CheckResult } from "./types.js";
export type { CheckResult };
const CHECKS: Record<string, () => CheckResult> = {
"node-version": checkNodeVersion,
"claude-binary": checkClaudeBinary,
"mcp-registered": checkMcpRegistered,
"hooks-registered": checkHooksRegistered,
"config-perms": checkConfigPerms,
"keypairs-valid": checkKeypairsValid,
};
export function runAllChecks(): CheckResult[] {
return Object.values(CHECKS).map((fn) => fn());
}
export function runCheck(name: string): CheckResult | null {
const fn = CHECKS[name];
return fn ? fn() : null;
}

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,5 @@
export interface CheckResult {
name: string;
ok: boolean;
message: string;
}

View File

@@ -0,0 +1,3 @@
export { t } from "./format.js";
export { detectLocale } from "./resolve.js";
export type { Locale } from "./resolve.js";

View File

@@ -0,0 +1,13 @@
import { en } from "~/locales/en.js";
type StringKey = keyof typeof en;
export function t(key: StringKey, vars?: Record<string, string>): string {
let str: string = en[key];
if (vars) {
for (const [k, v] of Object.entries(vars)) {
str = str.replace(new RegExp(`\\{${k}\\}`, "g"), v);
}
}
return str;
}

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,5 @@
export type Locale = "en";
export function detectLocale(): Locale {
return "en";
}

View File

@@ -0,0 +1 @@
export { joinMesh as claimInvite } from "~/services/mesh/facade.js";

View File

@@ -0,0 +1,58 @@
/**
* Broker /join HTTP enrollment.
*
* Takes a parsed invite + freshly generated keypair, POSTs to the
* broker, returns the member_id. Converts the broker's WSS URL to
* HTTPS for the /join call (same host, different protocol).
*/
export interface EnrollResult {
memberId: string;
alreadyMember: boolean;
}
function wsToHttp(wsUrl: string): string {
// wss://host/ws → https://host
// ws://host:port/ws → http://host:port
const u = new URL(wsUrl);
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
return `${httpScheme}//${u.host}`;
}
import type { InvitePayload } from "./parse-v1.js";
export async function enrollWithBroker(args: {
brokerWsUrl: string;
inviteToken: string;
invitePayload: InvitePayload;
peerPubkey: string;
displayName: string;
}): Promise<EnrollResult> {
const base = wsToHttp(args.brokerWsUrl);
const res = await fetch(`${base}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
invite_token: args.inviteToken,
invite_payload: args.invitePayload,
peer_pubkey: args.peerPubkey,
display_name: args.displayName,
}),
signal: AbortSignal.timeout(10_000),
});
const body = (await res.json()) as {
ok?: boolean;
memberId?: string;
error?: string;
alreadyMember?: boolean;
};
if (!res.ok || !body.ok || !body.memberId) {
throw new Error(
`broker /join failed (${res.status}): ${body.error ?? "unknown"}`,
);
}
return {
memberId: body.memberId,
alreadyMember: body.alreadyMember ?? false,
};
}

View File

@@ -0,0 +1,13 @@
export class InviteExpiredError extends Error {
constructor(code: string) {
super(`Invite "${code}" has expired`);
this.name = "InviteExpiredError";
}
}
export class InviteNotFoundError extends Error {
constructor(code: string) {
super(`Invite "${code}" not found`);
this.name = "InviteNotFoundError";
}
}

View File

@@ -0,0 +1,10 @@
export { generateInvite as generate } from "./generate.js";
export { isInviteUrl, extractInviteCode as parseUrl } from "./parse-url.js";
export { sendInviteEmail as sendEmail } from "./send-email.js";
export { InviteExpiredError, InviteNotFoundError } from "./errors.js";
export { parseInviteLink } from "./parse-v1.js";
export type { InvitePayload, ParsedInvite } from "./parse-v1.js";
export { enrollWithBroker } from "./enroll.js";
export type { EnrollResult } from "./enroll.js";
export { claimInviteV2, parseV2InviteInput } from "./v2.js";

View File

@@ -0,0 +1,27 @@
import { request } from "~/services/api/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { URLS } from "~/constants/urls.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
export async function generateInvite(
meshSlug: string,
opts?: { email?: string; expires_in?: string; max_uses?: number; role?: string },
): Promise<{ url: string; code: string; expires_at: string; emailed?: boolean }> {
const auth = getStoredToken();
if (!auth) throw new Error("Not signed in");
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
if (!userId) throw new Error("Invalid token");
return request<{ url: string; code: string; expires_at: string; emailed?: boolean }>({
path: `/cli/mesh/${meshSlug}/invite`,
method: "POST",
body: { user_id: userId, email: opts?.email, role: opts?.role },
baseUrl: BROKER_HTTP,
});
}

View File

@@ -0,0 +1,4 @@
export { generateInvite } from "./generate.js";
export { isInviteUrl, extractInviteCode } from "./parse-url.js";
export { claimInvite } from "./claim.js";
export { sendInviteEmail } from "./send-email.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,8 @@
export function isInviteUrl(input: string): boolean {
return /^https?:\/\/claudemesh\.com\/i\//.test(input) || /^ic:\/\//.test(input);
}
export function extractInviteCode(url: string): string | null {
const m = url.match(/\/i\/([A-Za-z0-9]+)/) || url.match(/^ic:\/\/([A-Za-z0-9]+)/);
return m?.[1] ?? null;
}

View File

@@ -0,0 +1,227 @@
/**
* Invite-link parser for claudemesh `ic://join/<base64url(JSON)>` links.
*
* v0.1.0: parses + shape-validates + checks expiry. Signature
* verification and one-time-use invite-token tracking land in Step 18.
*/
import { ensureSodium } from "~/services/crypto/facade.js";
export interface InvitePayload {
v: 1;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
signature: string;
}
export interface ParsedInvite {
payload: InvitePayload;
raw: string; // the original ic://join/... string
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
}
function validatePayload(obj: unknown): InvitePayload {
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
const o = obj as Record<string, unknown>;
if (o.v !== 1) throw new Error("invite payload: v must be 1");
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
return o as unknown as InvitePayload;
}
/** Canonical invite bytes — must match broker's canonicalInvite(). */
export function canonicalInvite(p: {
v: number;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
}): string {
return `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`;
}
/**
* Extract the raw base64url token from any accepted invite input.
*
* Accepts:
* - `https://claudemesh.com/i/<code>` (short URL — resolves via API)
* - `https://claudemesh.com/join/<token>` (long clickable link)
* - `https://claudemesh.com/<locale>/join/<token>` (i18n prefix)
* - `ic://join/<token>` (dev-era scheme)
* - `<token>` (raw base64url, last resort)
*/
export async function extractInviteToken(input: string): Promise<string> {
const trimmed = input.trim();
if (trimmed.startsWith("ic://join/")) {
const token = trimmed.slice("ic://join/".length).replace(/\/$/, "");
if (!token) throw new Error("invite link has no payload");
return token;
}
// Short URL — needs a network hop to resolve code → token.
const shortMatch = trimmed.match(
/^https?:\/\/([^/]+)(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
);
if (shortMatch) {
const host = shortMatch[1]!;
const code = shortMatch[2]!;
const res = await fetch(`https://${host}/api/public/invite-code/${code}`, {
headers: { accept: "application/json" },
});
if (!res.ok) {
throw new Error(`short invite code not found (status ${res.status})`);
}
const body = (await res.json()) as
| { found: true; token: string }
| { found: false };
if (!body.found) throw new Error("short invite code not found");
return body.token;
}
const httpsMatch = trimmed.match(
/^https?:\/\/[^/]+(?:\/[a-z]{2})?\/join\/([A-Za-z0-9_-]+)\/?$/,
);
if (httpsMatch) return httpsMatch[1]!;
// Last resort: treat as raw base64url token.
if (/^[A-Za-z0-9_-]+$/.test(trimmed) && trimmed.length > 20) {
return trimmed;
}
throw new Error(
`invalid invite format. Expected one of:\n` +
` https://claudemesh.com/i/<code>\n` +
` https://claudemesh.com/join/<token>\n` +
` ic://join/<token>\n` +
` <raw-token>\n` +
`Got: "${input.slice(0, 40)}${input.length > 40 ? "…" : ""}"`,
);
}
export async function parseInviteLink(link: string): Promise<ParsedInvite> {
const encoded = await extractInviteToken(link);
let json: string;
try {
json = Buffer.from(encoded, "base64url").toString("utf-8");
} catch (e) {
throw new Error(
`invite link base64 decode failed: ${e instanceof Error ? e.message : e}`,
);
}
let obj: unknown;
try {
obj = JSON.parse(json);
} catch (e) {
throw new Error(
`invite link JSON parse failed: ${e instanceof Error ? e.message : e}`,
);
}
const payload = validatePayload(obj);
// Expiry check (unix seconds).
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.expires_at < nowSeconds) {
throw new Error(
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
);
}
// Verify the ed25519 signature against the embedded owner_pubkey.
const s = await ensureSodium();
const canonical = canonicalInvite({
v: payload.v,
mesh_id: payload.mesh_id,
mesh_slug: payload.mesh_slug,
broker_url: payload.broker_url,
expires_at: payload.expires_at,
mesh_root_key: payload.mesh_root_key,
role: payload.role,
owner_pubkey: payload.owner_pubkey,
});
const sigOk = (() => {
try {
return s.crypto_sign_verify_detached(
s.from_hex(payload.signature),
s.from_string(canonical),
s.from_hex(payload.owner_pubkey),
);
} catch {
return false;
}
})();
if (!sigOk) {
throw new Error("invite signature invalid (link tampered?)");
}
return { payload, raw: link, token: encoded };
}
/**
* Encode a payload back to an `ic://join/...` link. Used for testing
* + for building links server-side once we add that flow.
*/
export function encodeInviteLink(payload: InvitePayload): string {
const json = JSON.stringify(payload);
const encoded = Buffer.from(json, "utf-8").toString("base64url");
return `ic://join/${encoded}`;
}
/**
* Sign and assemble an invite payload → ic://join/... link.
*/
export async function buildSignedInvite(args: {
v: 1;
mesh_id: string;
mesh_slug: string;
broker_url: string;
expires_at: number;
mesh_root_key: string;
role: "admin" | "member";
owner_pubkey: string;
owner_secret_key: string;
}): Promise<{ link: string; token: string; payload: InvitePayload }> {
const s = await ensureSodium();
const canonical = canonicalInvite({
v: args.v,
mesh_id: args.mesh_id,
mesh_slug: args.mesh_slug,
broker_url: args.broker_url,
expires_at: args.expires_at,
mesh_root_key: args.mesh_root_key,
role: args.role,
owner_pubkey: args.owner_pubkey,
});
const signature = s.to_hex(
s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(args.owner_secret_key),
),
);
const payload: InvitePayload = {
v: args.v,
mesh_id: args.mesh_id,
mesh_slug: args.mesh_slug,
broker_url: args.broker_url,
expires_at: args.expires_at,
mesh_root_key: args.mesh_root_key,
role: args.role,
owner_pubkey: args.owner_pubkey,
signature,
};
const json = JSON.stringify(payload);
const token = Buffer.from(json, "utf-8").toString("base64url");
return { link: `ic://join/${token}`, token, payload };
}

View File

@@ -0,0 +1,8 @@
export interface InviteInfo {
code: string;
url: string;
mesh_slug: string;
expires_at: string;
max_uses?: number;
role?: string;
}

View File

@@ -0,0 +1,5 @@
// Email delivery is handled server-side when email is passed to generateInvite.
// This file exists for the spec's completeness.
export async function sendInviteEmail(_email: string, _inviteUrl: string): Promise<void> {
// No-op: backend sends the email when invite is created with an email parameter
}

View File

@@ -0,0 +1,217 @@
/**
* v2 invite claim client.
*
* The v2 invite URL is a short opaque code (e.g. `claudemesh.com/i/abc12345`).
* The mesh root key is NOT embedded. Instead:
*
* 1. Client generates a fresh x25519 keypair (separate from the peer's
* ed25519 identity) just for this claim.
* 2. Client POSTs `recipient_x25519_pubkey` to
* `${appBaseUrl}/api/public/invites/:code/claim`.
* 3. Server responds with `sealed_root_key` (crypto_box_seal of the real
* mesh root key to the recipient pubkey) + mesh metadata +
* `canonical_v2` (the signed capability bytes).
* 4. Client unseals the root key with its x25519 secret key.
*
* Wire contract is LOCKED — see `docs/protocol.md` §v2 invites and
* `apps/broker/tests/invite-v2.test.ts`.
*/
import sodium from "libsodium-wrappers";
async function ensureSodium(): Promise<typeof sodium> {
await sodium.ready;
return sodium;
}
/**
* Generate a fresh x25519 (Curve25519) keypair suitable for
* `crypto_box_seal`. This is intentionally distinct from the peer's
* long-lived ed25519 identity — we do NOT want the mesh root key sealed
* against a key that's reused for signing.
*
* Returns the public key as URL-safe base64url (no padding) to match
* the format used by the broker's `sealed_root_key` response.
*/
export async function generateX25519Keypair(): Promise<{
publicKeyB64: string;
secretKey: Uint8Array;
}> {
const s = await ensureSodium();
const kp = s.crypto_box_keypair();
const publicKeyB64 = s.to_base64(
kp.publicKey,
s.base64_variants.URLSAFE_NO_PADDING,
);
return { publicKeyB64, secretKey: kp.privateKey };
}
export interface ClaimV2Result {
meshId: string;
memberId: string;
ownerPubkey: string;
canonicalV2: string;
/** Unsealed mesh root key, 32 raw bytes. */
rootKey: Uint8Array;
}
interface ClaimResponseBody {
sealed_root_key?: string;
mesh_id?: string;
member_id?: string;
owner_pubkey?: string;
canonical_v2?: string;
}
interface ClaimErrorBody {
error?: string;
code?: string;
message?: string;
}
/**
* Claim a v2 invite by its short code. Performs the x25519 keypair
* generation, POST, and local unseal of the returned `sealed_root_key`.
*
* Throws with a descriptive message on 4xx/5xx or on seal-open failure.
*/
export async function claimInviteV2(opts: {
appBaseUrl: string; // e.g. "https://claudemesh.com"
code: string;
}): Promise<ClaimV2Result> {
const s = await ensureSodium();
const { publicKeyB64, secretKey } = await generateX25519Keypair();
const publicKeyBytes = s.from_base64(
publicKeyB64,
s.base64_variants.URLSAFE_NO_PADDING,
);
const base = opts.appBaseUrl.replace(/\/$/, "");
const code = encodeURIComponent(opts.code);
const url = `${base}/api/public/invites/${code}/claim`;
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ recipient_x25519_pubkey: publicKeyB64 }),
signal: AbortSignal.timeout(15_000),
});
} catch (e) {
throw new Error(
`claim request failed (network): ${e instanceof Error ? e.message : String(e)}`,
);
}
// Parse body first — server returns JSON for both success and error.
let parsed: unknown = null;
try {
parsed = await res.json();
} catch {
// fall through with parsed=null
}
if (!res.ok) {
const err = (parsed ?? {}) as ClaimErrorBody;
const reason =
err.error ?? err.code ?? err.message ?? `HTTP ${res.status}`;
switch (res.status) {
case 400:
throw new Error(`invite claim rejected: ${reason}`);
case 404:
throw new Error(`invite not found: ${reason}`);
case 410:
throw new Error(`invite no longer usable: ${reason}`);
default:
throw new Error(`invite claim failed (${res.status}): ${reason}`);
}
}
const body = (parsed ?? {}) as ClaimResponseBody;
if (
!body.sealed_root_key ||
!body.mesh_id ||
!body.member_id ||
!body.owner_pubkey ||
!body.canonical_v2
) {
throw new Error(
`invite claim response malformed: missing required field(s)`,
);
}
// Unseal the root key with our x25519 secret.
let rootKey: Uint8Array;
try {
const sealed = s.from_base64(
body.sealed_root_key,
s.base64_variants.URLSAFE_NO_PADDING,
);
const opened = s.crypto_box_seal_open(sealed, publicKeyBytes, secretKey);
if (!opened) throw new Error("crypto_box_seal_open returned empty");
rootKey = opened;
} catch (e) {
throw new Error(
`failed to unseal root key (server sealed to wrong pubkey?): ${e instanceof Error ? e.message : String(e)}`,
);
}
if (rootKey.length !== 32) {
throw new Error(
`unsealed root key has wrong length: ${rootKey.length} (expected 32)`,
);
}
// TODO(v0.1.5): when the claim response grows a `signature` field,
// re-verify canonical_v2 against owner_pubkey locally as a
// belt-and-suspenders check against a compromised broker.
// For v0.1.x the broker is trusted: it verified capability_v2 before
// sealing, and a malicious broker could already lie about mesh_id.
return {
meshId: body.mesh_id,
memberId: body.member_id,
ownerPubkey: body.owner_pubkey,
canonicalV2: body.canonical_v2,
rootKey,
};
}
/**
* Parse a v2 invite input (bare code or full URL) into a short code.
*
* Accepted forms:
* - `abc12345`
* - `claudemesh.com/i/abc12345`
* - `https://claudemesh.com/i/abc12345`
* - `https://claudemesh.com/es/i/abc12345` (locale prefix)
*
* Returns `null` if the input doesn't look like a v2 code/URL — callers
* should fall back to the v1 `ic://join/...` parser in that case.
*/
export function parseV2InviteInput(input: string): string | null {
const trimmed = input.trim();
// Full URL with /i/<code>
const urlMatch = trimmed.match(
/^https?:\/\/[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
);
if (urlMatch) return urlMatch[1]!;
// Schemeless "claudemesh.com/i/<code>"
const schemelessMatch = trimmed.match(
/^[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
);
if (schemelessMatch) return schemelessMatch[1]!;
// Bare short code — base62, typically 8 chars. Be a little lenient
// (6-16) to accommodate future tweaks but stay tight enough not to
// collide with a v1 base64url token (which contains `-` / `_` and is
// much longer).
if (/^[A-Za-z0-9]{6,16}$/.test(trimmed)) return trimmed;
return null;
}

View File

@@ -0,0 +1 @@
export { registerShutdownHook, shutdown } from "./service-manager.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,32 @@
type ShutdownHook = () => void | Promise<void>;
const hooks: ShutdownHook[] = [];
let registered = false;
function onExit() {
for (const hook of hooks) {
try {
const result = hook();
if (result instanceof Promise) result.catch(() => {});
} catch {}
}
}
export function registerShutdownHook(hook: ShutdownHook): void {
hooks.push(hook);
if (!registered) {
registered = true;
process.on("exit", onExit);
process.on("SIGINT", () => { onExit(); process.exit(1); });
process.on("SIGTERM", () => { onExit(); process.exit(0); });
}
}
export async function shutdown(): Promise<void> {
for (const hook of hooks) {
try {
await hook();
} catch {}
}
hooks.length = 0;
}

View File

@@ -0,0 +1 @@
export { log, debug, warn, error } from "./logger.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,22 @@
const isDebug = process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true";
const isQuiet = process.argv.includes("-q") || process.argv.includes("--quiet");
function timestamp(): string {
return new Date().toISOString();
}
export function log(msg: string, ...args: unknown[]): void {
if (!isQuiet) console.log(msg, ...args);
}
export function debug(msg: string, ...args: unknown[]): void {
if (isDebug) console.error(`[${timestamp()}] DEBUG ${msg}`, ...args);
}
export function warn(msg: string, ...args: unknown[]): void {
console.error(`${msg}`, ...args);
}
export function error(msg: string, ...args: unknown[]): void {
console.error(`${msg}`, ...args);
}

View File

@@ -0,0 +1,2 @@
export { createMesh } from "./create.js";
export { renameMesh } from "./rename.js";

View File

@@ -0,0 +1,43 @@
import { request } from "~/services/api/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
import { setMeshConfig } from "~/services/config/facade.js";
import { URLS } from "~/constants/urls.js";
import type { JoinedMesh } from "~/services/config/facade.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
export async function createMesh(name: string, opts?: { template?: string; description?: string }): Promise<{ slug: string; id: string }> {
const auth = getStoredToken();
if (!auth) throw new Error("Not signed in");
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
if (!userId) throw new Error("Invalid token — run `claudemesh login` again");
// Generate keypair first so we can send the pubkey to the broker
const kp = await generateKeypair();
const result = await request<{ id: string; slug: string; name: string; member_id: string }>({
path: "/cli/mesh/create",
method: "POST",
body: { user_id: userId, name, pubkey: kp.publicKey, ...opts },
baseUrl: BROKER_HTTP,
});
const mesh: JoinedMesh = {
meshId: result.id,
memberId: result.member_id,
slug: result.slug,
name: result.name,
pubkey: kp.publicKey,
secretKey: kp.secretKey,
brokerUrl: URLS.BROKER,
joinedAt: new Date().toISOString(),
};
setMeshConfig(result.slug, mesh);
return { slug: result.slug, id: result.id };
}

View File

@@ -0,0 +1,6 @@
export class MeshNotFoundError extends Error {
constructor(slug: string) {
super(`Mesh "${slug}" not found`);
this.name = "MeshNotFoundError";
}
}

View File

@@ -0,0 +1,7 @@
export { listMeshes as list } from "./list.js";
export { createMesh as create } from "./create.js";
export { renameMesh as rename } from "./rename.js";
export { leaveMesh as leave } from "./leave.js";
export { joinMesh as join, joinMesh } from "./join.js";
export { resolveTarget } from "./resolve-target.js";
export { MeshNotFoundError } from "./errors.js";

View File

@@ -0,0 +1,6 @@
export { listMeshes } from "./list.js";
export { createMesh } from "./create.js";
export { renameMesh } from "./rename.js";
export { leaveMesh } from "./leave.js";
export { joinMesh } from "./join.js";
export { resolveTarget } from "./resolve-target.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,18 @@
import { pub } from "~/services/api/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
import { setMeshConfig } from "~/services/config/facade.js";
import { URLS } from "~/constants/urls.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export async function joinMesh(code: string, displayName: string): Promise<JoinedMesh> {
const kp = await generateKeypair();
const result = await pub.claimInvite(code, { pubkey: kp.publicKey, display_name: displayName });
const mesh: JoinedMesh = {
meshId: result.meshId, memberId: result.memberId, slug: result.slug,
name: result.name, pubkey: kp.publicKey, secretKey: kp.secretKey,
brokerUrl: result.brokerUrl || URLS.BROKER, joinedAt: new Date().toISOString(),
rootKey: result.rootKey,
};
setMeshConfig(result.slug, mesh);
return mesh;
}

View File

@@ -0,0 +1,5 @@
import { removeMeshConfig } from "~/services/config/facade.js";
export function leaveMesh(slug: string): boolean {
return removeMeshConfig(slug);
}

View File

@@ -0,0 +1,6 @@
import { readConfig } from "~/services/config/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export function listMeshes(): JoinedMesh[] {
return readConfig().meshes;
}

View File

@@ -0,0 +1,8 @@
import { my } from "~/services/api/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
export async function renameMesh(slug: string, newName: string): Promise<void> {
const auth = getStoredToken();
if (!auth) throw new Error("Not signed in");
await my.renameMesh(auth.session_token, slug, newName);
}

View File

@@ -0,0 +1,16 @@
import { readConfig } from "~/services/config/facade.js";
import { getLastUsed } from "~/services/state/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export function resolveTarget(meshFlag?: string): JoinedMesh | null {
const config = readConfig();
if (config.meshes.length === 0) return null;
if (meshFlag) return config.meshes.find(m => m.slug === meshFlag) ?? null;
const last = getLastUsed();
if (last) {
const found = config.meshes.find(m => m.slug === last.meshSlug);
if (found) return found;
}
if (config.meshes.length === 1) return config.meshes[0]!;
return null;
}

View File

@@ -0,0 +1,2 @@
// Mesh service uses config schemas directly
export type { JoinedMesh } from "~/services/config/facade.js";

View File

@@ -0,0 +1,25 @@
import { execFile } from "node:child_process";
import { platform } from "node:os";
export function openBrowser(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const os = platform();
let bin: string;
let args: string[];
if (os === "darwin") {
bin = "open";
args = [url];
} else if (os === "win32") {
bin = "cmd";
args = ["/c", "start", "", url];
} else {
bin = "xdg-open";
args = [url];
}
execFile(bin, args, (err) => {
if (err) reject(err);
else resolve();
});
});
}

View File

@@ -0,0 +1,37 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { existsSync } from "node:fs";
export function findClaudeBinary(): string | null {
const candidates = [
process.env.CLAUDE_BIN,
"/usr/local/bin/claude",
`${process.env.HOME}/.local/bin/claude`,
`${process.env.HOME}/.npm/bin/claude`,
].filter(Boolean) as string[];
for (const bin of candidates) {
if (existsSync(bin)) return bin;
}
const which = spawnSync("which", ["claude"], { encoding: "utf-8" });
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
return null;
}
export interface SpawnClaudeOpts {
args: string[];
env?: Record<string, string>;
cwd?: string;
}
export function spawnClaude(opts: SpawnClaudeOpts): SpawnSyncReturns<Buffer> {
const bin = findClaudeBinary();
if (!bin) throw new Error("Claude binary not found. Install with: npm i -g @anthropic-ai/claude-code");
return spawnSync(bin, opts.args, {
stdio: "inherit",
env: { ...process.env, ...opts.env },
cwd: opts.cwd,
});
}

View File

@@ -0,0 +1,3 @@
export { spawnClaude, findClaudeBinary } from "./claude.js";
export type { SpawnClaudeOpts } from "./claude.js";
export { openBrowser } from "./browser.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,2 @@
export { getLastUsed, setLastUsed } from "./last-used.js";
export type { LastUsed } from "./schemas.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,20 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { ensureConfigDir } from "~/services/config/facade.js";
import type { LastUsed } from "./schemas.js";
export function getLastUsed(): LastUsed | null {
if (!existsSync(PATHS.LAST_USED_FILE)) return null;
try {
const raw = readFileSync(PATHS.LAST_USED_FILE, "utf-8");
return JSON.parse(raw) as LastUsed;
} catch {
return null;
}
}
export function setLastUsed(entry: Omit<LastUsed, "timestamp">): void {
ensureConfigDir();
const data: LastUsed = { ...entry, timestamp: new Date().toISOString() };
writeFileSync(PATHS.LAST_USED_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
}

View File

@@ -0,0 +1,6 @@
export interface LastUsed {
meshSlug: string;
displayName?: string;
role?: string;
timestamp: string;
}

View File

@@ -0,0 +1,12 @@
import { isOptedOut } from "./opt-out.js";
export interface TelemetryEvent {
event: string;
properties?: Record<string, unknown>;
}
export function emit(event: TelemetryEvent): void {
if (isOptedOut()) return;
// Pass 1: telemetry is a no-op stub. Events are defined but not sent.
// Pass 2 adds PostHog or similar backend.
}

View File

@@ -0,0 +1,2 @@
export { emit } from "./emit.js";
export { isOptedOut, optOut } from "./opt-out.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";

View File

@@ -0,0 +1,17 @@
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { PATHS } from "~/constants/paths.js";
const OPT_OUT_FILE = join(PATHS.CONFIG_DIR, ".telemetry-opt-out");
export function isOptedOut(): boolean {
return process.env.CLAUDEMESH_TELEMETRY === "0" || existsSync(OPT_OUT_FILE);
}
export function optOut(): void {
writeFileSync(OPT_OUT_FILE, "", "utf-8");
}
export function optIn(): void {
try { unlinkSync(OPT_OUT_FILE); } catch {}
}

View File

@@ -0,0 +1,35 @@
import { URLS } from "~/constants/urls.js";
import { TIMINGS } from "~/constants/timings.js";
import { isNewer } from "~/utils/semver.js";
export interface UpdateInfo {
current: string;
latest: string;
updateAvailable: boolean;
}
export async function checkForUpdate(currentVersion: string): Promise<UpdateInfo> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMINGS.API_TIMEOUT_MS);
try {
const res = await fetch(URLS.NPM_REGISTRY, {
signal: controller.signal,
headers: { Accept: "application/vnd.npm.install-v1+json" },
});
if (!res.ok) return { current: currentVersion, latest: currentVersion, updateAvailable: false };
const data = (await res.json()) as { "dist-tags"?: { latest?: string } };
const latest = data["dist-tags"]?.latest ?? currentVersion;
return {
current: currentVersion,
latest,
updateAvailable: isNewer(currentVersion, latest),
};
} catch {
return { current: currentVersion, latest: currentVersion, updateAvailable: false };
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,2 @@
export { checkForUpdate } from "./check.js";
export type { UpdateInfo } from "./check.js";

View File

@@ -0,0 +1 @@
export * from "./facade.js";