refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- apps/cli/ is now the canonical CLI (was apps/cli-v2/). - apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag 'cli-v0-legacy-final' before deletion; git history preserves it too. - .github/workflows/release-cli.yml paths updated. - pnpm-lock.yaml regenerated. Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities): - 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member. - handleSend in broker fetches recipient grant maps once per send, drops messages silently when sender lacks the required capability. - POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric. - CLI grant/revoke/block now mirror to broker via syncToBroker. Auto-migrate on broker startup: - apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock before the HTTP server binds. Exits non-zero on failure so Coolify healthcheck fails closed. - Dockerfile copies packages/db/migrations into /app/migrations. - postgres 3.4.5 added as direct broker dep. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
apps/cli/src/services/api/client.ts
Normal file
70
apps/cli/src/services/api/client.ts
Normal 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 });
|
||||
}
|
||||
37
apps/cli/src/services/api/errors.ts
Normal file
37
apps/cli/src/services/api/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
apps/cli/src/services/api/facade.ts
Normal file
5
apps/cli/src/services/api/facade.ts
Normal 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";
|
||||
1
apps/cli/src/services/api/index.ts
Normal file
1
apps/cli/src/services/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
60
apps/cli/src/services/api/my.ts
Normal file
60
apps/cli/src/services/api/my.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
46
apps/cli/src/services/api/public.ts
Normal file
46
apps/cli/src/services/api/public.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
70
apps/cli/src/services/auth/callback-listener.ts
Normal file
70
apps/cli/src/services/auth/callback-listener.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/services/auth/client.ts
Normal file
51
apps/cli/src/services/auth/client.ts
Normal 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);
|
||||
}
|
||||
50
apps/cli/src/services/auth/dashboard-sync.ts
Normal file
50
apps/cli/src/services/auth/dashboard-sync.ts
Normal 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(/\/$/, "");
|
||||
}
|
||||
132
apps/cli/src/services/auth/device-code.ts
Normal file
132
apps/cli/src/services/auth/device-code.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
18
apps/cli/src/services/auth/errors.ts
Normal file
18
apps/cli/src/services/auth/errors.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
16
apps/cli/src/services/auth/facade.ts
Normal file
16
apps/cli/src/services/auth/facade.ts
Normal 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("");
|
||||
}
|
||||
5
apps/cli/src/services/auth/implementation.ts
Normal file
5
apps/cli/src/services/auth/implementation.ts
Normal 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";
|
||||
1
apps/cli/src/services/auth/index.ts
Normal file
1
apps/cli/src/services/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
21
apps/cli/src/services/auth/schemas.ts
Normal file
21
apps/cli/src/services/auth/schemas.ts
Normal 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 };
|
||||
}
|
||||
30
apps/cli/src/services/auth/token-store.ts
Normal file
30
apps/cli/src/services/auth/token-store.ts
Normal 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 {}
|
||||
}
|
||||
6
apps/cli/src/services/broker/envelope.ts
Normal file
6
apps/cli/src/services/broker/envelope.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
encryptDirect,
|
||||
decryptDirect,
|
||||
isDirectTarget,
|
||||
} from "~/services/crypto/facade.js";
|
||||
export type { Envelope } from "~/services/crypto/facade.js";
|
||||
13
apps/cli/src/services/broker/errors.ts
Normal file
13
apps/cli/src/services/broker/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
8
apps/cli/src/services/broker/facade.ts
Normal file
8
apps/cli/src/services/broker/facade.ts
Normal 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";
|
||||
17
apps/cli/src/services/broker/hello-sig.ts
Normal file
17
apps/cli/src/services/broker/hello-sig.ts
Normal 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) };
|
||||
}
|
||||
2
apps/cli/src/services/broker/implementation.ts
Normal file
2
apps/cli/src/services/broker/implementation.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BrokerClient } from "./ws-client.js";
|
||||
export type { Priority, ConnStatus, PeerInfo, InboundPush } from "./ws-client.js";
|
||||
1
apps/cli/src/services/broker/index.ts
Normal file
1
apps/cli/src/services/broker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
47
apps/cli/src/services/broker/manager.ts
Normal file
47
apps/cli/src/services/broker/manager.ts
Normal 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();
|
||||
}
|
||||
28
apps/cli/src/services/broker/schemas.ts
Normal file
28
apps/cli/src/services/broker/schemas.ts
Normal 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";
|
||||
2221
apps/cli/src/services/broker/ws-client.ts
Normal file
2221
apps/cli/src/services/broker/ws-client.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/cli/src/services/clipboard/facade.ts
Normal file
1
apps/cli/src/services/clipboard/facade.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { readClipboard, writeClipboard } from "./read.js";
|
||||
1
apps/cli/src/services/clipboard/index.ts
Normal file
1
apps/cli/src/services/clipboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
45
apps/cli/src/services/clipboard/read.ts
Normal file
45
apps/cli/src/services/clipboard/read.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
apps/cli/src/services/config/facade.ts
Normal file
9
apps/cli/src/services/config/facade.ts
Normal 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;
|
||||
}
|
||||
1
apps/cli/src/services/config/index.ts
Normal file
1
apps/cli/src/services/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
31
apps/cli/src/services/config/read.ts
Normal file
31
apps/cli/src/services/config/read.ts
Normal 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);
|
||||
}
|
||||
31
apps/cli/src/services/config/schemas.ts
Normal file
31
apps/cli/src/services/config/schemas.ts
Normal 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: [] };
|
||||
}
|
||||
51
apps/cli/src/services/config/write.ts
Normal file
51
apps/cli/src/services/config/write.ts
Normal 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;
|
||||
}
|
||||
59
apps/cli/src/services/crypto/box.ts
Normal file
59
apps/cli/src/services/crypto/box.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
55
apps/cli/src/services/crypto/facade.ts
Normal file
55
apps/cli/src/services/crypto/facade.ts
Normal 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,
|
||||
};
|
||||
65
apps/cli/src/services/crypto/file-crypto.ts
Normal file
65
apps/cli/src/services/crypto/file-crypto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
apps/cli/src/services/crypto/index.ts
Normal file
1
apps/cli/src/services/crypto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
25
apps/cli/src/services/crypto/keypair.ts
Normal file
25
apps/cli/src/services/crypto/keypair.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
11
apps/cli/src/services/crypto/random.ts
Normal file
11
apps/cli/src/services/crypto/random.ts
Normal 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));
|
||||
}
|
||||
2
apps/cli/src/services/device/facade.ts
Normal file
2
apps/cli/src/services/device/facade.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { getDeviceInfo } from "./info.js";
|
||||
export type { DeviceInfo } from "./info.js";
|
||||
1
apps/cli/src/services/device/index.ts
Normal file
1
apps/cli/src/services/device/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
19
apps/cli/src/services/device/info.ts
Normal file
19
apps/cli/src/services/device/info.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
8
apps/cli/src/services/health/check-claude-binary.ts
Normal file
8
apps/cli/src/services/health/check-claude-binary.ts
Normal 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" };
|
||||
}
|
||||
19
apps/cli/src/services/health/check-config-perms.ts
Normal file
19
apps/cli/src/services/health/check-config-perms.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
19
apps/cli/src/services/health/check-hooks-registered.ts
Normal file
19
apps/cli/src/services/health/check-hooks-registered.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
31
apps/cli/src/services/health/check-keypairs-valid.ts
Normal file
31
apps/cli/src/services/health/check-keypairs-valid.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
19
apps/cli/src/services/health/check-mcp-registered.ts
Normal file
19
apps/cli/src/services/health/check-mcp-registered.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
7
apps/cli/src/services/health/check-node-version.ts
Normal file
7
apps/cli/src/services/health/check-node-version.ts
Normal 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` };
|
||||
}
|
||||
27
apps/cli/src/services/health/facade.ts
Normal file
27
apps/cli/src/services/health/facade.ts
Normal 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;
|
||||
}
|
||||
1
apps/cli/src/services/health/index.ts
Normal file
1
apps/cli/src/services/health/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
5
apps/cli/src/services/health/types.ts
Normal file
5
apps/cli/src/services/health/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface CheckResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
3
apps/cli/src/services/i18n/facade.ts
Normal file
3
apps/cli/src/services/i18n/facade.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { t } from "./format.js";
|
||||
export { detectLocale } from "./resolve.js";
|
||||
export type { Locale } from "./resolve.js";
|
||||
13
apps/cli/src/services/i18n/format.ts
Normal file
13
apps/cli/src/services/i18n/format.ts
Normal 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;
|
||||
}
|
||||
1
apps/cli/src/services/i18n/index.ts
Normal file
1
apps/cli/src/services/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
5
apps/cli/src/services/i18n/resolve.ts
Normal file
5
apps/cli/src/services/i18n/resolve.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Locale = "en";
|
||||
|
||||
export function detectLocale(): Locale {
|
||||
return "en";
|
||||
}
|
||||
1
apps/cli/src/services/invite/claim.ts
Normal file
1
apps/cli/src/services/invite/claim.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { joinMesh as claimInvite } from "~/services/mesh/facade.js";
|
||||
58
apps/cli/src/services/invite/enroll.ts
Normal file
58
apps/cli/src/services/invite/enroll.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
apps/cli/src/services/invite/errors.ts
Normal file
13
apps/cli/src/services/invite/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
10
apps/cli/src/services/invite/facade.ts
Normal file
10
apps/cli/src/services/invite/facade.ts
Normal 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";
|
||||
27
apps/cli/src/services/invite/generate.ts
Normal file
27
apps/cli/src/services/invite/generate.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
4
apps/cli/src/services/invite/implementation.ts
Normal file
4
apps/cli/src/services/invite/implementation.ts
Normal 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";
|
||||
1
apps/cli/src/services/invite/index.ts
Normal file
1
apps/cli/src/services/invite/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
8
apps/cli/src/services/invite/parse-url.ts
Normal file
8
apps/cli/src/services/invite/parse-url.ts
Normal 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;
|
||||
}
|
||||
227
apps/cli/src/services/invite/parse-v1.ts
Normal file
227
apps/cli/src/services/invite/parse-v1.ts
Normal 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 };
|
||||
}
|
||||
8
apps/cli/src/services/invite/schemas.ts
Normal file
8
apps/cli/src/services/invite/schemas.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface InviteInfo {
|
||||
code: string;
|
||||
url: string;
|
||||
mesh_slug: string;
|
||||
expires_at: string;
|
||||
max_uses?: number;
|
||||
role?: string;
|
||||
}
|
||||
5
apps/cli/src/services/invite/send-email.ts
Normal file
5
apps/cli/src/services/invite/send-email.ts
Normal 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
|
||||
}
|
||||
217
apps/cli/src/services/invite/v2.ts
Normal file
217
apps/cli/src/services/invite/v2.ts
Normal 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;
|
||||
}
|
||||
1
apps/cli/src/services/lifecycle/facade.ts
Normal file
1
apps/cli/src/services/lifecycle/facade.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { registerShutdownHook, shutdown } from "./service-manager.js";
|
||||
1
apps/cli/src/services/lifecycle/index.ts
Normal file
1
apps/cli/src/services/lifecycle/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
32
apps/cli/src/services/lifecycle/service-manager.ts
Normal file
32
apps/cli/src/services/lifecycle/service-manager.ts
Normal 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;
|
||||
}
|
||||
1
apps/cli/src/services/logger/facade.ts
Normal file
1
apps/cli/src/services/logger/facade.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { log, debug, warn, error } from "./logger.js";
|
||||
1
apps/cli/src/services/logger/index.ts
Normal file
1
apps/cli/src/services/logger/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
22
apps/cli/src/services/logger/logger.ts
Normal file
22
apps/cli/src/services/logger/logger.ts
Normal 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);
|
||||
}
|
||||
2
apps/cli/src/services/mesh/client.ts
Normal file
2
apps/cli/src/services/mesh/client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createMesh } from "./create.js";
|
||||
export { renameMesh } from "./rename.js";
|
||||
43
apps/cli/src/services/mesh/create.ts
Normal file
43
apps/cli/src/services/mesh/create.ts
Normal 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 };
|
||||
}
|
||||
6
apps/cli/src/services/mesh/errors.ts
Normal file
6
apps/cli/src/services/mesh/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class MeshNotFoundError extends Error {
|
||||
constructor(slug: string) {
|
||||
super(`Mesh "${slug}" not found`);
|
||||
this.name = "MeshNotFoundError";
|
||||
}
|
||||
}
|
||||
7
apps/cli/src/services/mesh/facade.ts
Normal file
7
apps/cli/src/services/mesh/facade.ts
Normal 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";
|
||||
6
apps/cli/src/services/mesh/implementation.ts
Normal file
6
apps/cli/src/services/mesh/implementation.ts
Normal 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";
|
||||
1
apps/cli/src/services/mesh/index.ts
Normal file
1
apps/cli/src/services/mesh/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
18
apps/cli/src/services/mesh/join.ts
Normal file
18
apps/cli/src/services/mesh/join.ts
Normal 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;
|
||||
}
|
||||
5
apps/cli/src/services/mesh/leave.ts
Normal file
5
apps/cli/src/services/mesh/leave.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { removeMeshConfig } from "~/services/config/facade.js";
|
||||
|
||||
export function leaveMesh(slug: string): boolean {
|
||||
return removeMeshConfig(slug);
|
||||
}
|
||||
6
apps/cli/src/services/mesh/list.ts
Normal file
6
apps/cli/src/services/mesh/list.ts
Normal 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;
|
||||
}
|
||||
8
apps/cli/src/services/mesh/rename.ts
Normal file
8
apps/cli/src/services/mesh/rename.ts
Normal 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);
|
||||
}
|
||||
16
apps/cli/src/services/mesh/resolve-target.ts
Normal file
16
apps/cli/src/services/mesh/resolve-target.ts
Normal 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;
|
||||
}
|
||||
2
apps/cli/src/services/mesh/schemas.ts
Normal file
2
apps/cli/src/services/mesh/schemas.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Mesh service uses config schemas directly
|
||||
export type { JoinedMesh } from "~/services/config/facade.js";
|
||||
25
apps/cli/src/services/spawn/browser.ts
Normal file
25
apps/cli/src/services/spawn/browser.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
37
apps/cli/src/services/spawn/claude.ts
Normal file
37
apps/cli/src/services/spawn/claude.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
3
apps/cli/src/services/spawn/facade.ts
Normal file
3
apps/cli/src/services/spawn/facade.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { spawnClaude, findClaudeBinary } from "./claude.js";
|
||||
export type { SpawnClaudeOpts } from "./claude.js";
|
||||
export { openBrowser } from "./browser.js";
|
||||
1
apps/cli/src/services/spawn/index.ts
Normal file
1
apps/cli/src/services/spawn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
2
apps/cli/src/services/state/facade.ts
Normal file
2
apps/cli/src/services/state/facade.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { getLastUsed, setLastUsed } from "./last-used.js";
|
||||
export type { LastUsed } from "./schemas.js";
|
||||
1
apps/cli/src/services/state/index.ts
Normal file
1
apps/cli/src/services/state/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
20
apps/cli/src/services/state/last-used.ts
Normal file
20
apps/cli/src/services/state/last-used.ts
Normal 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");
|
||||
}
|
||||
6
apps/cli/src/services/state/schemas.ts
Normal file
6
apps/cli/src/services/state/schemas.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface LastUsed {
|
||||
meshSlug: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
12
apps/cli/src/services/telemetry/emit.ts
Normal file
12
apps/cli/src/services/telemetry/emit.ts
Normal 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.
|
||||
}
|
||||
2
apps/cli/src/services/telemetry/facade.ts
Normal file
2
apps/cli/src/services/telemetry/facade.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { emit } from "./emit.js";
|
||||
export { isOptedOut, optOut } from "./opt-out.js";
|
||||
1
apps/cli/src/services/telemetry/index.ts
Normal file
1
apps/cli/src/services/telemetry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
17
apps/cli/src/services/telemetry/opt-out.ts
Normal file
17
apps/cli/src/services/telemetry/opt-out.ts
Normal 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 {}
|
||||
}
|
||||
35
apps/cli/src/services/update/check.ts
Normal file
35
apps/cli/src/services/update/check.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
2
apps/cli/src/services/update/facade.ts
Normal file
2
apps/cli/src/services/update/facade.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { checkForUpdate } from "./check.js";
|
||||
export type { UpdateInfo } from "./check.js";
|
||||
1
apps/cli/src/services/update/index.ts
Normal file
1
apps/cli/src/services/update/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./facade.js";
|
||||
Reference in New Issue
Block a user